From 620fef4291b4287621a2cd9f1236e23ab11ec227 Mon Sep 17 00:00:00 2001 From: mighty-programmer <83077551+mighty-programmer@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:48:01 +0300 Subject: [PATCH 001/229] Add optional local matting and object selection models --- .github/workflows/local-models.yml | 72 + ATTRIBUTION.md | 15 + Cargo.lock | 340 +- Cargo.toml | 1 + apps/photocraft-cli/Cargo.toml | 2 +- apps/photocraft-cli/src/lib.rs | 26 +- apps/photocraft/Cargo.toml | 1 + apps/photocraft/src/main.rs | 11 +- contributors/people.toml | 3 + crates/engine/Cargo.toml | 2 + crates/engine/examples/local_models_bench.rs | 59 + crates/engine/src/commands.rs | 1 + crates/engine/src/cutout_cmds.rs | 17 +- crates/engine/src/file_cmds.rs | 7 +- crates/engine/src/jobs.rs | 3 +- crates/engine/src/lib.rs | 4 + crates/engine/src/model_cmds.rs | 502 ++ crates/engine/src/prefs.rs | 9 +- crates/engine/src/smartselect_cmds.rs | 56 +- crates/ml/Cargo.toml | 27 + crates/ml/README.md | 31 + crates/ml/licenses/BiRefNet-MIT.txt | 21 + crates/ml/licenses/ONNXRuntime-MIT.txt | 21 + .../ONNXRuntime-ThirdPartyNotices.txt | 6121 +++++++++++++++++ crates/ml/licenses/SAM2-Apache-2.0.txt | 201 + crates/ml/src/catalog.rs | 145 + crates/ml/src/image.rs | 127 + crates/ml/src/lib.rs | 68 + crates/ml/src/native.rs | 542 ++ crates/ml/src/test_models.rs | 45 + crates/ui-egui/Cargo.toml | 4 + crates/ui-egui/examples/snapshot.rs | 13 +- crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/prefs_ui.rs | 117 +- docs/architecture.md | 2 +- docs/development.md | 4 + docs/images/LICENSE-local-models.txt | 10 + docs/images/SOURCES.md | 2 + docs/images/local-models-before.png | Bin 0 -> 123594 bytes docs/images/local-models-comparison.png | Bin 0 -> 364764 bytes docs/images/local-models-preferences.png | Bin 0 -> 190125 bytes docs/local-models-validation.md | 85 + docs/local-models.md | 135 + scorecard/automation.toml | 4 +- 56 files changed, 8849 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/local-models.yml create mode 100644 crates/engine/examples/local_models_bench.rs create mode 100644 crates/engine/src/model_cmds.rs create mode 100644 crates/ml/Cargo.toml create mode 100644 crates/ml/README.md create mode 100644 crates/ml/licenses/BiRefNet-MIT.txt create mode 100644 crates/ml/licenses/ONNXRuntime-MIT.txt create mode 100644 crates/ml/licenses/ONNXRuntime-ThirdPartyNotices.txt create mode 100644 crates/ml/licenses/SAM2-Apache-2.0.txt create mode 100644 crates/ml/src/catalog.rs create mode 100644 crates/ml/src/image.rs create mode 100644 crates/ml/src/lib.rs create mode 100644 crates/ml/src/native.rs create mode 100644 crates/ml/src/test_models.rs create mode 100644 docs/images/LICENSE-local-models.txt create mode 100644 docs/images/local-models-before.png create mode 100644 docs/images/local-models-comparison.png create mode 100644 docs/images/local-models-preferences.png create mode 100644 docs/local-models-validation.md create mode 100644 docs/local-models.md diff --git a/.github/workflows/local-models.yml b/.github/workflows/local-models.yml new file mode 100644 index 0000000000..f5f8a97351 --- /dev/null +++ b/.github/workflows/local-models.yml @@ -0,0 +1,72 @@ +name: Optional local models + +on: + # Let contributors verify native platforms in their fork before maintainer approval of PR CI. + push: + paths: + - "crates/ml/**" + - "crates/engine/**" + - "crates/ui-egui/**" + - "apps/photocraft/**" + - "apps/photocraft-cli/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/local-models.yml" + pull_request: + paths: + - "crates/ml/**" + - "crates/engine/**" + - "crates/ui-egui/**" + - "apps/photocraft/**" + - "apps/photocraft-cli/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/local-models.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: local-models-${{ github.ref }} + cancel-in-progress: true + +jobs: + cpu: + if: ${{ github.event_name != 'push' || github.event.repository.fork }} + name: CPU (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + env: + CARGO_INCREMENTAL: "0" + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + key: optional-local-ml + - name: Install Linux UI dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ + libgl1-mesa-dev libgtk-3-dev + # Synthetic ONNX graphs exercise the actual runtime and both adapters. No model weights + # are fetched in CI; only ort's pinned build-time runtime redistribution is downloaded. + - name: Test native runtime and download verification + run: cargo test -p photocraft-ml --features onnx --locked + - name: Test commands and CLI with the optional backend + run: cargo test -p photocraft-engine -p photocraft-cli --features local-ml --locked + - name: Check native app and UI with the optional backend + run: cargo check -p photocraft -p photocraft-ui-egui --features local-ml --locked + - name: Clippy optional backend + run: cargo clippy -p photocraft-ml -p photocraft-engine -p photocraft-cli --features photocraft-ml/onnx,local-ml --all-targets --locked -- -D warnings diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index dfc31372f2..092941b417 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -54,10 +54,25 @@ The other built-in ICC profiles and the generated LUT looks are produced by code (`crates/cms/src/builtin.rs`, `crates/cms/src/lutfile.rs`) and dedicated to the public domain (CC0-1.0); they are not separate files. +## Optional local models and runtime + +Model weights are explicit downloads, not files committed or bundled in the default app. +Immutable revisions, artifact sizes and hashes are recorded in `crates/ml/src/catalog.rs`. +The download cache includes the original model licence and a source/revision notice. + +| Path / optional artifact | Title | Author | Source | License | +|---|---|---|---|---| +| Downloaded `birefnet-hr-matting-*/model.onnx` | BiRefNet HR Matting, FP32 ONNX export | Peng Zheng and BiRefNet contributors; export by PinkPixel | [Original checkpoint](https://huggingface.co/ZhengPeng7/BiRefNet_HR-matting), [pinned export](https://huggingface.co/PinkPixel/birefnet-hr-matting-onnx/tree/792518b9d4dfe9793891712677de25e554f948c6) | MIT, [`BiRefNet-MIT.txt`](crates/ml/licenses/BiRefNet-MIT.txt) | +| Downloaded `sam2.1-large-*/{vision_encoder,prompt_encoder_mask_decoder}.onnx{,_data}` | SAM 2.1 Hiera Large, FP32 ONNX export | Meta / SAM 2 contributors; export by ONNX Community | [Original checkpoint](https://huggingface.co/facebook/sam2.1-hiera-large), [pinned export](https://huggingface.co/onnx-community/sam2.1-hiera-large-ONNX/tree/3c23431f721e69cae82dbfd0c28fd692cc714021) | Apache-2.0, [`SAM2-Apache-2.0.txt`](crates/ml/licenses/SAM2-Apache-2.0.txt) | +| Build-time ONNX Runtime redistribution (`local-ml` only) | ONNX Runtime 1.28 CPU runtime | Microsoft and contributors; redistribution by pyke / ort | [ONNX Runtime](https://github.com/microsoft/onnxruntime), [ort 2.0.0-rc.13](https://github.com/pykeio/ort) | MIT, [`ONNXRuntime-MIT.txt`](crates/ml/licenses/ONNXRuntime-MIT.txt), [`third-party notices`](crates/ml/licenses/ONNXRuntime-ThirdPartyNotices.txt) | +| `crates/ml/src/test_models.rs` | Original tiny ONNX contract fixtures (Rust byte constants, no learned weights) | PhotoCraft contributors | Original synthetic graphs: mean/sigmoid, constant embeddings and scored masks | MIT OR Apache-2.0 | + ## Documentation and README | Path | Title | Author | Source | License | |---|---|---|---|---| +| `docs/images/local-models-before.png`, `docs/images/local-models-preferences.png` | PhotoCraft Preferences before / after optional models | PhotoCraft contributors | Original offscreen screenshots | MIT OR Apache-2.0, [`licence`](docs/images/LICENSE-local-models.txt) | +| `docs/images/local-models-comparison.png` | Classical / BiRefNet / SAM actual command outputs using *Girl with a Pearl Earring* | Artwork: Johannes Vermeer, c. 1665; presentation: PhotoCraft contributors | [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:1665_Girl_with_a_Pearl_Earring.jpg), exported masks | Artwork: public domain (PD-Art); presentation: MIT OR Apache-2.0, [`licence`](docs/images/LICENSE-local-models.txt) | | `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 | diff --git a/Cargo.lock b/Cargo.lock index 5d3156368d..c3a6f38646 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -556,6 +556,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bindgen" version = "0.72.1" @@ -645,7 +651,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.1", ] [[package]] @@ -654,6 +660,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -727,6 +742,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -885,7 +906,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -1112,6 +1133,15 @@ dependencies = [ "libm", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -1204,6 +1234,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -1263,6 +1303,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -2055,6 +2105,16 @@ dependencies = [ "libc", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -2074,6 +2134,17 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -2341,6 +2412,28 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac-sha256" +version = "1.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad320b3b96fb2a455a0726d16efe0a5afdbd34b71dea5bc53b05ea057714d4e" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -3170,6 +3263,22 @@ dependencies = [ "imgref", ] +[[package]] +name = "lzma-rust2" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619" + +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-owned" version = "0.3.4" @@ -3322,6 +3431,21 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk" version = "0.9.0" @@ -4008,6 +4132,30 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ort" +version = "2.0.0-rc.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336a1e2b38848325241c72889086886004e589b7c74f335e60a8e8db5138a0b" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", + "ureq", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf211e3776eea6aec988552fa118dd746d70e1b1e5e244058d1c98015f3e5872" +dependencies = [ + "hmac-sha256", + "lzma-rust2", + "ureq", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -4239,7 +4387,7 @@ dependencies = [ name = "photocraft-automation" version = "0.6.0" dependencies = [ - "base64", + "base64 0.22.1", "cap-std", "getrandom 0.3.4", "photocraft-codecs", @@ -4351,6 +4499,7 @@ dependencies = [ "photocraft-format", "photocraft-geom", "photocraft-io", + "photocraft-ml", "photocraft-ops", "photocraft-paint", "photocraft-plugins", @@ -4446,6 +4595,20 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "photocraft-ml" +version = "0.5.0" +dependencies = [ + "ort", + "photocraft-algo", + "photocraft-raster", + "serde", + "sha2", + "tempfile", + "thiserror 2.0.21", + "ureq", +] + [[package]] name = "photocraft-ops" version = "0.6.0" @@ -4556,7 +4719,7 @@ dependencies = [ name = "photocraft-ui-egui" version = "0.6.0" dependencies = [ - "base64", + "base64 0.22.1", "eframe", "egui", "egui_extras", @@ -5086,6 +5249,12 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.12.0" @@ -5258,6 +5427,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rmcp" version = "3.5.0" @@ -5385,6 +5568,41 @@ dependencies = [ "rustix 1.1.5", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -5563,6 +5781,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -5747,6 +5976,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "spin" version = "0.9.9" @@ -5805,6 +6045,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgtypes" version = "0.15.3" @@ -6190,6 +6436,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" version = "1.2.1" @@ -6267,6 +6519,41 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64 0.23.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -6285,7 +6572,7 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb", @@ -6306,6 +6593,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6727,6 +7020,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -6922,6 +7224,22 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -6931,6 +7249,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" @@ -7618,6 +7942,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index afcfdd1522..a91dac3aeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ photocraft-vector = { path = "crates/vector" } photocraft-format = { path = "crates/format" } photocraft-automation = { path = "crates/automation" } photocraft-plugins = { path = "crates/plugins" } +photocraft-ml = { path = "crates/ml" } photocraft-engine = { path = "crates/engine" } photocraft-ui-egui = { path = "crates/ui-egui" } photocraft-testkit = { path = "crates/testkit" } diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index a6d3b49389..156ff683bd 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -10,6 +10,7 @@ build = "build.rs" # Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose # it; official builds (CI, packaging/, release) build with `--features heif`. heif = ["photocraft-codecs/heif"] +local-ml = ["photocraft-engine/local-ml"] [lints] workspace = true @@ -34,4 +35,3 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros"] } [build-dependencies] # Icon + VERSIONINFO for photocraft-cli.exe; build.rs only uses it when targeting Windows. winresource = "0.1" - diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 345edf9657..2d1f48299b 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -232,11 +232,28 @@ fn print_json(out: &mut dyn Write, v: &Value, compact: bool) -> R { writeln!(out, "{s}").map_err(|e| e.to_string()) } +/// Only explicit, trusted CLI invocations receive a model-cache capability. MCP sessions keep +/// their existing filesystem authority; merely starting the CLI never downloads a model. +fn local_headless() -> Headless { + let mut h = Headless::trusted_local(); + configure_cli_models(&mut h.session); + h +} + +fn configure_cli_models(session: &mut photocraft_engine::Session) { + #[cfg(all(feature = "local-ml", not(target_arch = "wasm32")))] + if let Some(dir) = std::env::var_os("PHOTOCRAFT_MODEL_DIR") { + session.configure_local_models(dir.into()); + } + #[cfg(any(not(feature = "local-ml"), target_arch = "wasm32"))] + let _ = session; +} + fn info(a: &Args, out: &mut dyn Write) -> R { let [file] = a.positional.as_slice() else { return Err("info needs ".into()); }; - let mut h = Headless::trusted_local(); + let mut h = local_headless(); let opened = h.open(Path::new(file)).map_err(|e| e.to_string())?; let mut doc = h.inspect(None).map_err(|e| e.to_string())?; let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default(); @@ -272,7 +289,7 @@ fn command_list(a: &Args) -> Result, 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(); + let mut h = local_headless(); match (a.positional.as_slice(), a.get("--new")) { ([file], None) => { let o = h.open(Path::new(file)).map_err(|e| e.to_string())?; @@ -368,6 +385,9 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { inputs.sort(); let (mut ok, mut failed) = (0, 0); let mut written = photocraft_engine::file_cmds::OutputClaims::default(); + // Keep only model weights resident across files, rather than retaining their documents or + // loading a new native session for each image in the same explicit batch invocation. + let model_backend = local_headless().session.model_backend; // `--format .jpg` names outputs `.jpg`, as `--format jpg` does (#490). let format = a.get("--format").map(|f| f.strip_prefix('.').unwrap_or(f)); for input in &inputs { @@ -378,6 +398,7 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { let r = (|| -> Result, String> { written.check(&target_text)?; let mut h = Headless::trusted_local(); + h.session.model_backend = model_backend.clone(); h.open(input).map_err(|e| e.to_string())?; for (id, p) in &actions { h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?; @@ -412,6 +433,7 @@ fn droplet(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { p["output"] = json!(o); } let mut s = photocraft_engine::Session::new(); + configure_cli_models(&mut s); let r = s.execute("file.automate.runDroplet", p).map_err(|e| e.to_string())?; for f in r["files"].as_array().into_iter().flatten() { let _ = writeln!(out, "ok {}", f.as_str().unwrap_or_default()); diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index c031c4495e..586be540b5 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -11,6 +11,7 @@ build = "build.rs" # Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose # it; official builds (CI, packaging/, release) build with `--features heif`. heif = ["photocraft-codecs/heif"] +local-ml = ["photocraft-engine/local-ml"] [lints] workspace = true diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 709c2b9bb9..20a438765c 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -348,7 +348,16 @@ fn main() -> eframe::Result { services::xwayland_command(var("FLATPAK_ID").as_deref(), var("APPIMAGE").as_deref(), std::env::var_os("DISPLAY").is_some()); } } - let mut app = PhotocraftApp::new(Session::new(), services); + let session = Session::new(); + #[cfg(feature = "local-ml")] + let session = { + let mut session = session; + if let Some(dir) = services::config_dir() { + session.configure_local_models(dir.join("Models")); + } + session + }; + let mut app = PhotocraftApp::new(session, services); app.integrated_titlebar = cfg!(target_os = "macos"); app.custom_titlebar = custom_titlebar; // Only the title bar's free gap drags the window, never the menus (mac_window.rs). diff --git a/contributors/people.toml b/contributors/people.toml index af8591c1a9..b2b97d2438 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -90,3 +90,6 @@ sync_github_name = false [people.vangel76] sync_github_name = true + +[people.mighty-programmer] +sync_github_name = true diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index fa15c30986..3a08c52a83 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [features] # Real-file corpus tests (opt-in; `cargo xtask test-corpus`). Fail when the corpus is missing. corpus = [] +local-ml = ["photocraft-ml/onnx"] [dependencies] photocraft-geom = { workspace = true } @@ -31,6 +32,7 @@ photocraft-io = { workspace = true } photocraft-psd = { workspace = true } photocraft-format = { workspace = true } photocraft-plugins = { workspace = true } +photocraft-ml = { workspace = true } blake3 = { version = "1.8", default-features = false, features = ["pure"] } photocraft-codecs = { workspace = true } flate2 = { workspace = true } diff --git a/crates/engine/examples/local_models_bench.rs b/crates/engine/examples/local_models_bench.rs new file mode 100644 index 0000000000..b6a2e4d55a --- /dev/null +++ b/crates/engine/examples/local_models_bench.rs @@ -0,0 +1,59 @@ +//! Opt-in end-to-end timing on a caller-provided, public-domain 24–36 MP image. No downloads. +//! cargo run --release -p photocraft-engine --features local-ml --example local_models_bench -- +//! /absolute/model-cache /absolute/image.png [/absolute/cutout-directory] +#[cfg(all(feature = "local-ml", not(target_arch = "wasm32")))] +fn main() -> Result<(), Box> { + use photocraft_engine::Session; + use serde_json::json; + let args: Vec = std::env::args().skip(1).collect(); + let cache = args.first().ok_or("provide a model-cache directory")?; + let input = args.get(1).ok_or("provide a public-domain test image")?; + let decoded = photocraft_io::import(input, &photocraft_format::read_file(std::path::Path::new(input))?)?; + if let Some(out) = args.get(2) { + std::fs::create_dir_all(out)?; + } + let mut s = Session::new(); + s.configure_local_models(cache.into()); + s.add_document(decoded.document, None); + let bounds = s.active().ok_or("no document")?.doc.bounds(); + for (command, model) in [ + ("layer.removeBackground", "classical"), + ("layer.removeBackground", "birefnet-hr-matting"), + ("select.object", "classical"), + ("select.object", "sam2.1-large"), + ] { + for run in 0..2 { + let steps = s.active().ok_or("no document")?.history.past_len(); + let params = + json!({"model":model,"rect":[bounds.width() as f32*0.1,bounds.height() as f32*0.05,bounds.width() as f32*0.85,bounds.height() as f32*0.95]}); + let start = std::time::Instant::now(); + let result = s.execute(command, params); + println!( + "{}", + json!({"command":command,"model":model,"run":run,"width":bounds.width(),"height":bounds.height(),"ms":start.elapsed().as_secs_f64()*1000.0,"ok":result.is_ok()}) + ); + result?; + if run == 0 + && let Some(out) = args.get(2) + { + // Turn a selection into a mask for the comparison export; undo separately below. + if command == "select.object" { + s.execute("layer.layerMask.revealSelection", json!({}))?; + } + let d = s.active().ok_or("no document")?; + let export = photocraft_io::export(&d.doc, "comparison.png", &Default::default())?; + let path = std::path::Path::new(out).join(format!("{command}-{model}.png")); + std::fs::write(path, export.bytes)?; + } + while s.active().ok_or("no document")?.history.past_len() > steps { + s.execute("edit.undo", json!({}))?; + } + } + } + Ok(()) +} + +#[cfg(any(not(feature = "local-ml"), target_arch = "wasm32"))] +fn main() { + eprintln!("Enable local-ml on a native target to run this opt-in benchmark."); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 35974fc235..cbe37f7338 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1130,6 +1130,7 @@ fn build() -> Vec { v.extend(crate::vector_cmds::specs()); v.extend(crate::path_edit_cmds::specs()); v.extend(crate::smartselect_cmds::specs()); + v.extend(crate::model_cmds::specs()); v.extend(crate::cutout_cmds::specs()); v.extend(crate::symmetry_cmds::specs()); v.extend(crate::edit_cmds::specs()); diff --git a/crates/engine/src/cutout_cmds.rs b/crates/engine/src/cutout_cmds.rs index 762830ee83..c0ed7a2b6f 100644 --- a/crates/engine/src/cutout_cmds.rs +++ b/crates/engine/src/cutout_cmds.rs @@ -41,17 +41,26 @@ fn run(s: &mut Session, p: &Value) -> Result { check(doc, doc.layer(id).ok_or(EngineError::NoLayer(id))?).map_err(EngineError::Other)?; let sample_all = p.get("sampleAllLayers").and_then(Value::as_bool).unwrap_or(false); let refine = p.get("refine").and_then(Value::as_bool).unwrap_or(true); + let model = crate::model_cmds::subject_model(s, p)?; + let backend = model.map(|_| crate::model_cmds::backend(s)).transpose()?; crate::jobs::edit_job( s, "Remove Background", move |doc, _, ctx| { ctx.progress(0.1, "Finding subject"); - let found = with_doc_sampler(doc, Some(id), sample_all, |smp, d| subject::select_subject(smp, d.bounds())); + let found = match (model, backend.as_ref()) { + (Some(model), Some(backend)) => with_doc_sampler(doc, Some(id), sample_all, |smp, d| { + crate::model_cmds::infer_region(backend.as_ref(), model, smp, d, photocraft_ml::Prompt::Subject, ctx) + })?, + _ => with_doc_sampler(doc, Some(id), sample_all, |smp, d| subject::select_subject(smp, d.bounds())), + }; let mut region = found.ok_or_else(|| EngineError::Other("no subject found".into()))?; if ctx.cancelled() { return Err(EngineError::Cancelled); } - if refine { + // BiRefNet already estimates a soft alpha matte. Classical edge refinement would + // threshold or change those opacities, so it applies only to the classical selector. + if refine && model.is_none() { ctx.progress(0.6, "Refining edge"); let refined = with_doc_sampler(doc, Some(id), sample_all, |smp, d| { matting::refine_mask(smp, &matting::region_reader(®ion), region.bbox, d.bounds(), &REFINE) @@ -67,7 +76,7 @@ fn run(s: &mut Session, p: &Value) -> Result { let b = region.bbox; Ok([b.x0, b.y0, b.width() as i32, b.height() as i32]) }, - move |bounds| json!({ "layer": id.0, "bounds": bounds }), + move |bounds| json!({ "layer": id.0, "bounds": bounds, "model": model.map_or("classical", |m| m.name()) }), ) } @@ -78,7 +87,7 @@ pub fn specs() -> Vec { label: "Remove Background", menu: &[], shortcut: None, - params: r##"{"layer":id?,"sampleAllLayers":bool=false,"refine":bool=true} → {layer,bounds} (adds a layer mask from Select Subject; the Background becomes a normal layer)"##, + params: r##"{"layer":id?,"sampleAllLayers":bool=false,"refine":bool=true,"model":"classical|birefnet-hr-matting"?} → {layer,bounds,model} (model defaults to Preferences; refine applies to classical only; replaces the layer mask; the Background becomes a normal layer)"##, enabled, run, journal: true, diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 103a1f6f3b..52625beca6 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -770,7 +770,7 @@ impl OutputClaims { } } -fn batch(_s: &mut Session, p: &Value) -> Result { +fn batch(s: &mut Session, p: &Value) -> Result { let cmd = "file.automate.batch"; let steps = parse_steps(p.get("steps").or_else(|| p.get("action")).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "missing \"steps\"".into() })?)?; @@ -781,6 +781,11 @@ fn batch(_s: &mut Session, p: &Value) -> Result { let output = str_param(p, "output", cmd)?.to_string(); let format = p.get("format").and_then(Value::as_str).unwrap_or("same").to_string(); let r = process_files(&inputs, &output, &format, SaveOpts::from_params(p), "", &|scratch| { + // Reuse a host-granted backend (and its resident model) across inputs. A default or + // untrusted session still has no model-cache capability; nested steps retain its gate. + scratch.model_backend = s.model_backend.clone(); + scratch.authorize = s.authorize; + scratch.edit_prefs(|p| p.integrations = s.prefs().integrations.clone()); for (id, params) in &steps { scratch.execute(id, params.clone())?; } diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 9b612e3c4c..3155231a1f 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -253,7 +253,7 @@ pub enum Started { /// Commands that never edit the active document, allowed while a job runs on it. fn independent_of_document(id: &str) -> bool { - const PREFIXES: &[&str] = &["jobs.", "tools.", "view.", "window.", "help.", "brush.presets.", "gradient.presets."]; + const PREFIXES: &[&str] = &["jobs.", "models.", "tools.", "view.", "window.", "help.", "brush.presets.", "gradient.presets."]; const IDS: &[&str] = &["file.new", "file.close", "file.closeAll", "edit.preferences", "edit.colorSettings", "edit.keyboardShortcuts", "edit.menus"]; PREFIXES.iter().any(|p| id.starts_with(p)) || IDS.contains(&id) } @@ -623,6 +623,7 @@ impl Session { if let Some(v) = crate::float_cmds::before_command(self, id)? { return Ok(Started::Done(v)); } + let params = crate::model_cmds::inject_model(self, id, params); // Pixel commands follow the Channels panel target unless the caller names one. let run_params = crate::channel_cmds::inject_target(self, id, crate::commands::inject_kind(id, params.clone())); if let Err(why) = crate::smart_cmds::target_enabled(self, spec, ¶ms).unwrap_or_else(|| self.precondition(spec, &run_params)) { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5245eed1c4..63c82d1797 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -64,6 +64,7 @@ pub mod magnetic_cmds; pub mod mask_view_cmds; mod migrate_cmds; pub mod mode_cmds; +pub mod model_cmds; pub mod multichannel_cmds; pub mod notes_cmds; pub mod paint_cmds; @@ -346,6 +347,9 @@ pub struct Session { color_restrict: Option, /// Edit › Preferences, keyboard shortcuts, menu and toolbar customisation (see `prefs`). pub prefs: prefs::PrefsStore, + /// Optional native model service, injected by a platform frontend. An ordinary/test/web + /// session has none: creating a Session never downloads models or accesses a model directory. + pub model_backend: Option>, /// Edit menu state: Fade source, custom shape library (see `edit_menu_cmds`). pub edit_state: edit_menu_cmds::EditState, /// The pattern library (Window › Patterns; see `pattern.*`). diff --git a/crates/engine/src/model_cmds.rs b/crates/engine/src/model_cmds.rs new file mode 100644 index 0000000000..7524449fc8 --- /dev/null +++ b/crates/engine/src/model_cmds.rs @@ -0,0 +1,502 @@ +//! Optional model lifecycle commands and the colour/depth-agnostic selection adapter. +use std::sync::Arc; + +use photocraft_algo::segment::{Sampler, antialias_u8, trim_region}; +use photocraft_algo::selection::Region; +use photocraft_cms::{Builtin, Transform, transform::TransformOptions}; +use photocraft_doc::Document; +use photocraft_geom::Rect; +use photocraft_ml::{InferenceBackend, MaskKind, ModelId, Prompt}; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::jobs::JobCtx; +use crate::{EngineError, Result, Session}; + +fn error(e: photocraft_ml::Error) -> EngineError { + match e { + photocraft_ml::Error::Cancelled => EngineError::Cancelled, + e => EngineError::Other(e.to_string()), + } +} + +pub(crate) fn backend(s: &Session) -> Result> { + s.model_backend + .clone() + .ok_or_else(|| EngineError::Other("local models are not available in this build or session; choose Classical in Preferences › Integrations".into())) +} + +/// An explicit command choice overrides the saved preference, allowing an action or script to +/// request the same method every time. Inference never silently falls back or starts a download. +pub(crate) fn subject_model(s: &Session, p: &Value) -> Result> { + model_choice(p, s.prefs().integrations.subject_model.name(), ModelId::BiRefNet) +} + +pub(crate) fn object_model(s: &Session, p: &Value) -> Result> { + model_choice(p, s.prefs().integrations.object_model.name(), ModelId::Sam2) +} + +/// Resolve the preference before dispatch so recorded actions keep the method used, even if +/// the preference changes before playback. Explicit choices (including invalid ones) stay intact. +pub(crate) fn inject_model(s: &Session, command: &str, mut params: Value) -> Value { + let model = match command { + "select.subject" | "layer.removeBackground" => s.prefs().integrations.subject_model.name(), + "select.object" => s.prefs().integrations.object_model.name(), + _ => return params, + }; + if params.is_null() { + params = json!({}); + } + if let Some(fields) = params.as_object_mut() { + fields.entry("model").or_insert_with(|| json!(model)); + } + params +} + +fn model_choice(p: &Value, default: &str, allowed: ModelId) -> Result> { + let name = match p.get("model") { + Some(v) => v.as_str().ok_or_else(|| EngineError::Other("model must be a string".into()))?, + None => default, + }; + if name == "classical" { + return Ok(None); + } + match ModelId::parse(name) { + Some(id) if id == allowed => Ok(Some(id)), + _ => Err(EngineError::Other(format!("model must be classical or {} for this command", allowed.name()))), + } +} + +struct ModelSampler<'a> { + source: &'a dyn Sampler, + cmyk: Option>, + transform: Arc, +} + +impl Sampler for ModelSampler<'_> { + fn rgba(&self, rect: Rect) -> Vec<[f32; 4]> { + // rgb_scaled reads strips on rayon workers. Enter the document's CMYK context on each + // worker, and convert its RGB/gray profile before downsampling or compositing transparency. + let mut px = photocraft_color::convert::with_cmyk_space(self.cmyk.as_ref(), || self.source.rgba(rect)); + for p in &mut px { + let mut rgb = [0.0; 3]; + self.transform.eval(&p[..3], &mut rgb); + for (dst, v) in p.iter_mut().take(3).zip(rgb) { + *dst = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + } + if !p[3].is_finite() { + p[3] = 0.0; + } + } + px + } +} + +pub(crate) fn infer_region( + backend: &dyn InferenceBackend, + id: ModelId, + source: &dyn Sampler, + doc: &Document, + prompt: Prompt, + ctx: &JobCtx, +) -> Result> { + let bounds = doc.bounds(); + let (w, h) = (bounds.width() as usize, bounds.height() as usize); + let n = w + .checked_mul(h) + .filter(|n| *n > 0 && *n <= 64_000_000) + .ok_or_else(|| EngineError::Other("local models currently support canvases up to 64 megapixels".into()))?; + ctx.check()?; + ctx.progress(0.02, "Preparing image"); + let transform = photocraft_cms::transform::cached(&crate::color_cmds::composite_profile(doc), Builtin::Srgb.profile(), TransformOptions::default()) + .map_err(|e| EngineError::Other(format!("model colour conversion: {e}")))?; + let sampler = ModelSampler { source, cmyk: photocraft_compose::cmyk_space(doc), transform }; + let step = w.max(h).div_ceil(id.info().input_side).max(1); + let image = sampler.rgb_scaled(bounds, step); + ctx.check()?; + ctx.progress(0.1, "Running local model (CPU)"); + let mask = ctx.stage(0.1, 0.85, "Running local model (CPU)", |ctl| backend.infer(id, &image, prompt, ctl)).map_err(error)?; + mask.validate().map_err(error)?; + let mut values = vec![0u8; n]; + for (y, row) in values.chunks_exact_mut(w).enumerate() { + ctx.check()?; + for (x, dst) in row.iter_mut().enumerate() { + *dst = mask.coverage((x as f32 + 0.5) / w as f32, (y as f32 + 0.5) / h as f32); + } + if y % 64 == 0 { + ctx.progress(0.85 + 0.14 * y as f32 / h as f32, "Building mask"); + } + } + if mask.kind == MaskKind::Logits { + antialias_u8(&mut values, w, h); + } + ctx.check()?; + Ok(trim_region(Region { bbox: bounds, mask: values })) +} + +impl Session { + /// Inject a native model cache. Host code supplies the location; no arbitrary model path or + /// URL is accepted by an engine command. Default builds and wasm have no native backend. + #[cfg(all(feature = "local-ml", not(target_arch = "wasm32")))] + pub fn configure_local_models(&mut self, directory: std::path::PathBuf) { + self.model_backend = Some(Arc::new(photocraft_ml::NativeBackend::new(directory))); + } + + pub fn local_model_status(&self) -> Value { + let states = self.model_backend.as_ref().map(|b| b.status()).unwrap_or_default(); + let models: Vec = photocraft_ml::MODELS + .iter() + .map(|m| { + let status = states.iter().find(|s| s.id == m.id); + json!({"id": m.id, "label": m.label, "purpose": m.purpose, "license": m.license, + "downloadBytes": m.download_bytes(), "upstream": m.upstream, "exportSource": m.export_source, + "revision": m.revision, "installed": status.is_some_and(|s| s.installed), "busy": status.is_some_and(|s| s.busy)}) + }) + .collect(); + json!({"available": self.model_backend.is_some(), "models": models}) + } +} + +fn model_id(p: &Value, cmd: &str) -> Result { + p.get("id") + .and_then(Value::as_str) + .and_then(ModelId::parse) + .ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "id must be birefnet-hr-matting or sam2.1-large".into() }) +} + +fn available(s: &Session) -> std::result::Result<(), String> { + if s.model_backend.is_some() { Ok(()) } else { Err("local models are not available in this build or session".into()) } +} + +fn download(s: &mut Session, p: &Value) -> Result { + let id = model_id(p, "models.download")?; + let backend = backend(s)?; + crate::jobs::run( + s, + &format!("Downloading {}", id.info().label), + false, + move |ctx| ctx.stage(0.0, 1.0, "Downloading model", |ctl| backend.download(id, ctl)).map_err(error), + move |s, ()| Ok(s.local_model_status()), + ) +} + +fn remove(s: &mut Session, p: &Value) -> Result { + let id = model_id(p, "models.remove")?; + let backend = backend(s)?; + crate::jobs::run( + s, + &format!("Removing {}", id.info().label), + false, + move |ctx| ctx.stage(0.0, 1.0, "Removing model", |ctl| backend.remove(id, ctl)).map_err(error), + move |s, ()| { + s.prefs.edit(|p| match id { + ModelId::BiRefNet => p.integrations.subject_model = crate::prefs::SubjectModel::Classical, + ModelId::Sam2 => p.integrations.object_model = crate::prefs::ObjectModel::Classical, + }); + Ok(s.local_model_status()) + }, + ) +} + +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: "models.list", + label: "Local Selection Models", + menu: &[], + shortcut: None, + params: "{} → {available,models:[{id,label,installed,busy,downloadBytes,license,revision,upstream,exportSource}]}", + enabled: |_| Ok(()), + run: |s, _| Ok(s.local_model_status()), + journal: false, + }, + CommandSpec { + id: "models.download", + label: "Download Local Selection Model", + menu: &[], + shortcut: None, + params: r#"{"id":"birefnet-hr-matting|sam2.1-large"} (explicit download; size/SHA-256 verified; no document edits)"#, + enabled: available, + run: download, + journal: false, + }, + CommandSpec { + id: "models.remove", + label: "Remove Local Selection Model", + menu: &[], + shortcut: None, + params: r#"{"id":"birefnet-hr-matting|sam2.1-large"} (remove only this model; restore its selection preference to classical)"#, + enabled: available, + run: remove, + journal: false, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_algo::segment::RgbImage; + use photocraft_ml::{AlphaMask, ModelStatus}; + use photocraft_raster::Interrupt; + use std::sync::Mutex; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + #[derive(Default)] + struct Fake { + installed: AtomicBool, + downloads: AtomicUsize, + block: AtomicBool, + entered: AtomicBool, + calls: Mutex>, + } + impl InferenceBackend for Fake { + fn status(&self) -> Vec { + photocraft_ml::MODELS.iter().map(|m| ModelStatus { id: m.id, installed: self.installed.load(Ordering::Relaxed), busy: false }).collect() + } + fn download(&self, _: ModelId, _: &Interrupt<'_>) -> photocraft_ml::Result<()> { + self.downloads.fetch_add(1, Ordering::Relaxed); + self.installed.store(true, Ordering::Relaxed); + Ok(()) + } + fn remove(&self, _: ModelId, _: &Interrupt<'_>) -> photocraft_ml::Result<()> { + self.installed.store(false, Ordering::Relaxed); + Ok(()) + } + fn infer(&self, id: ModelId, image: &RgbImage, prompt: Prompt, ctl: &Interrupt<'_>) -> photocraft_ml::Result { + self.entered.store(true, Ordering::Relaxed); + if !self.installed.load(Ordering::Relaxed) { + return Err(photocraft_ml::Error::Unavailable("download first".into())); + } + self.calls.lock().unwrap().push((id, RgbImage { w: image.w, h: image.h, px: image.px.clone() }, prompt)); + if self.block.load(Ordering::Relaxed) { + for _ in 0..400 { + if ctl.cancelled() { + return Err(photocraft_ml::Error::Cancelled); + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + return Err(photocraft_ml::Error::Inference("test backend timed out".into())); + } + Ok(AlphaMask { width: 2, height: 1, values: vec![0.0, 1.0], kind: MaskKind::Alpha }) + } + } + fn session(mode: &str, depth: u32) -> (Session, Arc) { + let mut s = Session::new(); + s.execute("file.new", json!({"width":8,"height":4,"depth":depth,"mode":mode})).unwrap(); + let fake = Arc::new(Fake::default()); + fake.installed.store(true, Ordering::Relaxed); + s.model_backend = Some(fake.clone()); + (s, fake) + } + fn cov(s: &Session, x: i32) -> f32 { + s.active().unwrap().doc.selection.as_ref().map_or(0.0, |m| m.sample_channel(x, 2, 0)) + } + fn pixels(s: &Session) -> Vec { + let d = s.active().unwrap(); + d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().read_region(d.doc.bounds()) + } + + #[test] + fn learned_background_mask_preserves_pixels_soft_alpha_and_one_undo_at_all_depths_and_modes() { + for mode in ["rgb", "gray", "cmyk", "lab"] { + for depth in [8, 16, 32] { + let (mut s, fake) = session(mode, depth); + s.execute("select.rect", json!({"x":0,"y":0,"width":2,"height":4})).unwrap(); + let old_selection = s.active().unwrap().doc.selection.clone(); + let before = pixels(&s); + let steps = s.active().unwrap().history.past_len(); + s.execute("layer.removeBackground", json!({"model":"birefnet-hr-matting"})).unwrap(); + let d = s.active().unwrap(); + let mask = d.doc.layer(d.active_layer.unwrap()).unwrap().mask.as_ref().unwrap(); + assert_eq!(mask.value(0, 2), 0.0); + assert_eq!(mask.value(7, 2), 1.0); + assert!((mask.value(3, 2) - 0.376).abs() < 0.01); + assert_eq!(pixels(&s), before, "{mode} {depth}"); + assert!(d.doc.selection.is_none()); + assert_eq!(d.history.past_len(), steps + 1); + let calls = fake.calls.lock().unwrap(); + assert_eq!((calls[0].1.w, calls[0].1.h), (8, 4)); + assert!(calls[0].1.px.iter().flatten().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); + drop(calls); + s.execute("edit.undo", json!({})).unwrap(); + let d = s.active().unwrap(); + assert!(d.doc.layer(d.active_layer.unwrap()).unwrap().mask.is_none()); + assert_eq!(d.doc.selection.as_ref().unwrap().read_region(d.doc.bounds()), old_selection.as_ref().unwrap().read_region(d.doc.bounds())); + s.execute("edit.redo", json!({})).unwrap(); + assert!(s.active().unwrap().doc.layers[0].mask.is_some()); + } + } + } + + #[test] + fn learned_selection_combines_modes_and_maps_boxes_on_non_square_images() { + for (mode, left, right) in [("replace", false, true), ("add", true, true), ("subtract", true, false), ("intersect", false, false)] { + let (mut s, fake) = session("rgb", 16); + s.execute("select.rect", json!({"x":0,"y":0,"width":2,"height":4})).unwrap(); + let steps = s.active().unwrap().history.past_len(); + s.execute("select.object", json!({"rect":[6,3,-4,-2],"model":"sam2.1-large","mode":mode})).unwrap(); + assert_eq!(cov(&s, 0) > 0.5, left, "{mode}"); + assert_eq!(cov(&s, 7) > 0.5, right, "{mode}"); + let calls = fake.calls.lock().unwrap(); + assert_eq!(calls[0].2, Prompt::Box([0.25, 0.25, 0.75, 0.75])); + drop(calls); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(cov(&s, 0), 1.0); + assert_eq!(cov(&s, 7), 0.0); + } + } + + #[test] + fn missing_models_invalid_choices_and_bad_boxes_never_edit_or_download() { + let (mut s, fake) = session("rgb", 8); + fake.installed.store(false, Ordering::Relaxed); + let revision = s.active().unwrap().revision; + for (cmd, p) in [ + ("select.subject", json!({"model":"birefnet-hr-matting"})), + ("layer.removeBackground", json!({"model":"birefnet-hr-matting"})), + ("select.object", json!({"model":"sam2.1-large","rect":[0,0,8,4]})), + ("select.subject", json!({"model":"sam2.1-large"})), + ("select.subject", json!({"model":42})), + ("models.download", json!({"id":"../unsafe"})), + ("models.remove", json!({"id":42})), + ] { + assert!(s.execute(cmd, p).is_err(), "{cmd}"); + } + for rect in [json!([0, 0, "bad", 8, 4]), json!([0, 0, 8]), json!([1e100, 0, 8, 4]), json!([0, 0, 0, 0]), json!([100, 100, 8, 4])] { + assert!(s.execute("select.object", json!({"model":"sam2.1-large","rect":rect})).is_err()); + } + assert_eq!(s.active().unwrap().revision, revision); + assert_eq!(fake.downloads.load(Ordering::Relaxed), 0); + s.model_backend = None; + assert_eq!(s.execute("models.list", json!({})).unwrap()["available"], false); + assert!(s.execute("select.subject", json!({"model":"birefnet-hr-matting"})).is_err()); + } + + #[test] + fn downloads_need_no_document_never_replay_and_removal_resets_only_its_preference() { + let mut s = Session::new(); + let fake = Arc::new(Fake::default()); + s.model_backend = Some(fake.clone()); + s.execute("models.download", json!({"id":"birefnet-hr-matting"})).unwrap(); + assert!(s.journal.is_empty()); + assert_eq!(fake.downloads.load(Ordering::Relaxed), 1); + s.execute("prefs.set", json!({"values":{"integrations.subjectModel":"birefnet-hr-matting","integrations.objectModel":"sam2.1-large"}})).unwrap(); + s.execute("models.remove", json!({"id":"birefnet-hr-matting"})).unwrap(); + assert_eq!(s.prefs().integrations.subject_model, crate::prefs::SubjectModel::Classical); + assert_eq!(s.prefs().integrations.object_model, crate::prefs::ObjectModel::Sam2); + assert!(s.execute("prefs.set", json!({"path":"integrations.objectModel","value":"birefnet-hr-matting"})).is_err()); + let mut old = s.prefs_value(); + old["integrations"].as_object_mut().unwrap().remove("objectModel"); + old["integrations"].as_object_mut().unwrap().remove("subjectModel"); + let loaded: crate::prefs::Preferences = serde_json::from_value(old).unwrap(); + assert_eq!(loaded.integrations.subject_model, crate::prefs::SubjectModel::Classical); + assert_eq!(loaded.integrations.object_model, crate::prefs::ObjectModel::Classical); + } + + #[test] + fn preferences_select_a_model_and_explicit_classical_overrides_it() { + let (mut s, fake) = session("rgb", 32); + s.execute("prefs.set", json!({"path":"integrations.subjectModel","value":"birefnet-hr-matting"})).unwrap(); + s.execute("select.subject", json!({})).unwrap(); + assert_eq!(fake.calls.lock().unwrap().len(), 1); + s.execute("select.subject", json!({"model":"classical"})).unwrap(); + assert_eq!(fake.calls.lock().unwrap().len(), 1); + } + + #[test] + fn recorded_actions_keep_the_selected_method_when_preferences_change() { + let (mut s, fake) = session("rgb", 8); + s.edit_prefs(|p| { + p.integrations.subject_model = crate::prefs::SubjectModel::BiRefNet; + p.integrations.object_model = crate::prefs::ObjectModel::Sam2; + }); + s.execute("actions.record", json!({"name":"Models"})).unwrap(); + s.execute("select.subject", Value::Null).unwrap(); + s.execute("select.object", json!({"rect":[0,0,8,4]})).unwrap(); + s.execute("layer.removeBackground", json!({})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let action = &s.actions.list[0]; + assert_eq!(action.steps.len(), 3); + assert_eq!(action.steps[0].1["model"], "birefnet-hr-matting"); + assert_eq!(action.steps[1].1["model"], "sam2.1-large"); + assert_eq!(action.steps[2].1["model"], "birefnet-hr-matting"); + s.edit_prefs(|p| p.integrations = Default::default()); + assert!(s.execute("actions.play", json!({"action":"Models"})).unwrap().get("failed").is_none()); + assert_eq!(fake.calls.lock().unwrap().len(), 6); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn batch_and_droplet_share_only_the_explicitly_configured_model_capability() { + let dir = std::env::temp_dir().join(format!("photocraft-model-batch-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir(&dir).unwrap(); + let input = dir.join("input.png"); + let output = dir.join("output"); + std::fs::create_dir(&output).unwrap(); + let (mut s, fake) = session("rgb", 8); + let exported = photocraft_io::export(&s.active().unwrap().doc, "input.png", &Default::default()).unwrap(); + std::fs::write(&input, exported.bytes).unwrap(); + s.edit_prefs(|p| p.integrations.subject_model = crate::prefs::SubjectModel::BiRefNet); + let droplet = dir.join("models.pcdroplet"); + std::fs::write( + &droplet, + serde_json::to_vec(&json!({"photocraftDroplet":1,"action":{"steps":[["layer.removeBackground",{}]]},"options":{"format":"png"}})).unwrap(), + ) + .unwrap(); + let result = s.execute("file.automate.runDroplet", json!({"droplet":droplet,"input":[input],"output":output})).unwrap(); + assert_eq!(result["errors"], json!([])); + assert_eq!(fake.calls.lock().unwrap().len(), 1); + s.model_backend = None; + let result = + s.execute("file.automate.batch", json!({"steps":[["select.subject",{"model":"birefnet-hr-matting"}]],"input":[input],"output":output})).unwrap(); + assert_eq!(result["errors"].as_array().unwrap().len(), 1); + assert_eq!(fake.calls.lock().unwrap().len(), 1); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn model_input_uses_document_profile_and_existing_mask_replacement_undo_is_preserved() { + let (mut s, fake) = session("rgb", 32); + s.edit("linear pixels and old mask", |doc, _| { + doc.icc_profile = Some(Builtin::LinearSrgb.profile().to_bytes()); + let bounds = doc.bounds(); + doc.layers[0].surface_mut().unwrap().fill_rect(bounds, &[0.25, 0.25, 0.25, 1.0]); + doc.layers[0].mask = Some(photocraft_doc::LayerMask::hide_all()); + Ok(()) + }) + .unwrap(); + s.execute("select.subject", json!({"model":"birefnet-hr-matting","sampleAllLayers":false})).unwrap(); + let calls = fake.calls.lock().unwrap(); + assert!((calls[0].1.px[0][0] - 0.5371).abs() < 0.005, "linear RGB must be transformed to sRGB"); + drop(calls); + s.execute("layer.removeBackground", json!({"model":"birefnet-hr-matting"})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers[0].mask.as_ref().unwrap().value(7, 2), 1.0); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers[0].mask.as_ref().unwrap().value(7, 2), 0.0); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn cancelling_model_inference_discards_result_and_unlocks_document() { + let (mut s, fake) = session("rgb", 8); + fake.block.store(true, Ordering::Relaxed); + let before = s.active().unwrap().revision; + let crate::jobs::Started::Job(job) = s.start("layer.removeBackground", json!({"model":"birefnet-hr-matting"})).unwrap() else { panic!("expected job") }; + for _ in 0..200 { + if fake.entered.load(Ordering::Relaxed) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(fake.entered.load(Ordering::Relaxed)); + assert!(s.cancel_job(job)); + assert!(matches!(s.wait_job(job), Err(EngineError::Cancelled))); + assert_eq!(s.active().unwrap().revision, before); + assert!(s.active().unwrap().doc.layers[0].mask.is_none()); + s.execute("select.all", json!({})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_some()); + } +} diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 8ecdafa885..faa4fdadaa 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -108,6 +108,8 @@ choice!(FontPreview { Off = "off", Small = "small", Medium = "medium", Large = " choice!(RawColorSpace { Srgb = "srgb", AdobeRgb = "adobeRgb", ProPhoto = "proPhoto", DisplayP3 = "displayP3" } default AdobeRgb); choice!(RawDepth { Eight = "8", Sixteen = "16" } default Sixteen); choice!(RawSharpen { None = "none", Screen = "screen", Glossy = "glossy", Matte = "matte" } default None); +choice!(SubjectModel { Classical = "classical", BiRefNet = "birefnet-hr-matting" } default Classical); +choice!(ObjectModel { Classical = "classical", Sam2 = "sam2.1-large" } default Classical); impl Unit { /// Document pixels → this unit. `dpi` is the document resolution, `extent` the 100% length @@ -867,11 +869,14 @@ pub struct Integrations { pub allow_agent_control: bool, /// Default control port (0 = only when `--control` is given). pub control_port: u32, + /// Optional, downloaded local model; classical selection remains the default. + pub subject_model: SubjectModel, + pub object_model: ObjectModel, } impl Default for Integrations { fn default() -> Self { - Self { allow_agent_control: true, control_port: 0 } + Self { allow_agent_control: true, control_port: 0, subject_model: SubjectModel::Classical, object_model: ObjectModel::Classical } } } @@ -1065,6 +1070,8 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> { "performance.gpuBackend" => GpuBackend::NAMES, "performance.linuxDisplayServer" => LinuxDisplayServer::NAMES, "performance.renderingMode" => RenderingMode::NAMES, + "integrations.subjectModel" => SubjectModel::NAMES, + "integrations.objectModel" => ObjectModel::NAMES, _ => return None, }) } diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 5871ad6098..50ab59cb26 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -73,7 +73,7 @@ pub(crate) fn with_doc_sampler(doc: &Document, layer: Option, all_la /// caller gets `changed: false`, whatever the mode. (Select Subject and Object Selection run /// heuristics that often find nothing on busy photos; clearing the selection then looks like /// a dead button.) -fn apply(s: &mut Session, label: &str, region: Option, m: SelectionMode) -> Result { +pub(crate) fn apply(s: &mut Session, label: &str, region: Option, m: SelectionMode) -> Result { if region.is_none() { // Nothing found: leave the selection (and history) alone. let selected = s.active().ok_or(EngineError::NoDocument)?.doc.selection.is_some(); @@ -120,27 +120,73 @@ fn quick_selection(s: &mut Session, p: &Value) -> Result { fn rect_param(p: &Value) -> Option { let a = p.get("rect")?.as_array()?; - let v: Vec = a.iter().filter_map(Value::as_f64).collect(); - if v.len() < 4 { + let v: Vec = a.iter().map(Value::as_f64).collect::>()?; + if v.len() != 4 || v.iter().any(|v| !v.is_finite()) { return None; } // Negative sizes (dragging up/left) are normalised. let (x0, x1) = (v[0].min(v[0] + v[2]), v[0].max(v[0] + v[2])); let (y0, y1) = (v[1].min(v[1] + v[3]), v[1].max(v[1] + v[3])); + if [x0, y0, x1, y1].iter().any(|v| !v.is_finite() || *v < i32::MIN as f64 || *v > i32::MAX as f64) { + return None; + } Some(Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32)) } fn object_selection(s: &mut Session, p: &Value) -> Result { let rect = rect_param(p).ok_or_else(|| bad("select.object", "needs \"rect\": [x, y, w, h]"))?; + if let Some(id) = crate::model_cmds::object_model(s, p)? { + let d = s.active().ok_or(EngineError::NoDocument)?; + let canvas = d.doc.bounds(); + let rect = rect.intersect(&canvas); + if rect.is_empty() { + return Err(bad("select.object", "box must overlap the canvas")); + } + let prompt = photocraft_ml::Prompt::Box([ + (rect.x0 - canvas.x0) as f32 / canvas.width() as f32, + (rect.y0 - canvas.y0) as f32 / canvas.height() as f32, + (rect.x1 - canvas.x0) as f32 / canvas.width() as f32, + (rect.y1 - canvas.y0) as f32 / canvas.height() as f32, + ]); + return model_selection(s, p, id, prompt, "Object Selection", false); + } let region = with_sampler(s, b(p, "sampleAllLayers", false), |smp, doc| grabcut::object_select(smp, doc.bounds(), rect, 160_000))?; apply(s, "Object Selection", region, mode(p, "replace")) } fn select_subject(s: &mut Session, p: &Value) -> Result { + if let Some(id) = crate::model_cmds::subject_model(s, p)? { + return model_selection(s, p, id, photocraft_ml::Prompt::Subject, "Select Subject", true); + } let region = with_sampler(s, b(p, "sampleAllLayers", true), |smp, doc| subject::select_subject(smp, doc.bounds()))?; apply(s, "Select Subject", region, mode(p, "replace")) } +fn model_selection( + s: &mut Session, + p: &Value, + id: photocraft_ml::ModelId, + prompt: photocraft_ml::Prompt, + label: &'static str, + all_default: bool, +) -> Result { + let backend = crate::model_cmds::backend(s)?; + let d = s.active().ok_or(EngineError::NoDocument)?; + let (doc, layer) = (d.doc.clone(), d.active_layer); + let (all, m) = (b(p, "sampleAllLayers", all_default), mode(p, "replace")); + crate::jobs::run( + s, + label, + true, + move |ctx| with_doc_sampler(&doc, layer, all, |smp, d| crate::model_cmds::infer_region(backend.as_ref(), id, smp, d, prompt, ctx)), + move |s, region| { + let mut result = apply(s, label, region, m)?; + result["model"] = json!(id); + Ok(result) + }, + ) +} + fn focus_area(s: &mut Session, p: &Value) -> Result { let (range, noise) = (f(p, "range", 0.5).clamp(0.0, 1.0), f(p, "noise", 0.0).clamp(0.0, 1.0)); let region = with_sampler(s, b(p, "sampleAllLayers", true), |smp, doc| focus::focus_area(smp, doc.bounds(), range, noise))?; @@ -256,7 +302,7 @@ pub fn specs() -> Vec { "select.object", "Object Selection", [], - r##"{"rect":[x,y,w,h],"mode":"replace|add|subtract|intersect"="replace","sampleAllLayers":bool=false}"##, + r##"{"rect":[x,y,w,h],"mode":"replace|add|subtract|intersect"="replace","sampleAllLayers":bool=false,"model":"classical|sam2.1-large"? (defaults to Preferences)}"##, has_doc, object_selection ), @@ -264,7 +310,7 @@ pub fn specs() -> Vec { "select.subject", "Subject", ["Select"], - r##"{"sampleAllLayers":bool=true,"mode":"replace|add|subtract|intersect"="replace"}"##, + r##"{"sampleAllLayers":bool=true,"mode":"replace|add|subtract|intersect"="replace","model":"classical|birefnet-hr-matting"? (defaults to Preferences)}"##, has_doc, select_subject ), diff --git a/crates/ml/Cargo.toml b/crates/ml/Cargo.toml new file mode 100644 index 0000000000..cd64710423 --- /dev/null +++ b/crates/ml/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "photocraft-ml" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[features] +default = [] +# Native C++ runtime at the I/O edge; never enabled by default or linked into wasm. +onnx = ["dep:ort", "dep:ureq", "dep:sha2", "dep:tempfile"] + +[dependencies] +photocraft-algo = { workspace = true } +photocraft-raster = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +# Pin prereleases: the API and matching native runtime must be reviewed together. +ort = { version = "=2.0.0-rc.13", default-features = false, features = ["std", "api-27", "download-binaries", "tls-rustls"], optional = true } +# Pinned because the idle-timeout transport adapter uses ureq's unversioned seam. +ureq = { version = "=3.4.2", default-features = false, features = ["rustls"], optional = true } +sha2 = { version = "0.10", optional = true } +tempfile = { version = "3", optional = true } diff --git a/crates/ml/README.md b/crates/ml/README.md new file mode 100644 index 0000000000..3dcc9abf0a --- /dev/null +++ b/crates/ml/README.md @@ -0,0 +1,31 @@ +# photocraft-ml + +L4 boundary for optional local image segmentation. The default crate contains a pinned model +catalogue, image/mask validation and an `InferenceBackend` trait. The `onnx` Cargo feature adds +the native CPU backend using the safe `ort` API. It never links ONNX Runtime into wasm. + +Hosts inject a cache directory. Constructing a backend, querying its status, opening an image +or selecting a method never downloads anything. Only `download` contacts the pinned export +URLs. Downloads stream into a temporary directory, verify exact sizes and SHA-256 hashes, and +install by rename after every artifact succeeds. Failure/cancellation discards staging files. +Inference re-verifies artifacts before parsing; arbitrary model paths and URLs are not commands. + +One model's weights stay resident at a time. CPU arena and memory-pattern caching are disabled +to release large temporary tensors between runs instead of retaining peak allocations. +Per-model locks protect download/removal/inference, and a +shared session lock prevents two large inferences from running together. CPU runs use up to eight +threads; `RunOptions::terminate` handles cancellation. Slow session construction finishes before +the next cancellation check. Stalled HTTP reads time out after 30 seconds, separately from the +one-hour total body budget. `ureq` is pinned because its idle-timeout adapter uses the unversioned +transport seam without changing the default TLS/proxy connector. + +`RgbImage` inputs must already be sRGB; the engine converts the document profile through CMS. +Inputs are mapped to the training square with bilinear interpolation and ImageNet-normalized +NCHW. BiRefNet's reviewed export returns sigmoid alpha directly; SAM returns logits, with the +best predicted-IoU mask chosen and thresholding after interpolation. Box prompts share the +full-image normalized coordinates. `AlphaMask::coverage` supplies the existing selection/mask +coverage representation while source pixels retain their original depth and colour mode. + +See [local models](../../docs/local-models.md) for UI/CLI usage, limitations, exact provenance, +licences and reproducible checks. Tiny original synthetic ONNX graphs in `src/test_models.rs` +test the runtime contracts offline; they contain no learned weights and make no accuracy claim. diff --git a/crates/ml/licenses/BiRefNet-MIT.txt b/crates/ml/licenses/BiRefNet-MIT.txt new file mode 100644 index 0000000000..485921e827 --- /dev/null +++ b/crates/ml/licenses/BiRefNet-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 ZhengPeng + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/ml/licenses/ONNXRuntime-MIT.txt b/crates/ml/licenses/ONNXRuntime-MIT.txt new file mode 100644 index 0000000000..48bc6bb499 --- /dev/null +++ b/crates/ml/licenses/ONNXRuntime-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/ml/licenses/ONNXRuntime-ThirdPartyNotices.txt b/crates/ml/licenses/ONNXRuntime-ThirdPartyNotices.txt new file mode 100644 index 0000000000..fbd9f9a95f --- /dev/null +++ b/crates/ml/licenses/ONNXRuntime-ThirdPartyNotices.txt @@ -0,0 +1,6121 @@ +THIRD PARTY SOFTWARE NOTICES AND INFORMATION + +Do Not Translate or Localize + +This software incorporates material from third parties. Microsoft makes certain +open source code available at http://3rdpartysource.microsoft.com, or you may +send a check or money order for US $5.00, including the product name, the open +source component name, and version number, to: + +Source Code Compliance Team +Microsoft Corporation +One Microsoft Way +Redmond, WA 98052 +USA + +Notwithstanding any other terms, you may reverse engineer this software to the +extent required to debug changes to any libraries licensed under the GNU Lesser +General Public License. + +_____ + +Intel Math Kernel Library (Intel MKL) + +Intel Simplified Software License (Version April 2018) + +Copyright (c) 2018 Intel Corporation. + +Use and Redistribution. You may use and redistribute the software (the “Software”), without modification, +provided the following conditions are met: + +* Redistributions must reproduce the above copyright notice and the following terms of use in the Software +and in the documentation and/or other materials provided with the distribution. + +* Neither the name of Intel nor the names of its suppliers may be used to endorse or promote products +derived from this Software without specific prior written permission. + +* No reverse engineering, decompilation, or disassembly of this Software is permitted. + +Limited patent license. Intel grants you a world-wide, royalty-free, non-exclusive license under patents it now +or hereafter owns or controls to make, have made, use, import, offer to sell and sell (“Utilize”) this Software, +but solely to the extent that any such patent is necessary to Utilize the Software alone. The patent license +shall not apply to any combinations which include this software. No hardware per se is licensed hereunder. + +Third party and other Intel programs. “Third Party Programs” are the files listed in the “third-party-programs.txt” +text file that is included with the Software and may include Intel programs under separate license terms. +Third Party Programs, even if included with the distribution of the Materials, are governed by +separate license terms and those license terms solely govern your use of those programs. + +DISCLAIMER. THIS SOFTWARE IS PROVIDED "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT ARE DISCLAIMED. THIS SOFTWARE IS +NOT INTENDED FOR USE IN SYSTEMS OR APPLICATIONS WHERE FAILURE OF THE SOFTWARE +MAY CAUSE PERSONAL INJURY OR DEATH AND YOU AGREE THAT YOU ARE FULLY RESPONSIBLE FOR ANY +CLAIMS, COSTS, DAMAGES, EXPENSES, AND ATTORNEYS’ FEES ARISING OUT OF ANY SUCH USE, +EVEN IF ANY CLAIM ALLEGES THAT INTEL WAS NEGLIGENT REGARDING THE DESIGN OR MANUFACTURE OF +THE MATERIALS. + +LIMITATION OF LIABILITY. IN NO EVENT WILL INTEL BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY +OF SUCH DAMAGE. YOU AGREE TO INDEMNIFY AND HOLD INTEL HARMLESS AGAINST ANY CLAIMS +AND EXPENSES RESULTING FROM YOUR USE OR UNAUTHORIZED USE OF THE SOFTWARE. + +No support. Intel may make changes to the Software, at any time without notice, and is not obligated to +support, update or provide training for the Software. + +Termination. Intel may terminate your right to use the Software in the event of your breach of this Agreement +and you fail to cure the breach within a reasonable period of time. + +Feedback. Should you provide Intel with comments, modifications, corrections, enhancements or other input +(“Feedback”) related to the Software Intel will be free to use, disclose, reproduce, license or otherwise +distribute or exploit the Feedback in its sole discretion without any obligations or restrictions of any kind, +including without limitation, intellectual property rights or licensing obligations. + +Compliance with laws. You agree to comply with all relevant laws and regulations governing your use, +transfer, import or export (or prohibition thereof) of the Software. + +Governing law. All disputes will be governed by the laws of the United States of America and the State of +Delaware without reference to conflict of law principles and subject to the exclusive jurisdiction of the state or +federal courts sitting in the State of Delaware, and each party agrees that it submits to the personal +jurisdiction and venue of those courts and waives any objections. The United Nations Convention on +Contracts for the International Sale of Goods (1980) is specifically excluded and will not apply to the +Software. + +*Other names and brands may be claimed as the property of others. + +_____ + +protocolbuffers/protobuf + +Copyright 2008 Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Code generated by the Protocol Buffer compiler is owned by the owner +of the input file used when generating it. This code is not +standalone and requires a support library to be linked with it. This +support library is itself covered by the above license. + +_____ + +madler/zlib + +The deflate format used by zlib was defined by Phil Katz. The deflate and +zlib specifications were written by L. Peter Deutsch. Thanks to all the +people who reported problems and suggested various improvements in zlib; they +are too numerous to cite here. + +Copyright notice: + + (C) 1995-2017 Jean-loup Gailly and Mark Adler + + This software is provided 'as-is', without any express or implied + warranty. In no event will the authors be held liable for any damages + arising from the use of this software. + + Permission is granted to anyone to use this software for any purpose, + including commercial applications, and to alter it and redistribute it + freely, subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + + 3. This notice may not be removed or altered from any source distribution. + + Jean-loup Gailly Mark Adler + jloup@gzip.org madler@alumni.caltech.edu + +If you use the zlib library in a product, we would appreciate *not* receiving +lengthy legal documents to sign. The sources are provided for free but without +warranty of any kind. The library has been entirely written by Jean-loup +Gailly and Mark Adler; it does not include third-party code. + +If you redistribute modified sources, we would appreciate that you include in +the file ChangeLog history information documenting your changes. Please read +the FAQ for more information on the distribution of modified source versions. + +_____ + +pybind/pybind11 + +Copyright (c) 2016 Wenzel Jakob , All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Please also refer to the file CONTRIBUTING.md, which clarifies licensing of +external contributions to this project including patches, pull requests, etc. + +_____ + +onnx +Open Neural Network Exchange + +Copyright (c) Facebook, Inc. and Microsoft Corporation. All rights reserved. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +_____ + +Eigen + +MPL v2.0 +Mozilla Public License Version 2.0 + + +================================== + +1. Definitions + +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions + +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities + +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation + +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination + +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. + +_____ + +intel/dnnl + +Copyright 2016-2018 Intel Corporation + +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +sub-components: + +xbyak + +Copyright (c) 2007 MITSUNARI Shigeo. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. +Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. +Neither the name of the copyright owner nor the names of its contributors may +be used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF +THE POSSIBILITY OF SUCH DAMAGE. + +_____ + +Microsoft GSL + +Copyright (c) 2015 Microsoft Corporation. All rights reserved. + +This code is licensed under the MIT License (MIT). + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +_____ + +Tensorflow + +Copyright 2018 The TensorFlow Authors. All rights reserved. + +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, +and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by +the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all +other entities that control, are controlled by, or are under common +control with that entity. For the purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the +direction or management of such entity, whether by contract or +otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity +exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation +source, and configuration files. + +"Object" form shall mean any form resulting from mechanical +transformation or translation of a Source form, including but +not limited to compiled object code, generated documentation, +and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or +Object form, made available under the License, as indicated by a +copyright notice that is included in or attached to the work +(an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object +form, that is based on (or derived from) the Work and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes +of this License, Derivative Works shall not include works that remain +separable from, or merely link (or bind by name) to the interfaces of, +the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including +the original version of the Work and any modifications or additions +to that Work or Derivative Works thereof, that is intentionally +submitted to Licensor for inclusion in the Work by the copyright owner +or by an individual or Legal Entity authorized to submit on behalf of +the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, +and issue tracking systems that are managed by, or on behalf of, the +Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise +designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity +on behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the +Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated in this section) patent license to make, have made, +use, offer to sell, sell, import, and otherwise transfer the Work, +where such license applies only to those patent claims licensable +by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) +with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work +or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate +as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the +Work or Derivative Works thereof in any medium, with or without +modifications, and in Source or Object form, provided that You +meet the following conditions: + +(a) You must give any other recipients of the Work or +Derivative Works a copy of this License; and + +(b) You must cause any modified files to carry prominent notices +stating that You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works +that You distribute, all copyright, patent, trademark, and +attribution notices from the Source form of the Work, +excluding those notices that do not pertain to any part of +the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its +distribution, then any Derivative Works that You distribute must +include a readable copy of the attribution notices contained +within such NOTICE file, excluding those notices that do not +pertain to any part of the Derivative Works, in at least one +of the following places: within a NOTICE text file distributed +as part of the Derivative Works; within the Source form or +documentation, if provided along with the Derivative Works; or, +within a display generated by the Derivative Works, if and +wherever such third-party notices normally appear. The contents +of the NOTICE file are for informational purposes only and +do not modify the License. You may add Your own attribution +notices within Derivative Works that You distribute, alongside +or as an addendum to the NOTICE text from the Work, provided +that such additional attribution notices cannot be construed +as modifying the License. + +You may add Your own copyright statement to Your modifications and +may provide additional or different license terms and conditions +for use, reproduction, or distribution of Your modifications, or +for any such Derivative Works as a whole, provided Your use, +reproduction, and distribution of the Work otherwise complies with +the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, +any Contribution intentionally submitted for inclusion in the Work +by You to the Licensor shall be under the terms and conditions of +this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify +the terms of any separate license agreement you may have executed +with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade +names, trademarks, service marks, or product names of the Licensor, +except as required for reasonable and customary use in describing the +origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or +agreed to in writing, Licensor provides the Work (and each +Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied, including, without limitation, any warranties or conditions +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any +risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, +unless required by applicable law (such as deliberate and grossly +negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, +incidental, or consequential damages of any character arising as a +result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing +the Work or Derivative Works thereof, You may choose to offer, +and charge a fee for, acceptance of support, warranty, indemnity, +or other liability obligations and/or rights consistent with this +License. However, in accepting such obligations, You may act only +on Your own behalf and on Your sole responsibility, not on behalf +of any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason +of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017, The TensorFlow Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +_____ + +Microsoft Cognitive Toolkit (CNTK) + +Copyright (c) Microsoft Corporation. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, +merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +_____ + +NumPy License + +Copyright (c) 2005, NumPy Developers + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + Neither the name of the NumPy Developers nor the names of any contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +_____ + +Pytorch / Caffe2 + +From PyTorch: + +Copyright (c) 2016- Facebook, Inc (Adam Paszke) +Copyright (c) 2014- Facebook, Inc (Soumith Chintala) +Copyright (c) 2011-2014 Idiap Research Institute (Ronan Collobert) +Copyright (c) 2012-2014 Deepmind Technologies (Koray Kavukcuoglu) +Copyright (c) 2011-2012 NEC Laboratories America (Koray Kavukcuoglu) +Copyright (c) 2011-2013 NYU (Clement Farabet) +Copyright (c) 2006-2010 NEC Laboratories America (Ronan Collobert, Leon Bottou, Iain Melvin, Jason Weston) +Copyright (c) 2006 Idiap Research Institute (Samy Bengio) +Copyright (c) 2001-2004 Idiap Research Institute (Ronan Collobert, Samy Bengio, Johnny Mariethoz) + +From Caffe2: + +Copyright (c) 2016-present, Facebook Inc. All rights reserved. + +All contributions by Facebook: +Copyright (c) 2016 Facebook Inc. + +All contributions by Google: +Copyright (c) 2015 Google Inc. +All rights reserved. + +All contributions by Yangqing Jia: +Copyright (c) 2015 Yangqing Jia +All rights reserved. + +All contributions from Caffe: +Copyright(c) 2013, 2014, 2015, the respective contributors +All rights reserved. + +All other contributions: +Copyright(c) 2015, 2016 the respective contributors +All rights reserved. + +Caffe2 uses a copyright model similar to Caffe: each contributor holds +copyright over their contributions to Caffe2. The project versioning records +all such contribution and copyright details. If a contributor wants to further +mark their specific copyright on a particular contribution, they should +indicate their copyright solely in the commit message of the change when it is +committed. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the names of Facebook, Deepmind Technologies, NYU, NEC Laboratories America + and IDIAP Research Institute nor the names of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +_____ + +Caffe + +COPYRIGHT + +All contributions by the University of California: +Copyright (c) 2014-2017 The Regents of the University of California (Regents) +All rights reserved. + +All other contributions: +Copyright (c) 2014-2017, the respective contributors +All rights reserved. + +Caffe uses a shared copyright model: each contributor holds copyright over +their contributions to Caffe. The project versioning records all such +contribution and copyright details. If a contributor wants to further mark +their specific copyright on a particular contribution, they should indicate +their copyright solely in the commit message of the change when it is +committed. + +LICENSE + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +CONTRIBUTION AGREEMENT + +By contributing to the BVLC/caffe repository through pull-request, comment, +or otherwise, the contributor releases their content to the +license and copyright terms herein. + +_____ + +The LLVM Compiler Infrastructure + +============================================================================== +LLVM Release License +============================================================================== +University of Illinois/NCSA +Open Source License + +Copyright (c) 2003-2017 University of Illinois at Urbana-Champaign. +All rights reserved. + +Developed by: + + LLVM Team + + University of Illinois at Urbana-Champaign + + http://llvm.org + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal with +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimers. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimers in the + documentation and/or other materials provided with the distribution. + + * Neither the names of the LLVM Team, University of Illinois at + Urbana-Champaign, nor the names of its contributors may be used to + endorse or promote products derived from this Software without specific + prior written permission. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE +SOFTWARE. + +============================================================================== +Copyrights and Licenses for Third Party Software Distributed with LLVM: +============================================================================== +The LLVM software contains code written by third parties. Such software will +have its own individual LICENSE.TXT file in the directory in which it appears. +This file will describe the copyrights, license, and restrictions which apply +to that code. + +The disclaimer of warranty in the University of Illinois Open Source License +applies to all code in the LLVM Distribution, and nothing in any of the +other licenses gives permission to use the names of the LLVM Team or the +University of Illinois to endorse or promote products derived from this +Software. + +The following pieces of software have additional or alternate copyrights, +licenses, and/or restrictions: + +Program Directory +------- --------- +Google Test llvm/utils/unittest/googletest +OpenBSD regex llvm/lib/Support/{reg*, COPYRIGHT.regex} +pyyaml tests llvm/test/YAMLParser/{*.data, LICENSE.TXT} +ARM contributions llvm/lib/Target/ARM/LICENSE.TXT +md5 contributions llvm/lib/Support/MD5.cpp llvm/include/llvm/Support/MD5.h + +_____ + +google/benchmark + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +CONTRIBUTORS + +# People who have agreed to one of the CLAs and can contribute patches. +# The AUTHORS file lists the copyright holders; this file +# lists people. For example, Google employees are listed here +# but not in AUTHORS, because Google holds the copyright. +# +# Names should be added to this file only after verifying that +# the individual or the individual's organization has agreed to +# the appropriate Contributor License Agreement, found here: +# +# https://developers.google.com/open-source/cla/individual +# https://developers.google.com/open-source/cla/corporate +# +# The agreement for individuals can be filled out on the web. +# +# When adding J Random Contributor's name to this file, +# either J's name or J's organization's name should be +# added to the AUTHORS file, depending on whether the +# individual or corporate CLA was used. +# +# Names should be added to this file as: +# Name +# +# Please keep the list sorted. + +Albert Pretorius +Arne Beer +Billy Robert O'Neal III +Chris Kennelly +Christopher Seymour +David Coeurjolly +Deniz Evrenci +Dominic Hamon +Dominik Czarnota +Eric Fiselier +Eugene Zhuk +Evgeny Safronov +Federico Ficarelli +Felix Homann +Ismael Jimenez Martinez +Jern-Kuan Leong +JianXiong Zhou +Joao Paulo Magalhaes +John Millikin +Jussi Knuuttila +Kai Wolf +Kishan Kumar +Kaito Udagawa +Lei Xu +Matt Clarkson +Maxim Vafin +Nick Hutchinson +Oleksandr Sochka +Pascal Leroy +Paul Redmond +Pierre Phaneuf +Radoslav Yovchev +Raul Marin +Ray Glover +Robert Guo +Roman Lebedev +Shuo Chen +Tobias Ulvgård +Tom Madams +Yixuan Qiu +Yusuke Suzuki +Zbigniew Skowron + +AUTHORS + +# This is the official list of benchmark authors for copyright purposes. +# This file is distinct from the CONTRIBUTORS files. +# See the latter for an explanation. +# +# Names should be added to this file as: +# Name or Organization +# The email address is not required for organizations. +# +# Please keep the list sorted. + +Albert Pretorius +Arne Beer +Carto +Christopher Seymour +David Coeurjolly +Deniz Evrenci +Dirac Research +Dominik Czarnota +Eric Fiselier +Eugene Zhuk +Evgeny Safronov +Federico Ficarelli +Felix Homann +Google Inc. +International Business Machines Corporation +Ismael Jimenez Martinez +Jern-Kuan Leong +JianXiong Zhou +Joao Paulo Magalhaes +Jussi Knuuttila +Kaito Udagawa +Kishan Kumar +Lei Xu +Matt Clarkson +Maxim Vafin +MongoDB Inc. +Nick Hutchinson +Oleksandr Sochka +Paul Redmond +Radoslav Yovchev +Roman Lebedev +Shuo Chen +Steinar H. Gunderson +Stripe, Inc. +Yixuan Qiu +Yusuke Suzuki +Zbigniew Skowron + +_____ + +HalideIR + +Copyright (c) 2016 HalideIR contributors +Copyright (c) 2012-2014 MIT CSAIL, Google Inc., and other contributors +HalideIR is derived from the Halide project. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +_____ + +Distributed Machine Learning Common Codebase + +Copyright (c) 2015 by Contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +_____ + +DLPack: Open In Memory Tensor Structure + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017 by Contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +_____ + +HowardHinnant/date + +The source code in this project is released using the MIT License. There is no +global license for the project because each file is licensed individually with +different author names and/or dates. + +If you contribute to this project, please add your name to the license of each +file you modify. If you have already contributed to this project and forgot to +add your name to the license, please feel free to submit a new P/R to add your +name to the license in each file you modified. + +For convenience, here is a copy of the MIT license found in each file except +without author names or dates: + +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +_____ + +FreeBSD: getopt.c file + +Copyright (c) 1987, 1993, 1994 +The Regents of the University of California. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + 1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +3. Neither the name of the University nor the names of its contributors +may be used to endorse or promote products derived from this software +without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. +_____ + + +google/googletest + +Copyright 2008, Google Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +_____ + +G3log : Asynchronous logger with Dynamic Sinks + +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to +_____ + +Scikit-learn + +Copyright (c) 2007–2018 The scikit-learn developers. +All rights reserved. + + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + a. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + b. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + c. Neither the name of the Scikit-learn Developers nor the names of + its contributors may be used to endorse or promote products + derived from this software without specific prior written + permission. + + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +DAMAGE. + +_____ + +google/re2 + +Copyright (c) 2009 The RE2 Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +_____ +onnx/onnx-tensorrt + +MIT License + +Copyright (c) 2018, NVIDIA CORPORATION. All rights reserved. +Copyright (c) 2018 Open Neural Network Exchange + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +_____ +nvidia/cutlass + +Copyright (c) 2017 - 2022 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: BSD-3-Clause + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +_____ +Boost + +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +_____ + +JDAI-CV/DNNLibrary + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [2019] [JD.com Inc. JD AI] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +_____ + +google/flatbuffers + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2014 Google Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +_____ + +google/glog + +Copyright (c) 2008, Google Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +A function gettimeofday in utilities.cc is based on + +http://www.google.com/codesearch/p?hl=en#dR3YEbitojA/COPYING&q=GetSystemTimeAsFileTime%20license:bsd + +The license of this code is: + +Copyright (c) 2003-2008, Jouni Malinen and contributors +All Rights Reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name(s) of the above-listed copyright holder(s) nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +_____ + +abseil-cpp +https://github.com/abseil/abseil-cpp + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +_____ + +microsoft/wil + +MIT License + +Copyright (c) Microsoft Corporation. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE + +_____ + +nlohmann/json + +MIT License + +Copyright (c) 2013-2019 Niels Lohmann + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +_____ + +dcleblanc/SafeInt + +MIT License + +Copyright (c) 2018 Microsoft + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +_____ +Open MPI + +3-Clause BSD License + +Most files in this release are marked with the copyrights of the +organizations who have edited them. The copyrights below are in no +particular order and generally reflect members of the Open MPI core +team who have contributed code to this release. The copyrights for +code used under license from other parties are included in the +corresponding files. + +Copyright (c) 2004-2010 The Trustees of Indiana University and Indiana + University Research and Technology + Corporation. All rights reserved. +Copyright (c) 2004-2017 The University of Tennessee and The University + of Tennessee Research Foundation. All rights + reserved. +Copyright (c) 2004-2010 High Performance Computing Center Stuttgart, + University of Stuttgart. All rights reserved. +Copyright (c) 2004-2008 The Regents of the University of California. + All rights reserved. +Copyright (c) 2006-2017 Los Alamos National Security, LLC. All rights + reserved. +Copyright (c) 2006-2017 Cisco Systems, Inc. All rights reserved. +Copyright (c) 2006-2010 Voltaire, Inc. All rights reserved. +Copyright (c) 2006-2017 Sandia National Laboratories. All rights reserved. +Copyright (c) 2006-2010 Sun Microsystems, Inc. All rights reserved. + Use is subject to license terms. +Copyright (c) 2006-2017 The University of Houston. All rights reserved. +Copyright (c) 2006-2009 Myricom, Inc. All rights reserved. +Copyright (c) 2007-2017 UT-Battelle, LLC. All rights reserved. +Copyright (c) 2007-2017 IBM Corporation. All rights reserved. +Copyright (c) 1998-2005 Forschungszentrum Juelich, Juelich Supercomputing + Centre, Federal Republic of Germany +Copyright (c) 2005-2008 ZIH, TU Dresden, Federal Republic of Germany +Copyright (c) 2007 Evergrid, Inc. All rights reserved. +Copyright (c) 2008 Chelsio, Inc. All rights reserved. +Copyright (c) 2008-2009 Institut National de Recherche en + Informatique. All rights reserved. +Copyright (c) 2007 Lawrence Livermore National Security, LLC. + All rights reserved. +Copyright (c) 2007-2017 Mellanox Technologies. All rights reserved. +Copyright (c) 2006-2010 QLogic Corporation. All rights reserved. +Copyright (c) 2008-2017 Oak Ridge National Labs. All rights reserved. +Copyright (c) 2006-2012 Oracle and/or its affiliates. All rights reserved. +Copyright (c) 2009-2015 Bull SAS. All rights reserved. +Copyright (c) 2010 ARM ltd. All rights reserved. +Copyright (c) 2016 ARM, Inc. All rights reserved. +Copyright (c) 2010-2011 Alex Brick . All rights reserved. +Copyright (c) 2012 The University of Wisconsin-La Crosse. All rights + reserved. +Copyright (c) 2013-2016 Intel, Inc. All rights reserved. +Copyright (c) 2011-2017 NVIDIA Corporation. All rights reserved. +Copyright (c) 2016 Broadcom Limited. All rights reserved. +Copyright (c) 2011-2017 Fujitsu Limited. All rights reserved. +Copyright (c) 2014-2015 Hewlett-Packard Development Company, LP. All + rights reserved. +Copyright (c) 2013-2017 Research Organization for Information Science (RIST). + All rights reserved. +Copyright (c) 2017-2018 Amazon.com, Inc. or its affiliates. All Rights + reserved. +Copyright (c) 2018 DataDirect Networks. All rights reserved. +Copyright (c) 2018-2019 Triad National Security, LLC. All rights reserved. + +$COPYRIGHT$ + +Additional copyrights may follow + +$HEADER$ + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +- Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer listed + in this license in the documentation and/or other materials + provided with the distribution. + +- Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +The copyright holders provide no reassurances that the source code +provided does not infringe any patent, copyright, or any other +intellectual property rights of third parties. The copyright holders +disclaim any liability to any recipient for claims brought against +recipient by any third party for infringement of that parties +intellectual property rights. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +_____ + +The Android Open Source Project + +Copyright (C) 2017 The Android Open Source Project +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +------ + +libprotobuf-mutator + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + ----- + + openucx/ucx + https://github.com/openucx/ucx + + Copyright (c) 2014-2015 UT-Battelle, LLC. All rights reserved. + Copyright (C) 2014-2020 Mellanox Technologies Ltd. All rights reserved. + Copyright (C) 2014-2015 The University of Houston System. All rights reserved. + Copyright (C) 2015 The University of Tennessee and The University + of Tennessee Research Foundation. All rights reserved. + Copyright (C) 2016-2020 ARM Ltd. All rights reserved. + Copyright (c) 2016 Los Alamos National Security, LLC. All rights reserved. + Copyright (C) 2016-2020 Advanced Micro Devices, Inc. All rights reserved. + Copyright (C) 2019 UChicago Argonne, LLC. All rights reserved. + Copyright (c) 2018-2020 NVIDIA CORPORATION. All rights reserved. + Copyright (C) 2020 Huawei Technologies Co., Ltd. All rights reserved. + Copyright (C) 2016-2020 Stony Brook University. All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + 3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + ----- + + From PyTorch: + + Copyright (c) 2016- Facebook, Inc (Adam Paszke) + Copyright (c) 2014- Facebook, Inc (Soumith Chintala) + Copyright (c) 2011-2014 Idiap Research Institute (Ronan Collobert) + Copyright (c) 2012-2014 Deepmind Technologies (Koray Kavukcuoglu) + Copyright (c) 2011-2012 NEC Laboratories America (Koray Kavukcuoglu) + Copyright (c) 2011-2013 NYU (Clement Farabet) + Copyright (c) 2006-2010 NEC Laboratories America (Ronan Collobert, Leon Bottou, Iain Melvin, Jason Weston) + Copyright (c) 2006 Idiap Research Institute (Samy Bengio) + Copyright (c) 2001-2004 Idiap Research Institute (Ronan Collobert, Samy Bengio, Johnny Mariethoz) + + From Caffe2: + + Copyright (c) 2016-present, Facebook Inc. All rights reserved. + + All contributions by Facebook: + Copyright (c) 2016 Facebook Inc. + + All contributions by Google: + Copyright (c) 2015 Google Inc. + All rights reserved. + + All contributions by Yangqing Jia: + Copyright (c) 2015 Yangqing Jia + All rights reserved. + + All contributions from Caffe: + Copyright(c) 2013, 2014, 2015, the respective contributors + All rights reserved. + + All other contributions: + Copyright(c) 2015, 2016 the respective contributors + All rights reserved. + + Caffe2 uses a copyright model similar to Caffe: each contributor holds + copyright over their contributions to Caffe2. The project versioning records + all such contribution and copyright details. If a contributor wants to further + mark their specific copyright on a particular contribution, they should + indicate their copyright solely in the commit message of the change when it is + committed. + + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + 3. Neither the names of Facebook, Deepmind Technologies, NYU, NEC Laboratories America + and IDIAP Research Institute nor the names of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + +_____ + + mpi4py + https://github.com/mpi4py/mpi4py/ + + ======================= + LICENSE: MPI for Python + ======================= + + :Author: Lisandro Dalcin + :Contact: dalcinl@gmail.com + + + Copyright (c) 2019, Lisandro Dalcin. + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +_____ +huggingface/transformers + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +_____ +msgpack/msgpack-python + +Copyright (C) 2008-2011 INADA Naoki + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +_____ +lanpa/tensorboardX + +MIT License + +Copyright (c) 2017 Tzu-Wei Huang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +_____ +tensorflow/tensorboard + +Copyright 2017 The TensorFlow Authors. All rights reserved. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017, The TensorFlow Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +_____ + +cerberus + +Cerberus is a lightweight and extensible data validation library for Python. + +ISC License + +Copyright (c) 2012-2016 Nicola Iarocci. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + +_____ + +MurmurHash3 + +MIT license + +https://github.com/aappleby/smhasher + +SMHasher is a test suite designed to test the distribution, collision, and +performance properties of non-cryptographic hash functions. +This is the home for the MurmurHash family of hash functions along with the +SMHasher test suite used to verify them. +SMHasher is released under the MIT license. +All MurmurHash versions are public domain software, and the author disclaims all copyright to their code. + +_____ + +gtest-ios-framework + +https://github.com/mestevens/gtest-ios-framework + +Copyright (c) 2013 Matthew Stevens + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +_____ + +DLPack + +https://github.com/dmlc/dlpack + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017 by Contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +_____ + +emsdk + +MIT/Expat license + +https://github.com/emscripten-core/emsdk + +Copyright (c) 2018 Emscripten authors (see AUTHORS in Emscripten) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +---------------------------------------------------------------------------- + +This is the MIT/Expat License. For more information see: + +1. http://www.opensource.org/licenses/mit-license.php + +2. http://en.wikipedia.org/wiki/MIT_License + +_____ + +coremltools + +BSD-3-Clause License + +https://github.com/apple/coremltools + +Copyright (c) 2020, Apple Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder(s) nor the names of any contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +© 2021 GitHub, Inc. + +_____ + +react-native + +MIT License + +https://github.com/facebook/react-native + +Copyright (c) Facebook, Inc. and its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +_____ + +pytorch/cpuinfo + +BSD 2-Clause "Simplified" License + +https://github.com/pytorch/cpuinfo + +Copyright (c) 2019 Google LLC +Copyright (c) 2017-2018 Facebook Inc. +Copyright (C) 2012-2017 Georgia Institute of Technology +Copyright (C) 2010-2012 Marat Dukhan + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +_____ + +SQLite Is Public Domain + +All of the code and documentation in SQLite has been dedicated to the public +domain by the authors. All code authors, and representatives of the companies +they work for, have signed affidavits dedicating their contributions to the +public domain and originals of those signed affidavits are stored in a firesafe +at the main offices of Hwaci. Anyone is free to copy, modify, publish, use, +compile, sell, or distribute the original SQLite code, either in source code +form or as a compiled binary, for any purpose, commercial or non-commercial, +and by any means. + +The previous paragraph applies to the deliverable code and documentation in +SQLite - those parts of the SQLite library that you actually bundle and ship +with a larger application. Some scripts used as part of the build process (for +example the "configure" scripts generated by autoconf) might fall under other +open-source licenses. Nothing from these build scripts ever reaches the final +deliverable SQLite library, however, and so the licenses associated with those +scripts should not be a factor in assessing your rights to copy and use the +SQLite library. + +All of the deliverable code in SQLite has been written from scratch. No code +has been taken from other projects or from the open internet. Every line of +code can be traced back to its original author, and all of those authors have +public domain dedications on file. So the SQLite code base is clean and is +uncontaminated with licensed code from other projects. + +_____ + +google/XNNPACK + +BSD License + +For XNNPACK software + +Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. +Copyright 2019 Google LLC + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Facebook nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +_____ + +google/sentencepiece, https://github.com/google/sentencepiece +(included when statically linked with onnxruntime-extensions) + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +_____ + +dlfcn-win32/dlfcn-win32 is licensed under the MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +_____ + +The Python Imaging Library (PIL) is + + Copyright © 1997-2011 by Secret Labs AB + Copyright © 1995-2011 by Fredrik Lundh + +Pillow is the friendly PIL fork. It is + + Copyright © 2010-2023 by Alex Clark and contributors + +Like PIL, Pillow is licensed under the open source HPND License: + +By obtaining, using, and/or copying this software and/or its associated +documentation, you agree that you have read, understood, and will comply +with the following terms and conditions: + +Permission to use, copy, modify, and distribute this software and its +associated documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appears in all copies, and that +both that copyright notice and this permission notice appear in supporting +documentation, and that the name of Secret Labs AB or the author not be +used in advertising or publicity pertaining to distribution of the software +without specific, written prior permission. + +SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS +SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. +IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR BE LIABLE FOR ANY SPECIAL, +INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE +OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + +_____ + +openssl/openssl, https://github.com/openssl/openssl + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +_____ + +Tencent/rapidjson, https://github.com/Tencent/rapidjson + +Tencent is pleased to support the open source community by making RapidJSON available. + +Copyright (C) 2015 THL A29 Limited, a Tencent company, and Milo Yip. All rights reserved. + +If you have downloaded a copy of the RapidJSON binary from Tencent, please note that the RapidJSON binary is licensed under the MIT License. +If you have downloaded a copy of the RapidJSON source code from Tencent, please note that RapidJSON source code is licensed under the MIT License, except for the third-party components listed below which are subject to different license terms. Your integration of RapidJSON into your own projects may require compliance with the MIT License, as well as the other licenses applicable to the third-party components included within RapidJSON. To avoid the problematic JSON license in your own projects, it's sufficient to exclude the bin/jsonchecker/ directory, as it's the only code under the JSON license. +A copy of the MIT License is included in this file. + +Other dependencies and licenses: + +Open Source Software Licensed Under the BSD License: +-------------------------------------------------------------------- + +The msinttypes r29 +Copyright (c) 2006-2013 Alexander Chemeris +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +* Neither the name of copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Open Source Software Licensed Under the JSON License: +-------------------------------------------------------------------- + +json.org +Copyright (c) 2002 JSON.org +All Rights Reserved. + +JSON_checker +Copyright (c) 2002 JSON.org +All Rights Reserved. + + +Terms of the JSON License: +--------------------------------------------------- + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +The Software shall be used for Good, not Evil. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +Terms of the MIT License: +-------------------------------------------------------------------- + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +_____ + +boostorg/boost, https://github.com/boostorg/boost + +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +_____ + +libb64/libb64, https://github.com/libb64/libb64 + +Copyright-Only Dedication (based on United States law) or Public Domain Certification + +The person or persons who have associated work with this document (the "Dedicator" or "Certifier") hereby either (a) certifies that, to the best of his knowledge, the work of authorship identified is in the public domain of the country from which the work is published, or (b) hereby dedicates whatever copyright the dedicators holds in the work of authorship identified below (the "Work") to the public domain. A certifier, moreover, dedicates any copyright interest he may have in the associated work, and for these purposes, is described as a "dedicator" below. + +A certifier has taken reasonable steps to verify the copyright status of this work. Certifier recognizes that his good faith efforts may not shield him from liability if in fact the work certified is not in the public domain. + +Dedicator makes this dedication for the benefit of the public at large and to the detriment of the Dedicator's heirs and successors. Dedicator intends this dedication to be an overt act of relinquishment in perpetuity of all present and future rights under copyright law, whether vested or contingent, in the Work. Dedicator understands that such relinquishment of all rights includes the relinquishment of all rights to enforce (by lawsuit or otherwise) those copyrights in the Work. + +Dedicator recognizes that, once placed in the public domain, the Work may be freely reproduced, distributed, transmitted, used, modified, built upon, or otherwise exploited by anyone for any purpose, commercial or non-commercial, and in any way, including by methods that have not yet been invented or conceived. + +_____ + +posix pthread library, https://sourceforge.net/projects/pthreads4w + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +_____ + +Triton Inference Server & Client, https://github.com/triton-inference-server + +Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of NVIDIA CORPORATION nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +_____ + +microsoft/mimalloc, https://github.com/microsoft/mimalloc + +MIT License + +Copyright (c) 2018-2021 Microsoft Corporation, Daan Leijen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +_____ + +TensorFlow.js + +https://github.com/tensorflow/tfjs + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +—— + +curl/curl + +https://github.com/curl + +COPYRIGHT AND PERMISSION NOTICE + +Copyright (C) Daniel Stenberg, , and many +contributors, see the THANKS file. + +All rights reserved. + +Permission to use, copy, modify, and distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright +notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall not +be used in advertising or otherwise to promote the sale, use or other dealings +in this Software without prior written authorization of the copyright holder. + +_____ + +Intel neural-compressor + +https://github.com/intel/neural-compressor + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + ============================================================================ + + Copyright 2016-2019 Intel Corporation + Copyright 2018 YANDEX LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + This distribution includes third party software ("third party programs"). + This third party software, even if included with the distribution of + the Intel software, may be governed by separate license terms, including + without limitation, third party license terms, other Intel software license + terms, and open source software license terms. These separate license terms + govern your use of the third party programs as set forth in the + "THIRD-PARTY-PROGRAMS" file. + +_____ + +FlashAttention, https://github.com/Dao-AILab/flash-attention + +BSD 3-Clause License + +Copyright (c) 2022, the respective contributors, as shown by the AUTHORS file. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +_____ + +neural-speed + +https://github.com/intel/neural-speed + + Apache License + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + ============================================================================ + + Copyright 2016-2019 Intel Corporation + Copyright 2018 YANDEX LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + This distribution includes third party software ("third party programs"). + This third party software, even if included with the distribution of + the Intel software, may be governed by separate license terms, including + without limitation, third party license terms, other Intel software license + terms, and open source software license terms. These separate license terms + govern your use of the third party programs as set forth in the + "THIRD-PARTY-PROGRAMS" file. + +_____ + +dawn + +https://dawn.googlesource.com/dawn + + BSD 3-Clause License + + Copyright 2017-2023 The Dawn & Tint Authors + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + 3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +_____ + +KleidiAI + +https://gitlab.arm.com/kleidi/kleidiai + +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +Copyright 2024-2025 Arm Limited and/or its affiliates + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/crates/ml/licenses/SAM2-Apache-2.0.txt b/crates/ml/licenses/SAM2-Apache-2.0.txt new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/crates/ml/licenses/SAM2-Apache-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/ml/src/catalog.rs b/crates/ml/src/catalog.rs new file mode 100644 index 0000000000..d9d2287c5f --- /dev/null +++ b/crates/ml/src/catalog.rs @@ -0,0 +1,145 @@ +//! Reviewed FP32 exports. URLs contain immutable revisions; sizes and SHA-256s come from their +//! Hugging Face LFS metadata. Updating a model is a code review, never a remote manifest update. +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ModelId { + #[serde(rename = "birefnet-hr-matting")] + BiRefNet, + #[serde(rename = "sam2.1-large")] + Sam2, +} + +impl ModelId { + pub fn parse(id: &str) -> Option { + match id { + "birefnet-hr-matting" => Some(Self::BiRefNet), + "sam2.1-large" => Some(Self::Sam2), + _ => None, + } + } + pub fn name(self) -> &'static str { + match self { + Self::BiRefNet => "birefnet-hr-matting", + Self::Sam2 => "sam2.1-large", + } + } + pub fn index(self) -> usize { + match self { + Self::BiRefNet => 0, + Self::Sam2 => 1, + } + } + pub fn info(self) -> &'static ModelInfo { + match self { + Self::BiRefNet => &MODELS[0], + Self::Sam2 => &MODELS[1], + } + } +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Artifact { + pub file: &'static str, + pub url: &'static str, + pub bytes: u64, + pub sha256: &'static str, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelInfo { + pub id: ModelId, + pub label: &'static str, + pub purpose: &'static str, + pub input_side: usize, + pub revision: &'static str, + pub license: &'static str, + pub upstream: &'static str, + pub export_source: &'static str, + pub artifacts: &'static [Artifact], +} + +impl ModelInfo { + pub fn download_bytes(&self) -> u64 { + self.artifacts.iter().map(|a| a.bytes).sum() + } +} + +pub const MODELS: [ModelInfo; 2] = [ + ModelInfo { + id: ModelId::BiRefNet, + label: "BiRefNet HR Matting", + purpose: "Select Subject and Remove Background; detailed, soft edges", + input_side: 2048, + revision: "792518b9d4dfe9793891712677de25e554f948c6", + license: "MIT", + upstream: "https://huggingface.co/ZhengPeng7/BiRefNet_HR-matting", + export_source: "https://huggingface.co/PinkPixel/birefnet-hr-matting-onnx", + artifacts: &[Artifact { + file: "model.onnx", + url: "https://huggingface.co/PinkPixel/birefnet-hr-matting-onnx/resolve/792518b9d4dfe9793891712677de25e554f948c6/model.onnx", + bytes: 932_150_975, + sha256: "a548a1f3307250766bef50b174026b3cfa39a695bc1e42ec131c14fe82797ffd", + }], + }, + ModelInfo { + id: ModelId::Sam2, + label: "SAM 2.1 Large", + purpose: "Object Selection; drag a box around the object", + input_side: 1024, + revision: "3c23431f721e69cae82dbfd0c28fd692cc714021", + license: "Apache-2.0", + upstream: "https://huggingface.co/facebook/sam2.1-hiera-large", + export_source: "https://huggingface.co/onnx-community/sam2.1-hiera-large-ONNX", + artifacts: &[ + Artifact { + file: "vision_encoder.onnx", + url: "https://huggingface.co/onnx-community/sam2.1-hiera-large-ONNX/resolve/3c23431f721e69cae82dbfd0c28fd692cc714021/onnx/vision_encoder.onnx", + bytes: 1_350_487, + sha256: "ad3a8161c6bd6d8beea811300c60e20d626035feaa3c19622ace27b22e25bec8", + }, + Artifact { + file: "vision_encoder.onnx_data", + url: "https://huggingface.co/onnx-community/sam2.1-hiera-large-ONNX/resolve/3c23431f721e69cae82dbfd0c28fd692cc714021/onnx/vision_encoder.onnx_data", + bytes: 888_587_456, + sha256: "88c501a65b6aaeac99b65a275f3f559727404283930d107d499fda8714ba7e5a", + }, + Artifact { + file: "prompt_encoder_mask_decoder.onnx", + url: "https://huggingface.co/onnx-community/sam2.1-hiera-large-ONNX/resolve/3c23431f721e69cae82dbfd0c28fd692cc714021/onnx/prompt_encoder_mask_decoder.onnx", + bytes: 213_114, + sha256: "a21b3b09f6e5f5838d630d92a150914ca58f8f58b603b8f53fc32b03da982203", + }, + Artifact { + file: "prompt_encoder_mask_decoder.onnx_data", + url: "https://huggingface.co/onnx-community/sam2.1-hiera-large-ONNX/resolve/3c23431f721e69cae82dbfd0c28fd692cc714021/onnx/prompt_encoder_mask_decoder.onnx_data", + bytes: 20_958_208, + sha256: "bfdcd4f7ee04298a82fe1aaf1bd7b8edf544d36901e1a886f01892fb915bd63c", + }, + ], + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn catalog_is_pinned_and_ids_cannot_escape_storage() { + for m in MODELS { + assert_eq!(ModelId::parse(m.id.name()), Some(m.id)); + for a in m.artifacts { + assert!(a.url.starts_with("https://huggingface.co/")); + assert!(a.url.contains(m.revision)); + assert!(a.bytes > 0 && a.bytes < 2_000_000_000); + assert_eq!(a.sha256.len(), 64); + assert!(a.sha256.bytes().all(|c| c.is_ascii_hexdigit())); + assert!(!a.file.contains(['/', '\\'])); + } + } + for id in ["../model", "sam3", "", "https://example.com/model.onnx"] { + assert_eq!(ModelId::parse(id), None); + } + } +} diff --git a/crates/ml/src/image.rs b/crates/ml/src/image.rs new file mode 100644 index 0000000000..2381674e27 --- /dev/null +++ b/crates/ml/src/image.rs @@ -0,0 +1,127 @@ +use photocraft_algo::segment::RgbImage; +use photocraft_raster::Interrupt; + +use crate::{Error, Result, check}; + +const MAX_PIXELS: usize = 2048 * 2048; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum MaskKind { + /// A matting model's continuous opacity, interpolated without thresholding. + Alpha, + /// SAM logits: interpolate at destination resolution, then threshold at zero. + Logits, +} + +#[derive(Clone, Debug)] +pub struct AlphaMask { + pub width: usize, + pub height: usize, + pub values: Vec, + pub kind: MaskKind, +} + +impl AlphaMask { + pub fn validate(&self) -> Result<()> { + let n = self.width.checked_mul(self.height).filter(|n| *n > 0 && *n <= MAX_PIXELS); + if n != Some(self.values.len()) || self.values.iter().any(|v| !v.is_finite()) { + return Err(Error::Inference("invalid mask dimensions or non-finite values".into())); + } + Ok(()) + } + + /// Bilinear, half-pixel centres, with border clamping. Coordinates are normalized to the + /// source image, so non-square documents and prompts use the same mapping. + pub fn sample(&self, x: f32, y: f32) -> f32 { + bilinear(self.width, self.height, x, y, |i| self.values.get(i).copied().unwrap_or(0.0)) + } + + pub fn coverage(&self, x: f32, y: f32) -> u8 { + let v = self.sample(x, y); + match self.kind { + MaskKind::Alpha => (v.clamp(0.0, 1.0) * 255.0).round() as u8, + MaskKind::Logits => { + if v > 0.0 { + 255 + } else { + 0 + } + } + } + } +} + +fn bilinear(w: usize, h: usize, x: f32, y: f32, at: impl Fn(usize) -> f32) -> f32 { + if w.checked_mul(h).is_none_or(|n| n == 0 || n > MAX_PIXELS) || !x.is_finite() || !y.is_finite() { + return 0.0; + } + let x = (x * w as f32 - 0.5).clamp(0.0, (w - 1) as f32); + let y = (y * h as f32 - 0.5).clamp(0.0, (h - 1) as f32); + let (x0, y0) = (x.floor() as usize, y.floor() as usize); + let (x1, y1) = ((x0 + 1).min(w - 1), (y0 + 1).min(h - 1)); + let (fx, fy) = (x - x0 as f32, y - y0 as f32); + let a = at(y0 * w + x0) * (1.0 - fx) + at(y0 * w + x1) * fx; + let b = at(y1 * w + x0) * (1.0 - fx) + at(y1 * w + x1) * fx; + a * (1.0 - fy) + b * fy +} + +/// Model RGB is sRGB in 0..=1, resized to its training square and ImageNet-normalized NCHW. +/// The engine is responsible for converting the document profile before reaching this seam. +pub fn normalize(image: &RgbImage, side: usize, ctl: &Interrupt<'_>) -> Result> { + let n = image.w.checked_mul(image.h).filter(|n| *n > 0 && *n <= MAX_PIXELS); + let plane = side.checked_mul(side).filter(|n| *n > 0 && *n <= MAX_PIXELS); + if n != Some(image.px.len()) || plane.is_none() || image.px.iter().flatten().any(|v| !v.is_finite()) { + return Err(Error::Input("invalid image dimensions or values".into())); + } + let plane = plane.ok_or_else(|| Error::Input("invalid input size".into()))?; + let mut out = vec![0.0; plane * 3]; + let mean = [0.485, 0.456, 0.406]; + let std = [0.229, 0.224, 0.225]; + for (c, channel) in out.chunks_exact_mut(plane).enumerate() { + for (y, row) in channel.chunks_exact_mut(side).enumerate() { + check(ctl)?; + for (x, dst) in row.iter_mut().enumerate() { + let v = bilinear(image.w, image.h, (x as f32 + 0.5) / side as f32, (y as f32 + 0.5) / side as f32, |i| { + image.px.get(i).and_then(|p| p.get(c)).copied().unwrap_or(0.0) + }); + *dst = (v.clamp(0.0, 1.0) - mean.get(c).copied().unwrap_or(0.0)) / std.get(c).copied().unwrap_or(1.0); + } + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn normalized_rgb_uses_channels_first_and_keeps_aspect_mapping() { + let im = RgbImage { w: 2, h: 1, px: vec![[1.0, 0.0, 0.5], [0.0, 1.0, 0.5]] }; + let v = normalize(&im, 2, &Interrupt::NONE).unwrap(); + assert_eq!(v.len(), 12); + assert!((v[0] - (1.0 - 0.485) / 0.229).abs() < 1e-6); + assert!((v[5] - (1.0 - 0.456) / 0.224).abs() < 1e-6); + assert_eq!(v[0], v[2]); + assert_eq!(v[8], v[11]); + } + #[test] + fn matting_preserves_fractional_coverage_and_sam_thresholds_after_resize() { + let mut m = AlphaMask { width: 2, height: 1, values: vec![0.0, 1.0], kind: MaskKind::Alpha }; + assert_eq!(m.coverage(0.5, 0.5), 128); + assert_eq!(m.coverage(0.0, 0.5), 0); + assert_eq!(m.coverage(1.0, 0.5), 255); + m.kind = MaskKind::Logits; + m.values = vec![-2.0, 2.0]; + assert_eq!(m.coverage(0.75, 0.5), 255); + assert_eq!(m.coverage(0.25, 0.5), 0); + } + #[test] + fn malformed_tensors_and_cancellation_fail_gracefully() { + let im = RgbImage { w: usize::MAX, h: 2, px: vec![] }; + assert!(normalize(&im, 2048, &Interrupt::NONE).is_err()); + let m = AlphaMask { width: 1, height: 1, values: vec![f32::NAN], kind: MaskKind::Alpha }; + assert!(m.validate().is_err()); + let im = RgbImage { w: 1, h: 1, px: vec![[0.0; 3]] }; + assert!(matches!(normalize(&im, 2, &Interrupt::cancel_only(&|| true)), Err(Error::Cancelled))); + } +} diff --git a/crates/ml/src/lib.rs b/crates/ml/src/lib.rs new file mode 100644 index 0000000000..2d633be5b5 --- /dev/null +++ b/crates/ml/src/lib.rs @@ -0,0 +1,68 @@ +//! Optional, local image segmentation. The default build contains only the model catalogue and +//! the backend seam; `onnx` adds native CPU inference and explicit, verified downloads. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod catalog; +mod image; +pub use catalog::{MODELS, ModelId, ModelInfo}; +pub use image::{AlphaMask, MaskKind, normalize}; + +#[cfg(all(feature = "onnx", not(target_arch = "wasm32")))] +mod native; +#[cfg(all(feature = "onnx", not(target_arch = "wasm32")))] +pub use native::NativeBackend; + +#[cfg(all(test, feature = "onnx", not(target_arch = "wasm32")))] +mod test_models; + +use photocraft_algo::segment::RgbImage; +use photocraft_raster::Interrupt; +use serde::Serialize; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("cancelled")] + Cancelled, + #[error("invalid model input: {0}")] + Input(String), + #[error("local model unavailable: {0}")] + Unavailable(String), + #[error("model download: {0}")] + Download(String), + #[error("model inference: {0}")] + Inference(String), + #[error("model storage: {0}")] + Io(#[from] std::io::Error), +} + +pub type Result = std::result::Result; + +pub(crate) fn check(ctl: &Interrupt<'_>) -> Result<()> { + ctl.check().map_err(|_| Error::Cancelled) +} + +/// Coordinates relative to the full input image, in 0..=1. A box guides SAM to one object; +/// automatic foreground matting uses `Subject`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Prompt { + Subject, + Box([f32; 4]), +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelStatus { + pub id: ModelId, + pub installed: bool, + pub busy: bool, +} + +/// No document or UI types cross this seam. Tests can inject a deterministic backend; the web +/// and builds without `onnx` keep the existing classical algorithms and never access the network. +pub trait InferenceBackend: Send + Sync { + fn status(&self) -> Vec; + fn download(&self, id: ModelId, ctl: &Interrupt<'_>) -> Result<()>; + fn remove(&self, id: ModelId, ctl: &Interrupt<'_>) -> Result<()>; + fn infer(&self, id: ModelId, image: &RgbImage, prompt: Prompt, ctl: &Interrupt<'_>) -> Result; +} diff --git a/crates/ml/src/native.rs b/crates/ml/src/native.rs new file mode 100644 index 0000000000..25f61d195d --- /dev/null +++ b/crates/ml/src/native.rs @@ -0,0 +1,542 @@ +//! Native CPU backend. Model installation is explicit; inference never downloads anything. +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::Duration; + +use ort::session::{RunOptions, Session as OnnxSession, builder::GraphOptimizationLevel}; +use ort::value::Tensor; +use photocraft_algo::segment::RgbImage; +use photocraft_raster::Interrupt; +use sha2::{Digest, Sha256}; +use ureq::unversioned::transport::{Buffers, ConnectionDetails, Connector, DefaultConnector, NextTimeout, Transport}; + +use crate::catalog::Artifact; +use crate::{AlphaMask, Error, InferenceBackend, MaskKind, ModelId, ModelStatus, Prompt, Result, check, normalize}; + +impl From> for Error { + fn from(e: ort::Error) -> Self { + Self::Inference(e.to_string()) + } +} + +/// Platform frontends choose the directory. Constructing a backend does not create it, contact +/// a server, load ONNX Runtime or load any model. At most one model stays resident at a time. +pub struct NativeBackend { + root: PathBuf, + operations: [Mutex<()>; 2], + sessions: Mutex>, +} + +enum Sessions { + BiRefNet(OnnxSession), + Sam2 { encoder: OnnxSession, decoder: OnnxSession }, +} + +struct Loaded { + id: ModelId, + sessions: Sessions, +} + +/// ureq's body timeout covers the whole download, rather than each read. Keep large downloads +/// possible while bounding stalled reads (and cancellation cleanup) to 30 seconds. Delegate TLS, +/// redirects and proxy handling to its default connector; never weaken certificate validation. +#[derive(Debug)] +struct IdleConnector; +#[derive(Debug)] +struct IdleTransport(Box); +impl Connector for IdleConnector { + type Out = IdleTransport; + fn connect(&self, details: &ConnectionDetails<'_>, chained: Option<()>) -> std::result::Result, ureq::Error> { + Ok(DefaultConnector::default().connect(details, chained)?.map(IdleTransport)) + } +} +fn idle_timeout(timeout: NextTimeout) -> NextTimeout { + let after = ureq::unversioned::transport::time::Duration::from_secs(30); + if *timeout.after > *after { NextTimeout { after, reason: ureq::Timeout::RecvBody } } else { timeout } +} +impl Transport for IdleTransport { + fn buffers(&mut self) -> &mut dyn Buffers { + self.0.buffers() + } + fn transmit_output(&mut self, amount: usize, timeout: NextTimeout) -> std::result::Result<(), ureq::Error> { + self.0.transmit_output(amount, timeout) + } + fn await_input(&mut self, timeout: NextTimeout) -> std::result::Result { + self.0.await_input(idle_timeout(timeout)) + } + fn is_open(&mut self) -> bool { + self.0.is_open() + } + fn is_tls(&self) -> bool { + self.0.is_tls() + } +} + +impl NativeBackend { + pub fn new(root: PathBuf) -> Self { + Self { root, operations: Default::default(), sessions: Mutex::new(None) } + } + + fn directory(&self, id: ModelId) -> PathBuf { + self.root.join(format!("{}-{}", id.name(), id.info().revision)) + } + + fn operation(&self, id: ModelId) -> Result> { + let lock = self.operations.get(id.index()).ok_or_else(|| Error::Input("unknown model".into()))?; + match lock.try_lock() { + Ok(guard) => Ok(guard), + Err(std::sync::TryLockError::Poisoned(e)) => { + lock.clear_poison(); + Ok(e.into_inner()) + } + Err(std::sync::TryLockError::WouldBlock) => { + Err(Error::Unavailable(format!("{} is in use or downloading; try again when it finishes", id.info().label))) + } + } + } + + fn installed(&self, id: ModelId) -> bool { + let dir = self.directory(id); + fs::symlink_metadata(&dir).is_ok_and(|m| m.is_dir()) + && id.info().artifacts.iter().all(|a| fs::symlink_metadata(dir.join(a.file)).is_ok_and(|m| m.is_file() && m.len() == a.bytes)) + } + + fn verify(&self, id: ModelId, ctl: &Interrupt<'_>) -> Result<()> { + if !self.installed(id) { + return Err(Error::Unavailable(format!("download {} in Preferences › Integrations first", id.info().label))); + } + for a in id.info().artifacts { + check(ctl)?; + // Hash before parsing, including external weights. No ONNX from a mutable URL or an + // unverified local file is executed, and external data paths are pinned with it. + copy_verified(&mut File::open(self.directory(id).join(a.file))?, &mut std::io::sink(), a, ctl, |_| {})?; + } + Ok(()) + } +} + +impl InferenceBackend for NativeBackend { + fn status(&self) -> Vec { + crate::MODELS + .iter() + .map(|m| ModelStatus { + id: m.id, + installed: self.installed(m.id), + busy: self.operations.get(m.id.index()).is_some_and(|l| matches!(l.try_lock(), Err(std::sync::TryLockError::WouldBlock))), + }) + .collect() + } + + fn download(&self, id: ModelId, ctl: &Interrupt<'_>) -> Result<()> { + let _guard = self.operation(id)?; + check(ctl)?; + if self.installed(id) { + self.verify(id, ctl)?; + ctl.progress(1.0); + return Ok(()); + } + let dest = self.directory(id); + if dest.try_exists()? { + return Err(Error::Download("incomplete or corrupt installation; remove this model in Preferences and download it again".into())); + } + fs::create_dir_all(&self.root)?; + // Every error, timeout or cancellation drops the staging directory. Windows rename is + // atomic too because the destination does not exist; installed files are never overwritten. + let staging = tempfile::Builder::new().prefix("photocraft-model-").tempdir_in(&self.root)?; + let config = ureq::Agent::config_builder() + .https_only(true) + .max_redirects(5) + .timeout_resolve(Some(Duration::from_secs(15))) + .timeout_connect(Some(Duration::from_secs(15))) + .timeout_recv_response(Some(Duration::from_secs(30))) + .timeout_recv_body(Some(Duration::from_secs(3600))) + .build(); + let agent = ureq::Agent::with_parts(config, IdleConnector, ureq::unversioned::resolver::DefaultResolver::default()); + let total = id.info().download_bytes() as f32; + let mut completed = 0u64; + for a in id.info().artifacts { + check(ctl)?; + let mut response = agent + .get(a.url) + .header("User-Agent", "PhotoCraft-model-download") + .call() + .map_err(|e| Error::Download(format!("{}: {e}; try again", a.file)))?; + let mut file = File::create(staging.path().join(a.file))?; + copy_verified(&mut response.body_mut().as_reader(), &mut file, a, ctl, |bytes| ctl.progress((completed + bytes) as f32 / total * 0.98)).map_err( + |e| match e { + Error::Cancelled => e, + e => Error::Download(format!("{}: {e}; partial download discarded; try again", a.file)), + }, + )?; + file.flush()?; + file.sync_all()?; + completed += a.bytes; + } + let license = match id { + ModelId::BiRefNet => include_str!("../licenses/BiRefNet-MIT.txt"), + ModelId::Sam2 => include_str!("../licenses/SAM2-Apache-2.0.txt"), + }; + fs::write(staging.path().join("LICENSE.txt"), license)?; + fs::write( + staging.path().join("SOURCE.txt"), + format!("{}\nUpstream: {}\nONNX export: {}\nRevision: {}\n", id.info().label, id.info().upstream, id.info().export_source, id.info().revision), + )?; + check(ctl)?; + fs::rename(staging.path(), dest)?; + ctl.progress(1.0); + Ok(()) + } + + fn remove(&self, id: ModelId, ctl: &Interrupt<'_>) -> Result<()> { + let _guard = self.operation(id)?; + check(ctl)?; + let mut loaded = self.sessions.lock().unwrap_or_else(PoisonError::into_inner); + check(ctl)?; + if loaded.as_ref().is_some_and(|l| l.id == id) { + // Close external weight files before removing on Windows. + *loaded = None; + } + let path = self.directory(id); + if path.try_exists()? { + if !fs::symlink_metadata(&path)?.is_dir() { + return Err(Error::Unavailable("model installation is not a regular directory".into())); + } + fs::remove_dir_all(path)?; + } + ctl.progress(1.0); + Ok(()) + } + + fn infer(&self, id: ModelId, image: &RgbImage, prompt: Prompt, ctl: &Interrupt<'_>) -> Result { + let _guard = self.operation(id)?; + validate_prompt(id, prompt)?; + let input = normalize(image, id.info().input_side, ctl)?; + self.verify(id, ctl)?; + let mut loaded = match self.sessions.try_lock() { + Ok(guard) => guard, + Err(std::sync::TryLockError::Poisoned(e)) => { + let mut guard = e.into_inner(); + *guard = None; + self.sessions.clear_poison(); + guard + } + Err(std::sync::TryLockError::WouldBlock) => return Err(Error::Unavailable("another local model is running; try again when it finishes".into())), + }; + if loaded.as_ref().is_none_or(|l| l.id != id) { + *loaded = None; + check(ctl)?; + let dir = self.directory(id); + let sessions = match id { + ModelId::BiRefNet => Sessions::BiRefNet(session(&dir.join("model.onnx"))?), + ModelId::Sam2 => { + Sessions::Sam2 { encoder: session(&dir.join("vision_encoder.onnx"))?, decoder: session(&dir.join("prompt_encoder_mask_decoder.onnx"))? } + } + }; + *loaded = Some(Loaded { id, sessions }); + } + check(ctl)?; + ctl.progress(0.2); + let current = loaded.as_mut().ok_or_else(|| Error::Inference("model did not load".into()))?; + let result = match &mut current.sessions { + Sessions::BiRefNet(model) => birefnet(model, input, ctl), + Sessions::Sam2 { encoder, decoder } => sam2(encoder, decoder, input, prompt, ctl), + }?; + result.validate()?; + check(ctl)?; + ctl.progress(1.0); + Ok(result) + } +} + +fn validate_prompt(id: ModelId, p: Prompt) -> Result<()> { + match (id, p) { + (ModelId::BiRefNet, Prompt::Subject) => Ok(()), + (ModelId::Sam2, Prompt::Box([x0, y0, x1, y1])) if [x0, y0, x1, y1].iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)) && x0 < x1 && y0 < y1 => { + Ok(()) + } + _ => Err(Error::Input("this model does not support the requested prompt".into())), + } +} + +fn session(path: &Path) -> Result { + let threads = std::thread::available_parallelism().map_or(2, |v| v.get()).clamp(1, 8); + // Keeping weights resident is useful, but retaining peak 2048px intermediates in an arena + // (then allocating a contiguous memory-pattern block on the second run) can exhaust RAM. + // Prefer releasing temporary buffers over latency: these models are explicitly optional. + Ok(OnnxSession::builder()? + .with_execution_providers([ort::ep::CPU::default().with_arena_allocator(false).build()])? + .with_memory_pattern(false)? + .with_intra_threads(threads)? + .with_optimization_level(GraphOptimizationLevel::Level3)? + .commit_from_file(path)?) +} + +/// ORT supports terminating a run from another thread. The watcher exits at completion or +/// cancellation; it owns no document and never outlives the job. No fictitious per-node progress. +fn cancellable(ctl: &Interrupt<'_>, f: impl FnOnce(&RunOptions) -> Result) -> Result { + check(ctl)?; + let options = Arc::new(RunOptions::new()?); + let done = AtomicBool::new(false); + std::thread::scope(|scope| { + let monitor = std::thread::Builder::new().name("photocraft-model-cancel".into()).spawn_scoped(scope, || { + while !done.load(Ordering::Relaxed) { + if ctl.cancelled() { + let _ = options.terminate(); + break; + } + std::thread::sleep(Duration::from_millis(25)); + } + })?; + // Release the scoped watcher even if a dependency unexpectedly unwinds: the engine's + // last-resort panic guard must be able to report it instead of hanging while joining. + struct Complete<'a>(&'a AtomicBool); + impl Drop for Complete<'_> { + fn drop(&mut self) { + self.0.store(true, Ordering::Relaxed); + } + } + let complete = Complete(&done); + let result = f(&options); + drop(complete); + monitor.join().map_err(|_| Error::Inference("cancellation watcher failed".into()))?; + check(ctl)?; + result + }) +} + +fn birefnet(model: &mut OnnxSession, input: Vec, ctl: &Interrupt<'_>) -> Result { + let tensor = Tensor::from_array(([1usize, 3, 2048, 2048], input.into_boxed_slice()))?; + cancellable(ctl, |options| { + let outputs = model.run_with_options(ort::inputs!["image" => tensor], options)?; + let output = outputs.get("alpha").ok_or_else(|| Error::Inference("BiRefNet export has no alpha output".into()))?; + let (shape, values) = output.try_extract_tensor::()?; + if shape.as_ref() != [1, 1, 2048, 2048] { + return Err(Error::Inference("unexpected BiRefNet output shape".into())); + } + // This reviewed export already includes sigmoid: applying it twice turns backgrounds gray. + Ok(AlphaMask { width: 2048, height: 2048, values: values.to_vec(), kind: MaskKind::Alpha }) + }) +} + +fn sam2(encoder: &mut OnnxSession, decoder: &mut OnnxSession, input: Vec, prompt: Prompt, ctl: &Interrupt<'_>) -> Result { + let Prompt::Box(bbox) = prompt else { return Err(Error::Input("SAM needs a box".into())) }; + let image = Tensor::from_array(([1usize, 3, 1024, 1024], input.into_boxed_slice()))?; + let (e0, e1, e2) = cancellable(ctl, |options| { + let outputs = encoder.run_with_options(ort::inputs!["pixel_values" => image], options)?; + let embedding = |name| -> Result> { + let v = outputs.get(name).ok_or_else(|| Error::Inference(format!("SAM export has no {name} output")))?; + let (shape, values) = v.try_extract_tensor::()?; + Ok(Tensor::from_array((shape.to_vec(), values.to_vec().into_boxed_slice()))?) + }; + Ok((embedding("image_embeddings.0")?, embedding("image_embeddings.1")?, embedding("image_embeddings.2")?)) + })?; + ctl.progress(0.85); + // A padding point is ignored by SAM; the box supplies corner labels 2 and 3 inside the + // decoder. The reviewed export uses int64 labels and coordinates on its 1024-pixel square. + let points = Tensor::from_array(([1usize, 1, 1, 2], vec![0.0f32; 2].into_boxed_slice()))?; + let labels = Tensor::from_array(([1usize, 1, 1], vec![-1i64].into_boxed_slice()))?; + let boxes = Tensor::from_array(([1usize, 1, 4], bbox.map(|v| v * 1024.0).to_vec().into_boxed_slice()))?; + cancellable(ctl, |options| { + let outputs = decoder.run_with_options( + ort::inputs![ + "input_points" => points, + "input_labels" => labels, + "input_boxes" => boxes, + "image_embeddings.0" => e0, + "image_embeddings.1" => e1, + "image_embeddings.2" => e2, + ], + options, + )?; + let masks = outputs.get("pred_masks").ok_or_else(|| Error::Inference("SAM has no mask output".into()))?; + let scores = outputs.get("iou_scores").ok_or_else(|| Error::Inference("SAM has no score output".into()))?; + let (shape, values) = masks.try_extract_tensor::()?; + let (_, scores) = scores.try_extract_tensor::()?; + sam_mask(shape.as_ref(), values, scores) + }) +} + +fn sam_mask(shape: &[i64], values: &[f32], scores: &[f32]) -> Result { + let [1, 1, count, height, width] = shape else { return Err(Error::Inference("unexpected SAM output shape".into())) }; + if ![count, height, width].iter().all(|v| **v > 0) + || *count > 3 + || *height > 2048 + || *width > 2048 + || scores.len() != *count as usize + || scores.iter().any(|v| !v.is_finite()) + { + return Err(Error::Inference("invalid SAM mask dimensions or scores".into())); + } + let plane = (*width as usize).checked_mul(*height as usize).ok_or_else(|| Error::Inference("mask too large".into()))?; + if values.len() != plane * *count as usize { + return Err(Error::Inference("SAM mask length mismatch".into())); + } + let best = scores.iter().enumerate().max_by(|(_, a), (_, b)| a.total_cmp(b)).map_or(0, |(i, _)| i); + let chosen = values.get(best * plane..(best + 1) * plane).ok_or_else(|| Error::Inference("SAM mask missing".into()))?; + Ok(AlphaMask { width: *width as usize, height: *height as usize, values: chosen.to_vec(), kind: MaskKind::Logits }) +} + +fn copy_verified(reader: &mut dyn Read, writer: &mut dyn Write, artifact: &Artifact, ctl: &Interrupt<'_>, progress: impl Fn(u64)) -> Result<()> { + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + let mut count = 0u64; + loop { + check(ctl)?; + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + count = count.checked_add(read as u64).ok_or_else(|| Error::Download("download too large".into()))?; + if count > artifact.bytes { + return Err(Error::Download(format!("{} exceeds its pinned size", artifact.file))); + } + let chunk = buffer.get(..read).ok_or_else(|| Error::Download("invalid read length".into()))?; + writer.write_all(chunk)?; + hasher.update(chunk); + progress(count); + } + check(ctl)?; + if count != artifact.bytes || format!("{:x}", hasher.finalize()) != artifact.sha256 { + return Err(Error::Download(format!("{} failed size/SHA-256 verification; no model was installed", artifact.file))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ABC: Artifact = Artifact { + file: "test.onnx", + url: "https://example.invalid/test.onnx", + bytes: 3, + sha256: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + }; + + #[test] + fn stalled_reads_are_bounded_without_shortening_a_stricter_deadline() { + use ureq::unversioned::transport::time::Duration; + let t = NextTimeout { after: Duration::NotHappening, reason: ureq::Timeout::RecvBody }; + assert_eq!(idle_timeout(t).after, Duration::from_secs(30)); + let t = NextTimeout { after: Duration::from_secs(3), reason: ureq::Timeout::Global }; + assert_eq!(idle_timeout(t), t); + } + + #[test] + fn cancellation_watcher_exits_when_a_dependency_unwinds() { + let started = std::time::Instant::now(); + let r = std::panic::catch_unwind(|| cancellable::<()>(&Interrupt::NONE, |_| panic!("synthetic dependency panic"))); + assert!(r.is_err()); + assert!(started.elapsed() < Duration::from_secs(1)); + } + + #[test] + fn a_dependency_panic_does_not_leave_the_model_permanently_busy() { + let temp = tempfile::tempdir().unwrap(); + let backend = NativeBackend::new(temp.path().into()); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = backend.operation(ModelId::Sam2).unwrap(); + panic!("synthetic dependency panic"); + })); + assert!(backend.status().iter().all(|s| !s.busy)); + assert!(backend.operation(ModelId::Sam2).is_ok()); + } + + #[test] + fn cpu_runtime_executes_both_export_contracts_without_downloads() { + let dir = tempfile::tempdir().unwrap(); + for (name, bytes) in + [("matte.onnx", crate::test_models::MATTE), ("encoder.onnx", crate::test_models::ENCODER), ("decoder.onnx", crate::test_models::DECODER)] + { + fs::write(dir.path().join(name), bytes).unwrap(); + } + let mut matte = session(&dir.path().join("matte.onnx")).unwrap(); + let alpha = birefnet(&mut matte, vec![0.0; 3 * 2048 * 2048], &Interrupt::NONE).unwrap(); + assert_eq!(alpha.kind, MaskKind::Alpha); + assert!(alpha.values.iter().all(|v| (*v - 0.5).abs() < 1e-6)); + let mut encoder = session(&dir.path().join("encoder.onnx")).unwrap(); + let mut decoder = session(&dir.path().join("decoder.onnx")).unwrap(); + let mask = sam2(&mut encoder, &mut decoder, vec![0.0; 3 * 1024 * 1024], Prompt::Box([0.1, 0.2, 0.9, 0.8]), &Interrupt::NONE).unwrap(); + assert_eq!(mask.kind, MaskKind::Logits); + assert_eq!(mask.values, vec![1.0, 2.0]); + assert!(matches!(birefnet(&mut matte, vec![0.0; 3 * 2048 * 2048], &Interrupt::cancel_only(&|| true)), Err(Error::Cancelled))); + } + + #[test] + fn verified_stream_rejects_truncation_corruption_overflow_and_cancel() { + let mut out = Vec::new(); + copy_verified(&mut &b"abc"[..], &mut out, &ABC, &Interrupt::NONE, |_| {}).unwrap(); + assert_eq!(out, b"abc"); + for input in [b"ab".as_slice(), b"abd".as_slice(), b"abcd".as_slice()] { + assert!(copy_verified(&mut &input[..], &mut Vec::new(), &ABC, &Interrupt::NONE, |_| {}).is_err()); + } + assert!(matches!(copy_verified(&mut &b"abc"[..], &mut Vec::new(), &ABC, &Interrupt::cancel_only(&|| true), |_| {}), Err(Error::Cancelled))); + } + + #[test] + fn constructing_and_querying_a_backend_does_not_create_a_directory_or_download() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("models"); + let b = NativeBackend::new(root.clone()); + assert!(b.status().iter().all(|m| !m.installed)); + assert!(!root.exists()); + assert!(b.infer(ModelId::BiRefNet, &RgbImage::new(1, 1), Prompt::Subject, &Interrupt::NONE).is_err()); + assert!(!root.exists()); + } + + #[test] + fn remove_is_scoped_to_the_selected_model_and_busy_models_are_protected() { + let temp = tempfile::tempdir().unwrap(); + let b = NativeBackend::new(temp.path().into()); + let dir = b.directory(ModelId::BiRefNet); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("partial"), b"bad").unwrap(); + let unrelated = temp.path().join("preferences.json"); + fs::write(&unrelated, b"keep").unwrap(); + let guard = b.operation(ModelId::BiRefNet).unwrap(); + assert!(b.remove(ModelId::BiRefNet, &Interrupt::NONE).is_err()); + drop(guard); + b.remove(ModelId::BiRefNet, &Interrupt::NONE).unwrap(); + assert!(!dir.exists()); + assert_eq!(fs::read(unrelated).unwrap(), b"keep"); + } + + #[test] + fn sam_selects_the_highest_scoring_mask_and_rejects_bad_shapes() { + let m = sam_mask(&[1, 1, 3, 1, 2], &[-1.0, -1.0, 1.0, 2.0, 9.0, 9.0], &[0.2, 0.9, 0.4]).unwrap(); + assert_eq!(m.values, vec![1.0, 2.0]); + assert!(sam_mask(&[1, 1, i64::MAX, 1, 2], &[], &[]).is_err()); + assert!(sam_mask(&[1, 1, 1, 1, 2], &[0.0], &[1.0]).is_err()); + assert!(validate_prompt(ModelId::Sam2, Prompt::Box([0.0, 0.0, f32::NAN, 1.0])).is_err()); + } + + /// Explicit developer opt-in only. No normal test or first launch downloads weights. + #[test] + #[ignore = "requires PHOTOCRAFT_MODEL_TEST_DIR; PHOTOCRAFT_MODEL_TEST_DOWNLOAD=1 explicitly downloads ~1.8 GB"] + fn real_models_cpu_smoke() { + let root = std::env::var_os("PHOTOCRAFT_MODEL_TEST_DIR").expect("set a dedicated model test directory"); + let backend = NativeBackend::new(root.into()); + let mut image = RgbImage::new(192, 128); + for (i, p) in image.px.iter_mut().enumerate() { + let (x, y) = ((i % 192) as i32 - 96, (i / 192) as i32 - 64); + *p = if x * x + y * y < 45 * 45 { [0.9, 0.2, 0.1] } else { [0.1, 0.4, 0.7] }; + } + if std::env::var("PHOTOCRAFT_MODEL_TEST_DOWNLOAD").as_deref() == Ok("1") { + for id in [ModelId::BiRefNet, ModelId::Sam2] { + eprintln!("Downloading / verifying {}", id.name()); + backend.download(id, &Interrupt::NONE).unwrap(); + } + } + for id in [ModelId::BiRefNet, ModelId::Sam2] { + let prompt = if id == ModelId::Sam2 { Prompt::Box([0.2, 0.1, 0.8, 0.9]) } else { Prompt::Subject }; + let start = std::time::Instant::now(); + let mask = backend.infer(id, &image, prompt, &Interrupt::NONE).unwrap(); + mask.validate().unwrap(); + assert!(mask.values.iter().any(|v| v.abs() > 0.01)); + eprintln!("{} CPU: {:.2}s; mask {}x{}", id.name(), start.elapsed().as_secs_f64(), mask.width, mask.height); + } + } +} diff --git a/crates/ml/src/test_models.rs b/crates/ml/src/test_models.rs new file mode 100644 index 0000000000..fd31fccbb5 --- /dev/null +++ b/crates/ml/src/test_models.rs @@ -0,0 +1,45 @@ +//! Original synthetic ONNX protobufs: channel mean → sigmoid; constant embeddings; three +//! scored masks. No learned weights, network, Python runtime or external assets in CI. +//! Shapes and names deliberately match the reviewed export adapters, not model quality. +pub(super) const MATTE: &[u8] = &[ + 8, 9, 58, 237, 1, 10, 43, 18, 4, 97, 120, 101, 115, 34, 8, 67, 111, 110, 115, 116, 97, 110, 116, 42, 25, 10, 5, 118, 97, 108, 117, 101, 42, 13, 8, 1, 16, + 7, 58, 1, 1, 66, 4, 97, 120, 101, 115, 160, 1, 4, 10, 48, 10, 5, 105, 109, 97, 103, 101, 10, 4, 97, 120, 101, 115, 18, 4, 109, 101, 97, 110, 34, 10, 82, + 101, 100, 117, 99, 101, 77, 101, 97, 110, 42, 15, 10, 8, 107, 101, 101, 112, 100, 105, 109, 115, 24, 1, 160, 1, 2, 10, 22, 10, 4, 109, 101, 97, 110, 18, 5, + 97, 108, 112, 104, 97, 34, 7, 83, 105, 103, 109, 111, 105, 100, 18, 46, 80, 104, 111, 116, 111, 67, 114, 97, 102, 116, 32, 111, 114, 105, 103, 105, 110, + 97, 108, 32, 115, 121, 110, 116, 104, 101, 116, 105, 99, 32, 99, 111, 110, 116, 114, 97, 99, 116, 32, 102, 105, 120, 116, 117, 114, 101, 90, 33, 10, 5, + 105, 109, 97, 103, 101, 18, 24, 10, 22, 8, 1, 18, 18, 10, 2, 8, 1, 10, 2, 8, 3, 10, 3, 8, 128, 16, 10, 3, 8, 128, 16, 98, 33, 10, 5, 97, 108, 112, 104, 97, + 18, 24, 10, 22, 8, 1, 18, 18, 10, 2, 8, 1, 10, 2, 8, 1, 10, 3, 8, 128, 16, 10, 3, 8, 128, 16, 66, 4, 10, 0, 16, 18, +]; +pub(super) const ENCODER: &[u8] = &[ + 8, 9, 58, 218, 3, 10, 80, 18, 18, 105, 109, 97, 103, 101, 95, 101, 109, 98, 101, 100, 100, 105, 110, 103, 115, 46, 48, 34, 8, 67, 111, 110, 115, 116, 97, + 110, 116, 42, 48, 10, 5, 118, 97, 108, 117, 101, 42, 36, 8, 1, 8, 1, 8, 1, 8, 1, 16, 1, 34, 4, 0, 0, 0, 0, 66, 18, 105, 109, 97, 103, 101, 95, 101, 109, + 98, 101, 100, 100, 105, 110, 103, 115, 46, 48, 160, 1, 4, 10, 80, 18, 18, 105, 109, 97, 103, 101, 95, 101, 109, 98, 101, 100, 100, 105, 110, 103, 115, 46, + 49, 34, 8, 67, 111, 110, 115, 116, 97, 110, 116, 42, 48, 10, 5, 118, 97, 108, 117, 101, 42, 36, 8, 1, 8, 1, 8, 1, 8, 1, 16, 1, 34, 4, 0, 0, 128, 63, 66, + 18, 105, 109, 97, 103, 101, 95, 101, 109, 98, 101, 100, 100, 105, 110, 103, 115, 46, 49, 160, 1, 4, 10, 80, 18, 18, 105, 109, 97, 103, 101, 95, 101, 109, + 98, 101, 100, 100, 105, 110, 103, 115, 46, 50, 34, 8, 67, 111, 110, 115, 116, 97, 110, 116, 42, 48, 10, 5, 118, 97, 108, 117, 101, 42, 36, 8, 1, 8, 1, 8, + 1, 8, 1, 16, 1, 34, 4, 0, 0, 0, 64, 66, 18, 105, 109, 97, 103, 101, 95, 101, 109, 98, 101, 100, 100, 105, 110, 103, 115, 46, 50, 160, 1, 4, 18, 46, 80, + 104, 111, 116, 111, 67, 114, 97, 102, 116, 32, 111, 114, 105, 103, 105, 110, 97, 108, 32, 115, 121, 110, 116, 104, 101, 116, 105, 99, 32, 99, 111, 110, + 116, 114, 97, 99, 116, 32, 102, 105, 120, 116, 117, 114, 101, 90, 40, 10, 12, 112, 105, 120, 101, 108, 95, 118, 97, 108, 117, 101, 115, 18, 24, 10, 22, 8, + 1, 18, 18, 10, 2, 8, 1, 10, 2, 8, 3, 10, 3, 8, 128, 8, 10, 3, 8, 128, 8, 98, 44, 10, 18, 105, 109, 97, 103, 101, 95, 101, 109, 98, 101, 100, 100, 105, 110, + 103, 115, 46, 48, 18, 22, 10, 20, 8, 1, 18, 16, 10, 2, 8, 1, 10, 2, 8, 1, 10, 2, 8, 1, 10, 2, 8, 1, 98, 44, 10, 18, 105, 109, 97, 103, 101, 95, 101, 109, + 98, 101, 100, 100, 105, 110, 103, 115, 46, 49, 18, 22, 10, 20, 8, 1, 18, 16, 10, 2, 8, 1, 10, 2, 8, 1, 10, 2, 8, 1, 10, 2, 8, 1, 98, 44, 10, 18, 105, 109, + 97, 103, 101, 95, 101, 109, 98, 101, 100, 100, 105, 110, 103, 115, 46, 50, 18, 22, 10, 20, 8, 1, 18, 16, 10, 2, 8, 1, 10, 2, 8, 1, 10, 2, 8, 1, 10, 2, 8, + 1, 66, 4, 10, 0, 16, 18, +]; +pub(super) const DECODER: &[u8] = &[ + 8, 9, 58, 149, 4, 10, 86, 18, 10, 112, 114, 101, 100, 95, 109, 97, 115, 107, 115, 34, 8, 67, 111, 110, 115, 116, 97, 110, 116, 42, 62, 10, 5, 118, 97, 108, + 117, 101, 42, 50, 8, 1, 8, 1, 8, 3, 8, 1, 8, 2, 16, 1, 34, 24, 0, 0, 128, 191, 0, 0, 0, 192, 0, 0, 128, 63, 0, 0, 0, 64, 0, 0, 128, 64, 0, 0, 160, 64, 66, + 10, 112, 114, 101, 100, 95, 109, 97, 115, 107, 115, 160, 1, 4, 10, 70, 18, 10, 105, 111, 117, 95, 115, 99, 111, 114, 101, 115, 34, 8, 67, 111, 110, 115, + 116, 97, 110, 116, 42, 46, 10, 5, 118, 97, 108, 117, 101, 42, 34, 8, 1, 8, 1, 8, 3, 16, 1, 34, 12, 205, 204, 204, 61, 102, 102, 102, 63, 205, 204, 76, 62, + 66, 10, 105, 111, 117, 95, 115, 99, 111, 114, 101, 115, 160, 1, 4, 18, 46, 80, 104, 111, 116, 111, 67, 114, 97, 102, 116, 32, 111, 114, 105, 103, 105, 110, + 97, 108, 32, 115, 121, 110, 116, 104, 101, 116, 105, 99, 32, 99, 111, 110, 116, 114, 97, 99, 116, 32, 102, 105, 120, 116, 117, 114, 101, 90, 38, 10, 12, + 105, 110, 112, 117, 116, 95, 112, 111, 105, 110, 116, 115, 18, 22, 10, 20, 8, 1, 18, 16, 10, 2, 8, 1, 10, 2, 8, 1, 10, 2, 8, 1, 10, 2, 8, 2, 90, 34, 10, + 12, 105, 110, 112, 117, 116, 95, 108, 97, 98, 101, 108, 115, 18, 18, 10, 16, 8, 7, 18, 12, 10, 2, 8, 1, 10, 2, 8, 1, 10, 2, 8, 1, 90, 33, 10, 11, 105, 110, + 112, 117, 116, 95, 98, 111, 120, 101, 115, 18, 18, 10, 16, 8, 1, 18, 12, 10, 2, 8, 1, 10, 2, 8, 1, 10, 2, 8, 4, 90, 44, 10, 18, 105, 109, 97, 103, 101, 95, + 101, 109, 98, 101, 100, 100, 105, 110, 103, 115, 46, 48, 18, 22, 10, 20, 8, 1, 18, 16, 10, 2, 8, 1, 10, 2, 8, 1, 10, 2, 8, 1, 10, 2, 8, 1, 90, 44, 10, 18, + 105, 109, 97, 103, 101, 95, 101, 109, 98, 101, 100, 100, 105, 110, 103, 115, 46, 49, 18, 22, 10, 20, 8, 1, 18, 16, 10, 2, 8, 1, 10, 2, 8, 1, 10, 2, 8, 1, + 10, 2, 8, 1, 90, 44, 10, 18, 105, 109, 97, 103, 101, 95, 101, 109, 98, 101, 100, 100, 105, 110, 103, 115, 46, 50, 18, 22, 10, 20, 8, 1, 18, 16, 10, 2, 8, + 1, 10, 2, 8, 1, 10, 2, 8, 1, 10, 2, 8, 1, 98, 40, 10, 10, 112, 114, 101, 100, 95, 109, 97, 115, 107, 115, 18, 26, 10, 24, 8, 1, 18, 20, 10, 2, 8, 1, 10, 2, + 8, 1, 10, 2, 8, 3, 10, 2, 8, 1, 10, 2, 8, 2, 98, 32, 10, 10, 105, 111, 117, 95, 115, 99, 111, 114, 101, 115, 18, 18, 10, 16, 8, 1, 18, 12, 10, 2, 8, 1, 10, + 2, 8, 1, 10, 2, 8, 3, 66, 4, 10, 0, 16, 18, +]; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 479de86891..5854d17163 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -7,6 +7,10 @@ license.workspace = true [lints] workspace = true +[features] +default = [] +local-ml = ["photocraft-engine/local-ml"] + [dependencies] photocraft-geom = { workspace = true } photocraft-color = { workspace = true } diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 25ade0aa7d..bb0871e765 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -65,13 +65,24 @@ fn main() { let custom_titlebar = args.iter().any(|a| a == "--custom-titlebar"); // `--settle-ms N`: keep rendering frames for N ms before the capture (e.g. mid-job). let settle_ms: u64 = arg(&args, "--settle-ms").and_then(|s| s.parse().ok()).unwrap_or(0); + #[cfg(feature = "local-ml")] + let models_dir = arg(&args, "--models-dir"); let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(w, h)).with_pixels_per_point(scale).with_max_steps(64).wgpu().build_eframe(move |cc| { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); let mut services = services; services.is_wayland = wayland_notice; services.xwayland_command = wayland_notice.then(|| "WAYLAND_DISPLAY= photocraft".into()); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + let session = photocraft_engine::Session::new(); + #[cfg(feature = "local-ml")] + let session = { + let mut session = session; + if let Some(dir) = models_dir { + session.configure_local_models(dir.into()); + } + session + }; + let mut app = PhotocraftApp::new(session, services); app.background_jobs = background_jobs; app.custom_titlebar = custom_titlebar; // `--safe-gpu`: the CPU canvas, as the desktop app's `--safe-gpu` launch. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d8a91bba60..c121ee3403 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1,3 +1,5 @@ + + # Czech (cs) catalog. Columns, separated by a TAB: context source translation # # context empty a plain UI string; `source` is the English text in the code diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b6ab73ce9d..21b3a3f156 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1,3 +1,5 @@ + + # German (de) catalog. Columns, separated by a TAB: context source translation # # context empty a plain UI string; `source` is the English text in the code diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6c2b7adf7e..2100ea27ad 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1,3 +1,5 @@ + + # Greek (el) PhotoCraft interface catalog. # Columns separated by TAB: context, English source, Greek translation. # Original translations based on general Greek graphics/computing terminology. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f08fd2ceaa..0647b59dc3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1,3 +1,5 @@ + + # Spanish (es) catalog. Columns, separated by a TAB: context source translation # # context empty a plain UI string; `source` is the English text in the code diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e71f7cad0d..daa78d6f7d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1,3 +1,5 @@ + + # French (fr) catalog. Columns, separated by a TAB: context source translation # # context empty a plain UI string; `source` is the English text in the code diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 71fb019474..a5118bda91 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1,3 +1,5 @@ + + # Indonesian (id) catalog. Columns, separated by a TAB: context source translation # # context empty a plain UI string; `source` is the English text in the code diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c037107357..10356202de 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1,3 +1,5 @@ + + # Italian (it) catalog. Columns, separated by a TAB: context source translation # # context empty a plain UI string; `source` is the English text in the code diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 235f663785..1a83a49836 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1,3 +1,5 @@ + + # Japanese (ja) catalog. Columns, separated by a TAB: context source translation # # context empty a plain UI string; `source` is the English text in the code diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ca0923b7d1..afb127f18b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1,3 +1,5 @@ + + # Korean (ko) catalog. UTF-8: contextEnglish sourcetranslation. # Empty context: plain UI text; @id: command ID; @plural: one|other source # with one Korean form. Other contexts disambiguate English meanings. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d222d86fbe..e51132fdf3 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1,3 +1,5 @@ + + # Brazilian Portuguese (pt-br) catalog. Columns, separated by a TAB: context source translation # # context empty a plain UI string; `source` is the English text in the code diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 62b7c28fd5..56580dc26b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1,3 +1,5 @@ + + # Russian (ru) catalog. Columns, separated by a TAB: context source translation # # context empty a plain UI string; `source` is the English text in the code diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 52a4f5009b..1f9da7010d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1,3 +1,5 @@ + + # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns: # context English source Simplified Chinese translation # diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9f8ac1f94e..3fcd92cc04 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1,3 +1,5 @@ + + # Traditional Chinese (zh-hant) catalog. Columns, separated by a TAB: context source translation # # context empty a plain UI string; `source` is the English text in the code diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 9b0f48ac0c..67e3c596b8 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -884,7 +884,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map { f.insert("__gpuInfo".into(), json!(app.perf.gpu_info.lines())); let system = system_theme(app, ui.ctx()); - prefs_body(ui, f, system); + prefs_body(app, ui, f, system); } "shortcuts" => shortcuts_body(app, ui, f), "presets" => presets_body(app, ui, f), @@ -943,6 +943,9 @@ fn choice_label(v: &str) -> String { "metal" => "Metal".into(), "gl" => "OpenGL".into(), "cpu" => "CPU (no GPU acceleration)".into(), + "classical" => "Classical (built in)".into(), + "birefnet-hr-matting" => "BiRefNet HR Matting".into(), + "sam2.1-large" => "SAM 2.1 Large".into(), v => humanize(v), } } @@ -971,7 +974,7 @@ fn color_of(s: &str) -> Color32 { } /// Preferences: section list on the left, the section's settings on the right. -fn prefs_body(ui: &mut egui::Ui, f: &mut Map, system: Option) { +fn prefs_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, system: Option) { let t = Tokens::get(ui.ctx()); let mut section = f.get("section").and_then(Value::as_str).unwrap_or("general").to_string(); let mut values = f.get("values").cloned().unwrap_or(Value::Null); @@ -1024,7 +1027,7 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map, system: Option, system: Option, system: Option) { + let t = Tokens::get(ui.ctx()); + let status = app.session.local_model_status(); + let available = status["available"].as_bool().unwrap_or(false); + ui.add_space(10.0); + ui.label(RichText::new(tl!("Local selection models")).font(crate::theme::semibold(12.5)).color(t.text)); + ui.label(RichText::new(tl!("Models run on this device. Images stay on this device.")).color(t.text_dim)); + ui.label(RichText::new(tl!("Download a model before choosing it. Downloads do not change the current method.")).color(t.text_dim)); + if !available { + ui.label(RichText::new(tl!("Local models aren't available in this build or session.")).color(t.text_faint)); + } + ui.add_space(6.0); + for model in status["models"].as_array().into_iter().flatten() { + let id = model["id"].as_str().unwrap_or_default(); + let label = model["label"].as_str().unwrap_or_default(); + let installed = model["installed"].as_bool().unwrap_or(false); + let busy = model["busy"].as_bool().unwrap_or(false); + let mib = model["downloadBytes"].as_u64().unwrap_or(0).div_ceil(1024 * 1024); + ui.push_id(id, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(label).color(t.text)); + ui.label( + RichText::new(if busy { + tl!("In use…") + } else if installed { + tl!("Downloaded") + } else { + tl!("Not downloaded") + }) + .color(t.text_faint), + ); + }); + ui.label(RichText::new(tl!(model["purpose"].as_str().unwrap_or_default())).color(t.text_dim)); + ui.horizontal(|ui| { + let button = if installed { tl!("Remove download").to_string() } else { format!("{} ({mib} MiB)", tl!("Download")) }; + let clicked = ui.add_enabled_ui(available && !busy, |ui| crate::widgets::secondary_button(ui, &button, 190.0).clicked()).inner; + if clicked { + let command = if installed { "models.remove" } else { "models.download" }; + if app.run(command, json!({"id": id})).is_ok() && installed { + let pref = if id == "sam2.1-large" { "objectModel" } else { "subjectModel" }; + obj.insert(pref.into(), json!("classical")); + } + } + ui.hyperlink_to(model["license"].as_str().unwrap_or_default(), model["upstream"].as_str().unwrap_or_default()); + ui.hyperlink_to(tl!("Model details"), model["exportSource"].as_str().unwrap_or_default()); + }); + ui.add_space(8.0); + }); + } + ui.label(RichText::new(tl!("Large models can take time and use several GB of memory. Progress and Cancel appear in the status bar.")).color(t.text_faint)); +} + /// Preferences › Performance: what the app renders with now, and a reset of the GPU backend /// (a crashed start may have moved it to a safer choice). fn gpu_status_rows(ui: &mut egui::Ui, info: Option<&Value>, obj: &mut Map) { @@ -1249,7 +1309,21 @@ fn ctx_take_pick(ui: &egui::Ui) -> Option<(String, String)> { /// Generic editor for a section's fields: checkboxes, dropdowns for choices, colour swatches, /// number fields with the preference's range, text fields. -fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map, order: &[String], lang: crate::i18n::Lang, system: Option) { +fn field_label(section: &str, key: &str) -> String { + match (section, key) { + ("integrations", "subjectModel") => "Subject / background removal".into(), + ("integrations", "objectModel") => "Object selection".into(), + _ => humanize(key), + } +} + +fn model_choice_available(status: &Value, option: &str) -> bool { + option == "classical" + || (status["available"].as_bool().unwrap_or(false) + && status["models"].as_array().into_iter().flatten().any(|m| m["id"].as_str() == Some(option) && m["installed"].as_bool() == Some(true))) +} + +fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map, order: &[String], lang: crate::i18n::Lang, system: Option, models: &Value) { let t = Tokens::get(ui.ctx()); if section == "interface" { appearance_rows(ui, obj, system); @@ -1272,7 +1346,7 @@ fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map continue; } let v = obj.get(&k).cloned().unwrap_or(Value::Null); - let human = humanize(&k); + let human = field_label(section, &k); let label = tl!(&human).to_string(); match &v { Value::Bool(b) => { @@ -1298,7 +1372,14 @@ fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map } Value::String(s) if prefs::choices(&path).is_some() => { ui.label(RichText::new(tl!(&label)).color(t.text_dim)); - let opts = prefs::choices(&path).unwrap_or(&[]); + let opts: Vec<&str> = prefs::choices(&path) + .unwrap_or(&[]) + .iter() + .copied() + .filter(|v| { + !matches!(path.as_str(), "integrations.subjectModel" | "integrations.objectModel") || *v == s || model_choice_available(models, v) + }) + .collect(); let labels: Vec = opts.iter().map(|o| choice_label(o)).collect(); let pairs: Vec<(String, &str)> = opts.iter().map(|o| o.to_string()).zip(labels.iter().map(String::as_str)).collect(); let mut cur = s.clone(); @@ -1837,6 +1918,17 @@ mod tests { let shortcuts = serde_json::json!({"__prefsui": "shortcuts"}); assert_eq!(super::width(shortcuts.as_object().unwrap(), 3000.0), Some(720.0)); } + + #[test] + fn local_model_choices_need_a_backend_and_a_download() { + assert!(model_choice_available(&json!({"available":false}), "classical")); + assert!(!model_choice_available(&json!({"available":false}), "sam2.1-large")); + let mut status = json!({"available":true,"models":[{"id":"sam2.1-large","installed":false}]}); + assert!(!model_choice_available(&status, "sam2.1-large")); + status["models"][0]["installed"] = json!(true); + assert!(model_choice_available(&status, "sam2.1-large")); + assert!(!model_choice_available(&status, "birefnet-hr-matting")); + } use super::*; use std::sync::{Arc, Mutex}; @@ -2084,11 +2176,18 @@ mod tests { } let Some(obj) = v.get(sec).and_then(Value::as_object) else { continue }; for k in obj.keys() { - let mut labels = vec![humanize(k)]; + let mut labels = vec![field_label(sec, k)]; labels.extend(prefs::choices(&format!("{sec}.{k}")).into_iter().flatten().map(|c| choice_label(c))); missing.extend(labels.into_iter().filter(|l| !crate::i18n::has(lang, l))); } } + for model in session.local_model_status()["models"].as_array().into_iter().flatten() { + if let Some(purpose) = model["purpose"].as_str() + && !crate::i18n::has(lang, purpose) + { + missing.push(purpose.to_string()); + } + } missing.sort(); missing.dedup(); assert!(missing.is_empty(), "{}: untranslated preference labels: {missing:#?}", lang.code()); @@ -2391,8 +2490,10 @@ mod tests { let values = prefs::Preferences::default().to_json(); assert!(has_visible_fields(&values, "general")); assert!(has_visible_fields(&values, "fileHandling")); + assert!(has_visible_fields(&values, "integrations")); + assert!(has_visible_fields(&values, "enhancedControls")); // Every setting of these sections is still unimplemented. - for section in ["integrations", "scratchDisks"] { + for section in ["scratchDisks"] { assert!(!has_visible_fields(&values, section), "{section}"); } // "Fill new type layers with placeholder text" and "Use Escape to Commit" are live; other Type rows stay hidden. diff --git a/docs/architecture.md b/docs/architecture.md index 7db7ba0e24..eaa098d6f7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -154,7 +154,7 @@ photocraft/ └─ plan/ this directory ``` -**What exists today.** This layout is the target design. Built so far: `geom`, `cms`, `color`, `raster`, `psd`, `codecs`, `doc`, `ops`, `algo`, `paint`, `text`, `vector`, `compose`, `gpu`, `format`, `raw`, `io`, `plugins`, `engine`, `ui-egui`, `automation`, `testkit`, and the three apps. Not yet split out: `viewport` and `tools` live inside `ui-egui` and `engine`; `platform` services are function hooks injected by each app (`ui_egui::Services`); `adobe-assets` and `ml` are not started. +**What exists today.** This layout is the target design. Built so far: `geom`, `cms`, `color`, `raster`, `psd`, `codecs`, `doc`, `ops`, `algo`, `paint`, `text`, `vector`, `compose`, `gpu`, `format`, `raw`, `io`, `plugins`, `ml`, `engine`, `ui-egui`, `automation`, `testkit`, and the three apps. `ml` supplies the optional native CPU backend for BiRefNet matting and SAM 2.1 box selection; [local models](local-models.md) documents its downloads and limits. Not yet split out: `viewport` and `tools` live inside `ui-egui` and `engine`; `platform` services are function hooks injected by each app (`ui_egui::Services`); `adobe-assets` is not started. Web ML, depth and sky models remain planned. **Crate granularity:** start with the crates above. Split `algo` into `-adjust`, `-filters`, `-select`, `-inpaint` and `-warp` once any module passes about 10k lines, or once compile times hurt. Its internal module boundaries should already follow those lines. diff --git a/docs/development.md b/docs/development.md index e75aad769e..c62e7a79d8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -46,6 +46,10 @@ cargo xtask parity # Photoshop menu cove Image code is slow at `opt-level 0`, so the workspace profile builds dependencies at `opt-level 2`. Use `--release` for anything interactive. +Optional CPU models for Object Selection, Select Subject and Remove Background are enabled with +`--features local-ml`. Weights remain separate, explicit downloads. See [local models](local-models.md) +for Preferences, CLI, provenance and native runtime packaging details. + The dev profile builds with `debug = "line-tables-only"`: panic backtraces keep file and line, and test binaries link about twice as fast. To inspect local variables in a debugger, build with `CARGO_PROFILE_DEV_DEBUG=true`. ### Faster test loops diff --git a/docs/images/LICENSE-local-models.txt b/docs/images/LICENSE-local-models.txt new file mode 100644 index 0000000000..4936621edd --- /dev/null +++ b/docs/images/LICENSE-local-models.txt @@ -0,0 +1,10 @@ +local-models-before.png and local-models-preferences.png are original PhotoCraft UI screenshots +and are licensed under this repository's MIT OR Apache-2.0 terms (LICENSE-MIT / LICENSE-APACHE). + +local-models-comparison.png uses Johannes Vermeer's Girl with a Pearl Earring, c. 1665. +The original artwork and its faithful photographic reproduction are public domain (PD-Art). +Source: https://commons.wikimedia.org/wiki/File:1665_Girl_with_a_Pearl_Earring.jpg +The comparison layout and command output presentation are original PhotoCraft contributions, +licensed under this repository's MIT OR Apache-2.0 terms. + +No learned model weights or personal photographs are included in these documentation assets. diff --git a/docs/images/SOURCES.md b/docs/images/SOURCES.md index 723d1837fc..12a8896fa1 100644 --- a/docs/images/SOURCES.md +++ b/docs/images/SOURCES.md @@ -11,6 +11,8 @@ | `photocraft-transform.jpg` | Photocraft UI screenshot (offscreen snapshot) with *The Tetons and the Snake River*, Ansel Adams, 1942 (U.S. National Archives) | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg). UI: this project. | | `photocraft-type.jpg` | Photocraft UI screenshot (offscreen snapshot) with *Among the Sierra Nevada, California*, Albert Bierstadt, 1868 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Albert_Bierstadt_-_Among_the_Sierra_Nevada,_California_-_Google_Art_Project.jpg). UI: this project. | | `photocraft-export-light.jpg` | Photocraft UI screenshot (offscreen snapshot) with *The Kiss*, Gustav Klimt, 1907–1908 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Gustav_Klimt_016.jpg). UI: this project. | +| `local-models-before.png`, `local-models-preferences.png` | PhotoCraft Preferences, offscreen snapshots before / after optional local models | Original project UI, MIT OR Apache-2.0; [licence](LICENSE-local-models.txt). | +| `local-models-comparison.png` | Actual exported classical / BiRefNet / SAM masks over a checkerboard, using *Girl with a Pearl Earring*, Johannes Vermeer, c. 1665 | Artwork: public domain, [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:1665_Girl_with_a_Pearl_Earring.jpg); original layout: MIT OR Apache-2.0; [licence](LICENSE-local-models.txt). | Screenshots must use famous public-domain artwork (or content made in the app), never personal photos. diff --git a/docs/images/local-models-before.png b/docs/images/local-models-before.png new file mode 100644 index 0000000000000000000000000000000000000000..90442321a124caabbf7822b9addc221ee2533275 GIT binary patch literal 123594 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 Date: Thu, 8 Oct 2026 16:16:49 +0300 Subject: [PATCH 002/229] Keep model management independent of document state --- crates/engine/src/float_cmds.rs | 2 +- crates/engine/src/model_cmds.rs | 50 +++++++++++++++++++++++++++++++-- docs/local-models-validation.md | 14 +++++---- docs/local-models.md | 8 ++++++ 4 files changed, 65 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/float_cmds.rs b/crates/engine/src/float_cmds.rs index 892f754748..ea1bd37af1 100644 --- a/crates/engine/src/float_cmds.rs +++ b/crates/engine/src/float_cmds.rs @@ -205,7 +205,7 @@ pub fn drop_floating(s: &mut Session) -> Result { /// Commands that leave a floating piece floating: its own, and ones that don't touch documents. fn keeps_floating(id: &str) -> bool { - matches!(id, "select.float" | "select.drop") || ["view.", "window.", "help.", "prefs."].iter().any(|p| id.starts_with(p)) + matches!(id, "select.float" | "select.drop") || ["view.", "window.", "help.", "prefs.", "models."].iter().any(|p| id.starts_with(p)) } /// Before any command (`Session::dispatch`): Undo puts a floating piece back (and is used up: diff --git a/crates/engine/src/model_cmds.rs b/crates/engine/src/model_cmds.rs index 7524449fc8..8b209cfa63 100644 --- a/crates/engine/src/model_cmds.rs +++ b/crates/engine/src/model_cmds.rs @@ -84,9 +84,7 @@ impl Sampler for ModelSampler<'_> { for (dst, v) in p.iter_mut().take(3).zip(rgb) { *dst = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; } - if !p[3].is_finite() { - p[3] = 0.0; - } + p[3] = if p[3].is_finite() { p[3].clamp(0.0, 1.0) } else { 0.0 }; } px } @@ -395,6 +393,33 @@ mod tests { assert_eq!(loaded.integrations.object_model, crate::prefs::ObjectModel::Classical); } + #[test] + fn managing_models_does_not_drop_a_floating_selection_or_edit_the_document() { + let (mut s, _) = session("rgb", 8); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, layer| { + let bounds = doc.bounds(); + doc.layer_mut(layer.unwrap()).unwrap().surface_mut().unwrap().fill_rect(bounds, &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x":0,"y":0,"width":2,"height":2})).unwrap(); + s.execute("select.float", json!({"dx":2,"dy":1})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let history = s.active().unwrap().history.past_len(); + let journal = s.journal.len(); + for (command, params) in + [("models.list", json!({})), ("models.download", json!({"id":"birefnet-hr-matting"})), ("models.remove", json!({"id":"birefnet-hr-matting"}))] + { + s.execute(command, params).unwrap(); + let state = s.active().unwrap(); + assert!(Arc::ptr_eq(&state.doc, &doc)); + assert_eq!(state.history.past_len(), history); + assert_eq!(state.floating.as_ref().unwrap().offset, (2, 1)); + assert_eq!(s.journal.len(), journal); + } + } + #[test] fn preferences_select_a_model_and_explicit_classical_overrides_it() { let (mut s, fake) = session("rgb", 32); @@ -478,6 +503,25 @@ mod tests { assert_eq!(s.active().unwrap().doc.layers[0].mask.as_ref().unwrap().value(7, 2), 0.0); } + #[test] + fn out_of_range_and_non_finite_alpha_only_affect_the_model_input() { + // The existing segmentation sampler composites transparent pixels over neutral gray. + for (alpha, expected) in [(2.0, 0.25), (f32::NAN, 0.5)] { + let (mut s, fake) = session("rgb", 32); + s.edit("HDR alpha", |doc, _| { + doc.icc_profile = Some(Builtin::Srgb.profile().to_bytes()); + let bounds = doc.bounds(); + doc.layers[0].surface_mut().unwrap().fill_rect(bounds, &[0.25, 0.25, 0.25, alpha]); + Ok(()) + }) + .unwrap(); + let before: Vec = pixels(&s).iter().map(|v| v.to_bits()).collect(); + s.execute("select.subject", json!({"model":"birefnet-hr-matting","sampleAllLayers":false})).unwrap(); + assert_eq!(pixels(&s).iter().map(|v| v.to_bits()).collect::>(), before); + assert!(fake.calls.lock().unwrap()[0].1.px.iter().flatten().all(|v| v.is_finite() && (*v - expected).abs() < 0.005)); + } + } + #[test] #[cfg(not(target_arch = "wasm32"))] fn cancelling_model_inference_discards_result_and_unlocks_document() { diff --git a/docs/local-models-validation.md b/docs/local-models-validation.md index 41a31e6976..8bce5a5b62 100644 --- a/docs/local-models-validation.md +++ b/docs/local-models-validation.md @@ -62,7 +62,9 @@ cargo run --release -p photocraft-engine --features local-ml --example local_mod tests cover scoped removal, busy state and dependency panic recovery. - Engine tests cover RGB/Gray/CMYK/Lab at 8/16/32-bit, ICC input conversion, fractional alpha, layer-mask replacement, selection modes and rectangle mapping, one-step Undo/Redo, cancellation, - missing-model errors, recorded method choices and batch/droplet capability inheritance. + missing-model errors, recorded method choices, batch/droplet capability inheritance and model + management while a selection floats. Non-finite/out-of-range alpha is sanitized for model input + while original 32-bit pixel values are preserved. - Full engine, CLI and UI test suites pass locally. UI preference/localization coverage includes every supported complete-menu language. Default and `local-ml` build paths retain classical operation without installing models. @@ -76,10 +78,12 @@ cargo run --release -p photocraft-engine --features local-ml --example local_mod - `xtask perf --quick --bench perf_scenarios` passes for the existing selection/retouch scenarios; it does not establish a sub-100 ms budget for the optional models. -The dedicated `local-models.yml` workflow adds Linux, Windows and macOS CPU runtime tests and -native build checks. Windows/Linux results must be verified in CI before removing the draft -status; they have not been run on this Mac. Quantitative accuracy evaluation, packaged installer -enablement, web inference, point prompts and GPU acceleration remain separate work. +The dedicated `local-models.yml` workflow runs Linux, Windows and macOS CPU runtime tests and +native build checks. The [initial cross-platform run](https://github.com/mighty-programmer/photocraft/actions/runs/37780207526) +passes on all three platforms. Final document-state regression tests are being rerun in the +same matrix before this contribution leaves draft. Upstream PR CI needs maintainer approval for +this first contribution. Quantitative accuracy evaluation, packaged installer / sandbox download +checks, web inference, point prompts and GPU acceleration remain separate work. Image sources and licences: [SOURCES.md](images/SOURCES.md), [LICENSE-local-models.txt](images/LICENSE-local-models.txt). diff --git a/docs/local-models.md b/docs/local-models.md index 42be82d267..21056b559c 100644 --- a/docs/local-models.md +++ b/docs/local-models.md @@ -49,6 +49,14 @@ The desktop cache is `Models/` under the existing per-user configuration directo preferences. Each immutable model revision gets its own directory with `LICENSE.txt` and `SOURCE.txt`; inference verifies the hashes again, including SAM's external weights. +Linux uses the existing XDG configuration path, so an AppImage stores models outside its +read-only mounted image and a Flatpak uses its sandbox's XDG configuration directory. The current +Flatpak manifests omit network permission and do not enable `local-ml`. A distributor opting in +must also choose how explicit downloads receive network access (or prepopulate a verified cache); +this backend does not alter sandbox permissions. Windows uses `%APPDATA%\\Photocraft\\Models` +outside portable/override mode. Installer packaging and sandbox download checks remain separate +from the native CPU build matrix. + ## Commands and CLI | Command | Parameters | From ab2c0e7c2c763f0a4b700dac6d386e9509809e41 Mon Sep 17 00:00:00 2001 From: mighty-programmer <83077551+mighty-programmer@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:27:56 +0300 Subject: [PATCH 003/229] Record successful cross-platform model validation --- docs/local-models-validation.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/local-models-validation.md b/docs/local-models-validation.md index 8bce5a5b62..aec48b9254 100644 --- a/docs/local-models-validation.md +++ b/docs/local-models-validation.md @@ -79,9 +79,9 @@ cargo run --release -p photocraft-engine --features local-ml --example local_mod it does not establish a sub-100 ms budget for the optional models. The dedicated `local-models.yml` workflow runs Linux, Windows and macOS CPU runtime tests and -native build checks. The [initial cross-platform run](https://github.com/mighty-programmer/photocraft/actions/runs/37780207526) -passes on all three platforms. Final document-state regression tests are being rerun in the -same matrix before this contribution leaves draft. Upstream PR CI needs maintainer approval for +native build checks. The [final cross-platform run](https://github.com/mighty-programmer/photocraft/actions/runs/37783114338) +passes on all three platforms, including the final document-state and input regression tests. +The contribution is ready for review. Upstream PR CI needs maintainer approval for this first contribution. Quantitative accuracy evaluation, packaged installer / sandbox download checks, web inference, point prompts and GPU acceleration remain separate work. From 7f272aacd6534c1afb19c51913717e66ae808798 Mon Sep 17 00:00:00 2001 From: mighty-programmer <83077551+mighty-programmer@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:00:25 +0300 Subject: [PATCH 004/229] Translate optional model settings into Polish --- crates/ui-egui/src/i18n/pl.tsv | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 797a79a93d..87d310620f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1,3 +1,5 @@ + + # Polish (pl) catalog. Columns, separated by a TAB: context source translation # # context empty a plain UI string; `source` is the English text in the code From 58fceda01f29d3d624e304f2aa457edb003be0e8 Mon Sep 17 00:00:00 2001 From: mighty-programmer <83077551+mighty-programmer@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:15:33 +0300 Subject: [PATCH 005/229] Publish fixed model evaluation and register contributor real name --- contributors/people.toml | 1 + docs/local-models-validation.md | 53 ++++++++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index b2b97d2438..4ed028a4ab 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -92,4 +92,5 @@ sync_github_name = false sync_github_name = true [people.mighty-programmer] +real_name = "John Pantrakis" sync_github_name = true diff --git a/docs/local-models-validation.md b/docs/local-models-validation.md index aec48b9254..a07cbd4f58 100644 --- a/docs/local-models-validation.md +++ b/docs/local-models-validation.md @@ -2,8 +2,9 @@ Recorded 2026-10-08 on an Apple M3 Max with 36 GiB RAM, Rust 1.98.0, native CPU inference and the immutable model exports listed in [local-models.md](local-models.md). No learned weights -are included in this repository. This is an initial integration and performance check, not a -labelled accuracy benchmark or evidence that a model always improves on the classical method. +are included in this repository. The integration/performance checks below are supplemented by a +fixed 22-case evaluation. Its 20 labelled COCO targets are exploratory evidence, not a representative +or held-out benchmark, a soft-alpha matting assessment, or evidence of a universal winner. ## Preferences before and after @@ -41,8 +42,8 @@ selection, SAM object selection. ![Actual command outputs](images/local-models-comparison.png) The classical method works well on this example. BiRefNet supplies continuous edge alpha; -SAM leaves some unwanted holes. These results support keeping both choices available and -requiring a labelled multi-image benchmark before a general accuracy claim. +SAM leaves some unwanted holes. These results support keeping both choices available. The +exploratory multi-image evaluation below also includes failures and cases where Classical is better. Repeated BiRefNet runs initially exhausted memory with ONNX Runtime's default CPU arena and memory-pattern caching. Disabling both while retaining only the current model's weights let @@ -55,6 +56,44 @@ cargo run --release -p photocraft-engine --features local-ml --example local_mod /absolute/model-cache /absolute/image.png /absolute/comparison-directory ``` +## Fixed multi-image evaluation + +The [33-page comparison PDF and public reproducibility supplement](https://github.com/mighty-programmer/photocraft/releases/tag/model-evaluation-2026-10-08) +contain all 22 distinct cases: 20 COCO val2017 images with independent binary instance references, +plus two user-supplied LEGO photos for visual inspection. There are 44 paired comparisons, +88 final method outcomes and 86 actual exported cutouts. BiRefNet's no-subject chair result and +Classical's empty zebra object selection are explicit failures, counted as zero rather than omitted. + +| Feature | Classical mean target IoU | Learned mean target IoU | Learned wins / losses / near-ties | +|---|---:|---:|---:| +| Remove Background | 31.5% | 51.5% (BiRefNet HR Matting) | 15 / 2 / 3 | +| Box Object Selection | 52.5% | 79.1% (SAM 2.1 Large) | 16 / 3 / 1 | + +Cases were selected using a fixed hash seed before inference, one from each of 20 categories. +Alpha is thresholded at 128/255, and a win/loss requires over one percentage point of IoU change. +Object boxes are derived from the annotation with 10% padding and reused for both methods. +The two unlabelled LEGO photos are excluded from the means. Each background-removal reference +contains one instance; keeping other plausible foreground subjects counts as a false positive. +COCO polygons cannot establish hair, soft-alpha or transparent-glass matting quality. This selected +pilot may overlap training data, and its annotation-derived boxes are easier than imperfect user boxes. + +Median warm command times on the M3 Max / 36 GiB were 0.19 / 26.63 seconds for Classical / +BiRefNet removal and 0.20 / 5.59 seconds for Classical / SAM selection. These single-pass observations +exclude image import/export and initial/model-loading cases; they are not Windows/Linux timings. +Storage pressure interrupted two exports, which were rerun; the supplement preserves all 90 raw +attempts, 88 final outcomes and the recovery notes. Sampled resumed-run peak RSS was 13.35 GiB; +initial memory samples were not saved. + +All methods ran in the same release engine at benchmark commit +`6e2b7bc9b20938787cf1f7af9718a832a2edf716`. The Classical algorithm sources match stock 0.3.0 +`60224d3fb7d4006bcfcc97603c1611b9b756aebd`; the installed stock GUI was not timed separately. +Later UI/account changes were not re-benchmarked. The release tag identifies the tested engine, +and the supplement supplies the exact rectangles, annotation polygons/reference masks, source +hashes, command logs, CSV scores, model revisions and Rust harness. Its helper downloads and +hash-checks the 20 public inputs; model installation stays explicit. The report retains photo and +annotation attribution. Standalone user-photo originals, full-resolution cutouts and model weights +are not bundled in the public supplement or software repository. + ## Checks - ML unit tests execute both contracts through the actual CPU runtime using original tiny ONNX @@ -79,11 +118,11 @@ cargo run --release -p photocraft-engine --features local-ml --example local_mod it does not establish a sub-100 ms budget for the optional models. The dedicated `local-models.yml` workflow runs Linux, Windows and macOS CPU runtime tests and -native build checks. The [final cross-platform run](https://github.com/mighty-programmer/photocraft/actions/runs/37783114338) +native build checks. The [cross-platform run at `1904396`](https://github.com/mighty-programmer/photocraft/actions/runs/37849240044) passes on all three platforms, including the final document-state and input regression tests. The contribution is ready for review. Upstream PR CI needs maintainer approval for -this first contribution. Quantitative accuracy evaluation, packaged installer / sandbox download -checks, web inference, point prompts and GPU acceleration remain separate work. +this first contribution. Representative held-out/matting evaluation, packaged installer / sandbox +download checks, web inference, point prompts and GPU acceleration remain separate work. Image sources and licences: [SOURCES.md](images/SOURCES.md), [LICENSE-local-models.txt](images/LICENSE-local-models.txt). From 2591e5c6dc18cbb1d4b8322ece4af177e4695d27 Mon Sep 17 00:00:00 2001 From: mighty-programmer <83077551+mighty-programmer@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:19:21 +0300 Subject: [PATCH 006/229] Link exploratory evaluation without overstating matting quality --- docs/local-models.md | 14 +++++++++----- scorecard/automation.toml | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/local-models.md b/docs/local-models.md index 21056b559c..9b889f3bd4 100644 --- a/docs/local-models.md +++ b/docs/local-models.md @@ -134,10 +134,14 @@ PHOTOCRAFT_MODEL_TEST_DOWNLOAD=1 \ cargo test -p photocraft-ml --features onnx real_models_cpu_smoke -- --ignored --nocapture ``` -Omit `PHOTOCRAFT_MODEL_TEST_DOWNLOAD` to test existing verified files offline. A quantitative -accuracy benchmark with labelled hair, fine structures, multiple objects and transparent -materials remains necessary before making a general improvement claim. This initial contribution -keeps the smart-features scorecard partial for that reason. +Omit `PHOTOCRAFT_MODEL_TEST_DOWNLOAD` to test existing verified files offline. The +[22-case PDF and public supplement](https://github.com/mighty-programmer/photocraft/releases/tag/model-evaluation-2026-10-08) +include an exploratory comparison on 20 fixed COCO polygon targets plus two visual-only photos. +The protocol, model failures, measured scores and reproducible harness are documented in +[local-models-validation.md](local-models-validation.md). These binary instance references do not +establish labelled hair/soft-alpha/transparency matting quality. Representative quality evaluation +and web inference remain open, so the smart-features scorecard stays partial. See [validation and comparison](local-models-validation.md) for the measured 24 MP timings, -before/after Preferences screenshots, actual exported masks and outstanding platform checks. +before/after Preferences screenshots, actual exported masks, completed three-platform native +CI and outstanding packaged-installer/production checks. diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 7878cc73c4..97c6e3507a 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -58,7 +58,7 @@ id = "AUTO-219-7" target = "Local ONNX models behind a flag, managed in Preferences; subject select, background removal" status = "partial" issue = 219 -note = "local-ml: CPU BiRefNet alpha / SAM 2.1 box selection, pinned verified explicit downloads in Preferences; labelled accuracy benchmarks and web inference remain open" +note = "local-ml: CPU BiRefNet alpha / SAM 2.1 box selection, pinned verified explicit downloads in Preferences; exploratory 20-target COCO comparison in docs/local-models-validation.md; representative matting quality and web inference remain open" [[item]] id = "AUTO-219-8" From 52eb5a154321f3a9b129bc10952f8c3a7e71f0e5 Mon Sep 17 00:00:00 2001 From: mighty-programmer <83077551+mighty-programmer@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:57:46 +0300 Subject: [PATCH 007/229] Preserve upstream controls and verify rebased model integration --- .github/workflows/local-models.yml | 4 ++++ docs/local-models-validation.md | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/.github/workflows/local-models.yml b/.github/workflows/local-models.yml index f5f8a97351..79e6016932 100644 --- a/.github/workflows/local-models.yml +++ b/.github/workflows/local-models.yml @@ -66,6 +66,10 @@ jobs: run: cargo test -p photocraft-ml --features onnx --locked - name: Test commands and CLI with the optional backend run: cargo test -p photocraft-engine -p photocraft-cli --features local-ml --locked + - name: Test model preferences and translated labels + run: | + cargo test -p photocraft-ui-egui --features local-ml --locked prefs_ui::tests + cargo test -p photocraft-ui-egui --features local-ml --locked i18n::tests - name: Check native app and UI with the optional backend run: cargo check -p photocraft -p photocraft-ui-egui --features local-ml --locked - name: Clippy optional backend diff --git a/docs/local-models-validation.md b/docs/local-models-validation.md index a07cbd4f58..2ea48e8c0d 100644 --- a/docs/local-models-validation.md +++ b/docs/local-models-validation.md @@ -6,6 +6,25 @@ are included in this repository. The integration/performance checks below are su fixed 22-case evaluation. Its 20 labelled COCO targets are exploratory evidence, not a representative or held-out benchmark, a soft-alpha matting assessment, or evidence of a universal winner. +## Upstream integration check: 2026-10-09 + +Rebased on upstream `ec350d64aedd019afc5eda290cc32909bc9bab7d`. The integration keeps the +upstream Rotate View preference and Linux Wayland snapshot behavior alongside the optional +model controls. It adds model labels to the newly supported Greek catalog and keeps every +upstream translation and contributor entry. Additive catalog/self-credit blocks are placed +before existing entries to reduce conflicts with future upstream additions. + +The native optional-backend suites passed on macOS: engine unit tests **944 passed / 11 ignored**, +UI unit tests **1,107 passed / 7 ignored**, plus ML runtime and CLI integration tests. Six-package +all-target Clippy with warnings denied, the default native build, layers, 627/627 parity, +regenerated scorecard, optional-backend adversarial panic hunt, full L0–L6 wasm and optional +engine/UI wasm checks passed. Ignored tests are not counted as passes. The native matrix now +also runs model Preferences and complete-language catalog tests; its current run is linked from +[#1199](https://github.com/storytold/photocraft/pull/1199). + +The real-image measurements and screenshots below remain the earlier evidence at their stated +commits; rebasing does not turn them into a new accuracy or performance benchmark. + ## Preferences before and after Before, from the unmodified `5896f0b` source: From 69f1cbeeafbf013e31ce9d875e5f8e408a8f0671 Mon Sep 17 00:00:00 2001 From: mighty-programmer <83077551+mighty-programmer@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:19:56 +0300 Subject: [PATCH 008/229] Record current upstream checks and focus UI matrix targets --- .github/workflows/local-models.yml | 4 ++-- docs/local-models-validation.md | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/local-models.yml b/.github/workflows/local-models.yml index 79e6016932..8e387b2493 100644 --- a/.github/workflows/local-models.yml +++ b/.github/workflows/local-models.yml @@ -68,8 +68,8 @@ jobs: run: cargo test -p photocraft-engine -p photocraft-cli --features local-ml --locked - name: Test model preferences and translated labels run: | - cargo test -p photocraft-ui-egui --features local-ml --locked prefs_ui::tests - cargo test -p photocraft-ui-egui --features local-ml --locked i18n::tests + cargo test -p photocraft-ui-egui --features local-ml --locked --lib prefs_ui::tests + cargo test -p photocraft-ui-egui --features local-ml --locked --lib i18n::tests - name: Check native app and UI with the optional backend run: cargo check -p photocraft -p photocraft-ui-egui --features local-ml --locked - name: Clippy optional backend diff --git a/docs/local-models-validation.md b/docs/local-models-validation.md index 2ea48e8c0d..0b24e5a641 100644 --- a/docs/local-models-validation.md +++ b/docs/local-models-validation.md @@ -8,14 +8,14 @@ or held-out benchmark, a soft-alpha matting assessment, or evidence of a univers ## Upstream integration check: 2026-10-09 -Rebased on upstream `ec350d64aedd019afc5eda290cc32909bc9bab7d`. The integration keeps the -upstream Rotate View preference and Linux Wayland snapshot behavior alongside the optional +Integrated upstream `2515fa7cee624cf232c873ba283aa0359ec159d7`. The integration keeps the +upstream Rotate View preference, Linux display-server selection and Wayland snapshot behavior alongside the optional model controls. It adds model labels to the newly supported Greek catalog and keeps every upstream translation and contributor entry. Additive catalog/self-credit blocks are placed before existing entries to reduce conflicts with future upstream additions. -The native optional-backend suites passed on macOS: engine unit tests **944 passed / 11 ignored**, -UI unit tests **1,107 passed / 7 ignored**, plus ML runtime and CLI integration tests. Six-package +The native optional-backend suites passed on macOS: engine unit tests **945 passed / 11 ignored**, +UI unit tests **1,112 passed / 7 ignored**, plus ML runtime and CLI integration tests. Five-package all-target Clippy with warnings denied, the default native build, layers, 627/627 parity, regenerated scorecard, optional-backend adversarial panic hunt, full L0–L6 wasm and optional engine/UI wasm checks passed. Ignored tests are not counted as passes. The native matrix now From f3e0c208ce442c0f027d4b734c4fcb6995da27e5 Mon Sep 17 00:00:00 2001 From: mighty-programmer <83077551+mighty-programmer@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:37:09 +0300 Subject: [PATCH 009/229] Address local-model review conditions and preserve upstream updates --- .github/workflows/local-models.yml | 23 +------------ Cargo.lock | 2 +- contributors/people.toml | 9 ++--- crates/ui-egui/src/i18n/cs.tsv | 20 +++++++++++ crates/ui-egui/src/i18n/de.tsv | 20 +++++++++++ crates/ui-egui/src/i18n/el.tsv | 20 +++++++++++ crates/ui-egui/src/i18n/es.tsv | 20 +++++++++++ crates/ui-egui/src/i18n/fr.tsv | 20 +++++++++++ crates/ui-egui/src/i18n/id.tsv | 20 +++++++++++ crates/ui-egui/src/i18n/it.tsv | 20 +++++++++++ crates/ui-egui/src/i18n/ja.tsv | 20 +++++++++++ crates/ui-egui/src/i18n/ko.tsv | 20 +++++++++++ crates/ui-egui/src/i18n/nl.tsv | 20 +++++++++++ crates/ui-egui/src/i18n/pl.tsv | 20 +++++++++++ crates/ui-egui/src/i18n/pt-br.tsv | 20 +++++++++++ crates/ui-egui/src/i18n/ru.tsv | 20 +++++++++++ crates/ui-egui/src/i18n/uk.tsv | 20 +++++++++++ crates/ui-egui/src/i18n/zh-hans.tsv | 20 +++++++++++ crates/ui-egui/src/i18n/zh-hant.tsv | 20 +++++++++++ crates/ui-egui/src/prefs_ui.rs | 52 +++++++++++++++++++++++++---- docs/development.md | 8 +++++ docs/gaps.md | 11 ++++-- docs/local-models.md | 30 +++++++++++++++-- docs/scorecard.md | 12 +++---- 24 files changed, 423 insertions(+), 44 deletions(-) diff --git a/.github/workflows/local-models.yml b/.github/workflows/local-models.yml index 8e387b2493..bf382759a0 100644 --- a/.github/workflows/local-models.yml +++ b/.github/workflows/local-models.yml @@ -1,27 +1,7 @@ name: Optional local models +# This optional, native-only gate runs solely on an explicit maintainer/contributor request. on: - # Let contributors verify native platforms in their fork before maintainer approval of PR CI. - push: - paths: - - "crates/ml/**" - - "crates/engine/**" - - "crates/ui-egui/**" - - "apps/photocraft/**" - - "apps/photocraft-cli/**" - - "Cargo.toml" - - "Cargo.lock" - - ".github/workflows/local-models.yml" - pull_request: - paths: - - "crates/ml/**" - - "crates/engine/**" - - "crates/ui-egui/**" - - "apps/photocraft/**" - - "apps/photocraft-cli/**" - - "Cargo.toml" - - "Cargo.lock" - - ".github/workflows/local-models.yml" workflow_dispatch: permissions: @@ -33,7 +13,6 @@ concurrency: jobs: cpu: - if: ${{ github.event_name != 'push' || github.event.repository.fork }} name: CPU (${{ matrix.os }}) runs-on: ${{ matrix.os }} timeout-minutes: 45 diff --git a/Cargo.lock b/Cargo.lock index c3a6f38646..09b8031584 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4597,7 +4597,7 @@ dependencies = [ [[package]] name = "photocraft-ml" -version = "0.5.0" +version = "0.6.0" dependencies = [ "ort", "photocraft-algo", diff --git a/contributors/people.toml b/contributors/people.toml index 4ed028a4ab..60ad955ac5 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,6 +17,11 @@ # 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.mighty-programmer] +real_name = "John Pantrakis" +sync_github_name = true + + [people.AlanRockefeller] sync_github_name = true @@ -90,7 +95,3 @@ sync_github_name = false [people.vangel76] sync_github_name = true - -[people.mighty-programmer] -real_name = "John Pantrakis" -sync_github_name = true diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c121ee3403..ef70f0c8b7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -21,6 +21,26 @@ # (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin letters; the trailing "…" of # dialog commands is kept. +# Optional local selection models. + Subject / background removal Výběr subjektu / odstranění pozadí + Object selection Výběr objektu + Classical (built in) Klasická metoda (vestavěná) + BiRefNet HR Matting BiRefNet HR Matting + SAM 2.1 Large SAM 2.1 Large + Download Stáhnout + Download a model before choosing it. Downloads do not change the current method. Před výběrem model stáhněte. Stažení nemění aktuální metodu. + Downloaded Staženo + In use… Používá se… + Large models can take time and use several GB of memory. Progress and Cancel appear in the status bar. Velké modely mohou trvat déle a použít několik GB paměti. Průběh a Zrušit se zobrazují na stavovém řádku. + Local models aren't available in this build or session. Místní modely nejsou v tomto sestavení nebo relaci dostupné. + Local selection models Místní modely výběru + Model details Podrobnosti modelu + Models run on this device. Images stay on this device. Modely běží na tomto zařízení. Obrázky zůstávají na tomto zařízení. + Not downloaded Nestaženo + Remove download Odstranit stažený model + Select Subject and Remove Background; detailed, soft edges Vybrat objekt a odstranit pozadí; podrobné, měkké okraje + Object Selection; drag a box around the object Výběr objektu; nakreslete rámeček kolem objektu + File Soubor Edit Úpravy Image Obraz diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 21b3a3f156..de05e93eeb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -17,6 +17,26 @@ # way. Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing "…" of dialog commands is kept. Plural rule: one|other (n != 1 -> other). +# Optional local selection models. + Subject / background removal Motiv / Hintergrund entfernen + Object selection Objektauswahl + Classical (built in) Klassische Methode (integriert) + BiRefNet HR Matting BiRefNet HR Matting + SAM 2.1 Large SAM 2.1 Large + Download Herunterladen + Download a model before choosing it. Downloads do not change the current method. Laden Sie ein Modell herunter, bevor Sie es auswählen. Downloads ändern die aktuelle Methode nicht. + Downloaded Heruntergeladen + In use… Wird verwendet… + Large models can take time and use several GB of memory. Progress and Cancel appear in the status bar. Große Modelle können Zeit und mehrere GB Arbeitsspeicher benötigen. Fortschritt und Abbrechen erscheinen in der Statusleiste. + Local models aren't available in this build or session. Lokale Modelle sind in diesem Build oder dieser Sitzung nicht verfügbar. + Local selection models Lokale Auswahlmodelle + Model details Modelldetails + Models run on this device. Images stay on this device. Modelle laufen auf diesem Gerät. Bilder bleiben auf diesem Gerät. + Not downloaded Nicht heruntergeladen + Remove download Download entfernen + Select Subject and Remove Background; detailed, soft edges Motiv auswählen und Hintergrund entfernen; detaillierte, weiche Kanten + Object Selection; drag a box around the object Objektauswahl; einen Rahmen um das Objekt ziehen + File Datei Edit Bearbeiten Image Bild diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 2100ea27ad..2df9564fb9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4,6 +4,26 @@ # Columns separated by TAB: context, English source, Greek translation. # Original translations based on general Greek graphics/computing terminology. # Covers every source label the i18n coverage check lists. +# Optional local selection models. + Subject / background removal Θέμα / αφαίρεση φόντου + Object selection Επιλογή αντικειμένου + Classical (built in) Κλασική μέθοδος (ενσωματωμένη) + BiRefNet HR Matting BiRefNet HR Matting + SAM 2.1 Large SAM 2.1 Large + Download Λήψη + Download a model before choosing it. Downloads do not change the current method. Κάντε λήψη ενός μοντέλου πριν το επιλέξετε. Οι λήψεις δεν αλλάζουν την τρέχουσα μέθοδο. + Downloaded Έγινε λήψη + In use… Σε χρήση… + Large models can take time and use several GB of memory. Progress and Cancel appear in the status bar. Τα μεγάλα μοντέλα μπορεί να χρειαστούν χρόνο και αρκετά GB μνήμης. Η πρόοδος και η Ακύρωση εμφανίζονται στη γραμμή κατάστασης. + Local models aren't available in this build or session. Τα τοπικά μοντέλα δεν είναι διαθέσιμα σε αυτή την έκδοση ή συνεδρία. + Local selection models Τοπικά μοντέλα επιλογής + Model details Λεπτομέρειες μοντέλου + Models run on this device. Images stay on this device. Τα μοντέλα εκτελούνται σε αυτή τη συσκευή. Οι εικόνες παραμένουν σε αυτή τη συσκευή. + Not downloaded Δεν έχει γίνει λήψη + Remove download Διαγραφή λήψης + Select Subject and Remove Background; detailed, soft edges Επιλογή θέματος και αφαίρεση φόντου· λεπτομερή, απαλά όρια + Object Selection; drag a box around the object Επιλογή αντικειμένου· σύρετε ένα πλαίσιο γύρω από το αντικείμενο + File Αρχείο Edit Επεξεργασία Image Εικόνα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0647b59dc3..1a171b3d72 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -13,6 +13,26 @@ # resource, string table or product UI was consulted or copied; add entries the same way. # Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing "…" of dialog commands is kept. +# Optional local selection models. + Subject / background removal Sujeto / eliminación del fondo + Object selection Selección de objetos + Classical (built in) Método clásico (incluido) + BiRefNet HR Matting BiRefNet HR Matting + SAM 2.1 Large SAM 2.1 Large + Download Descargar + Download a model before choosing it. Downloads do not change the current method. Descarga un modelo antes de elegirlo. Las descargas no cambian el método actual. + Downloaded Descargado + In use… En uso… + Large models can take time and use several GB of memory. Progress and Cancel appear in the status bar. Los modelos grandes pueden tardar y usar varios GB de memoria. El progreso y Cancelar aparecen en la barra de estado. + Local models aren't available in this build or session. Los modelos locales no están disponibles en esta compilación o sesión. + Local selection models Modelos de selección locales + Model details Detalles del modelo + Models run on this device. Images stay on this device. Los modelos se ejecutan en este dispositivo. Las imágenes permanecen en este dispositivo. + Not downloaded Sin descargar + Remove download Eliminar descarga + Select Subject and Remove Background; detailed, soft edges Seleccionar sujeto y eliminar fondo; bordes suaves y detallados + Object Selection; drag a box around the object Selección de objetos; dibuja un recuadro alrededor del objeto + File Archivo Edit Edición Image Imagen diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index daa78d6f7d..decf620f00 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -20,6 +20,26 @@ # « : ; ? ! » and "%", and inside guillemets « … ». Product and technology names (PhotoCraft, # ArtCraft, OpenType, Lab ...) stay as they are; RGB/CMYK read RVB/CMJN; the trailing "…" of # dialog commands is kept. +# Optional local selection models. + Subject / background removal Sujet / suppression de l’arrière-plan + Object selection Sélection d’objet + Classical (built in) Méthode classique (intégrée) + BiRefNet HR Matting BiRefNet HR Matting + SAM 2.1 Large SAM 2.1 Large + Download Télécharger + Download a model before choosing it. Downloads do not change the current method. Téléchargez un modèle avant de le choisir. Le téléchargement ne change pas la méthode actuelle. + Downloaded Téléchargé + In use… En cours d’utilisation… + Large models can take time and use several GB of memory. Progress and Cancel appear in the status bar. Les grands modèles peuvent prendre du temps et utiliser plusieurs Go de mémoire. La progression et Annuler apparaissent dans la barre d’état. + Local models aren't available in this build or session. Les modèles locaux ne sont pas disponibles dans cette version ou session. + Local selection models Modèles de sélection locaux + Model details Détails du modèle + Models run on this device. Images stay on this device. Les modèles s’exécutent sur cet appareil. Les images restent sur cet appareil. + Not downloaded Non téléchargé + Remove download Supprimer le téléchargement + Select Subject and Remove Background; detailed, soft edges Sélectionner le sujet et supprimer l’arrière-plan ; contours fins et doux + Object Selection; drag a box around the object Sélection d’objet ; tracer un cadre autour de l’objet + File Fichier Edit Édition Image Image diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index a5118bda91..4bfa6ee628 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -13,6 +13,26 @@ # resource, string table or product UI was consulted or copied; add entries the same way. # Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing "…" of dialog commands is kept. +# Optional local selection models. + Subject / background removal Subjek / penghapusan latar belakang + Object selection Seleksi objek + Classical (built in) Metode klasik (bawaan) + BiRefNet HR Matting BiRefNet HR Matting + SAM 2.1 Large SAM 2.1 Large + Download Unduh + Download a model before choosing it. Downloads do not change the current method. Unduh model sebelum memilihnya. Unduhan tidak mengubah metode saat ini. + Downloaded Sudah diunduh + In use… Sedang digunakan… + Large models can take time and use several GB of memory. Progress and Cancel appear in the status bar. Model besar dapat membutuhkan waktu dan beberapa GB memori. Progres dan Batal muncul di bilah status. + Local models aren't available in this build or session. Model lokal tidak tersedia dalam build atau sesi ini. + Local selection models Model seleksi lokal + Model details Detail model + Models run on this device. Images stay on this device. Model dijalankan di perangkat ini. Gambar tetap berada di perangkat ini. + Not downloaded Belum diunduh + Remove download Hapus unduhan + Select Subject and Remove Background; detailed, soft edges Pilih Subjek dan Hapus Latar Belakang; tepi halus dan terperinci + Object Selection; drag a box around the object Pemilihan Objek; seret kotak di sekitar objek + File Berkas Edit Edit Image Gambar diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 10356202de..3a6daa3956 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -13,6 +13,26 @@ # resource, string table or product UI was consulted or copied; add entries the same way. # Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing … of dialog commands is kept. +# Optional local selection models. + Subject / background removal Soggetto / rimozione dello sfondo + Object selection Selezione oggetti + Classical (built in) Metodo classico (integrato) + BiRefNet HR Matting BiRefNet HR Matting + SAM 2.1 Large SAM 2.1 Large + Download Scarica + Download a model before choosing it. Downloads do not change the current method. Scarica un modello prima di sceglierlo. Il download non cambia il metodo attuale. + Downloaded Scaricato + In use… In uso… + Large models can take time and use several GB of memory. Progress and Cancel appear in the status bar. I modelli grandi possono richiedere tempo e diversi GB di memoria. Avanzamento e Annulla appaiono nella barra di stato. + Local models aren't available in this build or session. I modelli locali non sono disponibili in questa build o sessione. + Local selection models Modelli di selezione locali + Model details Dettagli del modello + Models run on this device. Images stay on this device. I modelli vengono eseguiti su questo dispositivo. Le immagini restano su questo dispositivo. + Not downloaded Non scaricato + Remove download Rimuovi download + Select Subject and Remove Background; detailed, soft edges Seleziona soggetto e rimuovi sfondo; bordi morbidi e dettagliati + Object Selection; drag a box around the object Selezione oggetto; trascina un riquadro attorno all’oggetto + File File Edit Modifica Image Immagine diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1a83a49836..79065f8561 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -17,6 +17,26 @@ # Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing "…" of dialog commands is kept. +# Optional local selection models. + Subject / background removal 被写体選択 / 背景の削除 + Object selection オブジェクト選択 + Classical (built in) 従来の方法(内蔵) + BiRefNet HR Matting BiRefNet HR Matting + SAM 2.1 Large SAM 2.1 Large + Download ダウンロード + Download a model before choosing it. Downloads do not change the current method. モデルをダウンロードしてから選択してください。ダウンロードだけでは現在の方法は変わりません。 + Downloaded ダウンロード済み + In use… 使用中… + Large models can take time and use several GB of memory. Progress and Cancel appear in the status bar. 大きなモデルは時間と数GBのメモリを使う場合があります。進捗とキャンセルはステータスバーに表示されます。 + Local models aren't available in this build or session. このビルドまたはセッションではローカルモデルを利用できません。 + Local selection models ローカル選択モデル + Model details モデルの詳細 + Models run on this device. Images stay on this device. モデルはこのデバイス上で実行されます。画像はこのデバイス上に留まります。 + Not downloaded 未ダウンロード + Remove download ダウンロードを削除 + Select Subject and Remove Background; detailed, soft edges 被写体を選択して背景を削除。細部を保った柔らかなエッジ + Object Selection; drag a box around the object オブジェクト選択。オブジェクトを囲む矩形をドラッグ + File ファイル Edit 編集 Image 画像 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index afb127f18b..d7eb91d102 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -9,6 +9,26 @@ # Licensed MIT OR Apache-2.0. See LICENSE-translations.txt and ATTRIBUTION.md. # Product names and technology names retain their spelling. +# Optional local selection models. + Subject / background removal 피사체 선택 / 배경 제거 + Object selection 개체 선택 + Classical (built in) 기존 방식 (내장) + BiRefNet HR Matting BiRefNet HR Matting + SAM 2.1 Large SAM 2.1 Large + Download 다운로드 + Download a model before choosing it. Downloads do not change the current method. 모델을 다운로드한 후 선택하세요. 다운로드는 현재 방식을 변경하지 않습니다. + Downloaded 다운로드됨 + In use… 사용 중… + Large models can take time and use several GB of memory. Progress and Cancel appear in the status bar. 큰 모델은 시간이 걸리고 수 GB의 메모리를 사용할 수 있습니다. 진행률과 취소는 상태 표시줄에 나타납니다. + Local models aren't available in this build or session. 이 빌드 또는 세션에서는 로컬 모델을 사용할 수 없습니다. + Local selection models 로컬 선택 모델 + Model details 모델 세부 정보 + Models run on this device. Images stay on this device. 모델은 이 기기에서 실행됩니다. 이미지는 이 기기에 보관됩니다. + Not downloaded 다운로드되지 않음 + Remove download 다운로드 삭제 + Select Subject and Remove Background; detailed, soft edges 피사체 선택 및 배경 제거; 섬세하고 부드러운 가장자리 + Object Selection; drag a box around the object 개체 선택; 개체 주위에 상자를 드래그하세요 + File 파일 Edit 편집 Image 이미지 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f32b2398f8..76c80402a2 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -8,6 +8,26 @@ # stay as they are; the trailing "…" of dialog commands is kept. UI text addresses the user # informally ("je") and uses sentence case. +# Optional local selection models. + Subject / background removal Onderwerp / achtergrond verwijderen + Object selection Objectselectie + Classical (built in) Klassiek (ingebouwd) + BiRefNet HR Matting BiRefNet HR Matting + SAM 2.1 Large SAM 2.1 Large + Download Downloaden + Download a model before choosing it. Downloads do not change the current method. Download een model voordat u het kiest. Downloads wijzigen de huidige methode niet. + Downloaded Gedownload + In use… In gebruik… + Large models can take time and use several GB of memory. Progress and Cancel appear in the status bar. Grote modellen kunnen lang duren en meerdere GB geheugen gebruiken. Voortgang en Annuleren verschijnen in de statusbalk. + Local models aren't available in this build or session. Lokale modellen zijn niet beschikbaar in deze build of sessie. + Local selection models Lokale selectiemodellen + Model details Modeldetails + Models run on this device. Images stay on this device. Modellen draaien op dit apparaat. Afbeeldingen blijven op dit apparaat. + Not downloaded Niet gedownload + Remove download Download verwijderen + Select Subject and Remove Background; detailed, soft edges Onderwerp selecteren en achtergrond verwijderen; gedetailleerde, zachte randen + Object Selection; drag a box around the object Objectselectie; sleep een kader rond het object + File Bestand Edit Bewerken Image Afbeelding diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 87d310620f..db8647efb3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -20,6 +20,26 @@ # Conventions: menus are nouns (Plik, Edycja, Obraz ...), commands are infinitives (Otwórz…, # Zapisz jako…), sentence case as in the English source. Quotes: „…”. +# Optional local selection models. + Subject / background removal Temat / usuwanie tła + Object selection Zaznaczanie obiektów + Classical (built in) Metoda klasyczna (wbudowana) + BiRefNet HR Matting BiRefNet HR Matting + SAM 2.1 Large SAM 2.1 Large + Download Pobierz + Download a model before choosing it. Downloads do not change the current method. Pobierz model przed jego wybraniem. Pobieranie nie zmienia bieżącej metody. + Downloaded Pobrano + In use… W użyciu… + Large models can take time and use several GB of memory. Progress and Cancel appear in the status bar. Duże modele mogą potrzebować czasu i kilku GB pamięci. Postęp i przycisk Anuluj są wyświetlane na pasku stanu. + Local models aren't available in this build or session. Modele lokalne nie są dostępne w tej wersji lub sesji. + Local selection models Lokalne modele zaznaczania + Model details Szczegóły modelu + Models run on this device. Images stay on this device. Modele działają na tym urządzeniu. Obrazy pozostają na tym urządzeniu. + Not downloaded Nie pobrano + Remove download Usuń pobrany model + Select Subject and Remove Background; detailed, soft edges Zaznacz temat i Usuń tło; szczegółowe, miękkie krawędzie + Object Selection; drag a box around the object Zaznaczanie obiektów; przeciągnij prostokąt wokół obiektu + File Plik Edit Edycja Image Obraz diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e51132fdf3..d37117c302 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -17,6 +17,26 @@ # entries the same way. Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) # stay as they are; the trailing "…" of dialog commands is kept. +# Optional local selection models. + Subject / background removal Assunto / remoção de fundo + Object selection Seleção de objetos + Classical (built in) Método clássico (incluído) + BiRefNet HR Matting BiRefNet HR Matting + SAM 2.1 Large SAM 2.1 Large + Download Baixar + Download a model before choosing it. Downloads do not change the current method. Baixe um modelo antes de escolhê-lo. O download não altera o método atual. + Downloaded Baixado + In use… Em uso… + Large models can take time and use several GB of memory. Progress and Cancel appear in the status bar. Modelos grandes podem demorar e usar vários GB de memória. O progresso e Cancelar aparecem na barra de status. + Local models aren't available in this build or session. Modelos locais não estão disponíveis nesta versão ou sessão. + Local selection models Modelos de seleção locais + Model details Detalhes do modelo + Models run on this device. Images stay on this device. Os modelos são executados neste dispositivo. As imagens permanecem neste dispositivo. + Not downloaded Não baixado + Remove download Remover download + Select Subject and Remove Background; detailed, soft edges Selecionar assunto e remover fundo; bordas suaves e detalhadas + Object Selection; drag a box around the object Seleção de objeto; arraste uma caixa ao redor do objeto + File Arquivo Edit Editar Image Imagem diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 56580dc26b..4126f2e6f5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -20,6 +20,26 @@ # the Russian typographic equivalent, with the space the source carries. # Menus +# Optional local selection models. + Subject / background removal Выделение объекта / удаление фона + Object selection Выделение объектов + Classical (built in) Классический метод (встроенный) + BiRefNet HR Matting BiRefNet HR Matting + SAM 2.1 Large SAM 2.1 Large + Download Скачать + Download a model before choosing it. Downloads do not change the current method. Скачайте модель перед выбором. Скачивание не меняет текущий метод. + Downloaded Скачано + In use… Используется… + Large models can take time and use several GB of memory. Progress and Cancel appear in the status bar. Большие модели могут требовать времени и нескольких ГБ памяти. Прогресс и отмена доступны в строке состояния. + Local models aren't available in this build or session. Локальные модели недоступны в этой сборке или сеансе. + Local selection models Локальные модели выделения + Model details Подробнее о модели + Models run on this device. Images stay on this device. Модели работают на этом устройстве. Изображения остаются на этом устройстве. + Not downloaded Не скачано + Remove download Удалить скачанное + Select Subject and Remove Background; detailed, soft edges Выделение объекта и удаление фона; детализированные мягкие края + Object Selection; drag a box around the object Выделение объекта; обведите объект прямоугольником + File Файл Edit Редактирование Image Изображение diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 96eabeca0b..db4b7ff1bd 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -9,6 +9,26 @@ # Licensed MIT OR Apache-2.0. See LICENSE-translations.txt and ATTRIBUTION.md. # Menus +# Optional local selection models. + Subject / background removal Об’єкт / видалення тла + Object selection Вибір об’єкта + Classical (built in) Класичний (вбудований) + BiRefNet HR Matting BiRefNet HR Matting + SAM 2.1 Large SAM 2.1 Large + Download Завантажити + Download a model before choosing it. Downloads do not change the current method. Завантажте модель перед вибором. Завантаження не змінюють поточний метод. + Downloaded Завантажено + In use… Використовується… + Large models can take time and use several GB of memory. Progress and Cancel appear in the status bar. Великі моделі можуть працювати довго та використовувати кілька ГБ пам’яті. Прогрес і Скасувати з’являються в рядку стану. + Local models aren't available in this build or session. Локальні моделі недоступні в цій збірці або сеансі. + Local selection models Локальні моделі вибору + Model details Відомості про модель + Models run on this device. Images stay on this device. Моделі працюють на цьому пристрої. Зображення залишаються на цьому пристрої. + Not downloaded Не завантажено + Remove download Видалити завантаження + Select Subject and Remove Background; detailed, soft edges Вибір об’єкта та видалення тла; докладні, м’які краї + Object Selection; drag a box around the object Вибір об’єкта; перетягніть рамку навколо об’єкта + File Файл Edit Редагування Image Зображення diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1f9da7010d..9abbbab805 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -13,6 +13,26 @@ # Licensed MIT OR Apache-2.0, as the code. # Product and technology names retain their spelling. See docs/localization-zh-hans.md. +# Optional local selection models. + Subject / background removal 主体选择 / 移除背景 + Object selection 对象选择 + Classical (built in) 传统方法(内置) + BiRefNet HR Matting BiRefNet HR Matting + SAM 2.1 Large SAM 2.1 Large + Download 下载 + Download a model before choosing it. Downloads do not change the current method. 请先下载模型再选择。下载不会改变当前方法。 + Downloaded 已下载 + In use… 使用中… + Large models can take time and use several GB of memory. Progress and Cancel appear in the status bar. 大型模型可能耗时并占用数 GB 内存。进度和取消按钮显示在状态栏中。 + Local models aren't available in this build or session. 此版本或会话无法使用本地模型。 + Local selection models 本地选择模型 + Model details 模型详情 + Models run on this device. Images stay on this device. 模型在此设备上运行。图像保留在此设备上。 + Not downloaded 未下载 + Remove download 删除下载 + Select Subject and Remove Background; detailed, soft edges 选择主体和移除背景;精细、柔和的边缘 + Object Selection; drag a box around the object 对象选择;拖动矩形框以包围对象 + File 文件 Edit 编辑 Image 图像 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3fcd92cc04..5490ada2ef 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -21,6 +21,26 @@ # kept. Full-width punctuation (,。:) inside sentences; half-width parentheses and the " · " # separators as in the English. +# Optional local selection models. + Subject / background removal 主體選取 / 移除背景 + Object selection 物件選取 + Classical (built in) 傳統方法(內建) + BiRefNet HR Matting BiRefNet HR Matting + SAM 2.1 Large SAM 2.1 Large + Download 下載 + Download a model before choosing it. Downloads do not change the current method. 請先下載模型再選擇。下載不會變更目前的方法。 + Downloaded 已下載 + In use… 使用中… + Large models can take time and use several GB of memory. Progress and Cancel appear in the status bar. 大型模型可能耗時並使用數 GB 記憶體。進度與取消按鈕顯示在狀態列中。 + Local models aren't available in this build or session. 此版本或工作階段無法使用本機模型。 + Local selection models 本機選取模型 + Model details 模型詳細資訊 + Models run on this device. Images stay on this device. 模型在此裝置上執行。影像保留在此裝置上。 + Not downloaded 尚未下載 + Remove download 刪除下載 + Select Subject and Remove Background; detailed, soft edges 選取主體和移除背景;精細、柔和的邊緣 + Object Selection; drag a box around the object 物件選取;拖曳矩形框以包圍物件 + File 檔案 Edit 編輯 Image 影像 diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 67e3c596b8..f5c6078d35 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1323,7 +1323,15 @@ fn model_choice_available(status: &Value, option: &str) -> bool { && status["models"].as_array().into_iter().flatten().any(|m| m["id"].as_str() == Some(option) && m["installed"].as_bool() == Some(true))) } -fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map, order: &[String], lang: crate::i18n::Lang, system: Option, models: &Value) { +fn section_fields( + ui: &mut egui::Ui, + section: &str, + obj: &mut Map, + order: &[String], + lang: crate::i18n::Lang, + system: Option, + models: &Value, +) { let t = Tokens::get(ui.ctx()); if section == "interface" { appearance_rows(ui, obj, system); @@ -1919,6 +1927,38 @@ mod tests { assert_eq!(super::width(shortcuts.as_object().unwrap(), 3000.0), Some(720.0)); } + #[test] + fn local_model_labels_cover_every_complete_language() { + let labels = [ + "Subject / background removal", + "Object selection", + "Classical (built in)", + "BiRefNet HR Matting", + "SAM 2.1 Large", + "Download", + "Download a model before choosing it. Downloads do not change the current method.", + "Downloaded", + "In use…", + "Large models can take time and use several GB of memory. Progress and Cancel appear in the status bar.", + "Local models aren't available in this build or session.", + "Local selection models", + "Model details", + "Models run on this device. Images stay on this device.", + "Not downloaded", + "Remove download", + "Select Subject and Remove Background; detailed, soft edges", + "Object Selection; drag a box around the object", + ]; + for info in &crate::i18n::LANGUAGES { + if !info.complete_menus { + continue; + } + let lang = crate::i18n::Lang::from_pref(info.code); + let missing: Vec<_> = labels.iter().filter(|label| !crate::i18n::has(lang, label)).collect(); + assert!(missing.is_empty(), "{}: missing model labels: {missing:?}", info.code); + } + } + #[test] fn local_model_choices_need_a_backend_and_a_download() { assert!(model_choice_available(&json!({"available":false}), "classical")); @@ -2493,9 +2533,7 @@ mod tests { assert!(has_visible_fields(&values, "integrations")); assert!(has_visible_fields(&values, "enhancedControls")); // Every setting of these sections is still unimplemented. - for section in ["scratchDisks"] { - assert!(!has_visible_fields(&values, section), "{section}"); - } + assert!(!has_visible_fields(&values, "scratchDisks")); // "Fill new type layers with placeholder text" and "Use Escape to Commit" are live; other Type rows stay hidden. assert!(has_visible_fields(&values, "type")); assert!(!prefs::is_hidden("type.fillNewTypeLayersWithPlaceholder")); @@ -2698,8 +2736,10 @@ mod tests { fn next_launch_notice_does_not_mark_unrelated_interface_preferences() { use egui_kittest::{Harness, kittest::Queryable}; let obj = json!({"showTooltips": true}).as_object().unwrap().clone(); - let mut h = - Harness::new_ui_state(|ui, obj: &mut Map| section_fields(ui, "interface", obj, &[], crate::i18n::Lang::from_pref("en"), None), obj); + let mut h = Harness::new_ui_state( + |ui, obj: &mut Map| section_fields(ui, "interface", obj, &[], crate::i18n::Lang::from_pref("en"), None, &json!({"available":false})), + obj, + ); h.run_steps(4); assert!(h.query_by_label("Applies at next launch.").is_none()); h.get_by_label("Show tooltips").click(); diff --git a/docs/development.md b/docs/development.md index c62e7a79d8..6941d55059 100644 --- a/docs/development.md +++ b/docs/development.md @@ -50,6 +50,14 @@ Optional CPU models for Object Selection, Select Subject and Remove Background a `--features local-ml`. Weights remain separate, explicit downloads. See [local models](local-models.md) for Preferences, CLI, provenance and native runtime packaging details. +The native `local-ml` feature also enables `ort`'s `download-binaries` feature. Its pinned +ONNX Runtime 1.28 redistribution is fetched at **build time**, separately from Cargo crates +and the optional learned weights. A fresh `local-ml` build therefore requires network access +and is **not reproducible offline from Cargo.lock alone**. `cargo --offline` controls Cargo +resolution; it does not supply that runtime archive. Default builds and wasm omit this +dependency. Enabled distributors must ship the runtime licence and third-party notices; +see [native runtime packaging](local-models.md#build-and-use). + The dev profile builds with `debug = "line-tables-only"`: panic backtraces keep file and line, and test binaries link about twice as fast. To inspect local variables in a debugger, build with `CARGO_PROFILE_DEV_DEBUG=true`. ### Faster test loops diff --git a/docs/gaps.md b/docs/gaps.md index b360009f4d..c716db2578 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -101,11 +101,15 @@ what an agent can't finish alone. ### G15. AI and generative features - **Missing:** Generative Fill and Expand, Generative Upscale, Neural Filters, Sky Replacement, - Super Zoom, ML Select Subject/Sky. There is no ML runtime (AUTO-219-7). Classical substitutes - exist (GrabCut/max-flow selection, Remove tool by non-local patch completion). + Super Zoom and ML Select Sky. The off-by-default `local-ml` backend supplies optional + BiRefNet subject matting/removal and SAM box selection (AUTO-219-7 remains partial). + Its 20-target COCO pilot is exploratory, not representative alpha-matting quality; + web inference and accelerated providers remain open. Classical substitutes exist + (GrabCut/max-flow selection, Remove tool by non-local patch completion). - **Evidence:** Photoshop's `Required/UXP` ships the Neural Filters gallery and its `.kys` has a Neural Filters taskspace; users ask for local models (#2313, #1996, #2682). -- **Estimate:** 300–600 h. **Human:** owner decision #41 (model licences, local vs remote). +- **Estimate:** 300–600 h. **Human:** owner decision #41 (generative provider interface). +- **2026-10-10 evidence:** [optional models](local-models.md), [pilot and limitations](local-models-validation.md); no new accuracy measurement from the rebase. ### G16. Plug-in compatibility - **Missing:** hosting Photoshop .8BF filters (#2647), UXP panels, CEP. PhotoCraft has its own @@ -211,5 +215,6 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-10 | minor | G15 records optional local selection models and the exploratory pilot; generative provider work remains open | | 2026-10-10 | minor | Checked against the alpha gate (no alpha blockers); #2374 marked closed | | 2026-10-10 | major | First version, from the full re-measure against Photoshop 2026 27.11.0 and the open issues | diff --git a/docs/local-models.md b/docs/local-models.md index 9b889f3bd4..1e76d6f6a1 100644 --- a/docs/local-models.md +++ b/docs/local-models.md @@ -101,6 +101,31 @@ decision, and this change does not enable the feature in official release workfl ## Provenance and review +### Why these community exports + +Source review on 2026-10-10 found no ONNX files in the exact official checkpoint trees: +[BiRefNet HR Matting at `5d6b6f8`](https://huggingface.co/ZhengPeng7/BiRefNet_HR-matting/tree/5d6b6f8adcb5b417c871b1d84ceaae9871355b7f) +publishes Safetensors; [Meta SAM 2.1 Large at `665f8e2`](https://huggingface.co/facebook/sam2.1-hiera-large/tree/665f8e2ad61cf5f53d65644ff27c8ee525124610) +publishes Safetensors and a PyTorch checkpoint. This Rust backend needs ONNX graphs, so it +retains the already evaluated FP32 exports below rather than introducing Python/PyTorch or +changing the benchmarked model variant. No Hugging Face Python or `trust_remote_code` runs. + +The [pinned PinkPixel card](https://huggingface.co/PinkPixel/birefnet-hr-matting-onnx/blob/792518b9d4dfe9793891712677de25e554f948c6/README.md) +identifies the original BiRefNet checkpoint and documents its 2048-pixel input/alpha contract. +The [pinned onnx-community configuration](https://huggingface.co/onnx-community/sam2.1-hiera-large-ONNX/blob/3c23431f721e69cae82dbfd0c28fd692cc714021/config.json) +specifies FP32 SAM 2 at 1024 pixels; that revision has no model card or conversion recipe. +Our review therefore relies on graph/IO inspection, actual CPU execution of both downloaded +models and the published 22-case output evidence, with failures retained. It does not claim +independent verification of the conversion's equivalence to Meta's checkpoint. + +Every selected artifact's LFS byte count and SHA-256 was rechecked against its immutable +export revision on 2026-10-10 and still matches the catalog. Exact pins, streaming integrity +checks, explicit opt-in, original-model licences and evaluated outputs make these +community artifacts reviewable; hashes establish identity, not training provenance or +upstream endorsement. A compatible author-published ONNX export can replace one through a +future reviewed catalog/adapter update and new output comparisons. + + All URLs, exact byte counts and SHA-256 hashes are in [`crates/ml/src/catalog.rs`](../crates/ml/src/catalog.rs), derived from the pinned exports' Hugging Face LFS metadata. A model update requires a code change and review; there is no mutable remote @@ -124,8 +149,9 @@ cargo test -p photocraft-engine -p photocraft-cli --features local-ml cargo clippy -p photocraft-ml -p photocraft-engine --features photocraft-ml/onnx,local-ml --all-targets -- -D warnings ``` -`.github/workflows/local-models.yml` exercises CPU runtime contracts, commands and native build -paths on Windows, Linux and macOS without downloading learned weights. The opt-in full-model +`.github/workflows/local-models.yml` is **manual-only** (`workflow_dispatch`): no push or +pull-request trigger. An explicit run exercises CPU runtime contracts, commands, Preferences, +catalogs and native build paths on Windows, Linux and macOS without downloading learned weights. The opt-in full-model smoke test is separate; the download flag is explicit and transfers about 1.8 GB: ```sh diff --git a/docs/scorecard.md b/docs/scorecard.md index b79ed215f3..5bf470ef2b 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,10 +9,10 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 15 not run, 7 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 5 | 11 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 21 | 15 | 6 | | -| [Workspace and UI](#workspace-and-ui) | 6 | 7 | 10 | settings that do nothing: 45 of 149 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 6 | 7 | 10 | settings that do nothing: 45 of 151 (target 0) | | [Type](#type) | 0 | 3 | 6 | | -| [Automation](#automation) | 3 | 2 | 6 | 628 of 628 menu items live (100.0%) | -| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | +| [Automation](#automation) | 3 | 3 | 5 | 628 of 628 menu items live (100.0%) | +| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 29 of 29 crates; panic_hunt present | | [Distribution](#distribution) | 4 | 4 | 3 | | | [Open bugs](#open-bugs) | 22 | 2 | 8 | | @@ -175,7 +175,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 45 of 149** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 45 of 151** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `general.beep_when_done`, `general.resize_image_during_place`, `general.always_create_smart_objects_when_placing`, `general.animated_zoom`, `general.zoom_resizes_windows`, `interface.dynamic_color_sliders`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `tools.enable_flick_panning`, `tools.vary_round_brush_hardness_on_hud`, `tools.show_transformation_values`, `file_handling.image_previews`, `file_handling.save_in_background`, `file_handling.ignore_exif_profile_tag`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `cursors.brush_preview_color`, `units_and_rulers.type_units`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `units_and_rulers.print_resolution`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.missing_glyph_protection`, `type_.show_font_names_in_english`, `type_.text_engine`, `type_.font_preview`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `enhanced_controls.zoom_with_trackpad_pinch`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. @@ -232,14 +232,14 @@ Menu wiring (`docs/parity-checklist.md`): 628 of 628 menu items live (100.0%). T | AUTO-219-4 | Replay tests for crop, filters, selections and styled text on a second document | missing | [#219](https://github.com/storytold/photocraft/issues/219) | One replay test (actions.rs:171: new layer, rect select, fill) | | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs:590-631 folder/array input, per-file errors; no recursion, template or cancel | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | -| AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | +| AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | partial | [#219](https://github.com/storytold/photocraft/issues/219) | local-ml: CPU BiRefNet alpha / SAM 2.1 box selection, pinned verified explicit downloads in Preferences; exploratory 20-target COCO comparison in docs/local-models-validation.md; representative matting quality and web inference remain open | | AUTO-219-8 | xtask check that every panel button routes through a command id | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No such xtask check | | AUTO-219-9 | Headless commands for in-canvas type editing, Liquify/Puppet strokes, transform handles | done | [#219](https://github.com/storytold/photocraft/issues/219) | type.hitTest, type.caret, type.navigate; filter.liquify strokes, edit.puppetWarp pins, type.edit, edit.transform | | AUTO-219-10 | Agent acceptance suite grown to 30-50 workflows | partial | [#219](https://github.com/storytold/photocraft/issues/219) | automation/tests/agent_tasks.rs runs 10 workflows over MCP | ## Reliability -Never-crash attribute on 28 of 28 crate roots. +Never-crash attribute on 29 of 29 crate roots. | Id | Target | Status | Issue | Note | |---|---|---|---|---| From f48f69b7c4974e114a6e70738ec2c1a3dcf250de Mon Sep 17 00:00:00 2001 From: mighty-programmer <83077551+mighty-programmer@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:43:48 +0300 Subject: [PATCH 010/229] Record final model integration validation on upstream 0.6 --- docs/local-models-validation.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/local-models-validation.md b/docs/local-models-validation.md index 0b24e5a641..4b75ef1c5d 100644 --- a/docs/local-models-validation.md +++ b/docs/local-models-validation.md @@ -6,6 +6,31 @@ are included in this repository. The integration/performance checks below are su fixed 22-case evaluation. Its 20 labelled COCO targets are exploratory evidence, not a representative or held-out benchmark, a soft-alpha matting assessment, or evidence of a universal winner. +## Review update: 2026-10-10 + +Rebased on upstream 0.6.0 (`ab76c242a1225efc680dc88bd761cc202e1e29df`). The optional model +workflow is manual-only (`workflow_dispatch`), with no push/PR triggers. The development guide +now identifies the ONNX Runtime build-time download and offline limitation. The +[export review rationale](local-models.md#why-these-community-exports) links exact official +checkpoint trees and community metadata, explains why ONNX re-exports remain selected, and +separates artifact identity from independently proven conversion equivalence. + +All 18 model labels now cover Dutch and Ukrainian and the other 14 complete catalogs, guarded +by a runtime lookup regression. Existing catalog entries and all other contributors' credits +are preserved. New entries are kept away from append locations to reduce future conflicts. +The lockfile preserves all 728 existing upstream package versions; it adds only the optional +backend entries. Model revisions/bytes and the historical benchmark remain unchanged. + +Fresh optional-native tests, lint, default builds, dependency layers, parity, scorecard, +adversarial command tests and wasm checks are linked from [#1199](https://github.com/storytold/photocraft/pull/1199). +Native engine tests passed 1,169 / 11 ignored; the final UI suite after the file-dialog update +passed 1,721 / 10 ignored, with all integration tests green. Five-package all-target Clippy +(`local-ml,heif`), the default build, layers, parity (628/628), scorecard, optional-feature +adversarial commands, full L0–L6 wasm and optional engine/UI wasm all pass. Ignored tests +are not counted as passes. An offscreen 1200×800 Preferences › Integrations capture was +inspected: both explicit downloads are offered while both selected methods remain Classical. +Historical screenshots below are not current 0.6.0 captures. + ## Upstream integration check: 2026-10-09 Integrated upstream `2515fa7cee624cf232c873ba283aa0359ec159d7`. The integration keeps the From 49e20eeace0be4218bd352fdd01da31aaf87db1c Mon Sep 17 00:00:00 2001 From: mighty-programmer <83077551+mighty-programmer@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:27:50 +0300 Subject: [PATCH 011/229] Fix inherited UI test warning and measure antialiased preview ink --- crates/ui-egui/src/file_dialog/tests.rs | 2 -- crates/ui-egui/tests/drag_preview_canvas.rs | 31 ++++++++++++--------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index d490e8b10c..dc4db4c51a 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -311,8 +311,6 @@ fn save_dialogs_remember_where_each_document_was_last_saved() { FileDialogRequest::Save { suggested } => suggested.clone(), other => panic!("save dialog expected, got {other:?}"), }; - // Suggestions are built with `Path::join`, which uses `\` on Windows (#2745). - let joined = |dir: &str, name: &str| std::path::Path::new(dir).join(name).to_string_lossy().into_owned(); // An export starts beside the document; the user saves it under /renders instead. app.pick_save("result.png", |_, _| Ok(Value::Null)).unwrap(); app.poll_file_dialog(&ctx, None); diff --git a/crates/ui-egui/tests/drag_preview_canvas.rs b/crates/ui-egui/tests/drag_preview_canvas.rs index b3c8cc3f09..4d6dd17a02 100644 --- a/crates/ui-egui/tests/drag_preview_canvas.rs +++ b/crates/ui-egui/tests/drag_preview_canvas.rs @@ -121,31 +121,35 @@ fn render(h: &mut Harness) -> Image { Image { w: img.width(), h: img.height(), px: img.into_raw() } } -/// Fraction of positions along the screen segment `a`–`b` (points) where a pixel within ±2 -/// physical pixels across the line passes `test`. -fn coverage(img: &Image, ppp: f32, a: Pos2, b: Pos2, test: impl Fn(u8) -> bool) -> f32 { +/// Mean contrasting ink along the screen segment `a`–`b` (points), using the strongest pixel +/// within ±2 physical pixels across the line. Antialiased pixels contribute their actual contrast +/// instead of disappearing at a binary luma cutoff. +fn coverage(img: &Image, ppp: f32, a: Pos2, b: Pos2, contrast: impl Fn(u8) -> f32) -> f32 { let (a, b) = (a * ppp, b * ppp); let len = a.distance(b).max(1.0); let dir = (b - a) / len; let normal = egui::vec2(-dir.y, dir.x); // Skip the ends (corners, where the other edges and the readout may be). let n = (len as usize).saturating_sub(16); - let mut hits = 0; + let mut ink = 0.0; for i in 0..n { let p = a + dir * (8.0 + i as f32); - let hit = (-2..=2).any(|o| { - let q = p + normal * o as f32; - img.luma(q.x.round() as i64, q.y.round() as i64).is_some_and(&test) - }); - hits += usize::from(hit); + let strongest = (-2..=2) + .filter_map(|o| { + let q = p + normal * o as f32; + img.luma(q.x.round() as i64, q.y.round() as i64) + }) + .map(&contrast) + .fold(0.0, f32::max); + ink += strongest; } - hits as f32 / n.max(1) as f32 + ink / n.max(1) as f32 } /// Ink that contrasts with `background`: dark on white, light on black. -fn ink(background: &str) -> impl Fn(u8) -> bool { +fn ink(background: &str) -> impl Fn(u8) -> f32 { let white = background == "white"; - move |l| if white { l < 64 } else { l > 192 } + move |l| if white { 1.0 - f32::from(l) / 255.0 } else { f32::from(l) / 255.0 } } /// The four edges of the document rectangle `[x0, y0]`–`[x1, y1]` on screen. @@ -181,8 +185,9 @@ fn marquee_previews_show_while_dragging_on_white_and_black_at_1x_and_2x() { for (mx, my) in [(200.0, 80.0), (300.0, 150.0), (200.0, 220.0), (100.0, 150.0)] { let m = screen(&h, mx, my); let along = if mx == 200.0 { egui::vec2(14.0 / ppp, 0.0) } else { egui::vec2(0.0, 14.0 / ppp) }; + let was = coverage(&before, ppp, m - along, m + along, ink(bg)); let now = coverage(&img, ppp, m - along, m + along, ink(bg)); - assert!(now > 0.3, "{tool} on {bg} @{ppp}x at {m:?}: {now:.2} contrasting"); + assert!(was < 0.05 && now > 0.3, "{tool} on {bg} @{ppp}x at {m:?}: {was:.2} → {now:.2} contrasting"); } } // Each move's frame shows the outline where the pointer is now (no stale frame). From fc48f21f0fa7ba5090f7dc3ee7767d8828ed413d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 04:34:27 +0200 Subject: [PATCH 012/229] Vanishing Point: on-canvas plane editor with Create/Edit Plane, grid and paste (#3122) Filter > Vanishing Point now opens a dialog mode on the canvas instead of the generic parameter dialog: - Create Plane (C): four clicks, in any order, make a plane (corners are normalised clockwise from the top-left, the order paste expects). - Edit Plane (V): drag corner nodes, or edge nodes to slide an edge along the plane's own perspective; click a plane to select it, Delete removes it. - The plane grid is drawn at the Grid Size, in the accent colour when the plane is valid, warning yellow when its perspective is doubtful and red (outline only) when the quad isn't convex (VpPlane::check). - With an image on the clipboard the selected plane previews it pasted in perspective; drag to move it, Width sets its size, New Layer renders it on a new layer. OK / Enter runs filter.vanishingPoint with the planes and the paste as one history step; the control channel drives the same session through {"ui": {...}}. The engine now rejects a drawn plane whose corners don't form a convex quad (bow-tie, dented) instead of warping along it. Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/vanishing.rs | 86 ++++ crates/engine/src/vp_cmds.rs | 33 +- crates/ui-egui/src/distort_ui.rs | 36 +- crates/ui-egui/src/i18n/cs.tsv | 8 + crates/ui-egui/src/i18n/de.tsv | 8 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 8 + crates/ui-egui/src/i18n/fr.tsv | 6 + crates/ui-egui/src/i18n/id.tsv | 6 + crates/ui-egui/src/i18n/it.tsv | 8 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 7 + crates/ui-egui/src/i18n/nl.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 6 + crates/ui-egui/src/i18n/ru.tsv | 6 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 8 + crates/ui-egui/src/i18n/zh-hant.tsv | 8 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/vp_ui.rs | 611 ++++++++++++++++++++++++++++ docs/scorecard.md | 2 +- scorecard/tools.toml | 2 +- 23 files changed, 864 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/src/vp_ui.rs diff --git a/crates/algo/src/vanishing.rs b/crates/algo/src/vanishing.rs index 5661d66c9d..77b7bb107c 100644 --- a/crates/algo/src/vanishing.rs +++ b/crates/algo/src/vanishing.rs @@ -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::() < 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]; diff --git a/crates/engine/src/vp_cmds.rs b/crates/engine/src/vp_cmds.rs index 4d386d60d7..185930298a 100644 --- a/crates/engine/src/vp_cmds.rs +++ b/crates/engine/src/vp_cmds.rs @@ -3,7 +3,7 @@ //! ([`photocraft_algo::vanishing`]). The edit lands on the active pixel layer, or on a new layer //! with `"newLayer": true` (Photoshop's advice for non-destructive Vanishing Point work). -use photocraft_algo::vanishing::{Edge, Scene, VpPlane, clone_stroke, paste}; +use photocraft_algo::vanishing::{Edge, PlaneCheck, Scene, VpPlane, clone_stroke, paste}; use photocraft_doc::{Document, Layer, LayerContent, LayerId}; use photocraft_geom::Rect; use photocraft_raster::Surface; @@ -34,12 +34,22 @@ pub fn scene(p: &Value, canvas: Rect) -> Result { let a = v.get("corners")?.as_array()?; Some([pt(a.first()?)?, pt(a.get(1)?)?, pt(a.get(2)?)?, pt(a.get(3)?)?]) }; - let first = corners(&planes[0]).ok_or_else(|| bad("the first plane needs four `corners`"))?; + // A drawn quad must be convex (Photoshop's red plane): a bow-tie or dented quad has no + // perspective to paste or clone along. + let drawn = |i: usize, c: [[f64; 2]; 4]| -> Result { + let pl = VpPlane { corners: c }; + if pl.check(center) == PlaneCheck::Invalid { + return Err(bad(format!("plane {i}: the four corners must form a convex quad"))); + } + Ok(pl) + }; + let first = planes.first().and_then(corners).ok_or_else(|| bad("the first plane needs four `corners`"))?; + let first = drawn(0, first)?; let focal = p.get("focalLength").and_then(Value::as_f64).filter(|f| *f > 0.0); - let mut sc = Scene::new(VpPlane { corners: first }, center, focal, canvas.width().max(canvas.height()) as f64); + let mut sc = Scene::new(first, center, focal, canvas.width().max(canvas.height()) as f64); for (i, pl) in planes.iter().enumerate().skip(1) { if let Some(c) = corners(pl) { - sc.planes.push(VpPlane { corners: c }); + sc.planes.push(drawn(i, c)?); continue; } let from = pl @@ -257,4 +267,19 @@ mod tests { let r = s.execute(VP, json!({"planes": planes, "newLayer": true})).unwrap(); assert_ne!(r["layer"].as_u64().unwrap(), floor.0); } + + #[test] + fn rejects_planes_that_are_not_convex() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200, "depth": 16})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let bowtie = json!([[20, 20], [180, 180], [180, 20], [20, 180]]); + let dent = json!([[20, 20], [180, 20], [60, 60], [20, 180]]); + let square = json!([[20, 20], [180, 20], [180, 180], [20, 180]]); + for planes in [json!([{"corners": bowtie}]), json!([{"corners": square}, {"corners": dent}])] { + let e = s.execute(VP, json!({"planes": planes})).unwrap_err().to_string(); + assert!(e.contains("convex"), "{e}"); + } + assert!(s.execute(VP, json!({"planes": [{"corners": square}]})).is_ok()); + } } diff --git a/crates/ui-egui/src/distort_ui.rs b/crates/ui-egui/src/distort_ui.rs index b3bda3a7f5..0a0d7914b0 100644 --- a/crates/ui-egui/src/distort_ui.rs +++ b/crates/ui-egui/src/distort_ui.rs @@ -1,10 +1,11 @@ -//! Interactive Liquify (full-window dialog), Puppet Warp and Perspective Warp (on-canvas modes). +//! Interactive Liquify (full-window dialog), Puppet Warp, Perspective Warp and Vanishing Point +//! (on-canvas modes). //! //! All three are previews over plain data (strokes, pins, planes); committing runs the engine -//! command with that data (`filter.liquify`, `edit.puppetWarp`, `edit.perspectiveWarp`, or the -//! smart-object twins), so the result is one history step and exactly replayable. This module +//! command with that data (`filter.liquify`, `edit.puppetWarp`, `edit.perspectiveWarp`, +//! `filter.vanishingPoint`, or the smart-object twins), so the result is one history step and exactly replayable. This module //! holds the shared runtime state and the hooks the shell calls (menus, pointer, keys, overlay, -//! options bar); the modes live in `liquify_ui`, `puppet_ui` and `perspective_ui`. +//! options bar); the modes live in `liquify_ui`, `puppet_ui`, `perspective_ui` and `vp_ui`. use std::sync::Arc; @@ -23,13 +24,14 @@ pub struct Distort { pub liquify: Option, pub puppet: Option, pub perspective: Option, + pub vanishing: Option, /// Filter Gallery dialog (gallery_ui). pub gallery: Option, } impl Distort { pub fn active(&self) -> bool { - self.liquify.is_some() || self.puppet.is_some() || self.perspective.is_some() + self.liquify.is_some() || self.puppet.is_some() || self.perspective.is_some() || self.vanishing.is_some() } /// Short description for `ui.inspect`. @@ -38,6 +40,7 @@ impl Distort { "liquify": self.liquify.as_ref().map(|d| d.describe()), "puppet": self.puppet.as_ref().map(|p| p.describe()), "perspective": self.perspective.as_ref().map(|p| p.describe()), + "vanishingPoint": self.vanishing.as_ref().map(|v| v.describe()), "gallery": self.gallery.as_ref().map(|g| g.describe()), }) } @@ -118,6 +121,8 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val "edit.perspectiveWarp" | "layer.smartObjects.perspectiveWarp" if ui.is_some() && app.distort.perspective.is_some() => { Some(crate::perspective_ui::control(app, ui.unwrap_or(&Value::Null))) } + "filter.vanishingPoint" if empty => Some(crate::vp_ui::begin(app, ctx).map(|_| json!({"vanishingPoint": app.distort.describe()["vanishingPoint"]}))), + "filter.vanishingPoint" if ui.is_some() && app.distort.vanishing.is_some() => Some(crate::vp_ui::control(app, ui.unwrap_or(&Value::Null))), _ => None, } } @@ -136,6 +141,10 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> crate::perspective_ui::pointer(app, ev, mods); return true; } + if app.distort.vanishing.is_some() { + crate::vp_ui::pointer(app, ev, mods); + return true; + } false } @@ -179,6 +188,16 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } return true; } + if app.distort.vanishing.is_some() { + if enter(ctx) { + let _ = crate::vp_ui::commit(app); + } else if esc(ctx) { + crate::vp_ui::escape(app); + } else { + crate::vp_ui::keys(app, ctx); + } + return true; + } false } @@ -190,6 +209,9 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform if let Some(p) = &app.distort.perspective { crate::perspective_ui::draw(p, painter, xf); } + if let Some(v) = &app.distort.vanishing { + crate::vp_ui::draw(v, painter, xf); + } } /// Options bar for the on-canvas modes. True when drawn. @@ -202,6 +224,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { crate::perspective_ui::options_bar(app, ui); return true; } + if app.distort.vanishing.is_some() { + crate::vp_ui::options_bar(app, ui); + return true; + } false } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 23339c72f5..b39e9381a8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2441,3 +2441,11 @@ fontWeight Italic Kurzíva Front Image Přední obraz Search settings… Hledat nastavení… No matching settings Žádná odpovídající nastavení + Edit Plane Upravit rovinu + Create Plane Vytvořit rovinu + Grid Size: Velikost mřížky: + New Layer Nová vrstva + Vanishing Point: click four corners to create a plane Úběžník: klepnutím na čtyři rohy vytvoříte rovinu + Vanishing Point is not active Úběžník není aktivní + Apply Vanishing Point ({key}) Použít úběžník ({key}) + Fix the red plane first: its corners must form a convex quad Nejprve opravte červenou rovinu: její rohy musí tvořit konvexní čtyřúhelník diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fa9bd5bf0f..b5eee5bed2 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2479,3 +2479,11 @@ fontWeight Italic Kursiv Front Image Vorderes Bild Search settings… Einstellungen durchsuchen… No matching settings Keine passenden Einstellungen + Edit Plane Ebene bearbeiten + Create Plane Ebene erstellen + Grid Size: Rastergröße: + New Layer Neue Ebene + Vanishing Point: click four corners to create a plane Fluchtpunkt: Klicken Sie auf vier Ecken, um eine Ebene zu erstellen + Vanishing Point is not active Fluchtpunkt ist nicht aktiv + Apply Vanishing Point ({key}) Fluchtpunkt anwenden ({key}) + Fix the red plane first: its corners must form a convex quad Korrigieren Sie zuerst die rote Ebene: Ihre Ecken müssen ein konvexes Viereck bilden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4c79317146..f01045950d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3548,3 +3548,8 @@ fontWeight Thin Πολύ λεπτά Front Image Μπροστινή εικόνα Search settings… Αναζήτηση ρυθμίσεων… No matching settings Δεν βρέθηκαν ρυθμίσεις + Grid Size: Μέγεθος πλέγματος: + Vanishing Point: click four corners to create a plane Σημείο φυγής: κάντε κλικ σε τέσσερις γωνίες για να δημιουργήσετε ένα επίπεδο + Vanishing Point is not active Το Σημείο φυγής δεν είναι ενεργό + Apply Vanishing Point ({key}) Εφαρμογή σημείου φυγής ({key}) + Fix the red plane first: its corners must form a convex quad Διορθώστε πρώτα το κόκκινο επίπεδο: οι γωνίες του πρέπει να σχηματίζουν κυρτό τετράπλευρο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a8be679f29..5517d8dadf 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2520,3 +2520,11 @@ fontWeight Italic Cursiva Front Image Imagen frontal Search settings… Buscar ajustes… No matching settings No hay ajustes que coincidan + Edit Plane Editar plano + Create Plane Crear plano + Grid Size: Tamaño de rejilla: + New Layer Capa nueva + Vanishing Point: click four corners to create a plane Punto de fuga: haga clic en cuatro esquinas para crear un plano + Vanishing Point is not active Punto de fuga no está activo + Apply Vanishing Point ({key}) Aplicar punto de fuga ({key}) + Fix the red plane first: its corners must form a convex quad Corrija primero el plano rojo: sus esquinas deben formar un cuadrilátero convexo diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 25c8e0f377..8d3121dd79 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2858,3 +2858,9 @@ fontWeight Italic Italique Front Image Image de premier plan Search settings… Rechercher des réglages… No matching settings Aucun réglage correspondant + Grid Size: Taille de la grille : + New Layer Nouveau calque + Vanishing Point: click four corners to create a plane Point de fuite : cliquez sur quatre coins pour créer un plan + Vanishing Point is not active Point de fuite n’est pas actif + Apply Vanishing Point ({key}) Appliquer le point de fuite ({key}) + Fix the red plane first: its corners must form a convex quad Corrigez d’abord le plan rouge : ses coins doivent former un quadrilatère convexe diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9030e72525..910342b185 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2845,3 +2845,9 @@ fontWeight Italic Miring Front Image Gambar Depan Search settings… Cari pengaturan… No matching settings Tidak ada pengaturan yang cocok + Grid Size: Ukuran Grid: + New Layer Layer Baru + Vanishing Point: click four corners to create a plane Vanishing Point: klik empat sudut untuk membuat bidang + Vanishing Point is not active Vanishing Point tidak aktif + Apply Vanishing Point ({key}) Terapkan Vanishing Point ({key}) + Fix the red plane first: its corners must form a convex quad Perbaiki bidang merah dulu: sudut-sudutnya harus membentuk segi empat cembung diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9993449c5a..81c3145854 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2510,3 +2510,11 @@ fontWeight Italic Corsivo Front Image Immagine in primo piano Search settings… Cerca impostazioni… No matching settings Nessuna impostazione corrispondente + Edit Plane Modifica piano + Create Plane Crea piano + Grid Size: Dimensione griglia: + New Layer Nuovo livello + Vanishing Point: click four corners to create a plane Punto di fuga: fai clic su quattro angoli per creare un piano + Vanishing Point is not active Punto di fuga non è attivo + Apply Vanishing Point ({key}) Applica punto di fuga ({key}) + Fix the red plane first: its corners must form a convex quad Correggi prima il piano rosso: i suoi angoli devono formare un quadrilatero convesso diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 310650e1dc..08f56447ed 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3459,3 +3459,8 @@ fontWeight Italic 斜体 Front Image 前面の画像 Search settings… 設定を検索… No matching settings 一致する設定はありません + Grid Size: グリッドサイズ: + Vanishing Point: click four corners to create a plane 消点:4 つの角をクリックして面を作成します + Vanishing Point is not active 消点はアクティブではありません + Apply Vanishing Point ({key}) 消点を適用({key}) + Fix the red plane first: its corners must form a convex quad 先に赤い面を修正してください:角が凸四角形になる必要があります diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 21cf014d52..81b10fb7ce 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3019,3 +3019,10 @@ fontWeight Italic 기울임 Front Image 전면 이미지 Search settings… 설정 검색… No matching settings 일치하는 설정 없음 + Edit Plane 평면 편집 + Create Plane 평면 만들기 + Grid Size: 격자 크기: + Vanishing Point: click four corners to create a plane 소실점: 네 모서리를 클릭하여 평면을 만드십시오 + Vanishing Point is not active 소실점이 활성화되어 있지 않습니다 + Apply Vanishing Point ({key}) 소실점 적용 ({key}) + Fix the red plane first: its corners must form a convex quad 먼저 빨간색 평면을 수정하십시오: 모서리가 볼록 사각형을 이루어야 합니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 20370b4326..456b8c3f44 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3618,3 +3618,8 @@ cameraRaw Whites Witten Search settings… Instellingen zoeken… No matching settings Geen overeenkomende instellingen PhotoCraft — Screen Color Picker PhotoCraft — Schermkleurkiezer + Grid Size: Rastergrootte: + Vanishing Point: click four corners to create a plane Verdwijnpunt: klik op vier hoeken om een vlak te maken + Vanishing Point is not active Verdwijnpunt is niet actief + Apply Vanishing Point ({key}) Verdwijnpunt toepassen ({key}) + Fix the red plane first: its corners must form a convex quad Herstel eerst het rode vlak: de hoeken moeten een convexe vierhoek vormen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ac086df7a4..8acdcd1c62 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3459,3 +3459,8 @@ cameraRaw Roughness Chropowatość Front Image Obraz na wierzchu Search settings… Szukaj ustawień… No matching settings Brak pasujących ustawień + Grid Size: Rozmiar siatki: + Vanishing Point: click four corners to create a plane Punkt zbieżności: kliknij cztery narożniki, aby utworzyć płaszczyznę + Vanishing Point is not active Punkt zbieżności nie jest aktywny + Apply Vanishing Point ({key}) Zastosuj punkt zbieżności ({key}) + Fix the red plane first: its corners must form a convex quad Najpierw popraw czerwoną płaszczyznę: jej narożniki muszą tworzyć czworokąt wypukły diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a664352cc2..1b58102b86 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2866,3 +2866,9 @@ fontWeight Italic Itálico Front Image Imagem frontal Search settings… Pesquisar configurações… No matching settings Nenhuma configuração encontrada + Grid Size: Tamanho da grade: + New Layer Nova camada + Vanishing Point: click four corners to create a plane Ponto de fuga: clique em quatro cantos para criar um plano + Vanishing Point is not active Ponto de fuga não está ativo + Apply Vanishing Point ({key}) Aplicar ponto de fuga ({key}) + Fix the red plane first: its corners must form a convex quad Corrija primeiro o plano vermelho: seus cantos devem formar um quadrilátero convexo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f7d3cee401..22eda77f08 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2725,3 +2725,9 @@ fontWeight Italic Курсив Rasterize Layer Style Растрировать стиль слоя Search settings… Поиск настроек… No matching settings Нет подходящих настроек + Grid Size: Размер сетки: + New Layer Новый слой + Vanishing Point: click four corners to create a plane Точка схода: щелкните четыре угла, чтобы создать плоскость + Vanishing Point is not active Точка схода не активна + Apply Vanishing Point ({key}) Применить точку схода ({key}) + Fix the red plane first: its corners must form a convex quad Сначала исправьте красную плоскость: ее углы должны образовывать выпуклый четырехугольник diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 1f9a076d8d..3fd50ffaf1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3546,3 +3546,8 @@ cameraRaw Masking Маскування Search settings… Пошук налаштувань… No matching settings Немає відповідних налаштувань PhotoCraft — Screen Color Picker PhotoCraft — Піпетка кольору екрана + Grid Size: Розмір сітки: + Vanishing Point: click four corners to create a plane Точка сходження: клацніть чотири кути, щоб створити площину + Vanishing Point is not active Точка сходження не активна + Apply Vanishing Point ({key}) Застосувати точку сходження ({key}) + Fix the red plane first: its corners must form a convex quad Спершу виправте червону площину: її кути мають утворювати опуклий чотирикутник diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index de1d2dcac2..644eb96e9c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2448,3 +2448,11 @@ fontWeight Italic 斜体 Front Image 前面的图像 Search settings… 搜索设置… No matching settings 没有匹配的设置 + Edit Plane 编辑平面 + Create Plane 创建平面 + Grid Size: 网格大小: + New Layer 新建图层 + Vanishing Point: click four corners to create a plane 消失点:单击四个角以创建平面 + Vanishing Point is not active 消失点未处于活动状态 + Apply Vanishing Point ({key}) 应用消失点({key}) + Fix the red plane first: its corners must form a convex quad 请先修正红色平面:其四个角必须构成凸四边形 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 697e5312ca..2150018ee0 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2418,3 +2418,11 @@ fontWeight Italic 斜體 Front Image 前面的影像 Search settings… 搜尋設定… No matching settings 沒有符合的設定 + Edit Plane 編輯平面 + Create Plane 建立平面 + Grid Size: 格點大小: + New Layer 新增圖層 + Vanishing Point: click four corners to create a plane 消失點:按一下四個角以建立平面 + Vanishing Point is not active 消失點未啟用 + Apply Vanishing Point ({key}) 套用消失點 ({key}) + Fix the red plane first: its corners must form a convex quad 請先修正紅色平面:其四個角必須構成凸四邊形 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0adf31b0f2..cbc2913acb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -170,6 +170,7 @@ mod type_transform; mod variables_ui; pub mod vector_ui; pub mod view_cmds; +pub mod vp_ui; pub mod warp_preview; pub mod wheel_nav; pub mod wide_angle_ui; diff --git a/crates/ui-egui/src/vp_ui.rs b/crates/ui-egui/src/vp_ui.rs new file mode 100644 index 0000000000..bdaad601c6 --- /dev/null +++ b/crates/ui-egui/src/vp_ui.rs @@ -0,0 +1,611 @@ +//! Filter › Vanishing Point on the canvas (dialog mode). +//! +//! **Create Plane** (C): four clicks set a plane's corners, in any order. **Edit Plane** (V): +//! drag a corner node, or an edge node to slide that edge along the plane's own perspective; +//! click a plane to select it, Delete removes it. The plane's grid is drawn at the Grid Size and, +//! like Photoshop's, in the accent colour when the plane is valid, yellow when its perspective is +//! doubtful and red when it is invalid ([`VpPlane::check`]). With an image on the clipboard the +//! selected plane shows it pasted in perspective; drag it to move it on the plane. ↩ / OK runs +//! `filter.vanishingPoint` with the planes and the paste, so it is one history step and an +//! action or agent can replay it exactly. + +use egui::{Color32, Pos2, Stroke, TextureHandle, pos2}; +use photocraft_algo::transform::Homography; +use photocraft_algo::vanishing::{PlaneCheck, Scene, VpPlane}; +use photocraft_doc::{LayerContent, LayerId}; +use photocraft_engine::vp_cmds::VP; +use photocraft_geom::Rect; +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, ViewXform}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub enum VpTool { + EditPlane, + CreatePlane, +} + +enum Drag { + /// A corner node, and the pointer offset from it. + Corner { plane: usize, corner: usize, offset: [f64; 2] }, + /// An edge node (0 top, 1 right, 2 bottom, 3 left): the edge slides along the plane's + /// perspective as it was at the press. + Edge { plane: usize, edge: usize, h: Homography, inv: Homography }, + /// The pasted image, and the pointer offset from its top-left in plane coordinates. + Paste { offset: [f64; 2] }, +} + +/// The clipboard image shown pasted onto the plane. +struct Clip { + texture: TextureHandle, + width: f64, + height: f64, +} + +pub struct VpSession { + pub layer: LayerId, + canvas: Rect, + pub planes: Vec, + pub tool: VpTool, + /// Corners clicked so far with Create Plane. + pending: Vec<[f64; 2]>, + pub selected: Option, + /// Grid cell size in px at the plane's nearest edge. + pub grid_size: f64, + /// Paste the clipboard onto the selected plane on OK. + pub paste: bool, + /// Top-left of the pasted image in plane coordinates (0..1). + pub paste_at: [f64; 2], + /// Width of the pasted image as a fraction of the plane's width. + pub paste_width: f64, + pub new_layer: bool, + clip: Option, + drag: Option, +} + +/// Most planes a session holds (the control channel can't make it allocate without bound). +const MAX_PLANES: usize = 64; +/// Most grid lines drawn per direction. +const MAX_LINES: usize = 200; + +impl VpSession { + pub fn describe(&self) -> Value { + let center = self.center(); + let planes: Vec = self.planes.iter().map(|p| json!({"corners": p.corners, "check": p.check(center)})).collect(); + json!({ + "layer": self.layer.0, + "tool": self.tool, + "planes": planes, + "pending": self.pending, + "selected": self.selected, + "gridSize": self.grid_size, + "paste": self.paste && self.clip.is_some(), + "pasteAt": self.paste_at, + "pasteWidth": self.paste_width, + "newLayer": self.new_layer, + }) + } + + fn center(&self) -> [f64; 2] { + let c = self.canvas; + [f64::from(c.x0 + c.x1) / 2.0, f64::from(c.y0 + c.y1) / 2.0] + } + + /// The planes with the shared camera the engine derives from the first one. + fn scene(&self) -> Option { + let first = self.planes.first()?.clone(); + let fallback = f64::from(self.canvas.width().max(self.canvas.height())); + let mut sc = Scene::new(first, self.center(), None, fallback); + sc.planes.extend(self.planes.iter().skip(1).cloned()); + Some(sc) + } + + /// Plane `i`'s unit square → image map and metric side lengths. + fn frame(&self, i: usize) -> Option<(Homography, f64, f64)> { + if i >= self.planes.len() { + return None; + } + let (h, lu, lv) = self.scene()?.frame(i)?; + (lu.is_finite() && lv.is_finite() && lu > 1e-12 && lv > 1e-12).then_some((h, lu, lv)) + } + + /// The plane the paste lands on. + fn paste_plane(&self) -> Option { + if !self.paste || self.clip.is_none() { + return None; + } + self.selected.filter(|i| *i < self.planes.len()).or_else(|| (!self.planes.is_empty()).then_some(0)) + } + + /// Size of the pasted image in plane coordinates (the engine keeps its metric aspect). + fn paste_extent(&self, lu: f64, lv: f64) -> Option<[f64; 2]> { + let c = self.clip.as_ref()?; + Some([self.paste_width, self.paste_width * lu / lv * c.height / c.width.max(1.0)]) + } + + /// The command's params: the planes, and the paste when there is one. + pub fn params(&self) -> Value { + let planes: Vec = self.planes.iter().map(|p| json!({"corners": p.corners})).collect(); + let mut p = json!({"planes": planes}); + if let Some(i) = self.paste_plane() { + p["paste"] = json!([{"plane": i, "at": self.paste_at, "width": self.paste_width}]); + } + if self.new_layer { + p["newLayer"] = json!(true); + } + p + } + + fn add_corner(&mut self, p: [f64; 2]) { + if self.planes.len() >= MAX_PLANES { + return; + } + self.pending.push(p); + if let Ok(corners) = <[[f64; 2]; 4]>::try_from(self.pending.as_slice()) { + self.pending.clear(); + self.planes.push(VpPlane { corners }.normalized()); + self.selected = Some(self.planes.len() - 1); + // Photoshop hands over to Edit Plane once a plane is closed. + self.tool = VpTool::EditPlane; + } + } + + fn delete_selected(&mut self) { + if let Some(i) = self.selected.take().filter(|i| *i < self.planes.len()) { + self.planes.remove(i); + self.drag = None; + } + } + + /// Image positions of the edge nodes of plane `i`. + fn edge_nodes(&self, i: usize) -> Option<[[f64; 2]; 4]> { + let h = self.planes.get(i)?.homography()?; + let at = |u: f64, v: f64| { + let (x, y) = h.apply(u, v); + [x, y] + }; + Some([at(0.5, 0.0), at(1.0, 0.5), at(0.5, 1.0), at(0.0, 0.5)]) + } + + fn press(&mut self, p: [f64; 2], tol: f64) { + if self.tool == VpTool::CreatePlane { + self.add_corner(p); + return; + } + let near = |c: [f64; 2]| (c[0] - p[0]).hypot(c[1] - p[1]) <= tol; + // The selected plane's nodes win over the others'. + let order: Vec = self.selected.into_iter().chain((0..self.planes.len()).rev()).filter(|i| *i < self.planes.len()).collect(); + for &i in &order { + if let Some(ci) = self.planes[i].corners.iter().position(|c| near(*c)) { + let c = self.planes[i].corners[ci]; + self.selected = Some(i); + self.drag = Some(Drag::Corner { plane: i, corner: ci, offset: [c[0] - p[0], c[1] - p[1]] }); + return; + } + if let Some(e) = self.edge_nodes(i).and_then(|n| n.iter().position(|c| near(*c))) + && let Some(h) = self.planes[i].homography() + && let Some(inv) = h.inverse() + { + self.selected = Some(i); + self.drag = Some(Drag::Edge { plane: i, edge: e, h, inv }); + return; + } + } + if let Some(i) = self.paste_plane() + && let Some((h, lu, lv)) = self.frame(i) + && let Some(ext) = self.paste_extent(lu, lv) + && let Some(inv) = h.inverse() + { + let (u, v) = inv.apply(p[0], p[1]); + let (a, b) = (self.paste_at, [self.paste_at[0] + ext[0], self.paste_at[1] + ext[1]]); + if (a[0]..=b[0]).contains(&u) && (a[1]..=b[1]).contains(&v) { + self.drag = Some(Drag::Paste { offset: [a[0] - u, a[1] - v] }); + return; + } + } + self.selected = self.scene().and_then(|sc| sc.plane_at(p)); + } + + fn drag_to(&mut self, p: [f64; 2]) { + match &self.drag { + Some(Drag::Corner { plane, corner, offset }) => { + if let Some(c) = self.planes.get_mut(*plane).and_then(|pl| pl.corners.get_mut(*corner)) { + *c = [p[0] + offset[0], p[1] + offset[1]]; + } + } + Some(Drag::Edge { plane, edge, h, inv }) => { + let (u, v) = inv.apply(p[0], p[1]); + // The plane's (u, v) rectangle after the move. + let (u0, v0, u1, v1) = match edge { + 0 => (0.0, v.min(0.99), 1.0, 1.0), + 1 => (0.0, 0.0, u.max(0.01), 1.0), + 2 => (0.0, 0.0, 1.0, v.max(0.01)), + _ => (u.min(0.99), 0.0, 1.0, 1.0), + }; + if let Some(q) = slid_quad(h, u0, v0, u1, v1) + && let Some(pl) = self.planes.get_mut(*plane) + { + pl.corners = q; + } + } + Some(Drag::Paste { offset }) => { + let offset = *offset; + if let Some(i) = self.paste_plane() + && let Some(inv) = self.planes.get(i).and_then(VpPlane::homography).and_then(|h| h.inverse()) + { + let (u, v) = inv.apply(p[0], p[1]); + let at = [(u + offset[0]).clamp(-1.0, 2.0), (v + offset[1]).clamp(-1.0, 2.0)]; + if at.iter().all(|x| x.is_finite()) { + self.paste_at = at; + } + } + } + None => {} + } + } +} + +/// Corners of the image of `[u0, u1] × [v0, v1]` under `h`, or `None` when part of it would lie +/// on or beyond the plane's horizon (where the edge can't follow the perspective). +fn slid_quad(h: &Homography, u0: f64, v0: f64, u1: f64, v1: f64) -> Option<[[f64; 2]; 4]> { + let m = &h.0; + let w = |u: f64, v: f64| m[6] * u + m[7] * v + m[8]; + let side = w(0.5, 0.5).signum(); + let uv = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]]; + if uv.iter().any(|c| w(c[0], c[1]) * side <= 1e-9 * w(0.5, 0.5).abs()) { + return None; + } + let q = uv.map(|c| { + let (x, y) = h.apply(c[0], c[1]); + [x, y] + }); + (VpPlane { corners: q }.check([0.0, 0.0]) != PlaneCheck::Invalid).then_some(q) +} + +/// Starts Vanishing Point on the active pixel layer. +pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> { + if app.ui.transform.is_some() { + return Err("finish Free Transform first".into()); + } + let st = app.session.active().ok_or("no document")?; + let layer = st.active_layer.ok_or("no active layer")?; + match st.doc.layer(layer).map(|l| &l.content) { + Some(LayerContent::Raster(_)) => {} + Some(other) => return Err(format!("Vanishing Point needs a pixel layer (active layer is {} {} layer)", other.article(), other.kind_name())), + None => return Err("no active layer".into()), + } + let canvas = st.doc.bounds(); + let key = app.ui.alloc_id(); + let clip = app.session.clipboard.as_ref().and_then(|c| { + let r = c.surface.content_bounds(); + (!r.is_empty()).then(|| Clip { + texture: ctx.load_texture(format!("vp-paste-{key}"), crate::distort_ui::surface_image(&c.surface, r, 1024), egui::TextureOptions::LINEAR), + width: f64::from(r.width()), + height: f64::from(r.height()), + }) + }); + app.distort.vanishing = Some(VpSession { + layer, + canvas, + planes: Vec::new(), + tool: VpTool::CreatePlane, + pending: Vec::new(), + selected: None, + grid_size: 100.0, + paste: clip.is_some(), + paste_at: [0.25, 0.25], + paste_width: 0.5, + new_layer: false, + clip, + drag: None, + }); + app.ui.status = tl!("Vanishing Point: click four corners to create a plane").into(); + Ok(()) +} + +/// OK: runs `filter.vanishingPoint` with the planes (and the paste). An invalid plane keeps the +/// mode open; with nothing to paste the mode just closes. +pub fn commit(app: &mut PhotocraftApp) -> Result { + let Some(s) = app.distort.vanishing.as_ref() else { return Ok(Value::Null) }; + let center = s.center(); + if s.planes.iter().any(|p| p.check(center) == PlaneCheck::Invalid) { + let msg = tl!("Fix the red plane first: its corners must form a convex quad"); + app.ui.status = msg.into(); + return Err(msg.into()); + } + let params = s.params(); + app.distort.vanishing = None; + if params.get("paste").is_none() { + return Ok(json!({"committed": false})); + } + app.run(VP, params).map(|r| json!({"committed": true, "result": r})) +} + +/// Control channel: `filter.vanishingPoint {"ui": {...}}` while the mode is active. +pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { + if ui.get("commit").and_then(Value::as_bool) == Some(true) { + return commit(app); + } + if ui.get("cancel").and_then(Value::as_bool) == Some(true) { + app.distort.vanishing = None; + return Ok(json!({"cancelled": true})); + } + let s = app.distort.vanishing.as_mut().ok_or(tl!("Vanishing Point is not active"))?; + if let Some(t) = ui.get("tool").and_then(Value::as_str) { + s.tool = match t { + "edit" | "editPlane" => VpTool::EditPlane, + "create" | "createPlane" => VpTool::CreatePlane, + other => return Err(format!("tool: edit|create, not `{other}`")), + }; + s.pending.clear(); + } + if let Some(p) = ui.get("planes") { + let planes: Vec = serde_json::from_value(p.clone()).map_err(|e| format!("bad planes: {e}"))?; + if planes.len() > MAX_PLANES { + return Err(format!("at most {MAX_PLANES} planes")); + } + s.planes = planes; + // A drag holds indices into the old list. + s.drag = None; + s.selected = (!s.planes.is_empty()).then(|| s.planes.len() - 1); + } + if let Some(i) = ui.get("select").and_then(Value::as_u64) { + s.selected = usize::try_from(i).ok().filter(|i| *i < s.planes.len()); + } + if let Some(g) = ui.get("gridSize").and_then(Value::as_f64).filter(|g| g.is_finite()) { + s.grid_size = g.clamp(2.0, 2000.0); + } + if let Some(b) = ui.get("paste").and_then(Value::as_bool) { + s.paste = b; + } + if let Some(at) = ui.get("pasteAt").and_then(|v| Some([v.get(0)?.as_f64()?, v.get(1)?.as_f64()?])) { + s.paste_at = at.map(|x| if x.is_finite() { x.clamp(-1.0, 2.0) } else { 0.25 }); + } + if let Some(w) = ui.get("pasteWidth").and_then(Value::as_f64).filter(|w| w.is_finite()) { + s.paste_width = w.clamp(0.01, 10.0); + } + if let Some(b) = ui.get("newLayer").and_then(Value::as_bool) { + s.new_layer = b; + } + Ok(s.describe()) +} + +pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) { + let tol = crate::distort_ui::tolerance(app); + let Some(s) = app.distort.vanishing.as_mut() else { return }; + match ev { + ToolEvent::Down { x, y, .. } => s.press([x, y], tol), + ToolEvent::Move { x, y, .. } => s.drag_to([x, y]), + ToolEvent::Up { x, y } => { + s.drag_to([x, y]); + s.drag = None; + } + } +} + +/// Keys while the mode is active (↩ and Esc are handled by `distort_ui::keys`). +pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { + use egui::{Key, Modifiers}; + if ctx.memory(|m| m.focused().is_some()) { + return; + } + let Some(s) = app.distort.vanishing.as_mut() else { return }; + let key = |k: Key| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); + if key(Key::V) { + s.tool = VpTool::EditPlane; + s.pending.clear(); + } else if key(Key::C) { + s.tool = VpTool::CreatePlane; + } else if key(Key::Delete) || key(Key::Backspace) { + s.delete_selected(); + } +} + +/// Esc: drops a half-clicked plane first, then leaves the mode. +pub fn escape(app: &mut PhotocraftApp) { + match app.distort.vanishing.as_mut() { + Some(s) if !s.pending.is_empty() => s.pending.clear(), + _ => app.distort.vanishing = None, + } +} + +pub fn draw(s: &VpSession, painter: &egui::Painter, xf: &ViewXform) { + let scr = |p: [f64; 2]| xf.to_screen(p[0] as f32, p[1] as f32); + let tokens = crate::theme::Tokens::get(painter.ctx()); + let center = s.center(); + // The paste first, under the grids. + if let Some(i) = s.paste_plane() + && s.planes.get(i).is_some_and(|p| p.check(center) != PlaneCheck::Invalid) + && let (Some((h, lu, lv)), Some(c)) = (s.frame(i), s.clip.as_ref()) + && let Some(ext) = s.paste_extent(lu, lv) + { + draw_paste(painter, &scr, &h, s.paste_at, ext, &c.texture); + } + for (i, p) in s.planes.iter().enumerate() { + let check = p.check(center); + let colour = match check { + PlaneCheck::Valid => tokens.accent, + PlaneCheck::Questionable => tokens.warning, + PlaneCheck::Invalid => tokens.danger, + }; + let selected = s.selected == Some(i); + // An invalid plane has no perspective to grid: just its red outline, as in Photoshop. + if check != PlaneCheck::Invalid + && let Some((h, lu, lv)) = s.frame(i) + { + let thin = Stroke::new(0.75, colour.gamma_multiply(0.6)); + let at = |u: f64, v: f64| { + let (x, y) = h.apply(u, v); + scr([x, y]) + }; + for u in grid_steps(p, lu, lv, s.grid_size, true) { + painter.line_segment([at(u, 0.0), at(u, 1.0)], thin); + } + for v in grid_steps(p, lu, lv, s.grid_size, false) { + painter.line_segment([at(0.0, v), at(1.0, v)], thin); + } + } + let pts: Vec = p.corners.iter().map(|c| scr(*c)).collect(); + painter.add(egui::Shape::closed_line(pts.clone(), Stroke::new(if selected { 2.0 } else { 1.25 }, colour))); + if selected { + let nodes = pts.into_iter().chain(s.edge_nodes(i).into_iter().flatten().map(scr)); + for c in nodes { + let r = egui::Rect::from_center_size(c, egui::vec2(7.0, 7.0)); + painter.rect_filled(r, 0.0, Color32::WHITE); + painter.rect_stroke(r, 0.0, Stroke::new(1.25, colour), egui::StrokeKind::Inside); + } + } + } + // Create Plane: the corners so far and a rubber band to the pointer. + if !s.pending.is_empty() { + let mut pts: Vec = s.pending.iter().map(|c| scr(*c)).collect(); + if let Some(hover) = painter.ctx().pointer_hover_pos() { + pts.push(hover); + } + painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.25, tokens.accent))); + for c in pts.iter().take(s.pending.len()) { + painter.rect_filled(egui::Rect::from_center_size(*c, egui::vec2(6.0, 6.0)), 0.0, tokens.accent); + } + } +} + +/// Plane coordinates (0..1, exclusive) of the grid lines across `u` (or `v`) for cells of +/// `size` px at the plane's nearest edge, square in the plane's metric. +fn grid_steps(p: &VpPlane, lu: f64, lv: f64, size: f64, along_u: bool) -> Vec { + let q = &p.corners; + let d = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).hypot(a[1] - b[1]); + // Image px per metric unit at the nearest (longest-looking) edge. + let k = [d(q[0], q[1]) / lu, d(q[3], q[2]) / lu, d(q[0], q[3]) / lv, d(q[1], q[2]) / lv].into_iter().fold(0.0, f64::max); + let cell = size.max(1.0) / k; + let len = if along_u { lu } else { lv }; + if !cell.is_finite() || cell <= 0.0 || !len.is_finite() { + return Vec::new(); + } + let step = cell / len; + (1..MAX_LINES).map(|j| j as f64 * step).take_while(|t| *t < 1.0 - 1e-9).collect() +} + +/// The clipboard image on the plane: a mesh through the plane's homography, cells outside the +/// plane left out (the engine clips the paste to the plane). +fn draw_paste(painter: &egui::Painter, scr: &dyn Fn([f64; 2]) -> Pos2, h: &Homography, at: [f64; 2], ext: [f64; 2], tex: &TextureHandle) { + const N: usize = 24; + let mut mesh = egui::Mesh::with_texture(tex.id()); + let tint = Color32::from_white_alpha(230); + for j in 0..=N { + for i in 0..=N { + let (s, t) = (i as f64 / N as f64, j as f64 / N as f64); + let (x, y) = h.apply(at[0] + s * ext[0], at[1] + t * ext[1]); + mesh.vertices.push(egui::epaint::Vertex { pos: scr([x, y]), uv: pos2(s as f32, t as f32), color: tint }); + } + } + for j in 0..N { + for i in 0..N { + let (u, v) = (at[0] + (i as f64 + 0.5) / N as f64 * ext[0], at[1] + (j as f64 + 0.5) / N as f64 * ext[1]); + if !(0.0..=1.0).contains(&u) || !(0.0..=1.0).contains(&v) { + continue; + } + let a = (j * (N + 1) + i) as u32; + mesh.add_triangle(a, a + 1, a + N as u32 + 2); + mesh.add_triangle(a, a + N as u32 + 2, a + N as u32 + 1); + } + } + painter.add(mesh); +} + +pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let Some(s) = app.distort.vanishing.as_mut() else { return }; + if crate::widgets::pill_tab(ui, tl!("Edit Plane"), s.tool == VpTool::EditPlane).on_hover_text("V").clicked() { + s.tool = VpTool::EditPlane; + s.pending.clear(); + } + if crate::widgets::pill_tab(ui, tl!("Create Plane"), s.tool == VpTool::CreatePlane).on_hover_text("C").clicked() { + s.tool = VpTool::CreatePlane; + } + crate::widgets::vline(ui, 22.0); + ui.label(tl!("Grid Size:")); + let mut g = s.grid_size as f32; + if crate::widgets::value_field(ui, &mut g, 2.0..=2000.0, "px", 56.0).changed() { + s.grid_size = f64::from(g); + } + if s.clip.is_some() { + crate::widgets::vline(ui, 22.0); + crate::widgets::checkbox(ui, &mut s.paste, tl!("Paste")); + if s.paste { + ui.label(tl!("Width:")); + let mut w = (s.paste_width * 100.0) as f32; + if crate::widgets::value_field(ui, &mut w, 1.0..=1000.0, "%", 56.0).changed() { + s.paste_width = f64::from(w) / 100.0; + } + } + crate::widgets::checkbox(ui, &mut s.new_layer, tl!("New Layer")); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::primary_button(ui, "✓", 32.0) + .on_hover_text(crate::i18n::fmt(tl!("Apply Vanishing Point ({key})"), &[("key", &crate::shortcuts::pretty("Enter"))])) + .clicked() + { + let _ = commit(app); + } else if crate::widgets::secondary_button(ui, "⊘", 32.0).on_hover_text(tl!("Cancel (Esc)")).clicked() { + app.distort.vanishing = None; + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app_with_layer() -> (egui::Context, PhotocraftApp) { + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": 240, "height": 160, "depth": 16})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session.execute("edit.fill", json!({"color": "#4080c0"})).unwrap(); + app.sync_views(); + (ctx, app) + } + + #[test] + fn four_clicks_make_a_plane_and_ok_pastes_in_one_step() { + let (ctx, mut app) = app_with_layer(); + // Something to paste: a red square on the clipboard. + app.session.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + app.session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.session.execute("edit.copy", json!({})).unwrap(); + app.session.execute("select.deselect", json!({})).unwrap(); + crate::distort_ui::menu(&mut app, &ctx, VP, &json!({})).unwrap().unwrap(); + let click = |app: &mut PhotocraftApp, x: f64, y: f64| { + crate::distort_ui::pointer(app, ToolEvent::Down { x, y, pressure: 1.0 }, egui::Modifiers::NONE); + crate::distort_ui::pointer(app, ToolEvent::Up { x, y }, egui::Modifiers::NONE); + }; + // A floor receding to the top, clicked counter-clockwise from the bottom-left. + for (x, y) in [(20.0, 150.0), (220.0, 150.0), (170.0, 60.0), (70.0, 60.0)] { + click(&mut app, x, y); + } + let s = app.distort.vanishing.as_ref().unwrap(); + assert_eq!(s.planes.len(), 1); + assert_eq!(s.planes[0].corners, [[70.0, 60.0], [170.0, 60.0], [220.0, 150.0], [20.0, 150.0]], "clockwise from the top-left"); + assert_eq!(s.tool, VpTool::EditPlane); + assert_eq!(s.describe()["planes"][0]["check"], "valid"); + // Edit Plane: drag the top edge node up; the edge slides along the perspective. + let top = s.edge_nodes(0).unwrap()[0]; + crate::distort_ui::pointer(&mut app, ToolEvent::Down { x: top[0], y: top[1], pressure: 1.0 }, egui::Modifiers::NONE); + crate::distort_ui::pointer(&mut app, ToolEvent::Up { x: top[0], y: top[1] - 15.0 }, egui::Modifiers::NONE); + let c = app.distort.vanishing.as_ref().unwrap().planes[0].corners; + assert!(c[0][1] < 60.0 && c[1][1] < 60.0 && c[1][0] - c[0][0] < 100.0, "taller and narrower at the far edge: {c:?}"); + assert_eq!(c[2], [220.0, 150.0]); + let before = app.session.active().unwrap().history.past_len(); + commit(&mut app).unwrap(); + assert!(app.distort.vanishing.is_none()); + assert_eq!(app.session.active().unwrap().history.past_len(), before + 1); + // The paste landed on the plane: red at its middle, blue outside it. + let st = app.session.active().unwrap(); + let l = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let h = VpPlane { corners: c }.homography().unwrap(); + let (x, y) = h.apply(0.5, 0.35); + assert!(l.rgba(x as i32, y as i32)[0] > 0.9, "{:?}", l.rgba(x as i32, y as i32)); + assert!(l.rgba(230, 20)[0] < 0.5, "outside the plane"); + } +} diff --git a/docs/scorecard.md b/docs/scorecard.md index 6c7f540004..aad562e6c8 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -158,7 +158,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | partial | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke brush, pencil, eraser (vector_cmds.rs:1170); brush-only button, no dialog | | TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.convertToShape with tolerance (vector_cmds.rs:1256); radius as a field only | | TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | -| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15) behind a parameter dialog | +| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | | TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo | | TOOL-213-17 | Red Eye tool: click a pupil to neutralize the red reflection | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.redEye (redeye_cmds.rs) + Tool::RedEye in the J flyout; Pupil Size and Darken Amount; catchlights kept; a miss is an error | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index c5190643a4..cefb18e599 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -206,7 +206,7 @@ id = "TOOL-214-14" target = "Vanishing Point window: draw and edit planes, paste, clone in perspective" status = "partial" issue = 214 -note = "filter.vanishingPoint (vp_cmds.rs:15) behind a parameter dialog" +note = "filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing" [[item]] id = "TOOL-214-15" From 914bb89a606722c7a902065438f80641831eeef5 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 08:57:16 +0200 Subject: [PATCH 013/229] Vanishing Point: tear off planes and clone in perspective (#3280) Tear-off planes in the Vanishing Point window: - Ctrl/Cmd-drag an edge node (Create or Edit Plane) tears off a plane at 90 degrees. Dragging its far edge node sets the depth, and Alt-dragging it sets the angle. - A torn plane is stored in its torn-off form (from/edge/angle/depth). Its corners are recomputed from its parent after every edit, the same way the engine builds the scene. It is drawn and validated like any other plane and sent to filter.vanishingPoint in that form. - Deleting a parent keeps its torn planes as drawn planes. Stamp tool (S): - Alt-click a plane to set the source, then drag to clone in perspective. - Options: Diameter, Hardness, Opacity and Aligned (later strokes keep the first stroke's offset). Cmd/Ctrl+Z removes the last stroke. - The canvas shows the strokes as they are painted. The preview paints them in pieces with the same frame the engine uses, and only the painted area recomposites. - The overlay shows the source crosshair and the foreshortened brush. - OK sends the strokes as the command's "clone" param, so the session is one history step. Algo: clone_stroke is split into clone_dabs (anchored stroke pieces, which return the changed area) and clone_source. sample() is safe for non-finite or edge coordinates. Engine: "clone" is parsed and validated up front. The engine caps clone strokes (4096), clone points (200k) and planes (64), checks numbers are finite, and checks the tear-off angle and depth ranges. It returns Err instead of silently dropping bad points. Clone strokes copy back only the area they changed, not the whole layer on every stroke. Scorecard TOOL-214-14 is now done. Still missing: the Marquee tool in the window and the Stamp's Heal modes. Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/vanishing.rs | 114 ++++- crates/engine/src/vp_cmds.rs | 169 +++++-- crates/ui-egui/src/canvas.rs | 7 + crates/ui-egui/src/distort_ui.rs | 6 +- crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/vp_ui.rs | 679 ++++++++++++++++++++++++++-- docs/scorecard.md | 4 +- scorecard/tools.toml | 4 +- 23 files changed, 925 insertions(+), 93 deletions(-) diff --git a/crates/algo/src/vanishing.rs b/crates/algo/src/vanishing.rs index 77b7bb107c..2ac85badb4 100644 --- a/crates/algo/src/vanishing.rs +++ b/crates/algo/src/vanishing.rs @@ -324,38 +324,97 @@ pub fn paste(scene: &Scene, i: usize, src: &Surface, src_rect: Rect, at: [f64; 2 Ok(Some(out)) } +/// The fixed frame of one clone stroke: the source and destination planes, the source offset in +/// plane-metric units and the brush radius, all set at the stroke's anchor (its first dab). +struct CloneFrame { + hs: Homography, + ls: (f64, f64), + hd: Homography, + hd_inv: Homography, + ld: (f64, f64), + offset: [f64; 2], + r_m: f64, +} + +impl CloneFrame { + fn new(scene: &Scene, source: [f64; 2], anchor: [f64; 2], size: f64) -> Option { + let (ps, pd) = (scene.plane_at(source)?, scene.plane_at(anchor)?); + let ((hs, lus, lvs), (hd, lud, lvd)) = (scene.frame(ps)?, scene.frame(pd)?); + let (hs_inv, hd_inv) = (hs.inverse()?, hd.inverse()?); + let ms = metric(&hs_inv, (lus, lvs), source); + let md0 = metric(&hd_inv, (lud, lvd), anchor); + // Brush radius in metric units (from the image size at the anchor). + let probe = metric(&hd_inv, (lud, lvd), [anchor[0] + size / 2.0, anchor[1]]); + let r_m = ((probe[0] - md0[0]).hypot(probe[1] - md0[1])).max(1e-9); + let offset = [ms[0] - md0[0], ms[1] - md0[1]]; + (offset.iter().all(|v| v.is_finite()) && r_m.is_finite()).then_some(CloneFrame { hs, ls: (lus, lvs), hd, hd_inv, ld: (lud, lvd), offset, r_m }) + } + + /// The source point (image px) that destination `p` copies from. + fn source_of(&self, p: [f64; 2]) -> [f64; 2] { + let m = metric(&self.hd_inv, self.ld, p); + let (x, y) = self.hs.apply((m[0] + self.offset[0]) / self.ls.0, (m[1] + self.offset[1]) / self.ls.1); + [x, y] + } +} + +/// Image point → plane-metric coordinates through the plane's inverse homography. +fn metric(inv: &Homography, l: (f64, f64), p: [f64; 2]) -> [f64; 2] { + let (u, v) = inv.apply(p[0], p[1]); + [u * l.0, v * l.1] +} + /// Perspective clone stamp: one stroke of dabs at image points `points` on the destination /// plane, copying from `source` (image px) with the offset fixed in plane-metric units at the /// first dab. `size` is the brush diameter in px at the first dab; `hardness` 0..=1. pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: &[[f64; 2]], size: f64, hardness: f64, opacity: f32) -> usize { let Some(first) = points.first() else { return 0 }; - let (Some(ps), Some(pd)) = (scene.plane_at(source), scene.plane_at(*first)) else { return 0 }; - let (Some((hs, lus, lvs)), Some((hd, lud, lvd))) = (scene.frame(ps), scene.frame(pd)) else { return 0 }; - let (Some(hs_inv), Some(hd_inv)) = (hs.inverse(), hd.inverse()) else { return 0 }; - let metric = |inv: &Homography, lu: f64, lv: f64, p: [f64; 2]| { - let (u, v) = inv.apply(p[0], p[1]); - [u * lu, v * lv] - }; - let ms = metric(&hs_inv, lus, lvs, source); - let md0 = metric(&hd_inv, lud, lvd, *first); - let offset = [ms[0] - md0[0], ms[1] - md0[1]]; - // Brush radius in metric units (from the image size at the first dab). - let probe = metric(&hd_inv, lud, lvd, [first[0] + size / 2.0, first[1]]); - let r_m = ((probe[0] - md0[0]).hypot(probe[1] - md0[1])).max(1e-9); + clone_dabs(scene, surf, source, *first, points, size, hardness, opacity).0 +} + +/// Where a clone stroke that starts at `anchor` copying from `source` samples for destination +/// `p`: the same plane-metric offset. A live preview paints a stroke in pieces with it, and +/// Aligned strokes keep the first stroke's offset. `None` when `source` or `anchor` is off the +/// planes. +pub fn clone_source(scene: &Scene, source: [f64; 2], anchor: [f64; 2], p: [f64; 2]) -> Option<[f64; 2]> { + let s = CloneFrame::new(scene, source, anchor, 1.0)?.source_of(p); + s.iter().all(|v| v.is_finite()).then_some(s) +} + +/// [`clone_stroke`]'s dabs at `points` for a stroke anchored at `anchor` (its first dab, which +/// fixes the offset and the brush's metric size), so a stroke can be painted in pieces exactly +/// as one. Returns the dabs painted and the area they changed. +#[allow(clippy::too_many_arguments)] +pub fn clone_dabs( + scene: &Scene, + surf: &mut Surface, + source: [f64; 2], + anchor: [f64; 2], + points: &[[f64; 2]], + size: f64, + hardness: f64, + opacity: f32, +) -> (usize, Rect) { + let Some(f) = CloneFrame::new(scene, source, anchor, size) else { return (0, Rect::EMPTY) }; + let (lud, lvd) = f.ld; let src_copy = surf.clone(); let fmt = surf.format(); let n = fmt.channels(); let mut dabs = 0; + let mut changed_area = Rect::EMPTY; for p in points { - let mc = metric(&hd_inv, lud, lvd, *p); + let mc = metric(&f.hd_inv, f.ld, *p); // Image-space footprint: map a metric circle's bounding points. let mut bb = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; for k in 0..16 { let a = k as f64 / 16.0 * std::f64::consts::TAU; - let (u, v) = ((mc[0] + r_m * a.cos()) / lud, (mc[1] + r_m * a.sin()) / lvd); - let (x, y) = hd.apply(u, v); + let (u, v) = ((mc[0] + f.r_m * a.cos()) / lud, (mc[1] + f.r_m * a.sin()) / lvd); + let (x, y) = f.hd.apply(u, v); bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x), bb[3].max(y)]; } + if bb.iter().any(|v| !v.is_finite()) { + continue; + } let area = Rect::new(bb[0].floor() as i32, bb[1].floor() as i32, (bb[2].ceil() as i32).saturating_add(1), (bb[3].ceil() as i32).saturating_add(1)); if area.is_empty() || area.width() > 4096 || area.height() > 4096 { continue; @@ -365,17 +424,16 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: let mut changed = false; for (i, px) in data.chunks_exact_mut(n).enumerate() { let (x, y) = (area.x0 as f64 + (i % w) as f64 + 0.5, area.y0 as f64 + (i / w) as f64 + 0.5); - let m = metric(&hd_inv, lud, lvd, [x, y]); - let d = (m[0] - mc[0]).hypot(m[1] - mc[1]) / r_m; - if d >= 1.0 { + let m = metric(&f.hd_inv, f.ld, [x, y]); + let d = (m[0] - mc[0]).hypot(m[1] - mc[1]) / f.r_m; + if d >= 1.0 || d.is_nan() { continue; } let hard = hardness.clamp(0.0, 0.99); let fall = if d <= hard { 1.0 } else { 1.0 - (d - hard) / (1.0 - hard) }; let k = (fall * fall * (3.0 - 2.0 * fall)) as f32 * opacity; // Source pixel at the same metric offset. - let sm = [m[0] + offset[0], m[1] + offset[1]]; - let (sx, sy) = hs.apply(sm[0] / lus, sm[1] / lvs); + let (sx, sy) = f.hs.apply((m[0] + f.offset[0]) / f.ls.0, (m[1] + f.offset[1]) / f.ls.1); let s = sample(&src_copy, sx - 0.5, sy - 0.5, n); for c in 0..n { px[c] += (s[c] - px[c]) * k; @@ -385,15 +443,22 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: if changed { surf.write_region(area, &data); dabs += 1; + changed_area = if changed_area.is_empty() { area } else { changed_area.union(&area) }; } } - dabs + (dabs, changed_area) } fn sample(s: &Surface, x: f64, y: f64, n: usize) -> Vec { + if !x.is_finite() || !y.is_finite() { + return vec![0.0; n]; + } let (x0, y0) = (x.floor() as i32, y.floor() as i32); let (tx, ty) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32); - let r = s.read_region(Rect::new(x0, y0, x0 + 2, y0 + 2)); + let r = s.read_region(Rect::new(x0, y0, x0.saturating_add(2), y0.saturating_add(2))); + if r.len() < 4 * n { + return vec![0.0; n]; + } (0..n).map(|c| (r[c] * (1.0 - tx) + r[n + c] * tx) * (1.0 - ty) + (r[2 * n + c] * (1.0 - tx) + r[3 * n + c] * tx) * ty).collect() } @@ -486,5 +551,8 @@ mod tests { let n = clone_stroke(&sc, &mut s, [sx, sy], &[[dx, dy]], 10.0, 0.8, 1.0); assert_eq!(n, 1); assert!(s.rgba(dx as i32, dy as i32)[0] > 0.8, "{:?}", s.rgba(dx as i32, dy as i32)); + // The stroke's anchor copies from the source itself. + let back = clone_source(&sc, [sx, sy], [dx, dy], [dx, dy]).unwrap(); + assert!((back[0] - sx).abs() < 1e-6 && (back[1] - sy).abs() < 1e-6, "{back:?}"); } } diff --git a/crates/engine/src/vp_cmds.rs b/crates/engine/src/vp_cmds.rs index 185930298a..e1e342a10a 100644 --- a/crates/engine/src/vp_cmds.rs +++ b/crates/engine/src/vp_cmds.rs @@ -3,7 +3,7 @@ //! ([`photocraft_algo::vanishing`]). The edit lands on the active pixel layer, or on a new layer //! with `"newLayer": true` (Photoshop's advice for non-destructive Vanishing Point work). -use photocraft_algo::vanishing::{Edge, PlaneCheck, Scene, VpPlane, clone_stroke, paste}; +use photocraft_algo::vanishing::{Edge, PlaneCheck, Scene, VpPlane, clone_dabs, paste}; use photocraft_doc::{Document, Layer, LayerContent, LayerId}; use photocraft_geom::Rect; use photocraft_raster::Surface; @@ -13,6 +13,11 @@ use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; pub const VP: &str = "filter.vanishingPoint"; +/// Most planes a scene holds. +pub const MAX_PLANES: usize = 64; +/// Most clone strokes, and dabs over all of them, one run paints. +pub const MAX_CLONE_STROKES: usize = 4096; +pub const MAX_CLONE_DABS: usize = 200_000; fn bad(msg: impl Into) -> EngineError { EngineError::BadParams { cmd: VP.into(), msg: msg.into() } @@ -29,6 +34,9 @@ pub fn scene(p: &Value, canvas: Rect) -> Result { .and_then(Value::as_array) .filter(|a| !a.is_empty()) .ok_or_else(|| bad("pass `planes`: [{\"corners\":[[x,y]×4]}, {\"from\":0,\"edge\":\"top\",\"angle\":90}]"))?; + if planes.len() > MAX_PLANES { + return Err(bad(format!("at most {MAX_PLANES} planes"))); + } let center = [(canvas.x0 + canvas.x1) as f64 / 2.0, (canvas.y0 + canvas.y1) as f64 / 2.0]; let corners = |v: &Value| -> Option<[[f64; 2]; 4]> { let a = v.get("corners")?.as_array()?; @@ -67,6 +75,12 @@ pub fn scene(p: &Value, canvas: Rect) -> Result { }; let angle = pl.get("angle").and_then(Value::as_f64).unwrap_or(90.0); let depth = pl.get("depth").and_then(Value::as_f64).unwrap_or(1.0); + if !angle.is_finite() || !(0.0..=360.0).contains(&angle) { + return Err(bad(format!("plane {i}: `angle` must be 0..360 degrees"))); + } + if !depth.is_finite() || depth <= 0.0 || depth > 1000.0 { + return Err(bad(format!("plane {i}: `depth` must be above 0 and at most 1000"))); + } sc.tear_off(from, edge, angle, depth).ok_or_else(|| bad(format!("plane {i} would lie behind the camera")))?; } if sc.planes.iter().any(|p| p.homography().is_none()) { @@ -75,6 +89,86 @@ pub fn scene(p: &Value, canvas: Rect) -> Result { Ok(sc) } +/// One stroke of `"clone"`. +#[derive(Clone, Debug, PartialEq)] +pub struct CloneSpec { + pub source: [f64; 2], + pub points: Vec<[f64; 2]>, + pub size: f64, + pub hardness: f64, + pub opacity: f64, +} + +/// Reads `"clone"`: finite points, at most [`MAX_CLONE_STROKES`] strokes and +/// [`MAX_CLONE_DABS`] dabs in all. +pub fn clone_specs(p: &Value) -> Result> { + let Some(v) = p.get("clone").filter(|v| !v.is_null()) else { return Ok(Vec::new()) }; + let strokes = v.as_array().ok_or_else(|| bad("`clone` is a list of strokes"))?; + if strokes.len() > MAX_CLONE_STROKES { + return Err(bad(format!("at most {MAX_CLONE_STROKES} clone strokes"))); + } + let finite = |q: [f64; 2]| q.iter().all(|v| v.is_finite() && v.abs() < 1e7); + let num = |c: &Value, k: &str, default: f64, lo: f64, hi: f64| -> Result { + match c.get(k) { + None | Some(Value::Null) => Ok(default), + Some(v) => v.as_f64().filter(|x| x.is_finite()).map(|x| x.clamp(lo, hi)).ok_or_else(|| bad(format!("clone `{k}` must be a number"))), + } + }; + let mut dabs = 0usize; + let mut out = Vec::with_capacity(strokes.len()); + for (k, c) in strokes.iter().enumerate() { + let source = c.get("source").and_then(pt).filter(|q| finite(*q)).ok_or_else(|| bad(format!("clone {k}: `source` [x,y]")))?; + let raw = c.get("points").and_then(Value::as_array).ok_or_else(|| bad(format!("clone {k}: `points` [[x,y], …]")))?; + dabs = dabs.saturating_add(raw.len()); + if dabs > MAX_CLONE_DABS { + return Err(bad(format!("at most {MAX_CLONE_DABS} clone points in all"))); + } + let points = + raw.iter().map(|q| pt(q).filter(|q| finite(*q))).collect::>>().ok_or_else(|| bad(format!("clone {k}: every point is [x,y]")))?; + out.push(CloneSpec { + source, + points, + size: num(c, "size", 40.0, 1.0, 2000.0)?, + hardness: num(c, "hardness", 50.0, 0.0, 100.0)? / 100.0, + opacity: num(c, "opacity", 100.0, 0.0, 100.0)? / 100.0, + }); + } + Ok(out) +} + +/// Paints clone stroke `c` on `surf`, or, with `work` (the image it samples), on `work` and copies +/// the pixels it changed to `surf`. Returns the dabs painted. +pub fn clone_into(sc: &Scene, surf: &mut Surface, work: Option<&mut Surface>, c: &CloneSpec) -> usize { + let Some(first) = c.points.first() else { return 0 }; + let Some(work) = work else { + return clone_dabs(sc, surf, c.source, *first, &c.points, c.size, c.hardness, c.opacity as f32).0; + }; + let before = work.clone(); + let (dabs, area) = clone_dabs(sc, work, c.source, *first, &c.points, c.size, c.hardness, c.opacity as f32); + copy_changed(surf, &before, work, area); + dabs +} + +/// Copies to `dst` the pixels of `area` where `after` differs from `before` (same format). +pub fn copy_changed(dst: &mut Surface, before: &Surface, after: &Surface, area: Rect) { + if area.is_empty() { + return; + } + let n = dst.format().channels(); + let (nw, ow, mut cur) = (after.read_region(area), before.read_region(area), dst.read_region(area)); + for ((c, a), b) in cur.chunks_exact_mut(n).zip(nw.chunks_exact(n)).zip(ow.chunks_exact(n)) { + if a != b { + c.copy_from_slice(a); + } + } + dst.write_region(area, &cur); +} + +/// The image a `"newLayer"` run's clone strokes sample: the visible document. +pub fn visible_image(doc: &Document) -> Surface { + crate::file_cmds::flattened(doc, doc.pixel_format()) +} + fn has_layer(s: &Session) -> std::result::Result<(), String> { let d = s.active().ok_or("no document open")?; match d.active_layer.and_then(|id| d.doc.layer(id)).map(|l| &l.content) { @@ -134,7 +228,7 @@ fn vanishing_point(s: &mut Session, p: &Value) -> Result { let width = item.get("width").and_then(Value::as_f64).unwrap_or(0.5).clamp(0.01, 10.0); pastes.push((plane, surf, rect, at, width)); } - let clones: Vec = p.get("clone").and_then(Value::as_array).cloned().unwrap_or_default(); + let clones = clone_specs(p)?; let new_layer = p.get("newLayer").and_then(Value::as_bool).unwrap_or(false); let mut dabs = 0usize; let target = s.edit("Vanishing Point", |doc: &mut Document, active| { @@ -147,7 +241,7 @@ fn vanishing_point(s: &mut Session, p: &Value) -> Result { active.ok_or_else(|| EngineError::Other("no active layer".into()))? }; // A new layer clones from the visible image; otherwise from the layer itself. - let base: Option = new_layer.then(|| crate::file_cmds::flattened(doc, doc.pixel_format())); + let base: Option = new_layer.then(|| visible_image(doc)); let locks = doc.effective_locks(id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; if locks.all || locks.pixels { @@ -159,36 +253,14 @@ fn vanishing_point(s: &mut Session, p: &Value) -> Result { over(surf, &out); } } - for (k, c) in clones.iter().enumerate() { - let source = c.get("source").and_then(pt).ok_or_else(|| bad(format!("clone {k}: `source` [x,y]")))?; - let points: Vec<[f64; 2]> = c.get("points").and_then(Value::as_array).map(|a| a.iter().filter_map(pt).collect()).unwrap_or_default(); - let size = c.get("size").and_then(Value::as_f64).unwrap_or(40.0).clamp(1.0, 2000.0); - let hardness = c.get("hardness").and_then(Value::as_f64).unwrap_or(50.0).clamp(0.0, 100.0) / 100.0; - let opacity = (c.get("opacity").and_then(Value::as_f64).unwrap_or(100.0).clamp(0.0, 100.0) / 100.0) as f32; - // Sample from the original pixels (merged with what's been pasted). - let mut work = match &base { - Some(b) => { - let mut w = b.clone(); - over(&mut w, surf); - w - } - None => surf.clone(), - }; - let before = work.clone(); - dabs += clone_stroke(&sc, &mut work, source, &points, size, hardness, opacity); - // Copy back only what the stroke changed. - let b = work.content_bounds().union(&before.content_bounds()); - if !b.is_empty() { - let (nw, ow) = (work.read_region(b), before.read_region(b)); - let n = surf.format().channels(); - let mut cur = surf.read_region(b); - for i in 0..cur.len() / n { - if nw[i * n..(i + 1) * n] != ow[i * n..(i + 1) * n] { - cur[i * n..(i + 1) * n].copy_from_slice(&nw[i * n..(i + 1) * n]); - } - } - surf.write_region(b, &cur); - } + // A new layer samples the visible image (merged with what's been pasted) and takes only + // the pixels the strokes change; otherwise the strokes paint the layer itself. + let mut work = base.map(|mut w| { + over(&mut w, surf); + w + }); + for c in &clones { + dabs += clone_into(&sc, surf, work.as_mut(), c); } surf.prune(); Ok(id) @@ -282,4 +354,35 @@ mod tests { } assert!(s.execute(VP, json!({"planes": [{"corners": square}]})).is_ok()); } + + #[test] + fn rejects_hostile_clone_and_tear_off_params() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200, "depth": 32})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let floor = json!({"corners": [[60, 60], [140, 60], [190, 190], [10, 190]]}); + for torn in [json!({"from": 0, "angle": 1e9}), json!({"from": 0, "depth": -1}), json!({"from": 0, "depth": 1e300})] { + let e = s.execute(VP, json!({"planes": [floor, torn]})).unwrap_err().to_string(); + assert!(e.contains("plane 1"), "{e}"); + } + let many: Vec<[f64; 2]> = vec![[100.0, 150.0]; MAX_CLONE_DABS + 1]; + for clone in [ + json!([{"points": [[100, 150]]}]), + json!([{"source": [100, 180], "points": [[100, 150], "x"]}]), + json!([{"source": [100, 180], "points": [[100, 150]], "size": "big"}]), + json!([{"source": [100, 180], "points": many}]), + json!({"source": [100, 180]}), + ] { + let label: String = clone.to_string().chars().take(80).collect(); + assert!(s.execute(VP, json!({"planes": [floor], "clone": clone})).is_err(), "{label}"); + } + let planes: Vec = std::iter::repeat_n(floor.clone(), MAX_PLANES + 1).collect(); + assert!(s.execute(VP, json!({"planes": planes})).is_err()); + // A clone on a torn-off wall, at 32 bits, sampling the floor. + let r = s + .execute(VP, json!({"planes": [floor, {"from": 0, "edge": "top", "angle": 90, "depth": 0.5}], "clone": [{"source": [100, 150], "points": [[100, 50], [104, 50]], "size": 8}]})) + .unwrap(); + assert_eq!(r["planes"].as_array().unwrap().len(), 2); + assert!(r["dabs"].as_u64().unwrap() >= 1, "{r}"); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3cd4d294d8..0f389f4971 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1164,6 +1164,13 @@ pub(crate) fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), no { return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }); } + // Between Vanishing Point stamp previews (and the document): the areas the strokes painted. + if seen.0 == now.0 + && let Some(st) = app.session.documents().get(idx) + && let Some(r) = crate::vp_ui::damage(app, st.doc.id, now.0, seen.1 ^ display_key, now.1) + { + return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }); + } // Between hovered blend modes (and the document): where the layer's blending shows. if seen.0 == now.0 && let Some(st) = app.session.documents().get(idx) diff --git a/crates/ui-egui/src/distort_ui.rs b/crates/ui-egui/src/distort_ui.rs index 0a0d7914b0..1acb42e909 100644 --- a/crates/ui-egui/src/distort_ui.rs +++ b/crates/ui-egui/src/distort_ui.rs @@ -86,7 +86,8 @@ pub(crate) fn surface_image(surf: &Surface, r: Rect, max_side: usize) -> ColorIm ColorImage::new([tw, th], px) } -/// The document shown on the canvas while Puppet or Perspective Warp previews a layer. +/// The document shown on the canvas while Puppet or Perspective Warp previews a layer, or +/// Vanishing Point's Stamp has painted. pub fn display_doc(app: &PhotocraftApp, idx: usize) -> Option<(Arc, u64)> { if app.session.active_index() != Some(idx) { return None; @@ -97,7 +98,8 @@ pub fn display_doc(app: &PhotocraftApp, idx: usize) -> Option<(Arc, u6 if let Some(p) = &app.distort.perspective { return Some((p.preview_doc.clone(), (1 << 42) + p.key)); } - None + // Vanishing Point: the clone strokes painted so far. + app.distort.vanishing.as_ref().and_then(crate::vp_ui::VpSession::shown) } /// Menu / control routing. `Some` when this module handles the invocation. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index b39e9381a8..ebcb09e1b4 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2449,3 +2449,6 @@ fontWeight Italic Kurzíva Vanishing Point is not active Úběžník není aktivní Apply Vanishing Point ({key}) Použít úběžník ({key}) Fix the red plane first: its corners must form a convex quad Nejprve opravte červenou rovinu: její rohy musí tvořit konvexní čtyřúhelník + Alt-click a plane to set the clone source Klepnutím s Alt na rovinu nastavte zdroj klonování + Too many clone strokes: apply Vanishing Point first Příliš mnoho tahů klonování: nejprve použijte Úběžník + Stamp Razítko diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b5eee5bed2..41ae8d221e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2487,3 +2487,5 @@ fontWeight Italic Kursiv Vanishing Point is not active Fluchtpunkt ist nicht aktiv Apply Vanishing Point ({key}) Fluchtpunkt anwenden ({key}) Fix the red plane first: its corners must form a convex quad Korrigieren Sie zuerst die rote Ebene: Ihre Ecken müssen ein konvexes Viereck bilden + Alt-click a plane to set the clone source Alt-Klick auf eine Ebene legt die Kopierquelle fest + Too many clone strokes: apply Vanishing Point first Zu viele Kopierstriche: Wenden Sie zuerst Fluchtpunkt an diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f01045950d..b156cd0717 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3553,3 +3553,5 @@ fontWeight Thin Πολύ λεπτά Vanishing Point is not active Το Σημείο φυγής δεν είναι ενεργό Apply Vanishing Point ({key}) Εφαρμογή σημείου φυγής ({key}) Fix the red plane first: its corners must form a convex quad Διορθώστε πρώτα το κόκκινο επίπεδο: οι γωνίες του πρέπει να σχηματίζουν κυρτό τετράπλευρο + Alt-click a plane to set the clone source Alt-κλικ σε ένα επίπεδο για να ορίσετε την πηγή κλωνοποίησης + Too many clone strokes: apply Vanishing Point first Πάρα πολλές πινελιές κλωνοποίησης: εφαρμόστε πρώτα το Σημείο φυγής diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5517d8dadf..79f84360c1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2528,3 +2528,5 @@ fontWeight Italic Cursiva Vanishing Point is not active Punto de fuga no está activo Apply Vanishing Point ({key}) Aplicar punto de fuga ({key}) Fix the red plane first: its corners must form a convex quad Corrija primero el plano rojo: sus esquinas deben formar un cuadrilátero convexo + Alt-click a plane to set the clone source Haga Alt-clic en un plano para definir el origen de clonación + Too many clone strokes: apply Vanishing Point first Demasiados trazos de clonación: aplique primero Punto de fuga diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 8d3121dd79..e319253071 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2864,3 +2864,5 @@ fontWeight Italic Italique Vanishing Point is not active Point de fuite n’est pas actif Apply Vanishing Point ({key}) Appliquer le point de fuite ({key}) Fix the red plane first: its corners must form a convex quad Corrigez d’abord le plan rouge : ses coins doivent former un quadrilatère convexe + Alt-click a plane to set the clone source Alt-clic sur un plan pour définir la source de duplication + Too many clone strokes: apply Vanishing Point first Trop de traits de duplication : appliquez d’abord Point de fuite diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 910342b185..66e496645b 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2851,3 +2851,5 @@ fontWeight Italic Miring Vanishing Point is not active Vanishing Point tidak aktif Apply Vanishing Point ({key}) Terapkan Vanishing Point ({key}) Fix the red plane first: its corners must form a convex quad Perbaiki bidang merah dulu: sudut-sudutnya harus membentuk segi empat cembung + Alt-click a plane to set the clone source Alt-klik sebuah bidang untuk menetapkan sumber kloning + Too many clone strokes: apply Vanishing Point first Terlalu banyak goresan kloning: terapkan Vanishing Point terlebih dahulu diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 81c3145854..5857ef220b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2518,3 +2518,5 @@ fontWeight Italic Corsivo Vanishing Point is not active Punto di fuga non è attivo Apply Vanishing Point ({key}) Applica punto di fuga ({key}) Fix the red plane first: its corners must form a convex quad Correggi prima il piano rosso: i suoi angoli devono formare un quadrilatero convesso + Alt-click a plane to set the clone source Alt-clic su un piano per impostare l’origine della clonazione + Too many clone strokes: apply Vanishing Point first Troppi tratti di clonazione: applica prima Punto di fuga diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 08f56447ed..383e83dfed 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3464,3 +3464,5 @@ fontWeight Italic 斜体 Vanishing Point is not active 消点はアクティブではありません Apply Vanishing Point ({key}) 消点を適用({key}) Fix the red plane first: its corners must form a convex quad 先に赤い面を修正してください:角が凸四角形になる必要があります + Alt-click a plane to set the clone source Alt キーを押しながら平面をクリックしてコピー元を設定 + Too many clone strokes: apply Vanishing Point first コピースタンプのストロークが多すぎます:先に消点を適用してください diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 81b10fb7ce..4e37562ff0 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3026,3 +3026,5 @@ fontWeight Italic 기울임 Vanishing Point is not active 소실점이 활성화되어 있지 않습니다 Apply Vanishing Point ({key}) 소실점 적용 ({key}) Fix the red plane first: its corners must form a convex quad 먼저 빨간색 평면을 수정하십시오: 모서리가 볼록 사각형을 이루어야 합니다 + Alt-click a plane to set the clone source Alt 키를 누른 채 평면을 클릭하여 복제 소스를 설정하십시오 + Too many clone strokes: apply Vanishing Point first 복제 획이 너무 많습니다. 먼저 소실점을 적용하십시오 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 456b8c3f44..106f3f347a 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3623,3 +3623,5 @@ cameraRaw Whites Witten Vanishing Point is not active Verdwijnpunt is niet actief Apply Vanishing Point ({key}) Verdwijnpunt toepassen ({key}) Fix the red plane first: its corners must form a convex quad Herstel eerst het rode vlak: de hoeken moeten een convexe vierhoek vormen + Alt-click a plane to set the clone source Alt-klik op een vlak om de kloonbron in te stellen + Too many clone strokes: apply Vanishing Point first Te veel kloonstreken: pas eerst Verdwijnpunt toe diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8acdcd1c62..aaeed61b53 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3464,3 +3464,5 @@ cameraRaw Roughness Chropowatość Vanishing Point is not active Punkt zbieżności nie jest aktywny Apply Vanishing Point ({key}) Zastosuj punkt zbieżności ({key}) Fix the red plane first: its corners must form a convex quad Najpierw popraw czerwoną płaszczyznę: jej narożniki muszą tworzyć czworokąt wypukły + Alt-click a plane to set the clone source Kliknij płaszczyznę z Alt, aby ustawić źródło klonowania + Too many clone strokes: apply Vanishing Point first Zbyt wiele pociągnięć klonowania: najpierw zastosuj Punkt zbieżności diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1b58102b86..b724f5d70c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2872,3 +2872,5 @@ fontWeight Italic Itálico Vanishing Point is not active Ponto de fuga não está ativo Apply Vanishing Point ({key}) Aplicar ponto de fuga ({key}) Fix the red plane first: its corners must form a convex quad Corrija primeiro o plano vermelho: seus cantos devem formar um quadrilátero convexo + Alt-click a plane to set the clone source Clique com Alt em um plano para definir a origem da clonagem + Too many clone strokes: apply Vanishing Point first Traços de clonagem demais: aplique Ponto de fuga primeiro diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 22eda77f08..6264aa9c00 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2731,3 +2731,5 @@ fontWeight Italic Курсив Vanishing Point is not active Точка схода не активна Apply Vanishing Point ({key}) Применить точку схода ({key}) Fix the red plane first: its corners must form a convex quad Сначала исправьте красную плоскость: ее углы должны образовывать выпуклый четырехугольник + Alt-click a plane to set the clone source Щёлкните плоскость с Alt, чтобы задать источник клонирования + Too many clone strokes: apply Vanishing Point first Слишком много мазков клонирования: сначала примените «Точку схода» diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3fd50ffaf1..cc39bf218b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3551,3 +3551,5 @@ cameraRaw Masking Маскування Vanishing Point is not active Точка сходження не активна Apply Vanishing Point ({key}) Застосувати точку сходження ({key}) Fix the red plane first: its corners must form a convex quad Спершу виправте червону площину: її кути мають утворювати опуклий чотирикутник + Alt-click a plane to set the clone source Клацніть площину з Alt, щоб задати джерело клонування + Too many clone strokes: apply Vanishing Point first Забагато мазків клонування: спочатку застосуйте «Точку сходження» diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 644eb96e9c..422ef6a1a0 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2456,3 +2456,6 @@ fontWeight Italic 斜体 Vanishing Point is not active 消失点未处于活动状态 Apply Vanishing Point ({key}) 应用消失点({key}) Fix the red plane first: its corners must form a convex quad 请先修正红色平面:其四个角必须构成凸四边形 + Alt-click a plane to set the clone source 按住 Alt 键单击平面以设置仿制源 + Too many clone strokes: apply Vanishing Point first 仿制描边过多:请先应用消失点 + Stamp 图章 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2150018ee0..09887f0bd5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2426,3 +2426,6 @@ fontWeight Italic 斜體 Vanishing Point is not active 消失點未啟用 Apply Vanishing Point ({key}) 套用消失點 ({key}) Fix the red plane first: its corners must form a convex quad 請先修正紅色平面:其四個角必須構成凸四邊形 + Alt-click a plane to set the clone source 按住 Alt 鍵按一下平面以設定仿製來源 + Too many clone strokes: apply Vanishing Point first 仿製筆畫過多:請先套用消失點 + Stamp 印章 diff --git a/crates/ui-egui/src/vp_ui.rs b/crates/ui-egui/src/vp_ui.rs index bdaad601c6..4e32edc72b 100644 --- a/crates/ui-egui/src/vp_ui.rs +++ b/crates/ui-egui/src/vp_ui.rs @@ -2,19 +2,27 @@ //! //! **Create Plane** (C): four clicks set a plane's corners, in any order. **Edit Plane** (V): //! drag a corner node, or an edge node to slide that edge along the plane's own perspective; -//! click a plane to select it, Delete removes it. The plane's grid is drawn at the Grid Size and, +//! click a plane to select it, Delete removes it. Ctrl/Cmd-dragging an edge node tears off a +//! perpendicular plane; dragging the torn plane's far edge node sets its depth and Alt-dragging +//! it its angle (it stays a torn-off plane, so it follows its parent). The plane's grid is drawn +//! at the Grid Size and, //! like Photoshop's, in the accent colour when the plane is valid, yellow when its perspective is //! doubtful and red when it is invalid ([`VpPlane::check`]). With an image on the clipboard the -//! selected plane shows it pasted in perspective; drag it to move it on the plane. ↩ / OK runs -//! `filter.vanishingPoint` with the planes and the paste, so it is one history step and an -//! action or agent can replay it exactly. +//! selected plane shows it pasted in perspective; drag it to move it on the plane. **Stamp** (S): +//! Alt-click a plane to set the source, then paint to clone in perspective (Diameter, Hardness, +//! Opacity, Aligned); the canvas shows the strokes as they are painted. ↩ / OK runs +//! `filter.vanishingPoint` with the planes, the paste and the strokes, so the whole session is +//! one history step and an action or agent can replay it exactly. + +use std::sync::Arc; use egui::{Color32, Pos2, Stroke, TextureHandle, pos2}; use photocraft_algo::transform::Homography; -use photocraft_algo::vanishing::{PlaneCheck, Scene, VpPlane}; -use photocraft_doc::{LayerContent, LayerId}; -use photocraft_engine::vp_cmds::VP; +use photocraft_algo::vanishing::{Edge, PlaneCheck, Scene, VpPlane, clone_dabs, clone_source}; +use photocraft_doc::{DocId, Document, Layer, LayerContent, LayerId}; +use photocraft_engine::vp_cmds::{CloneSpec, MAX_CLONE_DABS, MAX_CLONE_STROKES, VP, copy_changed, visible_image}; use photocraft_geom::Rect; +use photocraft_raster::Surface; use serde_json::{Value, json}; use crate::PhotocraftApp; @@ -25,6 +33,45 @@ use crate::canvas::{ToolEvent, ViewXform}; pub enum VpTool { EditPlane, CreatePlane, + Stamp, +} + +/// A plane torn off an edge of an earlier plane: the engine's +/// `{"from", "edge", "angle", "depth"}`, so it follows its parent when that is edited. +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize)] +pub struct Torn { + pub from: usize, + pub edge: Edge, + /// Degrees between the planes (90 = perpendicular). + pub angle: f64, + /// Extent as a multiple of the parent's adjacent side. + pub depth: f64, +} + +/// The Stamp tool's options. +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StampOptions { + /// Brush diameter in px where a stroke starts. + pub diameter: f64, + /// 0..=100. + pub hardness: f64, + /// 0..=100. + pub opacity: f64, + /// Later strokes keep the first stroke's source offset instead of restarting at the source. + pub aligned: bool, +} + +/// Edge node `i` (0 top, 1 right, 2 bottom, 3 left) as the engine's edge. +const EDGES: [Edge; 4] = [Edge::Top, Edge::Right, Edge::Bottom, Edge::Left]; + +fn edge_index(e: Edge) -> usize { + match e { + Edge::Top => 0, + Edge::Right => 1, + Edge::Bottom => 2, + Edge::Left => 3, + } } enum Drag { @@ -35,6 +82,28 @@ enum Drag { Edge { plane: usize, edge: usize, h: Homography, inv: Homography }, /// The pasted image, and the pointer offset from its top-left in plane coordinates. Paste { offset: [f64; 2] }, + /// The far edge node of torn-off plane `plane` (or of one being torn off): the drag sets + /// its depth, or with `rotate` (Alt) its angle, once it has left `start`. + Torn { plane: usize, rotate: bool, start: [f64; 2], moved: bool }, + /// Painting the last of the clone strokes. + Stamp, +} + +/// The clone strokes painted onto the target layer, shown on the canvas while stamping. +struct StampPreview { + /// The session's `rev` and New Layer it was painted with (either changing repaints it). + rev: u64, + new_layer: bool, + /// On a new layer: the visible image the strokes sample, with the strokes so far. + work: Option, + /// The target layer's pixels as painted so far. + out: Surface, + /// Strokes painted: those before `.0`, and the first `.1` points of stroke `.0`. + done: (usize, usize), + /// Preview keys shown, each with the area changed since the previous one (the first since + /// the document). + keys: Vec<(u64, Rect)>, + shown: Arc, } /// The clipboard image shown pasted onto the plane. @@ -47,7 +116,10 @@ struct Clip { pub struct VpSession { pub layer: LayerId, canvas: Rect, + /// The planes' corners; torn-off planes' are derived from their parents ([`Self::resolve`]). pub planes: Vec, + /// Per plane: how it was torn off, or `None` for a drawn plane (always the first one). + pub torn: Vec>, pub tool: VpTool, /// Corners clicked so far with Create Plane. pending: Vec<[f64; 2]>, @@ -63,21 +135,61 @@ pub struct VpSession { pub new_layer: bool, clip: Option, drag: Option, + pub stamp: StampOptions, + /// Stamp source (Alt-click), image px. + pub source: Option<[f64; 2]>, + /// Clone strokes painted so far, sent as `"clone"` on OK. + pub strokes: Vec, + /// The first stroke's (source, start) since the source was set: Aligned strokes keep its offset. + aligned_ref: Option<([f64; 2], [f64; 2])>, + /// Bumped whenever the planes or the strokes change other than by painting on. + rev: u64, + /// The document as the session began (the preview paints over it). + base: Arc, + doc_revision: u64, + preview: Option, } /// Most planes a session holds (the control channel can't make it allocate without bound). const MAX_PLANES: usize = 64; /// Most grid lines drawn per direction. const MAX_LINES: usize = 200; +/// Preview keys of the stamp preview: `PREVIEW_KEY + id` (see `canvas::display_doc`). +const PREVIEW_KEY: u64 = 1 << 42; + +fn union(a: Rect, b: Rect) -> Rect { + if a.is_empty() { + b + } else if b.is_empty() { + a + } else { + a.union(&b) + } +} impl VpSession { pub fn describe(&self) -> Value { let center = self.center(); - let planes: Vec = self.planes.iter().map(|p| json!({"corners": p.corners, "check": p.check(center)})).collect(); + let planes: Vec = self + .planes + .iter() + .enumerate() + .map(|(i, p)| { + let mut v = json!({"corners": p.corners, "check": p.check(center)}); + if let Some(t) = self.torn.get(i).copied().flatten() { + v["tornOff"] = json!(t); + } + v + }) + .collect(); json!({ "layer": self.layer.0, "tool": self.tool, "planes": planes, + "stamp": self.stamp, + "source": self.source, + "strokes": self.strokes.len(), + "dabs": self.dabs(), "pending": self.pending, "selected": self.selected, "gridSize": self.grid_size, @@ -125,19 +237,70 @@ impl VpSession { Some([self.paste_width, self.paste_width * lu / lv * c.height / c.width.max(1.0)]) } - /// The command's params: the planes, and the paste when there is one. + /// The command's params: the planes (torn-off ones as such), the paste and the clone + /// strokes when there are any. pub fn params(&self) -> Value { - let planes: Vec = self.planes.iter().map(|p| json!({"corners": p.corners})).collect(); + let planes: Vec = self + .planes + .iter() + .enumerate() + .map(|(i, p)| match self.torn.get(i).copied().flatten() { + Some(t) => json!({"from": t.from, "edge": t.edge, "angle": t.angle, "depth": t.depth}), + None => json!({"corners": p.corners}), + }) + .collect(); let mut p = json!({"planes": planes}); if let Some(i) = self.paste_plane() { p["paste"] = json!([{"plane": i, "at": self.paste_at, "width": self.paste_width}]); } + if !self.strokes.is_empty() { + let clone: Vec = self + .strokes + .iter() + .map(|c| json!({"source": c.source, "points": c.points, "size": c.size, "hardness": c.hardness * 100.0, "opacity": c.opacity * 100.0})) + .collect(); + p["clone"] = json!(clone); + } if self.new_layer { p["newLayer"] = json!(true); } p } + fn dabs(&self) -> usize { + self.strokes.iter().map(|c| c.points.len()).sum() + } + + /// Recomputes the torn-off planes' corners from their parents, in order (the way the engine + /// builds the scene), so they follow every edit. A plane that can't be torn off any more + /// (it would lie behind the camera) collapses to an invalid one. + fn resolve(&mut self) { + self.rev += 1; + self.torn.resize(self.planes.len(), None); + let Some(first) = self.planes.first().cloned() else { return }; + let fallback = f64::from(self.canvas.width().max(self.canvas.height())); + let mut sc = Scene::new(first, self.center(), None, fallback); + for i in 1..self.planes.len() { + match self.torn.get(i).copied().flatten().filter(|t| t.from < i) { + Some(t) => { + if sc.tear_off(t.from, t.edge, t.angle, t.depth).is_none() { + sc.planes.push(VpPlane { corners: [[0.0; 2]; 4] }); + } + } + None => sc.planes.push(self.planes[i].clone()), + } + } + self.planes = sc.planes; + } + + fn push_plane(&mut self, plane: VpPlane, torn: Option) { + self.torn.resize(self.planes.len(), None); + self.planes.push(plane); + self.torn.push(torn); + self.selected = Some(self.planes.len() - 1); + self.resolve(); + } + fn add_corner(&mut self, p: [f64; 2]) { if self.planes.len() >= MAX_PLANES { return; @@ -145,20 +308,194 @@ impl VpSession { self.pending.push(p); if let Ok(corners) = <[[f64; 2]; 4]>::try_from(self.pending.as_slice()) { self.pending.clear(); - self.planes.push(VpPlane { corners }.normalized()); - self.selected = Some(self.planes.len() - 1); + self.push_plane(VpPlane { corners }.normalized(), None); // Photoshop hands over to Edit Plane once a plane is closed. self.tool = VpTool::EditPlane; } } + /// Deletes the selected plane; planes torn off it keep their place as drawn planes. fn delete_selected(&mut self) { if let Some(i) = self.selected.take().filter(|i| *i < self.planes.len()) { + self.torn.resize(self.planes.len(), None); self.planes.remove(i); + self.torn.remove(i); + for t in self.torn.iter_mut() { + match t { + Some(c) if c.from == i => *t = None, + Some(c) if c.from > i => c.from -= 1, + _ => {} + } + } self.drag = None; + self.resolve(); } } + /// Starts tearing a plane off edge `edge` of plane `from` at the press `p`. + fn tear_off(&mut self, from: usize, edge: usize, p: [f64; 2]) { + if self.planes.len() >= MAX_PLANES { + return; + } + let Some(&e) = EDGES.get(edge) else { return }; + self.push_plane(VpPlane { corners: [[0.0; 2]; 4] }, Some(Torn { from, edge: e, angle: 90.0, depth: 0.5 })); + // As after closing a plane, Edit Plane takes over. + self.tool = VpTool::EditPlane; + self.pending.clear(); + self.drag = Some(Drag::Torn { plane: self.planes.len() - 1, rotate: false, start: p, moved: false }); + } + + /// Fits torn plane `i`'s depth (or with `rotate` its angle) so its far edge's middle is as + /// near `p` as it can get. + fn fit_torn(&mut self, i: usize, rotate: bool, p: [f64; 2]) { + let Some(t) = self.torn.get(i).copied().flatten() else { return }; + let Some(first) = self.planes.first().cloned() else { return }; + if t.from >= i || i >= self.planes.len() { + return; + } + let fallback = f64::from(self.canvas.width().max(self.canvas.height())); + let mut sc = Scene::new(first, self.center(), None, fallback); + sc.planes.extend(self.planes.iter().take(i).skip(1).cloned()); + let e = edge_index(t.edge); + let mut miss = |angle: f64, depth: f64| -> f64 { + if sc.tear_off(t.from, t.edge, angle, depth).is_none() { + return f64::MAX; + } + let Some(c) = sc.planes.pop().map(|pl| pl.corners) else { return f64::MAX }; + let (a, b) = (c[e], c[(e + 1) % 4]); + let d = ((a[0] + b[0]) / 2.0 - p[0]).hypot((a[1] + b[1]) / 2.0 - p[1]); + if d.is_finite() { d } else { f64::MAX } + }; + let best = |values: &mut dyn Iterator, miss: &mut dyn FnMut(f64) -> f64| { + values.map(|v| (miss(v), v)).filter(|m| m.0 < f64::MAX).min_by(|a, b| a.0.total_cmp(&b.0)).map(|m| m.1) + }; + let (angle, depth) = if rotate { + let Some(a) = best(&mut (1..180).map(f64::from), &mut |a| miss(a, t.depth)) else { return }; + let a = best(&mut (-10..=10).map(|k| a + f64::from(k) / 10.0), &mut |a| miss(a, t.depth)).unwrap_or(a); + (a.clamp(1.0, 179.0), t.depth) + } else { + let Some(d) = best(&mut (0..=160).map(|k| 0.02 * 1.06f64.powi(k)), &mut |d| miss(t.angle, d)) else { return }; + let d = best(&mut (-10..=10).map(|k| d * (1.0 + f64::from(k) * 0.006)), &mut |d| miss(t.angle, d)).unwrap_or(d); + (t.angle, d.clamp(0.02, 1000.0)) + }; + if let Some(Some(t)) = self.torn.get_mut(i) { + t.angle = angle; + t.depth = depth; + } + self.resolve(); + } + + /// Stamp press: Alt sets the source, otherwise a stroke starts. Returns a status message. + fn stamp_press(&mut self, p: [f64; 2], alt: bool) -> Option<&'static str> { + let sc = self.scene()?; + let on_plane = |q: [f64; 2]| sc.plane_at(q).is_some(); + if alt { + if !on_plane(p) { + return Some(tl!("Alt-click a plane to set the clone source")); + } + self.source = Some(p); + self.aligned_ref = None; + return None; + } + let Some(source) = self.source else { return Some(tl!("Alt-click a plane to set the clone source")) }; + if !on_plane(p) { + return None; + } + if self.strokes.len() >= MAX_CLONE_STROKES || self.dabs() >= MAX_CLONE_DABS { + return Some(tl!("Too many clone strokes: apply Vanishing Point first")); + } + let src = match self.aligned_ref.filter(|_| self.stamp.aligned) { + Some((s0, a0)) => clone_source(&sc, s0, a0, p).filter(|s| on_plane(*s)).unwrap_or(source), + None => source, + }; + if self.aligned_ref.is_none() { + self.aligned_ref = Some((src, p)); + } + let o = self.stamp; + self.strokes.push(CloneSpec { source: src, points: vec![p], size: o.diameter, hardness: o.hardness / 100.0, opacity: o.opacity / 100.0 }); + self.drag = Some(Drag::Stamp); + None + } + + /// Extends the current stroke to `p` with dabs a tenth of the brush apart. + fn stamp_to(&mut self, p: [f64; 2]) { + let room = MAX_CLONE_DABS.saturating_sub(self.dabs()); + let Some(c) = self.strokes.last_mut() else { return }; + let Some(last) = c.points.last().copied() else { return }; + let step = (c.size * 0.1).max(1.0); + let d = (p[0] - last[0]).hypot(p[1] - last[1]); + if !d.is_finite() || d < step { + return; + } + let n = ((d / step) as usize).min(room); + c.points.extend((1..=n).map(|k| { + let t = k as f64 * step / d; + [last[0] + (p[0] - last[0]) * t, last[1] + (p[1] - last[1]) * t] + })); + } + + /// Paints what the preview doesn't show yet (all of it after the planes changed). `key` + /// names a new preview. False when nothing changed. + fn paint_preview(&mut self, key: impl FnOnce() -> u64) -> bool { + if self.strokes.is_empty() { + let had = self.preview.is_some(); + self.preview = None; + return had; + } + let Some(sc) = self.scene() else { return false }; + let stale = self.preview.as_ref().is_none_or(|p| p.rev != self.rev || p.new_layer != self.new_layer); + let mut area = Rect::EMPTY; + if stale { + let Some(layer) = self.base.layer(self.layer).and_then(Layer::surface) else { return false }; + let (out, work) = if self.new_layer { (Surface::new(layer.format()), Some(visible_image(&self.base))) } else { (layer.clone(), None) }; + // The old preview's area changes back too. + let keys = self.preview.take().map(|p| p.keys).unwrap_or_default(); + area = keys.iter().fold(Rect::EMPTY, |a, k| union(a, k.1)); + self.preview = Some(StampPreview { rev: self.rev, new_layer: self.new_layer, work, out, done: (0, 0), keys, shown: self.base.clone() }); + } + let Some(pv) = self.preview.as_mut() else { return false }; + let (from_stroke, from_point) = pv.done; + for (si, c) in self.strokes.iter().enumerate().skip(from_stroke) { + let skip = if si == from_stroke { from_point } else { 0 }; + let (Some(anchor), Some(points)) = (c.points.first(), c.points.get(skip..)) else { continue }; + if points.is_empty() { + continue; + } + let (hard, opacity) = (c.hardness, c.opacity as f32); + let changed = match pv.work.as_mut() { + Some(work) => { + let before = work.clone(); + let (_, a) = clone_dabs(&sc, work, c.source, *anchor, points, c.size, hard, opacity); + copy_changed(&mut pv.out, &before, work, a); + a + } + None => clone_dabs(&sc, &mut pv.out, c.source, *anchor, points, c.size, hard, opacity).1, + }; + area = union(area, changed); + } + pv.done = (self.strokes.len() - 1, self.strokes.last().map_or(0, |c| c.points.len())); + if area.is_empty() && !stale { + return false; + } + let mut d = (*self.base).clone(); + if self.new_layer { + let mut l = Layer::raster(d.next_layer_name("Vanishing Point"), d.pixel_format()); + l.content = LayerContent::Raster(pv.out.clone()); + d.insert_above(Some(self.layer), l); + } else if let Some(LayerContent::Raster(s)) = d.layer_mut(self.layer).map(|l| &mut l.content) { + *s = pv.out.clone(); + } + pv.shown = Arc::new(d); + pv.keys.push((key(), area)); + true + } + + /// The document with the strokes painted, and its preview key. + pub(crate) fn shown(&self) -> Option<(Arc, u64)> { + let p = self.preview.as_ref()?; + Some((p.shown.clone(), PREVIEW_KEY + p.keys.last()?.0)) + } + /// Image positions of the edge nodes of plane `i`. fn edge_nodes(&self, i: usize) -> Option<[[f64; 2]; 4]> { let h = self.planes.get(i)?.homography()?; @@ -169,20 +506,40 @@ impl VpSession { Some([at(0.5, 0.0), at(1.0, 0.5), at(0.5, 1.0), at(0.0, 0.5)]) } - fn press(&mut self, p: [f64; 2], tol: f64) { - if self.tool == VpTool::CreatePlane { - self.add_corner(p); - return; + fn press(&mut self, p: [f64; 2], tol: f64, mods: egui::Modifiers) -> Option<&'static str> { + if self.tool == VpTool::Stamp { + return self.stamp_press(p, mods.alt); } let near = |c: [f64; 2]| (c[0] - p[0]).hypot(c[1] - p[1]) <= tol; // The selected plane's nodes win over the others'. let order: Vec = self.selected.into_iter().chain((0..self.planes.len()).rev()).filter(|i| *i < self.planes.len()).collect(); + // Ctrl/Cmd on an edge node tears off a perpendicular plane (with either plane tool). + if mods.command + && let Some((i, e)) = order.iter().find_map(|&i| Some((i, self.edge_nodes(i)?.iter().position(|c| near(*c))?))) + { + self.tear_off(i, e, p); + return None; + } + if self.tool == VpTool::CreatePlane { + self.add_corner(p); + return None; + } for &i in &order { + // A torn-off plane follows its parent: only its far edge node drags (depth, or + // with Alt the angle). + if let Some(t) = self.torn.get(i).copied().flatten() { + if self.edge_nodes(i).and_then(|n| n.get(edge_index(t.edge)).copied()).is_some_and(near) { + self.selected = Some(i); + self.drag = Some(Drag::Torn { plane: i, rotate: mods.alt, start: p, moved: false }); + return None; + } + continue; + } if let Some(ci) = self.planes[i].corners.iter().position(|c| near(*c)) { let c = self.planes[i].corners[ci]; self.selected = Some(i); self.drag = Some(Drag::Corner { plane: i, corner: ci, offset: [c[0] - p[0], c[1] - p[1]] }); - return; + return None; } if let Some(e) = self.edge_nodes(i).and_then(|n| n.iter().position(|c| near(*c))) && let Some(h) = self.planes[i].homography() @@ -190,7 +547,7 @@ impl VpSession { { self.selected = Some(i); self.drag = Some(Drag::Edge { plane: i, edge: e, h, inv }); - return; + return None; } } if let Some(i) = self.paste_plane() @@ -202,19 +559,30 @@ impl VpSession { let (a, b) = (self.paste_at, [self.paste_at[0] + ext[0], self.paste_at[1] + ext[1]]); if (a[0]..=b[0]).contains(&u) && (a[1]..=b[1]).contains(&v) { self.drag = Some(Drag::Paste { offset: [a[0] - u, a[1] - v] }); - return; + return None; } } self.selected = self.scene().and_then(|sc| sc.plane_at(p)); + None } fn drag_to(&mut self, p: [f64; 2]) { - match &self.drag { + match &mut self.drag { Some(Drag::Corner { plane, corner, offset }) => { if let Some(c) = self.planes.get_mut(*plane).and_then(|pl| pl.corners.get_mut(*corner)) { *c = [p[0] + offset[0], p[1] + offset[1]]; } + self.resolve(); } + Some(Drag::Torn { plane, rotate, start, moved }) => { + // A click without a drag leaves the plane as it is. + *moved |= (p[0] - start[0]).hypot(p[1] - start[1]) > 2.0; + if *moved { + let (i, r) = (*plane, *rotate); + self.fit_torn(i, r, p); + } + } + Some(Drag::Stamp) => self.stamp_to(p), Some(Drag::Edge { plane, edge, h, inv }) => { let (u, v) = inv.apply(p[0], p[1]); // The plane's (u, v) rectangle after the move. @@ -229,6 +597,7 @@ impl VpSession { { pl.corners = q; } + self.resolve(); } Some(Drag::Paste { offset }) => { let offset = *offset; @@ -277,6 +646,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> None => return Err("no active layer".into()), } let canvas = st.doc.bounds(); + let (base, doc_revision) = (st.doc.clone(), st.revision); let key = app.ui.alloc_id(); let clip = app.session.clipboard.as_ref().and_then(|c| { let r = c.surface.content_bounds(); @@ -290,6 +660,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> layer, canvas, planes: Vec::new(), + torn: Vec::new(), tool: VpTool::CreatePlane, pending: Vec::new(), selected: None, @@ -300,13 +671,21 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> new_layer: false, clip, drag: None, + stamp: StampOptions { diameter: 40.0, hardness: 50.0, opacity: 100.0, aligned: true }, + source: None, + strokes: Vec::new(), + aligned_ref: None, + rev: 0, + base, + doc_revision, + preview: None, }); app.ui.status = tl!("Vanishing Point: click four corners to create a plane").into(); Ok(()) } -/// OK: runs `filter.vanishingPoint` with the planes (and the paste). An invalid plane keeps the -/// mode open; with nothing to paste the mode just closes. +/// OK: runs `filter.vanishingPoint` with the planes (and the paste and the clone strokes). An +/// invalid plane keeps the mode open; with nothing to paste or clone the mode just closes. pub fn commit(app: &mut PhotocraftApp) -> Result { let Some(s) = app.distort.vanishing.as_ref() else { return Ok(Value::Null) }; let center = s.center(); @@ -317,7 +696,7 @@ pub fn commit(app: &mut PhotocraftApp) -> Result { } let params = s.params(); app.distort.vanishing = None; - if params.get("paste").is_none() { + if params.get("paste").is_none() && params.get("clone").is_none() { return Ok(json!({"committed": false})); } app.run(VP, params).map(|r| json!({"committed": true, "result": r})) @@ -337,19 +716,39 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { s.tool = match t { "edit" | "editPlane" => VpTool::EditPlane, "create" | "createPlane" => VpTool::CreatePlane, - other => return Err(format!("tool: edit|create, not `{other}`")), + "stamp" => VpTool::Stamp, + other => return Err(format!("tool: edit|create|stamp, not `{other}`")), }; s.pending.clear(); } if let Some(p) = ui.get("planes") { - let planes: Vec = serde_json::from_value(p.clone()).map_err(|e| format!("bad planes: {e}"))?; - if planes.len() > MAX_PLANES { - return Err(format!("at most {MAX_PLANES} planes")); - } + let (planes, torn) = parse_planes(p)?; s.planes = planes; + s.torn = torn; // A drag holds indices into the old list. s.drag = None; s.selected = (!s.planes.is_empty()).then(|| s.planes.len() - 1); + s.resolve(); + } + if let Some(o) = ui.get("stamp") { + let num = |k: &str, lo: f64, hi: f64| o.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.clamp(lo, hi)); + let st = &mut s.stamp; + st.diameter = num("diameter", 1.0, 2000.0).unwrap_or(st.diameter); + st.hardness = num("hardness", 0.0, 100.0).unwrap_or(st.hardness); + st.opacity = num("opacity", 0.0, 100.0).unwrap_or(st.opacity); + st.aligned = o.get("aligned").and_then(Value::as_bool).unwrap_or(st.aligned); + } + if let Some(src) = ui.get("source") { + s.source = match src { + Value::Null => None, + v => Some(pt(v).ok_or("source: [x, y]")?), + }; + s.aligned_ref = None; + } + if let Some(c) = ui.get("clone") { + s.strokes = photocraft_engine::vp_cmds::clone_specs(&json!({"clone": c})).map_err(|e| e.to_string())?; + s.drag = None; + s.rev += 1; } if let Some(i) = ui.get("select").and_then(Value::as_u64) { s.selected = usize::try_from(i).ok().filter(|i| *i < s.planes.len()); @@ -369,20 +768,96 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { if let Some(b) = ui.get("newLayer").and_then(Value::as_bool) { s.new_layer = b; } - Ok(s.describe()) + sync_preview(app); + Ok(app.distort.vanishing.as_ref().map(VpSession::describe).unwrap_or(Value::Null)) } -pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, _mods: egui::Modifiers) { +fn pt(v: &Value) -> Option<[f64; 2]> { + let p = [v.get(0)?.as_f64()?, v.get(1)?.as_f64()?]; + p.iter().all(|x| x.is_finite() && x.abs() < 1e7).then_some(p) +} + +/// The control channel's planes, in the command's form: `{"corners": [[x, y]×4]}` or +/// `{"from": earlier plane, "edge", "angle": 90, "depth": 1}`. +fn parse_planes(v: &Value) -> Result<(Vec, Vec>), String> { + let list = v.as_array().ok_or("planes: a list")?; + if list.len() > MAX_PLANES { + return Err(format!("at most {MAX_PLANES} planes")); + } + let (mut planes, mut torn) = (Vec::new(), Vec::new()); + for (i, p) in list.iter().enumerate() { + if let Some(c) = p.get("corners") { + let c = c.as_array().filter(|c| c.len() == 4).ok_or(format!("plane {i}: four corners"))?; + let corners = [0, 1, 2, 3].map(|k| c.get(k).and_then(pt)); + let [Some(a), Some(b), Some(c), Some(d)] = corners else { return Err(format!("plane {i}: corners are [x, y]")) }; + planes.push(VpPlane { corners: [a, b, c, d] }); + torn.push(None); + continue; + } + let from = p + .get("from") + .and_then(Value::as_u64) + .and_then(|f| usize::try_from(f).ok()) + .filter(|f| *f < i) + .ok_or(format!("plane {i}: `corners` or `from` (an earlier plane)"))?; + let edge = match p.get("edge").and_then(Value::as_str).unwrap_or("top") { + "top" => Edge::Top, + "right" => Edge::Right, + "bottom" => Edge::Bottom, + "left" => Edge::Left, + e => return Err(format!("plane {i}: unknown edge `{e}`")), + }; + let angle = p.get("angle").and_then(Value::as_f64).unwrap_or(90.0); + let depth = p.get("depth").and_then(Value::as_f64).unwrap_or(1.0); + if !(0.0..=360.0).contains(&angle) || !(depth > 0.0 && depth <= 1000.0) { + return Err(format!("plane {i}: angle 0..360, depth above 0 and at most 1000")); + } + planes.push(VpPlane { corners: [[0.0; 2]; 4] }); + torn.push(Some(Torn { from, edge, angle, depth })); + } + Ok((planes, torn)) +} + +/// Brings the stamp preview up to date with the strokes (and repaints it after the planes +/// changed, once no plane is being dragged). +pub(crate) fn sync_preview(app: &mut PhotocraftApp) { + let Some(s) = app.distort.vanishing.as_mut() else { return }; + if matches!(s.drag, Some(Drag::Corner { .. } | Drag::Edge { .. } | Drag::Torn { .. })) { + return; + } + let ui = &mut app.ui; + s.paint_preview(|| ui.alloc_id()); +} + +/// What changed between stamp preview (or document) key `seen` and `now` of document `doc` at +/// `revision`: the areas the strokes painted. `None` when either key isn't the session's. +pub(crate) fn damage(app: &PhotocraftApp, doc: DocId, revision: u64, seen: u64, now: u64) -> Option { + let s = app.distort.vanishing.as_ref().filter(|s| s.base.id == doc && s.doc_revision == revision)?; + let keys = &s.preview.as_ref()?.keys; + let at = |k: u64| if k == 0 { Some(0) } else { keys.iter().position(|e| PREVIEW_KEY + e.0 == k).map(|i| i + 1) }; + let (a, b) = (at(seen)?, at(now)?); + if a == b { + return None; + } + Some(keys.get(a.min(b)..a.max(b))?.iter().fold(Rect::EMPTY, |r, k| union(r, k.1))) +} + +pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { let tol = crate::distort_ui::tolerance(app); let Some(s) = app.distort.vanishing.as_mut() else { return }; match ev { - ToolEvent::Down { x, y, .. } => s.press([x, y], tol), + ToolEvent::Down { x, y, .. } => { + if let Some(msg) = s.press([x, y], tol, mods) { + app.ui.status = msg.into(); + } + } ToolEvent::Move { x, y, .. } => s.drag_to([x, y]), ToolEvent::Up { x, y } => { s.drag_to([x, y]); s.drag = None; } } + sync_preview(app); } /// Keys while the mode is active (↩ and Esc are handled by `distort_ui::keys`). @@ -398,9 +873,17 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { s.pending.clear(); } else if key(Key::C) { s.tool = VpTool::CreatePlane; + } else if key(Key::S) { + s.tool = VpTool::Stamp; + s.pending.clear(); } else if key(Key::Delete) || key(Key::Backspace) { s.delete_selected(); + } else if ctx.input_mut(|i| i.consume_key(Modifiers::COMMAND, Key::Z)) && s.strokes.pop().is_some() { + // Undo inside the window: the last clone stroke. + s.drag = None; + s.rev += 1; } + sync_preview(app); } /// Esc: drops a half-clicked plane first, then leaves the mode. @@ -469,6 +952,50 @@ pub fn draw(s: &VpSession, painter: &egui::Painter, xf: &ViewXform) { painter.rect_filled(egui::Rect::from_center_size(*c, egui::vec2(6.0, 6.0)), 0.0, tokens.accent); } } + if s.tool == VpTool::Stamp { + draw_stamp(s, painter, xf, &scr); + } +} + +/// Stamp: the source as a crosshair and the brush under the pointer as the circle it paints on +/// the plane, foreshortened. +fn draw_stamp(s: &VpSession, painter: &egui::Painter, xf: &ViewXform, scr: &dyn Fn([f64; 2]) -> Pos2) { + let outline = |pts: &[Pos2]| { + painter.add(egui::Shape::closed_line(pts.to_vec(), Stroke::new(2.5, Color32::from_black_alpha(160)))); + painter.add(egui::Shape::closed_line(pts.to_vec(), Stroke::new(1.0, Color32::WHITE))); + }; + if let Some(src) = s.source { + let c = scr(src); + for (a, b) in [(egui::vec2(-7.0, 0.0), egui::vec2(7.0, 0.0)), (egui::vec2(0.0, -7.0), egui::vec2(0.0, 7.0))] { + painter.line_segment([c + a, c + b], Stroke::new(2.5, Color32::from_black_alpha(160))); + painter.line_segment([c + a, c + b], Stroke::new(1.0, Color32::WHITE)); + } + } + let Some(hover) = painter.ctx().pointer_hover_pos() else { return }; + let p = xf.to_doc(hover); + let Some(sc) = s.scene() else { return }; + let Some(i) = sc.plane_at(p) else { return }; + let Some((h, lu, lv)) = s.frame(i) else { return }; + let Some(inv) = h.inverse() else { return }; + let metric = |q: [f64; 2]| { + let (u, v) = inv.apply(q[0], q[1]); + [u * lu, v * lv] + }; + let (m, probe) = (metric(p), metric([p[0] + s.stamp.diameter / 2.0, p[1]])); + let r = (probe[0] - m[0]).hypot(probe[1] - m[1]); + if !r.is_finite() || r <= 0.0 { + return; + } + let ring: Vec = (0..48) + .map(|k| { + let a = f64::from(k) / 48.0 * std::f64::consts::TAU; + let (x, y) = h.apply((m[0] + r * a.cos()) / lu, (m[1] + r * a.sin()) / lv); + scr([x, y]) + }) + .collect(); + if ring.iter().all(|q| q.is_finite()) { + outline(&ring); + } } /// Plane coordinates (0..1, exclusive) of the grid lines across `u` (or `v`) for cells of @@ -515,6 +1042,8 @@ fn draw_paste(painter: &egui::Painter, scr: &dyn Fn([f64; 2]) -> Pos2, h: &Homog } pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + // New Layer toggled last frame repaints the stamp preview. + sync_preview(app); let Some(s) = app.distort.vanishing.as_mut() else { return }; if crate::widgets::pill_tab(ui, tl!("Edit Plane"), s.tool == VpTool::EditPlane).on_hover_text("V").clicked() { s.tool = VpTool::EditPlane; @@ -523,7 +1052,27 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if crate::widgets::pill_tab(ui, tl!("Create Plane"), s.tool == VpTool::CreatePlane).on_hover_text("C").clicked() { s.tool = VpTool::CreatePlane; } + if crate::widgets::pill_tab(ui, tl!("Stamp"), s.tool == VpTool::Stamp).on_hover_text("S").clicked() { + s.tool = VpTool::Stamp; + s.pending.clear(); + } crate::widgets::vline(ui, 22.0); + if s.tool == VpTool::Stamp { + let st = &mut s.stamp; + for (label, value, range, unit) in [ + (tl!("Diameter:"), &mut st.diameter, 1.0..=2000.0, "px"), + (tl!("Hardness:"), &mut st.hardness, 0.0..=100.0, "%"), + (tl!("Opacity:"), &mut st.opacity, 0.0..=100.0, "%"), + ] { + ui.label(label); + let mut v = *value as f32; + if crate::widgets::value_field(ui, &mut v, range, unit, 56.0).changed() { + *value = f64::from(v); + } + } + crate::widgets::checkbox(ui, &mut st.aligned, tl!("Aligned")); + crate::widgets::vline(ui, 22.0); + } ui.label(tl!("Grid Size:")); let mut g = s.grid_size as f32; if crate::widgets::value_field(ui, &mut g, 2.0..=2000.0, "px", 56.0).changed() { @@ -539,6 +1088,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { s.paste_width = f64::from(w) / 100.0; } } + } + if s.clip.is_some() || !s.strokes.is_empty() || s.tool == VpTool::Stamp { crate::widgets::checkbox(ui, &mut s.new_layer, tl!("New Layer")); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -608,4 +1159,70 @@ mod tests { assert!(l.rgba(x as i32, y as i32)[0] > 0.9, "{:?}", l.rgba(x as i32, y as i32)); assert!(l.rgba(230, 20)[0] < 0.5, "outside the plane"); } + + #[test] + fn ctrl_drag_tears_off_a_wall_and_the_stamp_clones_in_one_step() { + let (ctx, mut app) = app_with_layer(); + // A green mark near the floor's front edge to clone. + app.session.execute("select.rect", json!({"x": 100, "y": 135, "width": 40, "height": 8})).unwrap(); + app.session.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + app.session.execute("select.deselect", json!({})).unwrap(); + crate::distort_ui::menu(&mut app, &ctx, VP, &json!({})).unwrap().unwrap(); + control(&mut app, &json!({"planes": [{"corners": [[70, 60], [170, 60], [220, 150], [20, 150]]}]})).unwrap(); + let ev = |app: &mut PhotocraftApp, e: ToolEvent, m: egui::Modifiers| crate::distort_ui::pointer(app, e, m); + let drag = |app: &mut PhotocraftApp, a: [f64; 2], b: [f64; 2], m: egui::Modifiers| { + ev(app, ToolEvent::Down { x: a[0], y: a[1], pressure: 1.0 }, m); + ev(app, ToolEvent::Move { x: (a[0] + b[0]) / 2.0, y: (a[1] + b[1]) / 2.0, pressure: 1.0 }, m); + ev(app, ToolEvent::Up { x: b[0], y: b[1] }, m); + }; + fn vp(app: &PhotocraftApp) -> &VpSession { + app.distort.vanishing.as_ref().unwrap() + } + + // Ctrl/Cmd-drag the top edge node up: a wall torn off the floor's far edge, 90° to it. + let top = vp(&app).edge_nodes(0).unwrap()[0]; + drag(&mut app, top, [top[0], top[1] - 35.0], egui::Modifiers::COMMAND); + let s = vp(&app); + assert_eq!(s.planes.len(), 2); + let t = s.torn[1].unwrap(); + assert_eq!((t.from, t.edge, t.angle), (0, Edge::Top, 90.0)); + let c = s.planes[1].corners; + assert!((c[0][1] + c[1][1]) / 2.0 < 40.0, "the far edge followed the pointer up: {c:?}"); + assert_ne!(s.describe()["planes"][1]["check"], "invalid"); + assert_eq!(s.params()["planes"][1], json!({"from": 0, "edge": "top", "angle": 90.0, "depth": t.depth})); + // Alt-dragging its far edge node turns it; editing the floor carries it along. + let far = s.edge_nodes(1).unwrap()[0]; + drag(&mut app, far, [far[0], far[1] + 12.0], egui::Modifiers::ALT); + assert_ne!(vp(&app).torn[1].unwrap().angle, 90.0); + let before = vp(&app).planes[1].corners; + drag(&mut app, [220.0, 150.0], [228.0, 150.0], egui::Modifiers::NONE); + assert_ne!(vp(&app).planes[1].corners, before); + assert!(vp(&app).torn[1].is_some()); + + // Stamp: painting needs a source first; Alt-click sets it on the floor's green mark. + control(&mut app, &json!({"tool": "stamp", "stamp": {"diameter": 12, "hardness": 80}})).unwrap(); + drag(&mut app, [120.0, 100.0], [120.0, 100.0], egui::Modifiers::NONE); + assert!(vp(&app).strokes.is_empty() && app.ui.status.contains("Alt-click"), "{}", app.ui.status); + drag(&mut app, [120.0, 139.0], [120.0, 139.0], egui::Modifiers::ALT); + assert_eq!(vp(&app).source, Some([120.0, 139.0])); + drag(&mut app, [118.0, 100.0], [126.0, 100.0], egui::Modifiers::NONE); + assert_eq!(vp(&app).strokes.len(), 1); + assert!(vp(&app).strokes[0].points.len() >= 3, "dabs along the drag"); + // The canvas shows the stroke; the document is untouched until OK. + let layer = app.session.active().unwrap().active_layer.unwrap(); + let green = |d: &Document| d.layer(layer).unwrap().surface().unwrap().rgba(122, 100)[1]; + let (shown, key) = crate::distort_ui::display_doc(&app, 0).expect("a stamp preview"); + assert!(green(&shown) > 0.8, "{}", green(&shown)); + let st = app.session.active().unwrap(); + assert!(green(&st.doc) < 0.6); + let area = damage(&app, st.doc.id, st.revision, 0, key).unwrap(); + assert!(area.contains(122, 100) && !area.contains(20, 20), "only the stroke recomposites: {area:?}"); + + let before = app.session.active().unwrap().history.past_len(); + let r = commit(&mut app).unwrap(); + assert_eq!(r["committed"], true, "{r}"); + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), before + 1, "one history step for the whole session"); + assert!(green(&st.doc) > 0.8, "{}", green(&st.doc)); + } } diff --git a/docs/scorecard.md b/docs/scorecard.md index aad562e6c8..270a865d40 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 15 not run, 7 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 5 | 11 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | -| [Tools](#tools) | 21 | 16 | 5 | | +| [Tools](#tools) | 22 | 15 | 5 | | | [Workspace and UI](#workspace-and-ui) | 6 | 7 | 10 | settings that do nothing: 43 of 150 (target 0) | | [Type](#type) | 0 | 3 | 6 | | | [Automation](#automation) | 3 | 2 | 6 | 630 of 630 menu items live (100.0%) | @@ -158,7 +158,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | partial | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke brush, pencil, eraser (vector_cmds.rs:1170); brush-only button, no dialog | | TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.convertToShape with tolerance (vector_cmds.rs:1256); radius as a field only | | TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | -| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing | +| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | done | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas window (vp_ui.rs: Create/Edit Plane, edge nodes, Ctrl/Cmd-drag tear-off planes with depth and Alt-drag angle, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane, Stamp tool cloning in perspective with live preview, all applied on OK as one step); the window's Marquee tool and the Stamp's Heal modes are still missing | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | | TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo | | TOOL-213-17 | Red Eye tool: click a pupil to neutralize the red reflection | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.redEye (redeye_cmds.rs) + Tool::RedEye in the J flyout; Pupil Size and Darken Amount; catchlights kept; a miss is an error | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index cefb18e599..c346038427 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -204,9 +204,9 @@ note = "Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI co [[item]] id = "TOOL-214-14" target = "Vanishing Point window: draw and edit planes, paste, clone in perspective" -status = "partial" +status = "done" issue = 214 -note = "filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing" +note = "filter.vanishingPoint (vp_cmds.rs:15); on-canvas window (vp_ui.rs: Create/Edit Plane, edge nodes, Ctrl/Cmd-drag tear-off planes with depth and Alt-drag angle, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane, Stamp tool cloning in perspective with live preview, all applied on OK as one step); the window's Marquee tool and the Stamp's Heal modes are still missing" [[item]] id = "TOOL-214-15" From 2c9fdb56e8a7546379f2233edb8273cf2110b3f8 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 09:25:09 +0200 Subject: [PATCH 014/229] Smart Filters: paint the filter mask directly, with undo (#3290) Clicking the filter mask thumbnail on a smart object's Smart Filters row targets the filter mask. Painting, fills, filters and adjustments then edit it through a new "target":"filterMask". The first stroke starts a white mask, black hides the smart filters, and each stroke is one undo step that re-renders the smart object. Alt-click shows the mask alone (view.layerMask "mask":"filter") and Shift-click disables it. The thumbnail draws the mask itself. ui.set takes filterMaskTarget. The painted mask is kept through .pcraft and PSD (FEid). Scorecard FILE-215-3 is done. Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/server.rs | 2 +- crates/engine/src/brush_cmds.rs | 2 +- crates/engine/src/channel_cmds.rs | 26 +++- crates/engine/src/commands.rs | 18 +-- crates/engine/src/fill_key_cmds.rs | 2 +- crates/engine/src/float_cmds.rs | 2 +- crates/engine/src/jobs.rs | 8 +- crates/engine/src/lib.rs | 7 ++ crates/engine/src/mask_view_cmds.rs | 44 +++++-- crates/engine/src/paint_cmds.rs | 4 +- crates/engine/src/redeye_cmds.rs | 2 +- crates/engine/src/retouch_cmds.rs | 6 +- crates/engine/src/smart_cmds.rs | 34 +++++ crates/engine/src/smart_cmds/tests.rs | 1 + .../src/smart_cmds/tests/filter_mask_paint.rs | 79 ++++++++++++ crates/engine/src/transform_cmds.rs | 2 + crates/ui-egui/src/canvas.rs | 6 +- crates/ui-egui/src/control.rs | 15 ++- crates/ui-egui/src/lib.rs | 46 +++++-- crates/ui-egui/src/mask_thumbs_ui/tests.rs | 1 + crates/ui-egui/src/panels.rs | 14 ++- crates/ui-egui/src/smart_ui.rs | 118 +++++++++++++++++- crates/ui-egui/src/state.rs | 6 + docs/control-protocol.md | 2 +- docs/scorecard.md | 4 +- scorecard/file_compat.toml | 4 +- 26 files changed, 406 insertions(+), 49 deletions(-) create mode 100644 crates/engine/src/smart_cmds/tests/filter_mask_paint.rs diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 74a4c623f4..104bb31115 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -228,7 +228,7 @@ pub struct MenuParams { 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, + /// vectorMaskTarget, filterMaskTarget (paint the active smart object's filter mask), selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio, /// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark), 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 diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 473edbaf02..8b21d867e6 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -984,7 +984,7 @@ pub fn specs() -> Vec { spec!( "paint.pencil", "Pencil", - r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…"="normal","erase":bool?,"block":bool=false (a hard 16×16 screen-px square, 16/zoom doc px; size, opacity, flow, hardness and dynamics ignored: the Eraser's Block mode),"zoom":view zoom=1,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, + r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…"="normal","erase":bool?,"block":bool=false (a hard 16×16 screen-px square, 16/zoom doc px; size, opacity, flow, hardness and dynamics ignored: the Eraser's Block mode),"zoom":view zoom=1,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##, has_paintable, pencil, true diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 321d0d1a6e..ef9d48c937 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -90,7 +90,7 @@ impl ChannelView { /// The layer mask shown on the canvas (#196) and how, if its layer still has a mask. pub fn shown_layer_mask<'a>(&self, doc: &'a Document) -> Option<(&'a photocraft_doc::LayerMask, crate::mask_view_cmds::MaskViewMode)> { let v = self.layer_mask?; - Some((doc.layer(v.layer)?.mask.as_ref()?, v.mode)) + Some((crate::mask_view_cmds::viewed_mask(doc.layer(v.layer)?, v.filter)?, v.mode)) } } @@ -483,6 +483,9 @@ fn mask_plane(s: &Session, p: &Value, cmd: &str) -> Result>> { pub(crate) enum Target { Pixels, Mask, + /// A smart object's filter mask (`"target":"filterMask"`): where it is black the smart + /// filters don't show (FILE-215-3). + FilterMask, Alpha(usize), QuickMask, } @@ -491,6 +494,7 @@ pub(crate) fn target_of(p: &Value) -> Target { match p.get("target") { Some(Value::String(s)) => match s.as_str() { "mask" => Target::Mask, + "filterMask" => Target::FilterMask, "quickMask" => Target::QuickMask, _ => Target::Pixels, }, @@ -547,10 +551,14 @@ fn edits_mask(id: &str) -> bool { /// The precondition of `id` when `p` targets the active layer's mask (`"target":"mask"`): the /// layer needs a mask. None when another precondition applies. pub(crate) fn mask_target_enabled(s: &Session, id: &str, p: &Value) -> Option> { - if target_of(p) != Target::Mask || !edits_mask(id) { - return None; + match target_of(p) { + // Every pixel command that follows the target paints, fills or filters the filter mask. + Target::FilterMask if routed(id) => Some(crate::active_layer_of(s).and_then(crate::smart_cmds::filter_mask_paintable)), + Target::Mask if edits_mask(id) => { + Some(crate::active_layer_of(s).and_then(|l| if l.mask.is_some() { Ok(()) } else { Err("the active layer has no layer mask".into()) })) + } + _ => None, } - Some(crate::active_layer_of(s).and_then(|l| if l.mask.is_some() { Ok(()) } else { Err("the active layer has no layer mask".into()) })) } /// Whether command `id` edits the targeted channel or mask when its params name no `"target"` @@ -597,6 +605,7 @@ pub(crate) fn inject_target(s: &Session, id: &str, params: Value) -> Value { ChannelTarget::Alpha(i) if i < st.doc.channels.len() => json!({ "channel": i }), ChannelTarget::Composite if st.doc.quick_mask.is_some() => json!("quickMask"), // Viewing the active layer's mask (⌥-click): pixel commands edit the mask. + ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some_and(|v| v.filter) => json!("filterMask"), ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() => json!("mask"), _ => return params, }; @@ -609,6 +618,15 @@ pub(crate) fn inject_target(s: &Session, id: &str, params: Value) -> Value { } } +/// The smart object whose filter mask a command with `params` edits (`"target":"filterMask"`): +/// its `"layer"` param, else the active layer. +pub(crate) fn filter_mask_layer(s: &Session, params: &Value) -> Option { + if target_of(params) != Target::FilterMask { + return None; + } + params.get("layer").and_then(Value::as_u64).map(LayerId).or_else(|| s.active()?.active_layer) +} + /// The colour channel pixel commands are limited to, if a single colour channel is targeted. pub(crate) fn color_restriction(s: &Session, id: &str, params: &Value) -> Option { if !routed(id) || is_channel_target(params) || crate::commands::is_mask_target(params) { diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index df6671f455..dcad3f153d 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -65,11 +65,15 @@ pub(crate) fn has_paintable(s: &Session) -> std::result::Result<(), String> { } } -/// Surface a paint command writes to: the layer's pixels, or its mask with `"target":"mask"`. +/// Surface a paint command writes to: the layer's pixels, its mask with `"target":"mask"`, or a +/// smart object's filter mask with `"target":"filterMask"`. pub(crate) fn paint_surface<'a>(doc: &'a mut Document, id: LayerId, p: &Value) -> Result<&'a mut photocraft_raster::Surface> { if !is_mask_target(p) { check_pixels_unlocked(doc, id)?; } + if crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::FilterMask { + return crate::smart_cmds::filter_mask_surface(doc, id); + } let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; if is_mask_target(p) { l.mask.as_mut().map(|m| &mut m.surface).ok_or_else(|| EngineError::Other("layer has no mask".into())) @@ -88,8 +92,10 @@ pub(crate) fn check_pixels_unlocked(doc: &Document, id: LayerId) -> Result<()> { Ok(()) } +/// The params target a grayscale mask of the layer: its layer mask (`"mask"`) or, on a smart +/// object, its filter mask (`"filterMask"`). pub(crate) fn is_mask_target(p: &Value) -> bool { - p.get("target").and_then(Value::as_str) == Some("mask") + matches!(p.get("target").and_then(Value::as_str), Some("mask" | "filterMask")) } fn can_undo(s: &Session) -> std::result::Result<(), String> { @@ -289,9 +295,7 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value) let id = layer_param(s, &Value::Null)?; return s.edit(label, |doc, _| { let sel = doc.selection.clone(); - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - let mask = l.mask.as_mut().ok_or_else(|| EngineError::Other("layer has no mask".into()))?; - pixels::adjust_mask(&mut mask.surface, &adj, sel.as_ref()); + pixels::adjust_mask(paint_surface(doc, id, p)?, &adj, sel.as_ref()); Ok(Value::Null) }); } @@ -393,7 +397,7 @@ fn build() -> Vec { "Fill…", ["Edit"], Some("Shift+F5"), - r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":index? (contents=history; default the oldest state),"colorAdaptation":bool=true (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?}"##, + r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":index? (contents=history; default the oldest state),"colorAdaptation":bool=true (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}?}"##, has_pixel_or_channel, crate::fill_cmds::fill ), @@ -887,7 +891,7 @@ fn build() -> Vec { "Brush Stroke", [], None, - r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…BrushSettings}?,"preset":name?,"size":0.5..5000 px?,"hardness":0..1?,"opacity":0..1?,"flow":0..1?,"spacing":0..10?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…"="normal","erase":bool?,"smoothing":0..1?,"zoom":number=1,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, + r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…BrushSettings}?,"preset":name?,"size":0.5..5000 px?,"hardness":0..1?,"opacity":0..1?,"flow":0..1?,"spacing":0..10?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…"="normal","erase":bool?,"smoothing":0..1?,"zoom":number=1,"seed":u64?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##, has_paintable, crate::brush_cmds::paint_stroke ), diff --git a/crates/engine/src/fill_key_cmds.rs b/crates/engine/src/fill_key_cmds.rs index 6235b629bb..f721a16e09 100644 --- a/crates/engine/src/fill_key_cmds.rs +++ b/crates/engine/src/fill_key_cmds.rs @@ -14,7 +14,7 @@ use crate::{EngineError, Result, Session}; /// The fill-key command ids (routed to the Channels panel target like `edit.fill`). pub const IDS: [&str; 4] = ["edit.fillForeground", "edit.fillBackground", "edit.fillForegroundPreserve", "edit.fillBackgroundPreserve"]; -const PARAMS: &str = r##"{"layer":id?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?}"##; +const PARAMS: &str = r##"{"layer":id?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}?}"##; fn enabled(s: &Session) -> std::result::Result<(), String> { let edit_fill = crate::commands::find("edit.fill").ok_or_else(|| "Fill is unavailable".to_string())?; diff --git a/crates/engine/src/float_cmds.rs b/crates/engine/src/float_cmds.rs index 892f754748..3cd81acab9 100644 --- a/crates/engine/src/float_cmds.rs +++ b/crates/engine/src/float_cmds.rs @@ -175,7 +175,7 @@ fn float(s: &mut Session, p: &Value) -> Result { let st = s.active_mut().ok_or(EngineError::NoDocument)?; if floating(st).is_none() { let layer = st.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?; - let parts = CutParts::new(&st.doc, layer, copy, background, crate::commands::is_mask_target(p))?; + let parts = CutParts::new(&st.doc, layer, copy, background, crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Mask)?; st.floating = Some(Floating { layer, offset: (0, 0), revision: st.revision, parts: Arc::new(parts) }); } let f = st.floating.as_mut().ok_or(EngineError::NoDocument)?; diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 9b612e3c4c..7892902232 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -182,6 +182,7 @@ struct Running { started_ms: f64, coalesce: Option, color_restrict: Option, + filter_mask_layer: Option, rx: std::sync::mpsc::Receiver>, apply: Option, #[cfg(not(target_arch = "wasm32"))] @@ -335,6 +336,7 @@ pub fn run( started_ms: now_ms(), coalesce: s.coalesce_request.clone(), color_restrict: s.color_restrict, + filter_mask_layer: s.filter_mask_layer, rx, apply: Some(erased), #[cfg(not(target_arch = "wasm32"))] @@ -637,6 +639,7 @@ impl Session { } self.coalesce_request = params.get("coalesce").and_then(Value::as_str).map(str::to_string); self.color_restrict = crate::channel_cmds::color_restriction(self, id, &run_params); + self.filter_mask_layer = crate::channel_cmds::filter_mask_layer(self, &run_params); self.jobs.spawn = background; // Last-resort guard (AGENTS.md, Never crash): a command that panics anyway fails with an // error instead of taking the app down. `edit` only commits a document after its closure @@ -646,6 +649,7 @@ impl Session { self.jobs.spawn = false; self.coalesce_request = None; self.color_restrict = None; + self.filter_mask_layer = None; let pending = self.jobs.pending.take(); match (r, pending) { (Err(e), Some(job)) => { @@ -699,7 +703,7 @@ impl Session { } fn apply_job(&mut self, job: &Running, apply: Option, payload: Payload) -> Result { - let Running { label, doc, snapshot, coalesce, color_restrict, command, params, journal, .. } = job; + let Running { label, doc, snapshot, coalesce, color_restrict, filter_mask_layer, command, params, journal, .. } = job; let prev_active = self.active; let target = match doc { Some(d) => { @@ -716,6 +720,7 @@ impl Session { }; self.coalesce_request = coalesce.clone(); self.color_restrict = *color_restrict; + self.filter_mask_layer = *filter_mask_layer; let r = match apply { Some(f) => std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self, payload))) .unwrap_or_else(|_| Err(EngineError::Other(format!("`{label}` failed with an internal error (logged); the document is unchanged")))), @@ -723,6 +728,7 @@ impl Session { }; self.coalesce_request = None; self.color_restrict = None; + self.filter_mask_layer = None; // Finish successful command bookkeeping on the document the job edited. // Restoring the viewed document first would attribute Fade and slice updates // to that document instead, when the user switched tabs mid-job. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3a56d63953..610a6453ac 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -347,6 +347,9 @@ pub struct Session { pub quick_mask_options: channel_cmds::QuickMaskOptions, /// Colour channel the running command may change (a single colour channel is targeted). color_restrict: Option, + /// Smart object whose filter mask the running command edits (`"target":"filterMask"`): each + /// edit re-renders it, in the same history step. + filter_mask_layer: Option, /// Edit › Preferences, keyboard shortcuts, menu and toolbar customisation (see `prefs`). pub prefs: prefs::PrefsStore, /// Edit menu state: Fade source, custom shape library (see `edit_menu_cmds`). @@ -533,6 +536,7 @@ impl Session { /// Apply an undoable edit to the active document. pub fn edit(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Option) -> Result) -> Result { let restrict = self.color_restrict; + let filter_mask_layer = self.filter_mask_layer; let st = self.active_mut().ok_or(EngineError::NoDocument)?; let before = st.doc.clone(); // Selecting layers is not a step, so the state this edit leaves behind targets what was @@ -544,6 +548,9 @@ impl Session { if let (Some(k), Some(id)) = (restrict, active) { channel_cmds::restrict_to_color(&before, &mut doc, id, k); } + if let Some(id) = filter_mask_layer { + smart_cmds::refresh_after_filter_mask_edit(&mut doc, id)?; + } st.doc = Arc::new(doc); st.active_layer = active; fix_selection(st); diff --git a/crates/engine/src/mask_view_cmds.rs b/crates/engine/src/mask_view_cmds.rs index 055557480e..598e844bd5 100644 --- a/crates/engine/src/mask_view_cmds.rs +++ b/crates/engine/src/mask_view_cmds.rs @@ -7,6 +7,9 @@ //! While a mask is shown, pixel commands without an explicit `target` edit that mask //! ([`crate::channel_cmds`]'s target injection), so painting in mask view paints the mask. //! The view ends when its layer stops being the active layer or loses its mask ([`fix`]). +//! +//! With `"mask":"filter"` the same view shows a smart object's filter mask (⌥-click the filter +//! mask thumbnail on its Smart Filters row), and painting while it's shown paints that mask. use photocraft_doc::LayerId; use serde::Serialize; @@ -35,6 +38,19 @@ pub enum MaskViewMode { pub struct LayerMaskView { pub layer: LayerId, pub mode: MaskViewMode, + /// The smart object's filter mask is shown, not its layer mask. + pub filter: bool, +} + +/// The mask a view of `l` shows: its layer mask, or (`filter`) its smart filter mask. +pub fn viewed_mask(l: &photocraft_doc::Layer, filter: bool) -> Option<&photocraft_doc::LayerMask> { + if !filter { + return l.mask.as_ref(); + } + match &l.content { + photocraft_doc::LayerContent::Smart(sm) => sm.filter_mask.as_ref(), + _ => None, + } } fn bad(msg: impl Into) -> EngineError { @@ -48,7 +64,7 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { /// The mask view of `st` when it is live: its layer is the active layer and still has a mask. pub fn current(st: &DocState) -> Option { let v = st.channel_view.layer_mask?; - let live = st.active_layer == Some(v.layer) && st.doc.layer(v.layer).is_some_and(|l| l.mask.is_some()); + let live = st.active_layer == Some(v.layer) && st.doc.layer(v.layer).is_some_and(|l| viewed_mask(l, v.filter).is_some()); live.then_some(v) } @@ -68,16 +84,26 @@ fn mode_name(v: Option) -> &'static str { } } -/// The view as JSON (`{"layer","mode"}`, or `null` when off); part of `document.inspect`. +/// The view as JSON (`{"layer","mode"}`, plus `"mask":"filter"` for a filter mask, or `null` when +/// off); part of `document.inspect`. pub fn view_json(st: &DocState) -> Value { match current(st) { + Some(v) if v.filter => json!({"layer": v.layer.0, "mode": mode_name(Some(v)), "mask": "filter"}), Some(v) => json!({"layer": v.layer.0, "mode": mode_name(Some(v))}), None => Value::Null, } } -/// `{"layer":id?,"mode":"off"|"gray"|"overlay"|"toggleGray"|"toggleOverlay"="toggleGray"}`. +/// `{"layer":id?,"mode":"off"|"gray"|"overlay"|"toggleGray"|"toggleOverlay"="toggleGray","mask":"layer"|"filter"="layer"}`. fn set_view(s: &mut Session, p: &Value) -> Result { + let filter = match p.get("mask") { + None | Some(Value::Null) => false, + Some(v) => match v.as_str() { + Some("layer") => false, + Some("filter") => true, + _ => return Err(bad("`mask` must be \"layer\" or \"filter\"")), + }, + }; let mode = match p.get("mode") { None | Some(Value::Null) => "toggleGray", Some(v) => v.as_str().ok_or_else(|| bad("`mode` must be a string"))?, @@ -88,9 +114,9 @@ fn set_view(s: &mut Session, p: &Value) -> Result { Some(v) => LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be a layer id"))?), }; let l = st.doc.layer(layer).ok_or(EngineError::NoLayer(layer))?; - let has_mask = l.mask.is_some(); + let has_mask = viewed_mask(l, filter).is_some(); let name = l.name.clone(); - let shown = current(st).filter(|v| v.layer == layer).map(|v| v.mode); + let shown = current(st).filter(|v| v.layer == layer && v.filter == filter).map(|v| v.mode); let want = match mode { "off" => None, "gray" => Some(MaskViewMode::Gray), @@ -100,14 +126,14 @@ fn set_view(s: &mut Session, p: &Value) -> Result { other => return Err(bad(format!("unknown mode `{other}` (off|gray|overlay|toggleGray|toggleOverlay)"))), }; if want.is_some() && !has_mask { - return Err(bad(format!("layer \"{name}\" has no layer mask"))); + return Err(bad(format!("layer \"{name}\" has no {}", if filter { "filter mask" } else { "layer mask" }))); } // ⌥-clicking another layer's mask selects that layer, as in Photoshop. if want.is_some() && s.active().and_then(|st| st.active_layer) != Some(layer) { s.select_layer(layer)?; } let st = s.active_mut().ok_or(EngineError::NoDocument)?; - let next = want.map(|mode| LayerMaskView { layer, mode }); + let next = want.map(|mode| LayerMaskView { layer, mode, filter }); // "off" for a layer whose mask isn't the one shown leaves the other view alone. let applies = next.is_some() || st.channel_view.layer_mask.is_some_and(|v| v.layer == layer); if applies && st.channel_view.layer_mask != next { @@ -121,7 +147,7 @@ fn set_view(s: &mut Session, p: &Value) -> Result { st.last_damage = Some(photocraft_geom::Rect::EMPTY); } let v = current(st); - Ok(json!({"layer": layer.0, "mode": mode_name(v.filter(|v| v.layer == layer))})) + Ok(json!({"layer": layer.0, "mode": mode_name(v.filter(|v| v.layer == layer && v.filter == filter)), "mask": if filter { "filter" } else { "layer" }})) } /// `\` needs a layer mask on the active layer; without one Photoshop's key does nothing. @@ -148,7 +174,7 @@ pub fn specs() -> Vec { label: "View Layer Mask", menu: &[], shortcut: None, - params: r##"{"layer":id?,"mode":"off"|"gray"|"overlay"|"toggleGray"|"toggleOverlay"="toggleGray"} (gray: the mask alone, ⌥-click its thumbnail; overlay: red rubylith over the composite, ⇧⌥-click or `\`; view state, not an undo step; painting while shown paints the mask)"##, + params: r##"{"layer":id?,"mode":"off"|"gray"|"overlay"|"toggleGray"|"toggleOverlay"="toggleGray","mask":"layer"|"filter"="layer" (filter: a smart object's filter mask)} (gray: the mask alone, ⌥-click its thumbnail; overlay: red rubylith over the composite, ⇧⌥-click or `\`; view state, not an undo step; painting while shown paints the mask)"##, enabled: has_doc, run: set_view, journal: false, diff --git a/crates/engine/src/paint_cmds.rs b/crates/engine/src/paint_cmds.rs index 7366758e93..90873dcd15 100644 --- a/crates/engine/src/paint_cmds.rs +++ b/crates/engine/src/paint_cmds.rs @@ -136,7 +136,7 @@ pub fn specs() -> Vec { label: "Paint Bucket", menu: &[], shortcut: None, - params: r##"{"x":px,"y":px,"tolerance":0..255=32,"contiguous":bool=true,"antiAlias":bool=true,"sampleAllLayers":bool=false,"contents":"foreground|pattern"="foreground","color":"#rrggbb"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":1..100=100,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, + params: r##"{"x":px,"y":px,"tolerance":0..255=32,"contiguous":bool=true,"antiAlias":bool=true,"sampleAllLayers":bool=false,"contents":"foreground|pattern"="foreground","color":"#rrggbb"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":1..100=100,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##, enabled: crate::commands::has_paintable, run: bucket, journal: true, @@ -146,7 +146,7 @@ pub fn specs() -> Vec { label: "Gradient", menu: &[], shortcut: None, - params: r##"{"from":[x,y],"to":[x,y],"style":"linear|radial|angle|reflected|diamond"="linear","colors":["#rrggbb",…]? (evenly spaced),"gradient":preset name?,"stops":[[t,"#rrggbb"|"foreground"|"background"],…]?,"transparency":[[t,0..100],…]? (default: the current gradient, see gradient.presets.select),"reverse":bool=false,"dither":bool=true,"opacity":1..100=100,"mode":"normal|multiply|…"="normal","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, + params: r##"{"from":[x,y],"to":[x,y],"style":"linear|radial|angle|reflected|diamond"="linear","colors":["#rrggbb",…]? (evenly spaced),"gradient":preset name?,"stops":[[t,"#rrggbb"|"foreground"|"background"],…]?,"transparency":[[t,0..100],…]? (default: the current gradient, see gradient.presets.select),"reverse":bool=false,"dither":bool=true,"opacity":1..100=100,"mode":"normal|multiply|…"="normal","target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##, enabled: crate::commands::has_paintable, run: gradient, journal: true, diff --git a/crates/engine/src/redeye_cmds.rs b/crates/engine/src/redeye_cmds.rs index f6a71da407..eefd1a37ba 100644 --- a/crates/engine/src/redeye_cmds.rs +++ b/crates/engine/src/redeye_cmds.rs @@ -256,7 +256,7 @@ pub fn specs() -> Vec { label: "Red Eye", menu: &[], shortcut: None, - params: r#"{"x":px, "y":px} | {"points":[[x,y],…] (at most 64)},"pupilSize":1..100=50,"darken":0..100=50,"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i} → {"damage":[x,y,w,h], "pixels":n}"#, + params: r#"{"x":px, "y":px} | {"points":[[x,y],…] (at most 64)},"pupilSize":1..100=50,"darken":0..100=50,"layer":id?=active,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i} → {"damage":[x,y,w,h], "pixels":n}"#, enabled, run: red_eye, journal: true, diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 75eedc899d..f06e38d81a 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -1038,7 +1038,7 @@ impl<'a> AllLayers<'a> { /// The brush every retouching command shares, plus the tool's own params. macro_rules! brush_params { ($extra:literal) => { - concat!("{", r#""points":[[x,y,pressure?],…],"size":1..5000 px=tool size,"hardness":0..100=tool hardness,"opacity":1..100=100,"flow":1..100=100,"spacing":1..1000 (% of size)=Brush Settings spacing,"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target"#, $extra, "}") + concat!("{", r#""points":[[x,y,pressure?],…],"size":1..5000 px=tool size,"hardness":0..100=tool hardness,"opacity":1..100=100,"flow":1..100=100,"spacing":1..1000 (% of size)=Brush Settings spacing,"layer":id?=active,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target"#, $extra, "}") }; } @@ -1096,7 +1096,7 @@ pub fn specs() -> Vec { label: "Patch", menu: &[], shortcut: None, - params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"source|destination"="source","contentAware":bool=false (fill the selection content-aware from the dragged-to place; a background job),"structure":1..7=4 (content-aware: 3..7 copy the middle exactly, 1..2 re-synthesise more of it),"color":0..10=0 (content-aware: how far the copy's level adapts to the selection's surroundings),"sampleAllLayers":bool=false (content-aware),"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset"}"#, + params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"source|destination"="source","contentAware":bool=false (fill the selection content-aware from the dragged-to place; a background job),"structure":1..7=4 (content-aware: 3..7 copy the middle exactly, 1..2 re-synthesise more of it),"color":0..10=0 (content-aware: how far the copy's level adapts to the selection's surroundings),"sampleAllLayers":bool=false (content-aware),"layer":id?=active,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset"}"#, enabled: patch::enabled, run: patch::patch, journal: true, @@ -1106,7 +1106,7 @@ pub fn specs() -> Vec { label: "Content-Aware Move", menu: &[], shortcut: None, - params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"move|extend"="move","structure":1..7=4 (3..7 copy the content exactly but for a band about a patch wide along its edge; 2 widens the band, 1 re-synthesises the content too),"color":0..10=0 (0 keeps the content's level; higher values bring it towards its new place, by up to about 0.04 × color),"sampleAllLayers":bool=false,"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset","mode"} (a background job; the selection moves with the content)"#, + params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"move|extend"="move","structure":1..7=4 (3..7 copy the content exactly but for a band about a patch wide along its edge; 2 widens the band, 1 re-synthesises the content too),"color":0..10=0 (0 keeps the content's level; higher values bring it towards its new place, by up to about 0.04 × color),"sampleAllLayers":bool=false,"layer":id?=active,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset","mode"} (a background job; the selection moves with the content)"#, enabled: content_aware_move::enabled, run: content_aware_move::content_aware_move, journal: true, diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 6e0c043578..11d59bd886 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -520,6 +520,40 @@ fn smart_mut(doc: &mut Document, id: LayerId) -> Result<&mut SmartObject> { } } +/// Whether `l`'s filter mask can be painted (`"target":"filterMask"`): a smart object with smart +/// filters. Its filter mask need not exist yet: painting starts it white (Reveal All), the mask +/// Photoshop adds with the first smart filter. +pub(crate) fn filter_mask_paintable(l: &Layer) -> std::result::Result<(), String> { + match &l.content { + LayerContent::Smart(sm) if !sm.smart_filters.is_empty() => Ok(()), + LayerContent::Smart(_) => Err(format!("smart object \"{}\" has no smart filters, so no filter mask", l.name)), + other => Err(format!("the active layer is {} {} layer, not a smart object", other.article(), other.kind_name())), + } +} + +/// The filter mask surface of smart object `id` for painting into, made white (Reveal All) first +/// when the object has smart filters but no filter mask yet. The caller re-renders the object +/// afterwards ([`refresh_after_filter_mask_edit`]). +pub(crate) fn filter_mask_surface(doc: &mut Document, id: LayerId) -> Result<&mut Surface> { + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + filter_mask_paintable(l).map_err(EngineError::Other)?; + let sm = smart_mut_of(l)?; + Ok(&mut sm.filter_mask.get_or_insert_with(LayerMask::reveal_all).surface) +} + +/// Re-renders smart object `id` after its filter mask was painted, filled or filtered, so the +/// stroke shows (where the mask is black the smart filters don't). Part of the same history step. +pub(crate) fn refresh_after_filter_mask_edit(doc: &mut Document, id: LayerId) -> Result<()> { + if matches!(doc.layer(id).map(|l| &l.content), Some(LayerContent::Smart(_))) { refresh_or_fail(doc, id) } else { Ok(()) } +} + +fn smart_mut_of(l: &mut Layer) -> Result<&mut SmartObject> { + match &mut l.content { + LayerContent::Smart(sm) => Ok(sm), + _ => Err(other("the layer is not a smart object")), + } +} + fn mask_from_selection(sel: &Surface) -> LayerMask { LayerMask { surface: sel.clone(), enabled: true, linked: true, density: 1.0, feather: 0.0 } } diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index 2ee54322bf..b36e6ef649 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -1,5 +1,6 @@ use super::*; +mod filter_mask_paint; mod selection; mod stack_cache; diff --git a/crates/engine/src/smart_cmds/tests/filter_mask_paint.rs b/crates/engine/src/smart_cmds/tests/filter_mask_paint.rs new file mode 100644 index 0000000000..b0061e7da9 --- /dev/null +++ b/crates/engine/src/smart_cmds/tests/filter_mask_paint.rs @@ -0,0 +1,79 @@ +//! FILE-215-3: painting a smart object's filter mask (`"target":"filterMask"`) hides the smart +//! filters where it is black, in one undo step, and the painted mask is saved. + +use super::*; + +/// A vertical black stroke over x = 8 into the filter mask, as the Brush tool sends it. +fn stroke(s: &mut Session, target: Option<&str>) -> Result { + let mut p = json!({"points": [[8, 2], [8, 46]], "size": 8, "hardness": 1, "color": "#000000"}); + if let Some(t) = target { + p["target"] = json!(t); + } + s.execute("paint.stroke", p) +} + +#[test] +fn painting_the_filter_mask_hides_the_filters_there_in_one_undo_step() { + for depth in DEPTHS { + let mut s = session(depth); + paint(&mut s); + let original = flat(&s); + convert(&mut s); + // Without smart filters there is no filter mask to paint. + assert!(matches!(stroke(&mut s, Some("filterMask")), Err(EngineError::Disabled(..))), "depth {depth}"); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + let blurred = flat(&s); + assert!(active_smart(&s).filter_mask.is_none()); + let steps = s.active().unwrap().history.past_len(); + + stroke(&mut s, Some("filterMask")).unwrap(); + let mask = active_smart(&s).filter_mask.expect("painting starts a white filter mask"); + assert_eq!((mask.value(8, 20), mask.value(30, 20)), (0.0, 1.0), "depth {depth}"); + let px = flat(&s); + let at = |v: &Vec<[f32; 4]>, x: i32, y: i32| v[(y * W + x) as usize]; + assert!(max_diff(&[at(&px, 8, 20)], &[at(&original, 8, 20)]) < 1e-6, "depth {depth}: black hides the blur"); + assert_eq!(at(&px, 30, 20), at(&blurred, 30, 20), "depth {depth}: white keeps it"); + assert!(active_smart(&s).smart_filters.len() == 1, "the stroke is no smart filter"); + + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one step"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(active_smart(&s).filter_mask.is_none()); + assert_eq!(flat(&s), blurred, "depth {depth}: undo brings the blur back"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(flat(&s), px); + + // The painted mask is saved: .pcraft and PSD give it back. + let doc = s.active().unwrap().doc.clone(); + let id = s.active().unwrap().active_layer.unwrap(); + let back = photocraft_format::load_from_bytes(&photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap()).unwrap(); + assert_eq!(smart(&back, id).unwrap().filter_mask, smart(&doc, id).unwrap().filter_mask, "depth {depth}: .pcraft"); + let psd = photocraft_io::export(&doc, "psd", &Default::default()).unwrap(); + let back = photocraft_io::import("x.psd", &psd.bytes).unwrap().document; + let sm = back.walk().into_iter().find_map(|(_, _, l)| if let LayerContent::Smart(sm) = &l.content { Some(sm.clone()) } else { None }).unwrap(); + let (mut a, mut b) = (Vec::new(), Vec::new()); + sm.filter_mask.as_ref().unwrap().values_into(doc.bounds(), &mut a); + mask.values_into(doc.bounds(), &mut b); + assert_eq!(a, b, "depth {depth}: PSD"); + } +} + +/// ⌥-click on the filter mask thumbnail shows it; painting then goes into it without a target. +#[test] +fn a_shown_filter_mask_takes_the_paint() { + let mut s = session(8); + paint(&mut s); + convert(&mut s); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + assert!(s.execute("view.layerMask", json!({"mode": "gray", "mask": "filter"})).is_err(), "no filter mask yet"); + stroke(&mut s, Some("filterMask")).unwrap(); + let v = s.execute("view.layerMask", json!({"mode": "gray", "mask": "filter"})).unwrap(); + assert_eq!(v["mode"], "gray"); + assert!(s.active().unwrap().channel_view.shown_layer_mask(&s.active().unwrap().doc).is_some()); + s.execute("paint.stroke", json!({"points": [[30, 2], [30, 46]], "size": 8, "hardness": 1, "color": "#000000"})).unwrap(); + let mask = active_smart(&s).filter_mask.unwrap(); + assert_eq!(mask.value(30, 20), 0.0, "the stroke went into the filter mask"); + let d = s.active().unwrap(); + assert!(matches!(d.doc.layer(d.active_layer.unwrap()).unwrap().content, LayerContent::Smart(_)), "still a smart object"); + // Bad params are refused, not a panic. + assert!(s.execute("view.layerMask", json!({"mask": 3})).is_err()); +} diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 88939fc7cd..0b0021ead8 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -156,6 +156,8 @@ pub fn lone_target<'a>(doc: &'a Document, layer: Option, p: &Value) -> let m = l.mask.as_ref().ok_or_else(|| EngineError::Other("layer has no mask".into()))?; Ok((!m.linked).then_some(&m.surface)) } + // A filter mask is not transformed on its own (it follows its smart object). + Target::FilterMask => Err(EngineError::Other("Free Transform can't transform a filter mask on its own".into())), Target::Alpha(i) => doc .channels .get(i) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a1fcda0adb..1bf6dcd0ae 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4794,9 +4794,13 @@ pub fn paint_target(app: &PhotocraftApp) -> serde_json::Value { ChannelTarget::Composite if st.doc.quick_mask.is_some() => return json!("quickMask"), _ => {} } + // A smart object's targeted (or shown) filter mask takes the paint (FILE-215-3). + if app.filter_mask_targeted() { + return json!("filterMask"); + } let has_mask = st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.mask.is_some()); // Viewing the mask (⌥-click its thumbnail, #196) paints the mask. - let viewing = photocraft_engine::mask_view_cmds::current(st).is_some(); + let viewing = photocraft_engine::mask_view_cmds::current(st).is_some_and(|v| !v.filter); json!(if (app.ui.mask_target || viewing) && has_mask { "mask" } else { "pixels" }) } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7710c228cd..642a4fc2b6 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -8,7 +8,7 @@ //! - `engine.commands`: list commands with enablement //! - `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size); the menu //! tree is `ui.menu.list` -//! - `ui.set {tool?, panels?, dock?, dockTabs?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerName?, brushPickerStroke?, brushPickerTip?, brushPickerScale?, brushSize?}`: +//! - `ui.set {tool?, panels?, dock?, dockTabs?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, filterMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerName?, brushPickerStroke?, brushPickerTip?, brushPickerScale?, brushSize?}`: //! change UI state; any other field is an error ([`UI_SET_FIELDS`]) //! - `ui.dialog.open {kind, fields?}` (kinds: newDocument, about, layerStyle {effect?}, colorPicker {target: foreground|background}, command {command}) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog, wait?}` / `ui.dialog.cancel {dialog}` //! - `ui.dialog.apply {dialog}`: commit Preferences changes without closing the dialog @@ -87,7 +87,7 @@ pub enum Outcome { /// field's value is validated before the first one is applied, so a typo, an unknown field, a /// bad value or a bad nested key can't reply with success while nothing — or only half of it — /// changed (#412). -pub const UI_SET_FIELDS: [&str; 33] = [ +pub const UI_SET_FIELDS: [&str; 34] = [ "tool", "panels", "dock", @@ -96,6 +96,7 @@ pub const UI_SET_FIELDS: [&str; 33] = [ "colorPanel", "maskTarget", "vectorMaskTarget", + "filterMaskTarget", "selectionMode", "zoom", "center", @@ -412,6 +413,8 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) let panels = merged_object(&app.ui.panels, p.get("panels"), "panels")?; let mask_target = bool_field(p, "maskTarget")?; let vector_mask_target = bool_field(p, "vectorMaskTarget")?; + // Target the active smart object's filter mask (FILE-215-3). + let filter_mask_target = bool_field(p, "filterMaskTarget")?; let selection_mode = uint_field(p, "selectionMode")?; let dock_tabs = merged_object(&app.ui.dock_tabs, p.get("dockTabs"), "dockTabs")?; let dock = whole_object(&app.ui.dock, p.get("dock"), "dock")?; @@ -563,6 +566,14 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) app.ui.mask_target &= !m; app.sync_mask_targets(); } + if let Some(m) = filter_mask_target { + app.ui.filter_mask_target = if m { app.session.active().and_then(|st| st.active_layer).map(|id| id.0) } else { None }; + if m { + app.ui.mask_target = false; + app.ui.vector_mask_target = false; + } + app.sync_mask_targets(); + } // Selection tools' options-bar mode: 0 New, 1 Add, 2 Subtract, 3 Intersect. if let Some(m) = selection_mode { app.ui.selection_mode = m.min(3) as u8; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c30796a5dd..5657c4655f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1594,18 +1594,35 @@ impl PhotocraftApp { /// ⌘I then inverts the mask, as in Photoshop (#780). A targeted alpha channel or Quick Mask /// mode wins, as the engine routes those itself. pub fn with_mask_target(&self, id: &str, params: Value) -> Value { - if !photocraft_engine::channel_cmds::follows_target(id) || params.get("target").is_some() || !self.layer_mask_targeted() { + if !photocraft_engine::channel_cmds::follows_target(id) || params.get("target").is_some() { return params; } + // A filter mask is painted, filled and filtered in place; it doesn't float. + let target = if self.filter_mask_targeted() && id != "select.float" { + "filterMask" + } else if self.layer_mask_targeted() { + "mask" + } else { + return params; + }; match params { Value::Object(mut m) => { - m.insert("target".into(), Value::from("mask")); + m.insert("target".into(), Value::from(target)); Value::Object(m) } - _ => serde_json::json!({ "target": "mask" }), + _ => serde_json::json!({ "target": target }), } } + /// The Layers panel targets the active smart object's filter mask (FILE-215-3), and no alpha + /// channel or Quick Mask takes over. + pub fn filter_mask_targeted(&self) -> bool { + let Some(st) = self.session.active() else { return false }; + let Some(id) = st.active_layer.filter(|id| self.ui.filter_mask_target == Some(id.0)) else { return false }; + let composite = st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::Composite && st.doc.quick_mask.is_none(); + composite && st.doc.layer(id).is_some_and(|l| matches!(&l.content, photocraft_doc::LayerContent::Smart(sm) if !sm.smart_filters.is_empty())) + } + /// The Layers panel targets the active layer's mask, and no alpha channel or Quick Mask /// takes over (the engine routes those itself). pub fn layer_mask_targeted(&self) -> bool { @@ -1619,9 +1636,21 @@ impl PhotocraftApp { /// pixels brings back that target's foreground/background pair, as in Photoshop (#2166). pub(crate) fn sync_mask_targets(&mut self) { if let Some(st) = self.session.active() { - if photocraft_engine::mask_view_cmds::current(st).is_some() { - self.ui.mask_target = true; - self.ui.vector_mask_target = false; + match photocraft_engine::mask_view_cmds::current(st) { + Some(v) if v.filter => { + self.ui.filter_mask_target = Some(v.layer.0); + self.ui.mask_target = false; + self.ui.vector_mask_target = false; + } + Some(_) => { + self.ui.mask_target = true; + self.ui.vector_mask_target = false; + self.ui.filter_mask_target = None; + } + None => {} + } + if self.ui.mask_target || self.ui.vector_mask_target { + self.ui.filter_mask_target = None; } if self.ui.vector_mask_target && !mask_thumbs_ui::has_vector_mask(st) { self.ui.vector_mask_target = false; @@ -1630,7 +1659,10 @@ impl PhotocraftApp { self.ui.mask_target = false; } } - let mask = self.layer_mask_targeted(); + if self.ui.filter_mask_target.is_some() && !self.filter_mask_targeted() { + self.ui.filter_mask_target = None; + } + let mask = self.layer_mask_targeted() || self.filter_mask_targeted(); self.session.tools.target_mask(mask); } diff --git a/crates/ui-egui/src/mask_thumbs_ui/tests.rs b/crates/ui-egui/src/mask_thumbs_ui/tests.rs index 3fd0904eb9..df404c3d1c 100644 --- a/crates/ui-egui/src/mask_thumbs_ui/tests.rs +++ b/crates/ui-egui/src/mask_thumbs_ui/tests.rs @@ -483,6 +483,7 @@ fn mask_view_gestures_fail_gracefully_without_a_mask() { h.state_mut().session.active_mut().unwrap().channel_view.layer_mask = Some(photocraft_engine::mask_view_cmds::LayerMaskView { layer: photocraft_doc::LayerId(987_654), mode: photocraft_engine::mask_view_cmds::MaskViewMode::Gray, + filter: false, }); assert!(canvas_px(&h).is_none()); h.run_steps(2); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 444036c57f..30e627d7d6 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2257,12 +2257,21 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if id == "ui.maskTarget" { app.ui.mask_target = p.as_bool().unwrap_or(false); app.ui.vector_mask_target = false; + app.ui.filter_mask_target = None; app.sync_mask_targets(); continue; } if id == "ui.vectorMaskTarget" { app.ui.vector_mask_target = p.as_bool().unwrap_or(false); app.ui.mask_target = false; + app.ui.filter_mask_target = None; + app.sync_mask_targets(); + continue; + } + if id == "ui.filterMaskTarget" { + app.ui.filter_mask_target = p.as_u64(); + app.ui.mask_target = false; + app.ui.vector_mask_target = false; app.sync_mask_targets(); continue; } @@ -2494,7 +2503,10 @@ fn layer_row( // Photoshop frames the targeted thumbnail (pixels, mask or vector mask) of the active layer // with corner brackets. if row.primary { - let target = if app.ui.vector_mask_target && vector_rect.is_some() { + let target = if app.ui.filter_mask_target == Some(l.id.0) && app.filter_mask_targeted() { + // The Smart Filters row's filter mask thumbnail carries the brackets. + None + } else if app.ui.vector_mask_target && vector_rect.is_some() { vector_rect } else if app.ui.mask_target { mask_rect.or(Some(thumb)) diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 99deaa60fe..59124a2669 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -1,5 +1,7 @@ //! Layers-panel pieces for smart objects: the thumbnail badge and the Smart Filters sub-rows //! (eye toggles; double-click a filter to re-open its dialog with the recorded parameters). +//! The Smart Filters row carries the filter mask thumbnail: click it to paint the filter mask, +//! ⌥-click to view it, ⇧-click to disable it (FILE-215-3). use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use photocraft_doc::{Layer, LayerContent, SmartObject}; @@ -72,8 +74,11 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: let x = rect.left() + indent + if index.is_none() { 0.0 } else { 16.0 }; if index.is_none() { let sw = Rect::from_center_size(pos2(x - 12.0, rect.center().y), vec2(14.0, 10.0)); - ui.painter().rect_filled(sw, 1.0, if sm.filter_mask.as_ref().is_some_and(|m| m.enabled) { Color32::from_gray(200) } else { Color32::WHITE }); - ui.painter().rect_stroke(sw, 1.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + paint_filter_mask_thumb(app, ui, sw, l, sm); + let sw_resp = ui.interact(sw.expand(2.0), ui.id().with(("sf-mask", l.id.0)), Sense::click()); + if sw_resp.clicked() { + actions.extend(filter_mask_click(l, sm, ui.input(|i| i.modifiers))); + } } ui.painter().text(pos2(x, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }); if let Some(i) = index @@ -84,6 +89,64 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: } } +/// The filter mask thumbnail: the mask itself (white where the filters show), crossed out when +/// it is disabled, with the target brackets when painting goes into it. +fn paint_filter_mask_thumb(app: &PhotocraftApp, ui: &egui::Ui, sw: Rect, l: &Layer, sm: &SmartObject) { + let t = Tokens::get(ui.ctx()); + let p = ui.painter(); + p.rect_filled(sw, 1.0, Color32::WHITE); + if let Some((m, canvas)) = sm.filter_mask.as_ref().zip(app.session.active().map(|d| d.doc.bounds())) { + // A coarse sample of the canvas, one cell per thumbnail point, as a mesh (rectangles + // this small would be rounded away to nothing). + let (cols, rows) = (sw.width() as i32, sw.height() as i32); + let mut mesh = egui::Mesh::default(); + for j in 0..rows { + for i in 0..cols { + let x = canvas.x0 + (((i as f32 + 0.5) / cols as f32) * canvas.width() as f32) as i32; + let y = canvas.y0 + (((j as f32 + 0.5) / rows as f32) * canvas.height() as f32) as i32; + let v = m.surface.sample_channel(x, y, 0).clamp(0.0, 1.0); + if v < 1.0 { + let cell = Rect::from_min_size(pos2(sw.left() + i as f32, sw.top() + j as f32), vec2(1.0, 1.0)); + mesh.add_colored_rect(cell, Color32::from_gray((v * 255.0).round() as u8)); + } + } + } + p.add(egui::Shape::mesh(mesh)); + if !m.enabled { + p.line_segment([sw.left_top(), sw.right_bottom()], Stroke::new(1.5, Color32::from_rgb(200, 40, 40))); + p.line_segment([sw.left_bottom(), sw.right_top()], Stroke::new(1.5, Color32::from_rgb(200, 40, 40))); + } + } + p.rect_stroke(sw, 1.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + if app.ui.filter_mask_target == Some(l.id.0) && app.filter_mask_targeted() { + crate::mask_thumbs_ui::paint_brackets(p, sw, t.text); + } +} + +/// What a click on the filter mask thumbnail does: target it for painting (selecting the smart +/// object), ⌥ view it alone (⇧⌥ as an overlay), ⇧ disable or enable it. +fn filter_mask_click(l: &Layer, sm: &SmartObject, m: egui::Modifiers) -> Vec<(String, Value)> { + let layer = l.id.0; + if m.alt { + let mode = if m.shift { "toggleOverlay" } else { "toggleGray" }; + return sm + .filter_mask + .is_some() + .then(|| (photocraft_engine::mask_view_cmds::ID.to_string(), json!({"layer": layer, "mode": mode, "mask": "filter"}))) + .into_iter() + .collect(); + } + if m.shift { + return sm + .filter_mask + .as_ref() + .map(|f| ("layer.smartFilter.disableFilterMask".to_string(), json!({"layer": layer, "enabled": !f.enabled}))) + .into_iter() + .collect(); + } + vec![("layer.select".into(), json!({"layer": layer})), ("ui.filterMaskTarget".into(), json!(layer))] +} + /// Re-opens a smart filter's dialog with its recorded parameters; OK runs /// `layer.smartFilter.setParams` instead of adding another filter (see `dialogs::confirm`). pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: &SmartObject, index: usize) { @@ -121,3 +184,54 @@ pub fn confirm_command(fields: &serde_json::Map, command: String, None => (command, params), } } + +#[cfg(test)] +mod tests { + use photocraft_doc::LayerContent; + use photocraft_engine::Session; + use serde_json::json; + + use crate::{PhotocraftApp, Services}; + + fn smart(app: &PhotocraftApp) -> (photocraft_doc::Layer, photocraft_doc::SmartObject) { + let st = app.session.active().unwrap(); + let l = st.doc.layer(st.active_layer.unwrap()).unwrap().clone(); + let LayerContent::Smart(sm) = l.content.clone() else { panic!("not a smart object") }; + (l, sm) + } + + /// FILE-215-3: clicking the filter mask thumbnail targets it, the Brush paints into it (not + /// the layer mask, no rasterize prompt), and clicking the layer thumbnail goes back. + #[test] + fn the_filter_mask_thumbnail_targets_the_filter_mask() { + let mut app = PhotocraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + app.run("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap(); + let (l, sm) = smart(&app); + let actions = super::filter_mask_click(&l, &sm, egui::Modifiers::NONE); + assert_eq!(actions.last().unwrap().0, "ui.filterMaskTarget"); + assert!(super::filter_mask_click(&l, &sm, egui::Modifiers::ALT).is_empty(), "nothing to view yet"); + app.ui.filter_mask_target = Some(l.id.0); + app.sync_views(); + assert!(app.filter_mask_targeted()); + assert_eq!(crate::canvas::paint_target(&app), json!("filterMask")); + assert_eq!(app.with_mask_target("filter.blur.gaussianBlur", json!({}))["target"], "filterMask"); + assert!(crate::rasterize_prompt::needed(&app).is_none(), "painting the filter mask needs no rasterizing"); + app.run( + "paint.stroke", + json!({"points": [[4, 0], [4, 31]], "size": 4, "hardness": 1, "color": "#000000", "target": crate::canvas::paint_target(&app)}), + ) + .unwrap(); + let (l, sm) = smart(&app); + assert_eq!(sm.filter_mask.as_ref().unwrap().value(4, 16), 0.0); + assert!(l.mask.is_none(), "no layer mask"); + assert_eq!(super::filter_mask_click(&l, &sm, egui::Modifiers::ALT)[0].1["mask"], "filter"); + // Targeting a layer mask (or the pixels, `ui.maskTarget`) ends it. + app.ui.mask_target = true; + app.sync_views(); + assert_eq!(app.ui.filter_mask_target, None, "a layer mask target replaces it"); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index deb82b7885..9b9b94c34d 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -920,6 +920,11 @@ pub struct UiState { /// tools edit it (#196). Never set together with `mask_target`. #[serde(default)] pub vector_mask_target: bool, + /// The smart object (layer id) whose filter mask is targeted (its thumbnail on the Smart + /// Filters row clicked): painting, fills, filters and adjustments edit that mask + /// (FILE-215-3). Never set together with `mask_target` or `vector_mask_target`. + #[serde(default)] + pub filter_mask_target: Option, /// Brush Preset picker opened by a right-click on the canvas or the options-bar brush chip: /// its screen position (points). #[serde(default)] @@ -1086,6 +1091,7 @@ impl Default for UiState { symmetry_transform: None, mask_target: false, vector_mask_target: false, + filter_mask_target: None, brush_picker: None, brush_picker_list: crate::brush_picker::list_state(), brush_picker_size: None, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1c69df465f..79b9a2b4a6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -43,7 +43,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given. `params` must be a JSON object (omit it or pass `null` for none); an array, string, number or boolean is an error naming the command, in every transport (control channel, `serve`, MCP and the CLI) - `engine.commands`: list commands with enablement - `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `brushPicker` is `null` while closed and `{pos: [x, y], size: [w, h]?, list: {collapsed, filter, renaming, showName, showStroke, showTip, scale}}` while the Brush Preset picker is open (coordinates in screen points; `size` is the picker's content size once its corner grip was dragged, `null` before that, and the drag remembers both dimensions; the three `show*` flags pick the parts of each preset's card — at least one is always on; `scale` is the footer slider, 0.15–2 with 1 the standard, multiplying the cards' width and height — the heights stop at half size and the name and tip-size text hold their 1.0 size — and dropping the tips' size numbers at 0.40 and below; the parts and the scale are remembered across restarts, while the search, collapsed groups and a rename in progress are not). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`) -- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerName?, brushPickerStroke?, brushPickerTip?, brushPickerScale?, gradientBlendMode?, gradientClassic?, shapeStroke?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` is `list` or `grid` for the Brushes panel; `brushPickerName`, `brushPickerStroke` and `brushPickerTip` are the Brush Preset picker's card parts (booleans, at least one on), and `brushPickerScale` its footer card-scale slider (0.15 to 2, 1 the standard); `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"], tabOrder: {"layers": ["Paths", "Layers", "Channels"]}}`, the right-dock groups top to bottom, their heights in points, the groups collapsed to their tab strip and each group's tab order left to right by panel name (a group left out keeps its default order, unknown names are ignored and tabs not listed follow in their default order; `dockTabs` keeps indexing tabs in their default order); `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour; `maskTarget` targets the active layer's mask, which, like Photoshop, swaps in the foreground/background pair kept for masks (white/black until one is picked) and swaps the pixels' pair back when the pixels are targeted again) +- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, filterMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerName?, brushPickerStroke?, brushPickerTip?, brushPickerScale?, gradientBlendMode?, gradientClassic?, shapeStroke?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` is `list` or `grid` for the Brushes panel; `brushPickerName`, `brushPickerStroke` and `brushPickerTip` are the Brush Preset picker's card parts (booleans, at least one on), and `brushPickerScale` its footer card-scale slider (0.15 to 2, 1 the standard); `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"], tabOrder: {"layers": ["Paths", "Layers", "Channels"]}}`, the right-dock groups top to bottom, their heights in points, the groups collapsed to their tab strip and each group's tab order left to right by panel name (a group left out keeps its default order, unknown names are ignored and tabs not listed follow in their default order; `dockTabs` keeps indexing tabs in their default order); `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour; `maskTarget` targets the active layer's mask, which, like Photoshop, swaps in the foreground/background pair kept for masks (white/black until one is picked) and swaps the pixels' pair back when the pixels are targeted again; `filterMaskTarget` targets the active smart object's filter mask, so painting, fills, filters and adjustments edit it (`"target":"filterMask"`)) - `typeTransform` is `null` outside a temporary Ctrl/Cmd Type-tool drag. During a drag it reports its source document/revision, original affine, oriented `frame` (layer, local bounds, document-space corners and pivot), and captured `gesture`. Drive it with `ui.pointer` and `command: true` at `down`; `move`/`up` may release the modifier. The document/history remain unchanged during preview, and `up` applies one `type.edit` within `textEdit`'s session. Pressing Escape during the drag cancels only the preview. This pointer state is never restored from saved UI state. - `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once - `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar. diff --git a/docs/scorecard.md b/docs/scorecard.md index 67789593e5..e3c2e41859 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 16 not run, 6 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 5 | 11 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | +| [File compatibility](#file-compatibility) | 6 | 11 | 10 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 21 | 17 | 4 | | | [Workspace and UI](#workspace-and-ui) | 6 | 7 | 10 | settings that do nothing: 43 of 151 (target 0) | | [Type](#type) | 0 | 3 | 6 | | @@ -100,7 +100,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t |---|---|---|---|---| | FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded | | FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | -| FILE-215-3 | Filter mask can be painted directly, with undo | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:382 mask only from the selection | +| FILE-215-3 | Filter mask can be painted directly, with undo | done | [#215](https://github.com/storytold/photocraft/issues/215) | Clicking the filter mask thumbnail on the Smart Filters row targets it (ui.filterMaskTarget, smart_ui.rs). Painting, fills, filters and adjustments with "target":"filterMask" then edit it (a white mask starts on the first stroke) and re-render the smart object, one undo step per stroke (channel_cmds Target::FilterMask, Session::edit). ⌥-click shows it (view.layerMask "mask":"filter"), ⇧-click disables it. Kept through .pcraft and PSD FEid (smart_cmds/tests/filter_mask_paint.rs) | | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | | FILE-215-6 | Smart Object corner skew and perspective (quad or homography transform) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | doc lib.rs:312 SmartObject.transform is an Affine | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 1c3d798e87..44424f26c4 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -22,9 +22,9 @@ note = "smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both w [[item]] id = "FILE-215-3" target = "Filter mask can be painted directly, with undo" -status = "missing" +status = "done" issue = 215 -note = "smart_cmds.rs:382 mask only from the selection" +note = "Clicking the filter mask thumbnail on the Smart Filters row targets it (ui.filterMaskTarget, smart_ui.rs). Painting, fills, filters and adjustments with \"target\":\"filterMask\" then edit it (a white mask starts on the first stroke) and re-render the smart object, one undo step per stroke (channel_cmds Target::FilterMask, Session::edit). ⌥-click shows it (view.layerMask \"mask\":\"filter\"), ⇧-click disables it. Kept through .pcraft and PSD FEid (smart_cmds/tests/filter_mask_paint.rs)" [[item]] id = "FILE-215-4" From 15a3ce86f0b04b1177f9d6f025dc21bee5320dc5 Mon Sep 17 00:00:00 2001 From: Ratul Paul Date: Sun, 11 Oct 2026 13:32:17 +0600 Subject: [PATCH 015/229] Add intermediate UI scale presets between 100% and 125% --- contributors/people.toml | 3 ++ crates/engine/src/prefs.rs | 2 +- crates/engine/src/prefs/tests.rs | 15 ++++++++++ crates/ui-egui/src/i18n/cs.tsv | 4 +++ crates/ui-egui/src/i18n/de.tsv | 4 +++ crates/ui-egui/src/i18n/el.tsv | 4 +++ crates/ui-egui/src/i18n/es.tsv | 4 +++ crates/ui-egui/src/i18n/fr.tsv | 4 +++ crates/ui-egui/src/i18n/id.tsv | 4 +++ crates/ui-egui/src/i18n/it.tsv | 4 +++ crates/ui-egui/src/i18n/ja.tsv | 4 +++ crates/ui-egui/src/i18n/ko.tsv | 4 +++ crates/ui-egui/src/i18n/nl.tsv | 4 +++ crates/ui-egui/src/i18n/pl.tsv | 4 +++ crates/ui-egui/src/i18n/pt-br.tsv | 4 +++ crates/ui-egui/src/i18n/ru.tsv | 4 +++ crates/ui-egui/src/i18n/uk.tsv | 4 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 4 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 4 +++ crates/ui-egui/src/prefs_ui.rs | 43 +++++++++++++++++++++++++++-- docs/ui-design.md | 3 +- 21 files changed, 126 insertions(+), 4 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 0adfc2f4a1..062ac7f5f0 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -93,3 +93,6 @@ sync_github_name = true [people.FinnNk] sync_github_name = true + +[people.WebWork-rrs] +display_name = "Ratul Paul" diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 7c7d3b8080..b5f4b8161e 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -61,7 +61,7 @@ choice!(DarkTheme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", Sol choice!(LightTheme { StudioLight = "studioLight", Classic = "classic", Adwaita = "adwaita", BreezeLight = "breezeLight" } default StudioLight); choice!(CanvasColor { Default = "default", Black = "black", DarkGray = "darkGray", MediumGray = "mediumGray", LightGray = "lightGray", Custom = "custom" } default Default); choice!(CanvasBorder { DropShadow = "dropShadow", Line = "line", None = "none" } default DropShadow); -choice!(UiScale { Auto = "auto", P75 = "75", P80 = "80", P85 = "85", P90 = "90", P95 = "95", P100 = "100", P125 = "125", P150 = "150", P175 = "175", P200 = "200", P250 = "250", P300 = "300" } default Auto); +choice!(UiScale { Auto = "auto", P75 = "75", P80 = "80", P85 = "85", P90 = "90", P95 = "95", P100 = "100", P105 = "105", P110 = "110", P115 = "115", P120 = "120", P125 = "125", P150 = "150", P175 = "175", P200 = "200", P250 = "250", P300 = "300" } default Auto); choice!( /// Graphics backend of the desktop app's window and GPU canvas (applies at next launch). /// `auto` lets PhotoCraft pick (DX12 for Intel adapters on Windows); `cpu` composites on the diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index bc896f4efc..a3b6386204 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -22,6 +22,21 @@ fn defaults_match_photoshop() { } } +#[test] +fn intermediate_ui_scale_presets_survive_restart() { + let mut s = Session::new(); + assert_eq!(s.execute("prefs.get", json!({"path": "interface.uiScale"})).unwrap(), json!("auto")); + for scale in ["100", "105", "110", "115", "120", "125"] { + s.execute("prefs.set", json!({"path": "interface.uiScale", "value": scale})).unwrap(); + let mut restored = Session::new(); + restored.load_prefs_json(&s.prefs_to_json()).unwrap(); + assert_eq!(restored.execute("prefs.get", json!({"path": "interface.uiScale"})).unwrap(), json!(scale)); + } + // Presets remain discrete choices: arbitrary values must not silently replace a valid scale. + assert!(s.execute("prefs.set", json!({"path": "interface.uiScale", "value": "112"})).is_err()); + assert_eq!(s.execute("prefs.get", json!({"path": "interface.uiScale"})).unwrap(), json!("125")); +} + /// #2264: the Studio workspace dot pattern can be hidden without changing themes. #[test] fn canvas_dot_grid_can_be_disabled_persisted_and_reset() { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 33f32c2d31..d52d1a6f64 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -600,6 +600,10 @@ Fit Layer(s) on Screen Přizpůsobit vrstvy obrazovce Fit Artboard on Screen Přizpůsobit kreslicí plochu obrazovce 100% 100 % + 105% 105 % + 110% 110 % + 115% 115 % + 120% 120 % 200% 200 % 75% 75 % 80% 80 % diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 27e71fe3fb..8190b5b20a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -596,6 +596,10 @@ Fit Layer(s) on Screen Ebenen auf den Bildschirm einpassen Fit Artboard on Screen Zeichenbereich auf den Bildschirm einpassen 100% 100 % + 105% 105 % + 110% 110 % + 115% 115 % + 120% 120 % 200% 200 % Print Size Druckgröße Pixel Aspect Ratio Pixel-Seitenverhältnis diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4821be08a8..a4b63fcfa5 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3288,6 +3288,10 @@ cameraRaw Yellow Κίτρινο # Labels added on main since the first batch 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% 180° 180° 200% 200% 3 x 3 3 x 3 diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index bbaac744cf..2ca938ff43 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -592,6 +592,10 @@ Fit Layer(s) on Screen Encajar capas en pantalla Fit Artboard on Screen Encajar lienzo en pantalla 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% 200% 200% 75% 75% 80% 80% diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 68a9dff4b4..b92f2877e6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -583,6 +583,10 @@ Fit Layer(s) on Screen Adapter le(s) calque(s) à l'écran Fit Artboard on Screen Adapter le plan de travail à l'écran 100% 100 % + 105% 105 % + 110% 110 % + 115% 115 % + 120% 120 % 200% 200 % 75% 75 % 80% 80 % diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9355f23685..22cdc7bfbf 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -576,6 +576,10 @@ Fit Layer(s) on Screen Pas Layer di Layar Fit Artboard on Screen Pas Artboard di Layar 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% 200% 200% 75% 75% 80% 80% diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 43e52295ae..b40170da06 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -590,6 +590,10 @@ Fit Layer(s) on Screen Adatta livello/i a schermo Fit Artboard on Screen Adatta tavola a schermo 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% 200% 200% 75% 75% 80% 80% diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1c66b52c8a..683592b75e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -595,6 +595,10 @@ Fit Layer(s) on Screen レイヤーを画面サイズに合わせる Fit Artboard on Screen アートボードを画面サイズに合わせる 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% 200% 200% 75% 75% 80% 80% diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 8484888eed..299671b6db 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -578,6 +578,10 @@ Fit Layer(s) on Screen 레이어를 화면에 맞추기 Fit Artboard on Screen 아트보드를 화면에 맞추기 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% 200% 200% Print Size 인쇄 크기 Pixel Aspect Ratio 픽셀 종횡비 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 173af2b176..c731776d73 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -587,6 +587,10 @@ Fit Layer(s) on Screen Laag/lagen passend in scherm Fit Artboard on Screen Tekengebied passend in scherm 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% 200% 200% Print Size Afdrukgrootte Pixel Aspect Ratio Pixelverhouding diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 71b80b30bc..9413ad1765 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -593,6 +593,10 @@ Fit Layer(s) on Screen Dopasuj warstwy do ekranu Fit Artboard on Screen Dopasuj obszar roboczy do ekranu 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% 200% 200% 75% 75% 80% 80% diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ae367d5406..b6fabeeb11 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -580,6 +580,10 @@ Fit Layer(s) on Screen Ajustar camada(s) à tela Fit Artboard on Screen Ajustar prancheta à tela 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% 200% 200% 75% 75% 80% 80% diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 27c8e43922..7217249bb7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -393,6 +393,10 @@ Zoom Out Уменьшить Fit on Screen По размеру экрана 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% 200% 200% 75% 75% 80% 80% diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 315a2b5fdb..375973cf00 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -372,6 +372,10 @@ Zoom Out Зменшити Fit on Screen Умістити на екрані 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% 200% 200% 75% 75% 80% 80% diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 977736fc44..381e0f07ca 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -595,6 +595,10 @@ Fit Layer(s) on Screen 使图层适合屏幕 Fit Artboard on Screen 使画板适合屏幕 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% 200% 200% 75% 75% 80% 80% diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 049194ed6a..e194d0bba9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -600,6 +600,10 @@ Fit Layer(s) on Screen 使圖層符合螢幕 Fit Artboard on Screen 使工作區域符合螢幕 100% 100% + 105% 105% + 110% 110% + 115% 115% + 120% 120% 200% 200% 75% 75% 80% 80% diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index c2def4db7d..e5d9e53bde 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -981,7 +981,7 @@ fn choice_label(v: &str) -> String { match v { "cm" => "Centimeters".into(), "mm" => "Millimeters".into(), - "75" | "80" | "85" | "90" | "95" | "100" | "125" | "150" | "175" | "200" | "250" | "300" => format!("{v}%"), + "75" | "80" | "85" | "90" | "95" | "100" | "105" | "110" | "115" | "120" | "125" | "150" | "175" | "200" | "250" | "300" => format!("{v}%"), "8" => "8 Bits/Channel".into(), "16" => "16 Bits/Channel".into(), "postScript" => "PostScript (72 points/inch)".into(), @@ -2445,7 +2445,21 @@ mod tests { step(vec2(2560.0, 1440.0), 1.75, 1.75); step(vec2(3840.0, 2160.0), 1.0, 2.0); } - for (pref, expected) in [("200", 2.0), ("125", 1.25), ("150", 1.5), ("100", 1.0), ("95", 0.95), ("90", 0.9), ("85", 0.85), ("80", 0.8), ("auto", 1.5)] { + for (pref, expected) in [ + ("200", 2.0), + ("125", 1.25), + ("150", 1.5), + ("100", 1.0), + ("105", 1.05), + ("110", 1.1), + ("115", 1.15), + ("120", 1.2), + ("95", 0.95), + ("90", 0.9), + ("85", 0.85), + ("80", 0.8), + ("auto", 1.5), + ] { app.run("prefs.set", json!({"values": {"interface.uiScale": pref}})).unwrap(); let mut input = egui::RawInput::default(); input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(1.5); @@ -2455,6 +2469,31 @@ mod tests { } } + #[test] + fn intermediate_ui_scale_choices_can_be_applied_from_preferences() { + use egui_kittest::{Harness, kittest::Queryable}; + let (mut app, store) = app_with_store(); + app.run("prefs.set", json!({"path": "interface.uiScale", "value": "100"})).unwrap(); + open_preferences(&mut app, "interface"); + let mut h = Harness::builder().with_size(vec2(1400.0, 1100.0)).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app + }); + h.run_steps(4); + let mut current = "100%"; + for (label, value) in [("105%", "105"), ("110%", "110"), ("115%", "115"), ("120%", "120")] { + h.get_by_value(current).click(); + h.run_steps(2); + h.get_by_label(label).click(); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + assert_eq!(h.state().session.prefs().interface.ui_scale.name(), value); + assert_eq!(stored(&store)["interface"]["uiScale"], value); + current = label; + } + } + #[test] fn ui_font_size_commands_follow_themes_and_dpi_and_reset_live() { let (mut app, _) = app_with_store(); diff --git a/docs/ui-design.md b/docs/ui-design.md index f897c83da0..e66fe92cbf 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -153,7 +153,8 @@ Wayland's fractional scale or X11's `Xft.dpi`). Only when the system reports no a 4K or larger monitor does Auto use 200%, so text and controls remain readable. Detection uses the current monitor, including portrait displays, and updates when the window moves between monitors. If monitor size is unavailable, Auto follows system DPI. The fixed choices (75% to 300%) set an absolute UI -scale, allowing large 4K displays to use smaller controls when desired. Canvas zoom shortcuts +scale, including 105%, 110%, 115% and 120% for finer control between 100% and 125%, allowing +large 4K displays to use smaller controls when desired. Canvas zoom shortcuts continue to control the document independently of UI scaling. Interface → UI Font Size changes interface text independently of UI Scale and canvas zoom. From 6c9ec00fa80adc34e10461083be4675e901f205e Mon Sep 17 00:00:00 2001 From: durtyrobbin Date: Sun, 11 Oct 2026 12:40:20 -0400 Subject: [PATCH 016/229] studio: live mixer foundation Preserve validated painting-studio phase 1 as its own source commit. Content tree: 371bfeb4cdc79f3e2e9357047141a393811ec1a5 Qualification details and remaining gates are retained in the phase documentation. Local commit authorized by durtyrobbin; no publication or upstream merge. --- crates/engine/examples/mixer_probe.rs | 66 +++ crates/engine/src/brush_cmds.rs | 63 +-- crates/engine/src/brush_cmds/mixer_live.rs | 402 ++++++++++++++++++ .../engine/src/brush_cmds/mixer_live_tests.rs | 226 ++++++++++ crates/engine/src/float_cmds.rs | 3 +- crates/engine/src/lib.rs | 2 + crates/paint/src/mixer.rs | 174 +++++--- crates/ui-egui/src/alt_click_tests.rs | 30 ++ crates/ui-egui/src/alt_grab.rs | 2 +- crates/ui-egui/src/brush_panel_tests.rs | 6 +- crates/ui-egui/src/canvas.rs | 201 ++++++++- crates/ui-egui/src/panels.rs | 26 ++ crates/ui-egui/src/shortcuts.rs | 3 + docs/mixer-live.md | 147 +++++++ 14 files changed, 1241 insertions(+), 110 deletions(-) create mode 100644 crates/engine/examples/mixer_probe.rs create mode 100644 crates/engine/src/brush_cmds/mixer_live.rs create mode 100644 crates/engine/src/brush_cmds/mixer_live_tests.rs create mode 100644 docs/mixer-live.md diff --git a/crates/engine/examples/mixer_probe.rs b/crates/engine/examples/mixer_probe.rs new file mode 100644 index 0000000000..d39b297661 --- /dev/null +++ b/crates/engine/examples/mixer_probe.rs @@ -0,0 +1,66 @@ +//! Reproducible CPU-engine latency probe; not a hardware pointer-to-display qualification. +use photocraft_engine::{Session, brush_cmds::mixer_live::LiveMixer}; +use photocraft_geom::Rect; +use photocraft_paint::{ + BrushSettings, Stroke, StrokePoint, + mixer::{MixerState, apply_mixer_stroke}, +}; +use photocraft_raster::Surface; +use serde_json::json; +use std::time::Instant; + +fn main() -> Result<(), Box> { + let mut results = Vec::new(); + for size in [16.0, 64.0, 256.0] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":6000,"height":4000,"background":"#808080"}))?; + s.execute("layer.new.layer", json!({"name":"Source"}))?; + s.edit("fixture", |doc, id| { + if let Some(layer) = id.and_then(|id| doc.layer_mut(id)) + && let Some(surface) = layer.surface_mut() + { + surface.fill_rect(Rect::new(0, 0, 500, 800), &[1.0, 0.0, 0.0, 1.0]); + surface.fill_rect(Rect::new(500, 0, 1000, 800), &[0.0, 1.0, 0.0, 1.0]); + } + Ok(()) + })?; + s.execute("layer.new.layer", json!({"name":"Paint"}))?; + let baseline_doc = s.active().ok_or("no document")?.doc.clone(); + let params = json!({"points":[[200,400]],"size":size,"seed":42,"wet":100,"mix":85,"brush":{"pressureSize":false,"spacing":0.25}}); + let start = Instant::now(); + let mut live = LiveMixer::begin(&s, ¶ms)?; + let begin_ms = start.elapsed().as_secs_f64() * 1000.0; + let mut times = Vec::new(); + let mut points = vec![StrokePoint::new(200.0, 400.0, 1.0)]; + for i in 1..=60 { + let point = StrokePoint::new(200.0 + 10.0 * f64::from(i), 400.0 + 20.0 * (f64::from(i) / 10.0).sin(), 1.0); + let start = Instant::now(); + live.push(&[point])?; + times.push(start.elapsed().as_secs_f64() * 1000.0); + points.push(point); + } + let area = live.bounds(); + let commit = live.prepare_commit(&mut s)?; + let start = Instant::now(); + s.execute("paint.mixerBrush", commit)?; + let commit_ms = start.elapsed().as_secs_f64() * 1000.0; + let mut baseline = Surface::new(photocraft_color::PixelFormat::RGBA8); + let mut state = MixerState::default(); + state.load(s.tools.foreground); + let stroke = Stroke { brush: BrushSettings { size, pressure_size: false, spacing: 0.25, ..Default::default() }, points }; + let settings = photocraft_paint::MixerSettings { wet: 1.0, mix: 0.85, ..Default::default() }; + // Baseline's historical frozen-composite path, including its one source composition. + let start = Instant::now(); + let buffer = photocraft_compose::render(&baseline_doc, area); + let mut source = Surface::new(photocraft_color::PixelFormat::RGBA32F); + source.write_region(area, &buffer.px.iter().flatten().copied().collect::>()); + apply_mixer_stroke(&mut baseline, Some(&source), &stroke, &settings, &mut state, None, false); + let baseline_ms = start.elapsed().as_secs_f64() * 1000.0; + times.sort_by(f64::total_cmp); + results.push(json!({"size":size,"document":[6000,4000],"layers":3,"moves":60,"begin_ms":begin_ms, + "move_median_ms":times.get(30),"move_p95_ms":times.get(57),"move_max_ms":times.last(), + "prepared_commit_ms":commit_ms,"baseline_frozen_composite_full_stroke_ms":baseline_ms})); + } + println!("{}", serde_json::to_string_pretty(&json!({"scope":"CPU engine only; no OS, tablet, display, or GPU latency claim","results":results}))?); + Ok(()) +} diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index a0a217c303..d984363bca 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -8,7 +8,7 @@ use photocraft_doc::LayerContent; use photocraft_geom::Rect; -use photocraft_paint::mixer::{MixerSettings, apply_mixer_stroke}; +use photocraft_paint::mixer::MixerSettings; use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement}; use photocraft_paint::{BrushPreset, BrushSettings, GrayTile, Stroke, StrokePoint, StrokeRenderer, TipShape, render_stroke}; use photocraft_raster::Surface; @@ -546,51 +546,9 @@ fn pct(p: &Value, k: &str, d: f32) -> f32 { num(p, k).unwrap_or(d).clamp(0.0, 100.0) / 100.0 } +pub mod mixer_live; fn mixer_brush(s: &mut Session, p: &Value) -> Result { - let cmd = "paint.mixerBrush"; - let pts = parse_points(p, cmd)?; - let brush = resolve_brush(s, p, cmd)?; - // The brush's Mixer Brush options, overridden by the scalar params. - let bm = &brush.mixer; - let m = MixerSettings { - wet: pct(p, "wet", bm.wet * 100.0), - load: pct(p, "load", bm.load * 100.0), - mix: pct(p, "mix", bm.mix * 100.0), - flow: pct(p, "flow", bm.flow * 100.0), - sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers), - }; - let sample_all = m.sample_all_layers; - let (clean, load_after) = (flag(p, "cleanAfterStroke", true), flag(p, "loadAfterStroke", true)); - let mut state = s.tools.mixer.clone(); - if load_after || state.reservoir.is_none() { - state.load(color(p.get("color"), s.tools.foreground)); - } - let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) }; - let stroke = Stroke { brush, points: pts }; - let dmg = s.edit("Mixer Brush", |doc, _| { - let sel = doc.selection.clone(); - let sample = if sample_all { - let ds = photocraft_paint::dabs(&stroke); - let ctx = photocraft_paint::BrushContext::new(&stroke.brush); - let area = ds.iter().fold(Rect::EMPTY, |r, d| r.union(&ctx.dab_rect(d, false))); - let buf = photocraft_compose::render(doc, area); - let mut surf = Surface::new(photocraft_color::PixelFormat::RGBA32F); - let flat: Vec = buf.px.iter().flatten().copied().collect(); - if !area.is_empty() { - surf.write_region(area, &flat); - } - Some(surf) - } else { - None - }; - let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; - Ok(apply_mixer_stroke(surf, sample.as_ref(), &stroke, &m, &mut state, sel.as_ref(), lock)) - })?; - if clean { - state.clean(); - } - s.tools.mixer = state; - Ok(damage_json(s, dmg)) + mixer_live::run(s, p) } fn color_replacement(s: &mut Session, p: &Value) -> Result { @@ -918,6 +876,16 @@ macro_rules! spec { /// Brush command specs. pub fn specs() -> Vec { vec![ + spec!( + "brush.mixer.sample", + "Sample Mixer Brush", + r#"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?]],"sampleAllLayers":bool=brush setting,"size":px?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}}"#, + |s| s.active().map(|_| ()).ok_or_else(|| "no document".into()), + mixer_live::sample, + true + ), + spec!("brush.mixer.load", "Load Mixer Brush", r##"{"color":"#rrggbb"?=foreground}"##, always, mixer_live::load, true), + spec!("brush.mixer.clean", "Clean Mixer Brush", "{}", always, mixer_live::clean, true), spec!( "paint.pencil", "Pencil", @@ -929,7 +897,7 @@ pub fn specs() -> Vec { spec!( "paint.mixerBrush", "Mixer Brush", - r##"{"points":[…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##, + r##"{"points":[…] (max 65536 samples, 2048px per segment),"brush":{…}?,"preset":name?,"size":0.5..512 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke,"loadAfterStroke":bool=brush.mixer.reloadAfterStroke,"seed":u64?,"mixerState":{reservoir,amount,pickup,spatial} (optional captured replay state; spatial is null or 4096 RGBA colors)}"##, has_paintable, mixer_brush, true @@ -962,3 +930,6 @@ pub fn specs() -> Vec { #[cfg(test)] mod tests; + +#[cfg(test)] +mod mixer_live_tests; diff --git a/crates/engine/src/brush_cmds/mixer_live.rs b/crates/engine/src/brush_cmds/mixer_live.rs new file mode 100644 index 0000000000..21f4c7e99e --- /dev/null +++ b/crates/engine/src/brush_cmds/mixer_live.rs @@ -0,0 +1,402 @@ +//! Incremental Mixer Brush transaction. Source pixels are composed before each dab; +//! only the selected target changes. Dropping the transaction cancels it. +use super::*; +use photocraft_color::{PixelFormat, SampleType}; +use photocraft_doc::{Document, LayerId}; +use photocraft_paint::dynamics::DabGenerator; +use photocraft_paint::mixer::{MixerState, apply_mixer_dab}; +use photocraft_paint::retouch::{Region, mix_back}; +use photocraft_paint::{BrushContext, Dab}; +use std::collections::BTreeSet; +use std::sync::Arc; + +const CMD: &str = "paint.mixerBrush"; +const MAX_TILES: usize = 256; +const TILE: i32 = 256; + +fn valid_color(color: &[f32; 4]) -> bool { + color.iter().all(|v| v.is_finite() && v.abs() <= 1_000_000.0) +} + +fn valid_state(state: &MixerState) -> bool { + state.amount.is_finite() + && state.reservoir.as_ref().is_none_or(valid_color) + && state.pickup.as_ref().is_none_or(valid_color) + && state.spatial.as_ref().is_none_or(|colors| colors.len() == 4096 && colors.iter().all(valid_color)) +} + +#[derive(Clone)] +struct Core { + doc: Arc, + generator: DabGenerator, + ctx: BrushContext, + pre: Surface, + work: Surface, + tiles: BTreeSet<(i32, i32)>, + state: MixerState, + settings: MixerSettings, + layer: Option, + params: Value, + selection: Option, + lock: bool, + color_restrict: Option, + count: usize, + last: Option, + bounds: Rect, +} + +impl Core { + fn apply(&mut self, dabs: &[Dab]) -> Result { + if self.count.saturating_add(dabs.len()) > 250_000 { + return Err(bad(CMD, "stroke exceeds 250000 dabs; start another stroke")); + } + let mut damage = Rect::EMPTY; + for dab in dabs { + let index = self.count; + self.count += 1; + let r = self.ctx.dab_rect(dab, false); + if r.is_empty() { + continue; + } + let full = self.ctx.dab_rect(dab, false); + if u64::from(full.width()).saturating_mul(u64::from(full.height())) > 1_048_576 { + return Err(bad(CMD, "Mixer Brush footprint exceeds 1048576 pixels")); + } + // Float working tiles avoid repeated integer-depth quantization. Initialize only + // touched tiles, keeping unchanged source tiles shared by the document snapshots. + for ty in r.y0.div_euclid(TILE)..=(r.y1 - 1).div_euclid(TILE) { + for tx in r.x0.div_euclid(TILE)..=(r.x1 - 1).div_euclid(TILE) { + if !self.tiles.contains(&(tx, ty)) { + if self.tiles.len() >= MAX_TILES { + return Err(bad(CMD, "Mixer Brush stroke working set exceeds 256 tiles")); + } + let tile = Rect::new(tx * TILE, ty * TILE, (tx + 1) * TILE, (ty + 1) * TILE); + self.work.write_region(tile, &self.pre.read_region(tile)); + self.tiles.insert((tx, ty)); + } + } + } + // Render the visible document including the active layer exactly once, before + // deposition. Masks, transforms, blend modes and adjustments use the compositor. + let sample = self.settings.sample_all_layers.then(|| { + let buf = photocraft_compose::render(&self.doc, r); + let mut sample = Surface::new(PixelFormat::RGBA32F); + sample.write_region(r, &buf.px.iter().flatten().copied().collect::>()); + sample + }); + let fp = self.ctx.footprint(dab, index); + let mut region = Region::read(&self.work, r); + apply_mixer_dab(&mut region, &fp, self.pre.format(), sample.as_ref(), &self.settings, &mut self.state, self.lock); + region.write(&mut self.work); + let original = Region::read(&self.pre, r); + let (target, _) = crate::channel_cmds::target_surface(Arc::make_mut(&mut self.doc), self.layer, &self.params)?; + mix_back(target, &original, ®ion, r, 1.0, self.selection.as_ref(), self.lock); + if let Some(k) = self.color_restrict { + let n = target.channels(); + let mut values = target.read_region(r); + let before = self.pre.read_region(r); + for (new, old) in values.chunks_exact_mut(n).zip(before.chunks_exact(n)) { + for (c, (new, old)) in new.iter_mut().zip(old).enumerate() { + if c != k { + *new = *old; + } + } + } + target.write_region(r, &values); + } + damage = damage.union(&r); + } + self.bounds = self.bounds.union(&damage); + Ok(damage) + } +} + +/// A real, cancellable paint result, displayed by the existing damage-aware canvas. +pub struct LiveMixer { + pub doc: Arc, + pub seed: u64, + core: Core, + state: MixerState, + tail: Rect, + cmyk: Option>, + original: Arc, + points: Vec, + original_state: MixerState, +} + +impl LiveMixer { + pub fn begin(s: &Session, p: &Value) -> Result { + has_paintable(s).map_err(EngineError::Other)?; + if p.get("points").and_then(Value::as_array).is_some_and(|v| v.len() > 65_536) { + return Err(bad(CMD, "stroke exceeds 65536 recorded input samples")); + } + let points = parse_points(p, CMD)?; + let brush = resolve_brush(s, p, CMD)?; + // Reject unsupported sizes before allocating a dense brush footprint. + if brush.size > 512.0 { + return Err(bad(CMD, "live Mixer Brush size is limited to 512 pixels")); + } + let bm = &brush.mixer; + let settings = MixerSettings { + wet: pct(p, "wet", bm.wet * 100.0), + load: pct(p, "load", bm.load * 100.0), + mix: pct(p, "mix", bm.mix * 100.0), + flow: pct(p, "flow", bm.flow * 100.0), + sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers) && crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels, + clean_after_stroke: flag(p, "cleanAfterStroke", bm.clean_after_stroke), + reload_after_stroke: flag(p, "loadAfterStroke", bm.reload_after_stroke), + }; + let initial_state = if let Some(value) = p.get("mixerState") { + if value.get("spatial").and_then(Value::as_array).is_some_and(|v| v.len() != 4096) { + return Err(bad(CMD, "spatial reservoir must contain exactly 4096 colors")); + } + serde_json::from_value::(value.clone()).map_err(|e| bad(CMD, e.to_string()))? + } else { + s.tools.mixer.clone() + }; + let mut state = initial_state; + if !valid_state(&state) { + return Err(bad(CMD, "loaded paint must be finite and color magnitudes at most 1000000")); + } + if state.spatial.is_none() && settings.reload_after_stroke { + let color = color(p.get("color"), s.tools.foreground); + if !valid_color(&color) { + return Err(bad(CMD, "loaded color must be finite and magnitudes at most 1000000")); + } + state.load(color); + } + let layer = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) }; + let mut doc = (*s.active().ok_or(EngineError::NoDocument)?.doc).clone(); + let selection = doc.selection.clone(); + let (target, lock) = crate::channel_cmds::target_surface(&mut doc, layer, p)?; + let pre = target.clone(); + let work = Surface::new(PixelFormat { sample: SampleType::F32, ..pre.format() }); + let core = Core { + doc: Arc::new(doc), + generator: DabGenerator::new(&brush, num(p, "zoom").unwrap_or(1.0)), + ctx: BrushContext::new(&brush), + pre, + work, + tiles: BTreeSet::new(), + state: state.clone(), + settings, + layer, + params: p.clone(), + selection, + lock, + color_restrict: crate::channel_cmds::color_restriction(s, CMD, p), + count: 0, + last: None, + bounds: Rect::EMPTY, + }; + let mut live = Self { + doc: core.doc.clone(), + seed: brush.seed, + core, + state, + tail: Rect::EMPTY, + cmyk: doc_cmyk_space(s), + original: s.active().ok_or(EngineError::NoDocument)?.doc.clone(), + points: Vec::new(), + original_state: s.tools.mixer.clone(), + }; + live.push(&points)?; + Ok(live) + } + + pub fn bounds(&self) -> Rect { + self.core.bounds.union(&self.tail) + } + pub fn wants_time(&self) -> bool { + self.core.generator.wants_time() + } + + /// Batches and individual input samples produce identical results. The speculative end + /// dab / smoothing tail is displayed but never fed into the next accepted input. + pub fn push(&mut self, points: &[StrokePoint]) -> Result { + check_coords(points, CMD)?; + if self.points.len().saturating_add(points.len()) > 65_536 { + return Err(bad(CMD, "stroke exceeds 65536 recorded input samples")); + } + let mut last = self.core.last; + for point in points { + if last.is_some_and(|p| (point.x - p.x).hypot(point.y - p.y) > 2048.0) { + return Err(bad(CMD, "input segment exceeds 2048 pixels; supply interpolated input")); + } + last = Some(*point); + } + let cmyk = self.cmyk.clone(); + photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || { + let old_tail = self.tail; + let mut damage = Rect::EMPTY; + for point in points { + let (mut dabs, mut dual) = (Vec::new(), Vec::new()); + self.core.generator.push(&[*point], &mut dabs, &mut dual); + damage = damage.union(&self.core.apply(&dabs)?); + self.core.last = Some(*point); + } + let mut finished = self.core.clone(); + let (mut dabs, mut dual) = (Vec::new(), Vec::new()); + finished.generator.finish(&mut dabs, &mut dual); + self.tail = finished.apply(&dabs)?; + self.doc = finished.doc; + self.state = finished.state; + self.points.extend_from_slice(points); + Ok(damage.union(&old_tail).union(&self.tail)) + }) + } + + fn commit_params(&self) -> Result { + let mut params = self.core.params.clone(); + params["points"] = serde_json::to_value(&self.points).map_err(|e| bad(CMD, e.to_string()))?; + params["brush"] = serde_json::to_value(&self.core.ctx.brush).map_err(|e| bad(CMD, e.to_string()))?; + params["seed"] = json!(self.seed); + params["color"] = json!(self.core.ctx.brush.color); + if params.get("mixerState").is_none() { + params["mixerState"] = serde_json::to_value(&self.original_state).map_err(|e| bad(CMD, e.to_string()))?; + } + Ok(params) + } + + /// Stage the already rendered pixels for the ordinary journaled command. Refuse stale + /// document/tool state. The command consumes this single-use cache only for identical params. + pub fn prepare_commit(self, session: &mut Session) -> Result { + if !session.active().is_some_and(|st| Arc::ptr_eq(&st.doc, &self.original) && (self.core.layer.is_none() || st.active_layer == self.core.layer)) + || session.tools.mixer != self.original_state + { + return Err(bad(CMD, "document or loaded paint changed during the stroke")); + } + let params = self.commit_params()?; + session.prepared_mixer = Some(self); + Ok(params) + } +} + +pub(super) fn run(s: &mut Session, p: &Value) -> Result { + let cached = s.prepared_mixer.take().filter(|live| { + s.active().is_some_and(|st| Arc::ptr_eq(&st.doc, &live.original)) + && s.tools.mixer == live.original_state + && live.commit_params().is_ok_and(|params| params == *p) + }); + let live = match cached { + Some(live) => live, + None => LiveMixer::begin(s, p)?, + }; + let damage = live.bounds(); + s.edit("Mixer Brush", |doc, _| { + *doc = (*live.doc).clone(); + Ok(()) + })?; + let mut state = live.state; + if live.core.settings.clean_after_stroke { + state.clean(); + } + if live.core.settings.reload_after_stroke && state.reservoir.is_some() { + state.amount = 1.0; + } + s.tools.mixer = state; + Ok(damage_json(s, damage)) +} + +/// Sampling changes only session tool state, never document pixels or document Undo. +pub(super) fn sample(s: &mut Session, p: &Value) -> Result { + if p.get("points").and_then(Value::as_array).is_some_and(|v| v.len() != 1) { + return Err(bad("brush.mixer.sample", "exactly one sample point is required")); + } + let points = parse_points(p, "brush.mixer.sample")?; + if points.len() != 1 { + return Err(bad("brush.mixer.sample", "exactly one sample point is required")); + } + let mut brush = resolve_brush(s, p, "brush.mixer.sample")?; + if brush.size > 512.0 { + return Err(bad("brush.mixer.sample", "sampling size is limited to 512 pixels")); + } + brush.flow = 1.0; + let dabs = photocraft_paint::dabs(&Stroke { brush: brush.clone(), points }); + let dab = dabs.first().ok_or_else(|| bad("brush.mixer.sample", "brush has no contact"))?; + let ctx = BrushContext::new(&brush); + let rect = ctx.dab_rect(dab, false); + if u64::from(rect.width()).saturating_mul(u64::from(rect.height())) > 1_048_576 { + return Err(bad("brush.mixer.sample", "sampling footprint is too large")); + } + let fp = ctx.footprint(dab, 0); + let st = s.active().ok_or(EngineError::NoDocument)?; + let mut doc = crate::float_cmds::displayed(st, (0, 0)).unwrap_or_else(|| (*st.doc).clone()); + let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_id(s, p)?) }; + let cmyk = doc_cmyk_space(s); + let load = photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || -> Result> { + let surface = if flag(p, "sampleAllLayers", brush.mixer.sample_all_layers) && crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels { + let buf = photocraft_compose::render(&doc, rect); + let mut surface = Surface::new(PixelFormat::RGBA32F); + surface.write_region(rect, &buf.px.iter().flatten().copied().collect::>()); + surface + } else if crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels { + id.and_then(|id| doc.layer(id)) + .and_then(|layer| layer.surface()) + .cloned() + .ok_or_else(|| bad("brush.mixer.sample", "current layer has no raster pixels to sample"))? + } else { + crate::channel_cmds::target_surface(&mut doc, id, p)?.0.clone() + }; + let mut load = Vec::with_capacity(4096); + let (sin, cos) = dab.angle.sin_cos(); + for row in 0..64 { + for col in 0..64 { + let mut u = ((col as f32 + 0.5) / 32.0 - 1.0) * dab.radius; + let mut v = ((row as f32 + 0.5) / 32.0 - 1.0) * (dab.radius * dab.roundness).max(0.5); + if dab.flip_x { + u = -u; + } + if dab.flip_y { + v = -v; + } + let (mut dx, mut up) = (u * cos + v * sin, u * sin - v * cos); + if dab.proj_scale < 0.999 { + let (sin, cos) = dab.proj_angle.sin_cos(); + let along = (dx * cos + up * sin) * (dab.proj_scale.max(0.05) - 1.0); + dx += along * cos; + up += along * sin; + } + let x = (dab.center.x as f32 + dx).floor() as i32; + let y = (dab.center.y as f32 - up).floor() as i32; + let mut color = surface.rgba(x, y); + color[3] *= fp.at(x, y).clamp(0.0, 1.0); + load.push(color); + } + } + Ok(load) + })?; + let mut average = [0.0f32; 4]; + for c in &load { + for k in 0..3 { + average[k] += c[k] * c[3]; + } + average[3] += c[3]; + } + if average[3] > 0.0 { + for k in 0..3 { + average[k] /= average[3]; + } + } + average[3] = 1.0; + s.tools.mixer.load(average); + s.tools.mixer.spatial = Some(load); + s.tools.mixer.clean(); + Ok(json!({"width":64,"height":64,"color":average})) +} + +pub(super) fn load(s: &mut Session, p: &Value) -> Result { + let color = color(p.get("color"), s.tools.foreground); + if !valid_color(&color) { + return Err(bad("brush.mixer.load", "loaded color must be finite and magnitudes at most 1000000")); + } + s.tools.mixer.load(color); + Ok(json!({"loaded":true})) +} + +pub(super) fn clean(s: &mut Session, _: &Value) -> Result { + s.tools.mixer = MixerState::default(); + // Cleaning is an explicit empty-brush choice, so do not silently reload the next stroke. + s.tools.brush.mixer.reload_after_stroke = false; + Ok(json!({"loaded":false})) +} diff --git a/crates/engine/src/brush_cmds/mixer_live_tests.rs b/crates/engine/src/brush_cmds/mixer_live_tests.rs new file mode 100644 index 0000000000..b13dc6ed3e --- /dev/null +++ b/crates/engine/src/brush_cmds/mixer_live_tests.rs @@ -0,0 +1,226 @@ +use super::mixer_live::LiveMixer; +use super::*; +use photocraft_color::PixelFormat; + +fn fixture(fmt: PixelFormat) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width":192,"height":96,"background":"#808080"})).unwrap(); + s.execute("layer.new.layer", json!({"name":"Source"})).unwrap(); + s.edit("fixture", |doc, id| { + let (surface, _) = crate::channel_cmds::target_surface(doc, *id, &json!({}))?; + *surface = Surface::new(fmt); + surface.fill_rect(Rect::new(0, 0, 96, 96), &[1.0, 0.0, 0.0, 1.0]); + surface.fill_rect(Rect::new(96, 0, 192, 96), &[0.0, 1.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("layer.new.layer", json!({"name":"Paint"})).unwrap(); + s.edit("depth", |doc, id| { + *crate::channel_cmds::target_surface(doc, *id, &json!({}))?.0 = Surface::new(fmt); + Ok(()) + }) + .unwrap(); + s +} + +fn target(s: &Session) -> Surface { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().clone() +} + +#[test] +fn mixer_live_empty_layer_exact_commit_undo_source_preservation_and_chunking() { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut s = fixture(fmt); + assert!(s.tools.brush.mixer.sample_all_layers); + let before = s.active().unwrap().doc.clone(); + let params = json!({"points":[[40,48]],"seed":47,"size":24,"wet":100,"mix":100,"cleanAfterStroke":false}); + let mut live = LiveMixer::begin(&s, ¶ms).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + assert!(live.doc.layer(id).unwrap().surface().unwrap().rgba(40, 48)[3] > 0.0); + assert_eq!(target(&s).rgba(40, 48)[3], 0.0, "preview must not edit the session"); + let mut points = vec![StrokePoint::new(40.0, 48.0, 1.0)]; + for x in [56.0, 72.0, 88.0, 104.0, 120.0, 136.0, 152.0] { + let p = StrokePoint::new(x, 48.0, 1.0); + live.push(&[p]).unwrap(); + points.push(p); + } + let preview = live.doc.layer(id).unwrap().surface().unwrap().clone(); + let mut full = params.clone(); + full["points"] = json!(points); + s.execute("paint.mixerBrush", full).unwrap(); + assert_eq!(preview.read_region(before.bounds()), target(&s).read_region(before.bounds()), "live/replay tolerance is exactly zero"); + for old in &before.layers { + if old.id != id { + assert_eq!( + old.surface().unwrap().read_region(before.bounds()), + s.active().unwrap().doc.layer(old.id).unwrap().surface().unwrap().read_region(before.bounds()) + ); + } + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(target(&s).read_region(before.bounds()), before.layer(id).unwrap().surface().unwrap().read_region(before.bounds())); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(target(&s).read_region(before.bounds()), preview.read_region(before.bounds())); + } +} + +#[test] +fn mixer_live_current_layer_matches_reference_and_cancels() { + let mut s = fixture(PixelFormat::RGBA32F); + let p = json!({"points":[[30,30],[60,34],[100,60]],"seed":9,"size":16,"sampleAllLayers":false,"wet":65,"mix":70,"load":90}); + let mut expected = target(&s); + let brush = resolve_brush(&s, &p, "paint.mixerBrush").unwrap(); + let mut state = photocraft_paint::mixer::MixerState::default(); + state.load(s.tools.foreground); + photocraft_paint::mixer::apply_mixer_stroke( + &mut expected, + None, + &Stroke { brush, points: parse_points(&p, "paint.mixerBrush").unwrap() }, + &MixerSettings { wet: 0.65, mix: 0.70, load: 0.90, sample_all_layers: false, ..Default::default() }, + &mut state, + None, + false, + ); + let live = LiveMixer::begin(&s, &p).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let actual = live.doc.layer(id).unwrap().surface().unwrap().read_region(s.active().unwrap().doc.bounds()); + let expected = expected.read_region(s.active().unwrap().doc.bounds()); + let max = actual.iter().zip(expected).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(max <= 1e-6, "reference error {max}"); + drop(live); + assert_eq!(target(&s).rgba(30, 30)[3], 0.0); + s.execute("brush.mixer.clean", json!({})).unwrap(); + s.execute("paint.mixerBrush", json!({"points":[[30,30],[60,30]],"sampleAllLayers":false,"wet":0})).unwrap(); + assert_eq!(target(&s).rgba(30, 30)[3], 0.0, "clean brush must stay empty"); +} + +#[test] +fn mixer_sample_is_spatial_and_never_edits_document() { + let mut s = fixture(PixelFormat::RGBA8); + let before = s.active().unwrap().doc.clone(); + s.execute("brush.mixer.sample", json!({"points":[[96,48]],"size":40,"brush":{"hardness":1.0,"pressureSize":false}})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&before, &s.active().unwrap().doc)); + let load = s.tools.mixer.spatial.as_ref().unwrap(); + assert_eq!(load.len(), 4096); + assert!(load.iter().any(|p| p[0] > 0.9 && p[1] < 0.1 && p[3] > 0.9)); + assert!(load.iter().any(|p| p[1] > 0.9 && p[0] < 0.1 && p[3] > 0.9)); + s.execute("paint.mixerBrush", json!({"points":[[48,48]],"size":40,"wet":0,"mix":0,"brush":{"hardness":1.0,"pressureSize":false}})).unwrap(); + assert!(target(&s).rgba(40, 48)[0] > 0.9); + assert!(target(&s).rgba(56, 48)[1] > 0.9); + assert!(s.tools.mixer.spatial.is_some(), "sample persists across strokes"); +} + +#[test] +fn mixer_live_smoothing_tail_and_invalid_input() { + let mut s = fixture(PixelFormat::RGBA16); + let p = json!({"points":[[20,40]],"size":12,"seed":42,"brush":{"smoothing":{"amount":0.6,"catchUpOnEnd":true}}}); + let mut live = LiveMixer::begin(&s, &p).unwrap(); + live.push(&[StrokePoint::new(60.0, 40.0, 0.6)]).unwrap(); + live.push(&[StrokePoint::new(100.0, 48.0, 1.0)]).unwrap(); + let before = live.doc.clone(); + assert!(live.push(&[StrokePoint::new(f64::NAN, 0.0, 1.0)]).is_err()); + assert!(std::sync::Arc::ptr_eq(&before, &live.doc)); + let mut commit = p.clone(); + commit["points"] = json!([[20, 40, 1], [60, 40, 0.6], [100, 48, 1]]); + s.execute("paint.mixerBrush", commit).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + assert_eq!( + live.doc.layer(id).unwrap().surface().unwrap().read_region(s.active().unwrap().doc.bounds()), + target(&s).read_region(s.active().unwrap().doc.bounds()) + ); + assert!(s.execute("paint.mixerBrush", json!({"points":[[0,0]],"size":5000})).is_err()); + assert!(s.execute("brush.mixer.sample", json!({"points":[[0,0],[1,1]]})).is_err()); +} + +#[test] +fn mixer_sampling_obeys_masks_opacity_blend_and_current_layer_switch() { + let mut s = fixture(PixelFormat::RGBA8); + let source = s.active().unwrap().doc.layers.iter().find(|l| l.name == "Source").unwrap().id; + s.edit("source composition", |doc, _| { + let layer = doc.layer_mut(source).unwrap(); + layer.opacity = 0.4; + layer.blend = photocraft_color::BlendMode::Multiply; + layer.mask = Some(photocraft_doc::LayerMask::hide_all()); + Ok(()) + }) + .unwrap(); + let expected = photocraft_compose::render(&s.active().unwrap().doc, Rect::new(39, 39, 40, 40)).px[0]; + s.execute("brush.mixer.sample", json!({"points":[[40,40]],"size":8})).unwrap(); + let actual = s.tools.mixer.reservoir.unwrap(); + for c in 0..3 { + assert!((actual[c] - expected[c]).abs() < 1e-5); + } + s.execute("brush.mixer.clean", json!({})).unwrap(); + s.execute("paint.mixerBrush", json!({"points":[[40,40],[80,40]],"size":8,"wet":100,"mix":100,"sampleAllLayers":false})).unwrap(); + assert_eq!(target(&s).rgba(50, 40)[3], 0.0, "current empty layer provides no paint"); + s.execute("paint.mixerBrush", json!({"points":[[40,40],[80,40]],"size":8,"wet":100,"mix":100,"sampleAllLayers":true})).unwrap(); + assert!(target(&s).rgba(50, 40)[3] > 0.9, "all layers supplies the visible composite"); +} + +#[test] +fn mixer_overlapping_dabs_resample_the_evolving_composite() { + let mut s = fixture(PixelFormat::RGBA32F); + s.execute("tools.setColors", json!({"foreground":"#0000ff"})).unwrap(); + let params = json!({"points":[[50,48]],"size":20,"seed":1,"wet":100,"mix":50,"cleanAfterStroke":false}); + let mut live = LiveMixer::begin(&s, ¶ms).unwrap(); + live.push(&[StrokePoint::new(54.0, 48.0, 1.0), StrokePoint::new(50.0, 48.0, 1.0)]).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let pixel = live.doc.layer(id).unwrap().surface().unwrap().rgba(50, 48); + assert!(pixel[2] > 0.5, "deposited blue feeds back into later pickups: {pixel:?}"); +} + +#[test] +fn mixer_prepared_commit_uses_displayed_pixels_and_rejects_stale_documents() { + let mut s = fixture(PixelFormat::RGBA8); + let p = json!({"points":[[20,40]],"size":16,"target":"pixels","seed":55}); + let mut live = LiveMixer::begin(&s, &p).unwrap(); + live.push(&[StrokePoint::new(80.0, 40.0, 0.6)]).unwrap(); + let shown = live.doc.clone(); + let id = s.active().unwrap().active_layer.unwrap(); + let steps = s.active().unwrap().history.past_len(); + let commit = live.prepare_commit(&mut s).unwrap(); + assert!(s.prepared_mixer.is_some()); + s.execute("paint.mixerBrush", commit.clone()).unwrap(); + assert!(s.prepared_mixer.is_none()); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1); + assert_eq!(target(&s).read_region(shown.bounds()), shown.layer(id).unwrap().surface().unwrap().read_region(shown.bounds())); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("paint.mixerBrush", commit).unwrap(); + assert_eq!(target(&s).read_region(shown.bounds()), shown.layer(id).unwrap().surface().unwrap().read_region(shown.bounds())); + let stale = LiveMixer::begin(&s, &p).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + assert!(stale.prepare_commit(&mut s).is_err()); +} + +#[test] +fn mixer_load_commands_preserve_floating_selection_and_history() { + let mut s = fixture(PixelFormat::RGBA8); + s.execute("select.all", json!({})).unwrap(); + s.execute("select.float", json!({"dx":8,"dy":0,"copy":true})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let steps = s.active().unwrap().history.past_len(); + for (command, params) in [("brush.mixer.sample", json!({"points":[[96,48]],"size":24})), ("brush.mixer.load", json!({})), ("brush.mixer.clean", json!({}))] + { + s.execute(command, params).unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &s.active().unwrap().doc)); + assert_eq!(s.active().unwrap().history.past_len(), steps); + assert!(crate::float_cmds::floating(s.active().unwrap()).is_some()); + } +} + +#[test] +fn mixer_rejects_malformed_load_and_retains_or_reloads_paint() { + let mut s = fixture(PixelFormat::RGBA8); + let before = s.active().unwrap().doc.clone(); + for state in [json!({"spatial":[]}), json!({"spatial":[[1,0,0,1]]}), json!({"amount":"bad"}), json!({"reservoir":[1e300,0,0,1]})] { + assert!(s.execute("paint.mixerBrush", json!({"points":[[40,40]],"mixerState":state})).is_err()); + assert!(std::sync::Arc::ptr_eq(&before, &s.active().unwrap().doc)); + } + assert!(s.execute("brush.mixer.load", json!({"color":[1e300,0,0,1]})).is_err()); + s.execute("brush.mixer.load", json!({"color":"#ff0000"})).unwrap(); + s.execute("paint.mixerBrush", json!({"points":[[40,40],[80,40]],"size":10,"loadAfterStroke":false,"load":10})).unwrap(); + assert!(s.tools.mixer.amount > 0.0 && s.tools.mixer.amount < 1.0); + s.execute("paint.mixerBrush", json!({"points":[[40,40],[80,40]],"size":10,"loadAfterStroke":true,"load":10})).unwrap(); + assert_eq!(s.tools.mixer.amount, 1.0); +} diff --git a/crates/engine/src/float_cmds.rs b/crates/engine/src/float_cmds.rs index 892f754748..645d761fd5 100644 --- a/crates/engine/src/float_cmds.rs +++ b/crates/engine/src/float_cmds.rs @@ -205,7 +205,8 @@ pub fn drop_floating(s: &mut Session) -> Result { /// Commands that leave a floating piece floating: its own, and ones that don't touch documents. fn keeps_floating(id: &str) -> bool { - matches!(id, "select.float" | "select.drop") || ["view.", "window.", "help.", "prefs."].iter().any(|p| id.starts_with(p)) + matches!(id, "select.float" | "select.drop" | "brush.mixer.sample" | "brush.mixer.load" | "brush.mixer.clean") + || ["view.", "window.", "help.", "prefs."].iter().any(|p| id.starts_with(p)) } /// Before any command (`Session::dispatch`): Undo puts a floating piece back (and is used up: diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ca09663707..a802cf1607 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -325,6 +325,8 @@ pub struct Session { docs: Vec, active: Option, pub tools: ToolState, + /// One validated Mixer Brush result awaiting its normal command dispatch. Never persisted. + pub(crate) prepared_mixer: Option, /// Log of executed commands (for action recording and debugging). pub journal: Vec<(String, Value)>, /// Coalescing key of the command being executed. diff --git a/crates/paint/src/mixer.rs b/crates/paint/src/mixer.rs index afb8092cd2..4c35019cba 100644 --- a/crates/paint/src/mixer.rs +++ b/crates/paint/src/mixer.rs @@ -26,11 +26,13 @@ pub struct MixerSettings { pub flow: f32, /// Pick up colour from all visible layers instead of the target layer only. pub sample_all_layers: bool, + pub clean_after_stroke: bool, + pub reload_after_stroke: bool, } impl Default for MixerSettings { fn default() -> Self { - Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false } + Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: true, clean_after_stroke: true, reload_after_stroke: true } } } @@ -44,11 +46,14 @@ pub struct MixerState { pub amount: f32, /// Colour picked up from the canvas (straight RGBA), None = clean. pub pickup: Option<[f32; 4]>, + /// A bounded 64 x 64 spatial load in tip-local coordinates. Empty cells carry no paint. + pub spatial: Option>, } impl MixerState { /// Load the reservoir with a colour (full). pub fn load(&mut self, color: [f32; 4]) { + self.spatial = None; self.reservoir = Some(color); self.amount = 1.0; } @@ -58,6 +63,34 @@ impl MixerState { } } +/// Sample a spatial load at a dab pixel using the dab's rotation, aspect and flips. +/// Keeping the reservoir in tip coordinates lets it follow subsequent brush poses. +pub fn load_at(load: &[[f32; 4]], dab: &crate::Dab, x: f32, y: f32) -> [f32; 4] { + let mut dx = x - dab.center.x as f32; + let mut dy = y - dab.center.y as f32; + if dab.proj_scale < 0.999 { + let (sin, cos) = dab.proj_angle.sin_cos(); + let along = (dx * cos - dy * sin) * (1.0 / dab.proj_scale.max(0.05) - 1.0); + dx += along * cos; + dy -= along * sin; + } + let (sin, cos) = dab.angle.sin_cos(); + let mut u = (dx * cos - dy * sin) / dab.radius.max(0.01); + let mut v = (dx * sin + dy * cos) / (dab.radius * dab.roundness).max(0.5); + if dab.flip_x { + u = -u; + } + if dab.flip_y { + v = -v; + } + if !(-1.0..1.0).contains(&u) || !(-1.0..1.0).contains(&v) { + return [0.0; 4]; + } + let col = ((u + 1.0) * 32.0) as usize; + let row = ((v + 1.0) * 32.0) as usize; + load.get(row * 64 + col).copied().unwrap_or([0.0; 4]) +} + /// Paint a Mixer Brush stroke. `sample` is the surface colour is picked up from (the layer itself /// when None; a flattened composite for Sample All Layers). Returns the damaged rectangle. pub fn apply_mixer_stroke( @@ -70,6 +103,23 @@ pub fn apply_mixer_stroke( lock_transparency: bool, ) -> Rect { let fmt = target.format(); + let mut st = stroke.clone(); + st.brush.opacity = 1.0; + apply_dab_stroke(target, &st, selection, lock_transparency, 0, |work, fp| { + apply_mixer_dab(work, fp, fmt, sample, m, state, lock_transparency); + }) +} + +/// One incremental wet-paint dab, shared by recorded strokes and the live document transaction. +pub fn apply_mixer_dab( + work: &mut crate::retouch::Region, + fp: &crate::retouch::Footprint, + fmt: photocraft_color::PixelFormat, + sample: Option<&Surface>, + m: &MixerSettings, + state: &mut MixerState, + lock_transparency: bool, +) { let n = fmt.channels(); let a = alpha_index(&fmt); let nc = fmt.mode.color_channels(); @@ -84,70 +134,86 @@ pub fn apply_mixer_stroke( let mut amount = if res.is_some() { state.amount.clamp(0.0, 1.0) } else { 0.0 }; let mut pickup: Option> = state.pickup.map(native); let (wet, mix, flow, load) = (m.wet.clamp(0.0, 1.0), m.mix.clamp(0.0, 1.0), m.flow.clamp(0.0, 1.0), m.load.clamp(0.0, 1.0)); - let mut st = stroke.clone(); - st.brush.opacity = 1.0; let mut acc = vec![0.0f32; n]; let mut paint = vec![0.0f32; n]; - let dmg = apply_dab_stroke(target, &st, selection, lock_transparency, 0, |work, fp| { - // Transfer › Wetness / Mix Jitter scale the settings per dab. - let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0)); - // Sample the canvas under the dab. - acc.iter_mut().for_each(|v| *v = 0.0); - let (mut wsum, mut asum) = (0.0f32, 0.0f32); - let r = fp.rect.intersect(&work.rect); + // Transfer › Wetness / Mix Jitter scale the settings per dab. + let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0)); + // Sample the canvas under the dab. + acc.iter_mut().for_each(|v| *v = 0.0); + let (mut wsum, mut asum) = (0.0f32, 0.0f32); + let r = fp.rect.intersect(&work.rect); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let w = fp.at(x, y); + if w <= 0.0 { + continue; + } + let px: Vec = match sample { + Some(s) => { + let v = s.pixel(x, y); + if s.format() == fmt { v } else { from_rgba(&fmt, s.rgba(x, y)) } + } + None => work.px(x, y).to_vec(), + }; + let al = a.map_or(1.0, |ai| px[ai]); + for c in 0..nc { + acc[c] += px[c] * w * al; + } + wsum += w; + asum += w * al; + } + } + if asum > 1e-6 && wet > 0.0 { + let ca = asum / wsum.max(1e-6); + let canvas: Vec = (0..n).map(|c| if c < nc { acc[c] / asum } else { 1.0 }).collect(); + pickup = Some(match pickup.take() { + None => canvas, + Some(p) => p.iter().zip(&canvas).map(|(p, c)| p + (c - p) * PICKUP_RATE * wet * ca).collect(), + }); + } + let wr = if res.is_some() { amount * (1.0 - mix * wet) } else { 0.0 }; + let wp = if pickup.is_some() { mix * wet } else { 0.0 }; + let total = wr + wp; + if total > 1e-6 { + for c in 0..n { + let rv = res.as_ref().map_or(0.0, |v| v[c]); + let pv = pickup.as_ref().map_or(0.0, |v| v[c]); + paint[c] = (rv * wr + pv * wp) / total; + } + let dep = total.min(1.0) * flow; for y in r.y0..r.y1 { for x in r.x0..r.x1 { - let w = fp.at(x, y); - if w <= 0.0 { - continue; - } - let px: Vec = match sample { - Some(s) => { - let v = s.pixel(x, y); - if s.format() == fmt { v } else { from_rgba(&fmt, s.rgba(x, y)) } - } - None => work.px(x, y).to_vec(), - }; - let al = a.map_or(1.0, |ai| px[ai]); - for c in 0..nc { - acc[c] += px[c] * w * al; - } - wsum += w; - asum += w * al; - } - } - if asum > 1e-6 && wet > 0.0 { - let ca = asum / wsum.max(1e-6); - let canvas: Vec = (0..n).map(|c| if c < nc { acc[c] / asum } else { 1.0 }).collect(); - pickup = Some(match pickup.take() { - None => canvas, - Some(p) => p.iter().zip(&canvas).map(|(p, c)| p + (c - p) * PICKUP_RATE * wet * ca).collect(), - }); - } - let wr = if res.is_some() { amount * (1.0 - mix * wet) } else { 0.0 }; - let wp = if pickup.is_some() { mix * wet } else { 0.0 }; - let total = wr + wp; - if total > 1e-6 { - for c in 0..n { - let rv = res.as_ref().map_or(0.0, |v| v[c]); - let pv = pickup.as_ref().map_or(0.0, |v| v[c]); - paint[c] = (rv * wr + pv * wp) / total; - } - let dep = total.min(1.0) * flow; - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - let k = fp.at(x, y) * dep; - if k > 0.0 { + let k = fp.at(x, y) * dep; + if k > 0.0 { + if let Some(load) = &state.spatial { + let loaded = load_at(load, &fp.dab, x as f32 + 0.5, y as f32 + 0.5); + let local = from_rgba(&fmt, loaded); + let local_wr = wr * loaded[3]; + let local_total = local_wr + wp; + if local_total > 1e-6 { + let mut mixed = paint.clone(); + for (c, value) in mixed.iter_mut().enumerate() { + *value = (local.get(c).copied().unwrap_or(0.0) * local_wr + + pickup.as_ref().and_then(|p| p.get(c)).copied().unwrap_or(0.0) * wp) + / local_total; + } + if let Some(ai) = a + && let Some(alpha) = mixed.get_mut(ai) + { + *alpha = 1.0; + } + over_native(&fmt, work.px_mut(x, y), &mixed, fp.at(x, y) * local_total.min(1.0) * flow, lock_transparency); + } + } else { over_native(&fmt, work.px_mut(x, y), &paint, k, lock_transparency); } } } } - amount *= 1.0 - 0.08 * (1.0 - load); - }); + } + amount *= 1.0 - 0.08 * (1.0 - load); state.amount = amount; state.pickup = pickup.map(|p| to_rgba(&fmt, &p)); - dmg } #[cfg(test)] diff --git a/crates/ui-egui/src/alt_click_tests.rs b/crates/ui-egui/src/alt_click_tests.rs index d4a20b04ae..b027c3bf7f 100644 --- a/crates/ui-egui/src/alt_click_tests.rs +++ b/crates/ui-egui/src/alt_click_tests.rs @@ -111,6 +111,36 @@ fn alt_click_with_the_brush_samples_the_foreground() { assert_eq!(h.state().session.tools.foreground, [0.0, 1.0, 0.0, 1.0], "⌥-click sampled green"); } +#[test] +fn mixer_alt_click_samples_the_tip_through_zoomed_rotated_canvas_input() { + for (zoom, rotation) in [(0.5, 0.0), (1.0, 0.0), (2.0, 30.0)] { + let mut app = two_colours(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("tools.setBrush", json!({"brush":{"size":24,"roundness":0.4,"hardness":1.0,"pressureSize":false,"mixer":{"wet":0.0,"mix":0.0}}})).unwrap(); + let mut h = harness(app); + h.state_mut().ui.tool = Tool::MixerBrush; + h.state_mut().ui.views[0].zoom = zoom; + h.state_mut().ui.views[0].rotation = rotation; + h.run_steps(2); + let before = h.state().session.active().unwrap().doc.clone(); + let steps = h.state().session.active().unwrap().history.past_len(); + click(&mut h, 100.0, 50.0, Modifiers::ALT); + let state = &h.state().session.tools.mixer; + let load = state.spatial.as_ref().expect("Alt loads a spatial brush tip"); + assert!(load.iter().any(|c| c[0] > 0.9 && c[1] < 0.1 && c[3] > 0.9)); + assert!(load.iter().any(|c| c[1] > 0.9 && c[0] < 0.1 && c[3] > 0.9)); + assert!(std::sync::Arc::ptr_eq(&before, &h.state().session.active().unwrap().doc)); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps); + click(&mut h, 40.0, 50.0, Modifiers::NONE); + let st = h.state().session.active().unwrap(); + let surface = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + assert!(surface.rgba(34, 50)[0] > 0.9 && surface.rgba(46, 50)[1] > 0.9, "loaded halves follow document coordinates at {zoom}x/{rotation}°"); + assert_eq!(st.history.past_len(), steps + 1); + assert!(crate::alt_grab::alt_click_tool(Tool::MixerBrush, Modifiers::ALT)); + assert!(!crate::alt_grab::alt_click_tool(Tool::MixerBrush, Modifiers::ALT | Modifiers::CTRL)); + } +} + #[test] fn alt_click_sets_the_clone_source_and_a_drag_paints_it() { let mut h = harness(two_colours()); diff --git a/crates/ui-egui/src/alt_grab.rs b/crates/ui-egui/src/alt_grab.rs index 0ba09bad7a..bd1efea422 100644 --- a/crates/ui-egui/src/alt_grab.rs +++ b/crates/ui-egui/src/alt_grab.rs @@ -29,7 +29,7 @@ struct Watch { /// Tools whose ⌥-click does something: the ⌥ Eyedropper and the clone source point. pub(crate) fn alt_click_tool(tool: Tool, mods: egui::Modifiers) -> bool { - crate::canvas::alt_samples(tool, mods) || (mods.alt && !mods.ctrl && matches!(tool, Tool::CloneStamp | Tool::Healing)) + crate::canvas::alt_samples(tool, mods) || (mods.alt && !mods.ctrl && matches!(tool, Tool::MixerBrush | Tool::CloneStamp | Tool::Healing)) } /// The status-bar message for an ⌥-click the desktop took. diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index c61b48c1be..c599a5a093 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -97,7 +97,7 @@ fn edit_section(i: usize, b: &mut BrushSettings) { b.transfer.flow = dynamic(0.25, Control::Fade, 0.3); b.transfer.wetness = dynamic(0.35, Control::PenPressure, 0.1); b.transfer.mix = dynamic(0.45, Control::Fade, 0.2); - b.mixer = paint::MixerSettings { wet: 0.9, load: 0.15, mix: 0.7, flow: 0.8, sample_all_layers: true }; + b.mixer = paint::MixerSettings { wet: 0.9, load: 0.15, mix: 0.7, flow: 0.8, sample_all_layers: true, ..Default::default() }; } 7 => { let p = &mut b.pose; @@ -391,11 +391,11 @@ fn mixer_brush_options_update_the_persisted_mixer_settings() { h.drop_at(at); h.run_steps(2); - assert!(h.state().session.tools.brush.mixer.sample_all_layers); + assert!(!h.state().session.tools.brush.mixer.sample_all_layers); assert_eq!(h.state().session.journal.len(), initial + 1); let (id, params) = h.state().session.journal.last().unwrap(); assert_eq!(id, "tools.setBrush"); - assert_eq!(params["brush"]["mixer"]["sampleAllLayers"], true); + assert_eq!(params["brush"]["mixer"]["sampleAllLayers"], false); } #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a04e299f19..55ec7c4397 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -353,6 +353,7 @@ enum EngineStroke { Brush(Box), Retouch(photocraft_engine::retouch_cmds::LiveRetouch), Dab(photocraft_engine::retouch_cmds::LiveDab), + Mixer(Box), } impl EngineStroke { @@ -361,6 +362,7 @@ impl EngineStroke { fn wants_time(&self) -> bool { match self { EngineStroke::Brush(l) => l.wants_time(), + EngineStroke::Mixer(l) => l.wants_time(), EngineStroke::Retouch(_) | EngineStroke::Dab(_) => false, } } @@ -370,6 +372,7 @@ impl EngineStroke { Self::Brush(s) => &s.doc, Self::Retouch(s) => &s.doc, Self::Dab(s) => &s.doc, + Self::Mixer(s) => &s.doc, } } fn bounds(&self) -> DRect { @@ -377,6 +380,7 @@ impl EngineStroke { Self::Brush(s) => s.bounds(), Self::Retouch(s) => s.bounds(), Self::Dab(s) => s.bounds(), + Self::Mixer(s) => s.bounds(), } } fn push(&mut self, pts: &[photocraft_engine::paint::StrokePoint]) -> photocraft_engine::Result { @@ -384,12 +388,14 @@ impl EngineStroke { Self::Brush(s) => s.push(pts), Self::Retouch(s) => s.push(pts), Self::Dab(s) => s.push(pts), + Self::Mixer(s) => s.push(pts), } } /// The jitter seed the commit must reuse (the Clone Stamp's comes from the session brush). fn seed(&self) -> Option { match self { Self::Brush(s) => Some(s.seed), + Self::Mixer(s) => Some(s.seed), Self::Retouch(_) => None, Self::Dab(_) => None, } @@ -444,8 +450,10 @@ fn stroke_params(app: &PhotocraftApp, tool: Tool, erase: bool, points: &[Vec bool { - matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser | Tool::Blur | Tool::Sharpen | Tool::Smudge | Tool::Dodge | Tool::Burn | Tool::Sponge) - || live_retouch_command(tool).is_some() + matches!( + tool, + Tool::Brush | Tool::Pencil | Tool::MixerBrush | Tool::Eraser | Tool::Blur | Tool::Sharpen | Tool::Smudge | Tool::Dodge | Tool::Burn | Tool::Sponge + ) || live_retouch_command(tool).is_some() } /// The retouching tools drawn live (`LiveRetouch`), by the command their live stroke previews. @@ -561,7 +569,10 @@ fn begin_live_stroke(app: &PhotocraftApp) -> Option { static STROKES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let st = app.session.active()?; let d = app.drag.as_ref()?; - let stroke = if let Some((cmd, mut p)) = crate::retouch_ui::dab_params(app, d.tool) { + let stroke = if d.tool == Tool::MixerBrush { + let p = stroke_params(app, d.tool, false, &app.stylus.stroke_points(&d.points)); + EngineStroke::Mixer(Box::new(photocraft_engine::brush_cmds::mixer_live::LiveMixer::begin(&app.session, &p).ok()?)) + } else if let Some((cmd, mut p)) = crate::retouch_ui::dab_params(app, d.tool) { // The params `retouch_ui::finish_stroke` commits; Sample All Layers isn't previewed. p["points"] = json!(d.points); p["target"] = paint_target(app); @@ -583,6 +594,20 @@ fn begin_live_stroke(app: &PhotocraftApp) -> Option { /// Render the drag points the live stroke hasn't seen yet, with the pen pressure, tilt and /// rotation the commit's `paint.stroke` gets for them. +pub(crate) fn cancel_mixer_on_context_change(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + if !app.drag.as_ref().is_some_and(|drag| drag.tool == Tool::MixerBrush) { + return false; + } + let changed = app.ui.tool != Tool::MixerBrush + || app.live_stroke.as_ref().is_some_and(|live| !app.session.active().is_some_and(|st| st.doc.id == live.doc && st.revision == live.revision)); + if changed || ctx.input(|input| !input.focused) || ctx.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + app.drag = None; + app.live_stroke = None; + return true; + } + false +} + fn feed_live_stroke(app: &mut PhotocraftApp) { // A batched move replays several samples in one frame and calls this once at the end, so the // per-sample calls from `tool_event` are skipped (see `canvas_view`). @@ -609,7 +634,15 @@ fn feed_live_stroke(app: &mut PhotocraftApp) { l.fed = d.points.len(); match l.stroke.push(&pts) { Ok(r) => l.damage.push(r), - Err(_) => app.live_stroke = None, + Err(e) => { + let mixer = d.tool == Tool::MixerBrush; + app.live_stroke = None; + if mixer { + app.drag = None; + app.ui.status = e.to_string(); + app.ui.status_error = true; + } + } } } @@ -621,9 +654,16 @@ fn tap_at_peak_pressure(app: &mut PhotocraftApp) { return; } let peak = f64::from(app.stylus.tap_pressure()); - if let Some(p) = app.drag.as_mut().filter(|d| d.points.len() == 1).and_then(|d| d.points.first_mut()) { + let mut refresh_mixer = false; + if let Some(d) = app.drag.as_mut().filter(|d| d.points.len() == 1) + && let Some(p) = d.points.first_mut() + { + refresh_mixer = d.tool == Tool::MixerBrush && peak > p[2]; p[2] = p[2].max(peak); } + if refresh_mixer { + app.live_stroke = begin_live_stroke(app); + } } /// While a stroke is held still, its point repeats this often (in ms) so time-based brush @@ -3959,6 +3999,17 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) return; } // View › Snap / Snap To and smart guides (snap_ui.rs). + if app.ui.tool == Tool::MixerBrush && mods.alt && !mods.ctrl && app.drag.is_none() { + if let ToolEvent::Down { x, y, pressure } = ev { + let pen = app.stylus.sample().unwrap_or_default(); + let p = json!({"points":[[x,y,pressure,pen.tilt_x,pen.tilt_y,pen.rotation]],"target":paint_target(app)}); + if let Err(e) = app.run("brush.mixer.sample", p) { + app.ui.status = e; + app.ui.status_error = true; + } + } + return; + } let raw = ev; // A press anywhere but on the floating piece (or with ⇧ / ⌥, to draw) drops it first; the // Move tool (⌘ with a painting tool too, `event_tool`) drags it from anywhere. @@ -4231,6 +4282,18 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) app.stylus.record_point(); } app.live_stroke = if strokes_live(tool) { begin_live_stroke(app) } else { None }; + if tool == Tool::MixerBrush && app.live_stroke.is_none() { + // A refused mixer stroke must never fall back to a stand-in trail and paint + // unexpectedly on release. Surface the engine's concrete error immediately. + if let Some(d) = app.drag.as_ref() { + let p = stroke_params(app, tool, false, &app.stylus.stroke_points(&d.points)); + if let Err(e) = photocraft_engine::brush_cmds::mixer_live::LiveMixer::begin(&app.session, &p) { + app.ui.status = e.to_string(); + app.ui.status_error = true; + } + } + app.drag = None; + } } ToolEvent::Move { x, y, pressure } => { // Polygonal Lasso: ⌥ held while the button is down draws freehand into the polygon; @@ -4456,6 +4519,36 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { { app.last_stroke_end = Some((st.doc.id, [end[0], end[1]])); } + if d.tool == Tool::MixerBrush { + // The release can carry a new endpoint, including moves deferred in this frame. + // Feed those samples before offering the already-rendered document for commit. + app.drag = Some(d); + let deferred = std::mem::replace(&mut app.defer_live_stroke, false); + feed_live_stroke(app); + app.defer_live_stroke = deferred; + let Some(d) = app.drag.take() else { return }; + let live = app.live_stroke.take(); + if let Some(l) = live { + let (doc, key, steps) = (l.doc, l.key, l.damage.len() as u64); + if let EngineStroke::Mixer(mixer) = l.stroke { + match mixer.prepare_commit(&mut app.session) { + Ok(p) => { + if app.run("paint.mixerBrush", p).is_ok() { + shown_as_document(app, doc, |k| k == 0 || (key..=key + steps).contains(&k)); + } + } + Err(e) => { + app.ui.status = e.to_string(); + app.ui.status_error = true; + } + } + } + } else { + let p = stroke_params(app, d.tool, false, &app.stylus.stroke_points(&d.points)); + let _ = app.run("paint.mixerBrush", p); + } + return; + } // A live Clone Stamp preview ends here; the commit below replaces it. if live_retouch_command(d.tool).is_some() || crate::retouch_ui::dab_params(app, d.tool).is_some() { app.live_stroke = None; @@ -5591,6 +5684,104 @@ mod tests { } } + #[test] + fn mixer_drag_shows_actual_mixed_paint_before_release_and_alt_never_paints() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width":120,"height":60,"background":"#00ff00"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("tools.setColors", json!({"foreground":"#ff0000"})).unwrap(); + app.run("tools.setBrush", json!({"brush":{"size":16,"mixer":{"wet":1.0,"mix":0.7},"smoothing":{"amount":0.0}}})).unwrap(); + app.ui.tool = Tool::MixerBrush; + let before = app.session.active().unwrap().doc.clone(); + let count = app.session.active().unwrap().history.past_len(); + let alt = egui::Modifiers { alt: true, ..Default::default() }; + tool_event(&mut app, ToolEvent::Down { x: 30.0, y: 30.0, pressure: 1.0 }, alt); + tool_event(&mut app, ToolEvent::Move { x: 35.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 35.0, y: 30.0 }, egui::Modifiers::NONE); + assert!(app.drag.is_none()); + assert!(app.session.tools.mixer.spatial.is_some()); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + app.run("brush.mixer.load", json!({})).unwrap(); + tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 70.0, y: 30.0, pressure: 0.6 }, egui::Modifiers::NONE); + assert!(app.live_stroke.is_some()); + let shown = display_doc(&mut app, 0).0; + let id = app.session.active().unwrap().active_layer.unwrap(); + let pixel = shown.layer(id).unwrap().surface().unwrap().rgba(45, 30); + assert!(pixel[0] > 0.0 && pixel[1] > 0.0 && pixel[3] > 0.0, "actual red/green mixture {pixel:?}"); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + assert_eq!(app.session.active().unwrap().history.past_len(), count); + tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 30.0 }, egui::Modifiers::NONE); + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), count + 1); + assert_eq!( + shown.layer(id).unwrap().surface().unwrap().read_region(before.bounds()), + st.doc.layer(id).unwrap().surface().unwrap().read_region(before.bounds()) + ); + app.session.undo(); + assert_eq!(app.session.active().unwrap().doc.layer(id).unwrap().surface().unwrap().rgba(45, 30)[3], 0.0); + } + + #[test] + fn mixer_release_includes_a_new_endpoint_and_deferred_frame_samples() { + for deferred in [false, true] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width":120,"height":60,"background":"#00ff00"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("tools.setBrush", json!({"size":12,"pressureSize":false})).unwrap(); + app.ui.tool = Tool::MixerBrush; + let before = app.session.active().unwrap().doc.clone(); + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 30.0, pressure: 1.0 }, m); + app.defer_live_stroke = deferred; + tool_event(&mut app, ToolEvent::Move { x: 50.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Up { x: 95.0, y: 30.0 }, m); + let doc = app.session.active().unwrap().doc.clone(); + let id = app.session.active().unwrap().active_layer.unwrap(); + assert!(doc.layer(id).unwrap().surface().unwrap().rgba(92, 30)[3] > 0.0); + let params = app.session.journal.iter().rev().find(|(name, _)| name == "paint.mixerBrush").unwrap().1.clone(); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(*app.session.active().unwrap().doc, *before); + app.run("paint.mixerBrush", params).unwrap(); + assert_eq!(*app.session.active().unwrap().doc, *doc); + } + } + + #[test] + fn mixer_escape_focus_loss_and_tool_switch_cancel_without_an_edit() { + for mode in 0..3 { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width":120,"height":60,"background":"#00ff00"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.ui.tool = Tool::MixerBrush; + let before = app.session.active().unwrap().doc.clone(); + let steps = app.session.active().unwrap().history.past_len(); + tool_event(&mut app, ToolEvent::Down { x: 30.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); + assert!(app.live_stroke.is_some()); + let ctx = egui::Context::default(); + let mut raw = egui::RawInput { focused: mode != 1, ..Default::default() }; + if mode == 0 { + raw.events.push(egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }); + } + if mode == 2 { + app.ui.tool = Tool::Brush; + } + ctx.begin_pass(raw); + assert!(cancel_mixer_on_context_change(&mut app, &ctx)); + let _ = ctx.end_pass(); + tool_event(&mut app, ToolEvent::Up { x: 30.0, y: 30.0 }, egui::Modifiers::NONE); + assert!(app.drag.is_none() && app.live_stroke.is_none()); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + } + } + #[test] fn brush_drag_shows_the_real_stroke_and_commits_it() { // The drag used to draw a hard, flat stand-in and only showed the soft brush on release. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 444036c57f..b3ba2472b2 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -973,6 +973,32 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } widgets::vline(ui, 22.0); widgets::checkbox(ui, &mut b.mixer.sample_all_layers, tl!("Sample All Layers")); + ui.menu_button(tl!("Paint Load"), |ui| { + ui.label(tl!("Alt-click the canvas to load the brush tip")); + if ui.button(tl!("Load Brush")).clicked() { let _ = app.run("brush.mixer.load", json!({})); } + if ui.button(tl!("Clean Brush")).clicked() { + let _ = app.run("brush.mixer.clean", json!({})); + b.mixer.reload_after_stroke = false; + } + widgets::checkbox(ui, &mut b.mixer.clean_after_stroke, tl!("Clean pickup after stroke")); + widgets::checkbox(ui, &mut b.mixer.reload_after_stroke, tl!("Reload after stroke")); + let (rect, _) = ui.allocate_exact_size(egui::vec2(64.0, 64.0), egui::Sense::hover()); + ui.painter().rect_filled(rect, 0.0, t.canvas); + let state = &app.session.tools.mixer; + let mut buffer = photocraft_compose::Buffer::transparent(photocraft_geom::Rect::new(0, 0, 32, 32)); + for (i, pixel) in buffer.px.iter_mut().enumerate() { + *pixel = state.spatial.as_ref().and_then(|v| v.get((i / 32) * 128 + (i % 32) * 2)).copied() + .or(state.reservoir).unwrap_or([0.0; 4]); + pixel[3] *= state.amount; + } + let display = app.session.active().and_then(|st| app.session.color.canvas_display_for(&st.doc, app.session.color.main_display).ok()); + let image = display.map_or_else(|| buffer.to_rgba8(), |display| display.to_rgba8(&buffer)); + for y in 0..32 { for x in 0..32 { + let c = image.pixels.get((y * 32 + x) * 4..(y * 32 + x + 1) * 4).unwrap_or(&[0; 4]); + let color = Color32::from_rgba_unmultiplied(c[0],c[1],c[2],c[3]); + ui.painter().rect_filled(egui::Rect::from_min_size(rect.min + egui::vec2(x as f32 * 2.0,y as f32 * 2.0),egui::vec2(2.0,2.0)),0.0,color); + }} + }); } Tool::RectMarquee | Tool::EllipseMarquee | Tool::SingleRowMarquee | Tool::SingleColumnMarquee if t.pro => { ui.spacing_mut().item_spacing.x = 2.0; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 6584dfa042..ec88de0db1 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -276,6 +276,9 @@ pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInpu } pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { + if crate::canvas::cancel_mixer_on_context_change(app, ctx) { + return; + } // However the tool was picked (toolbar, flyout, shortcut, automation), its group's key brings // it back later (#2608). app.ui.remember_group_tool(); diff --git a/docs/mixer-live.md b/docs/mixer-live.md new file mode 100644 index 0000000000..46dd923b68 --- /dev/null +++ b/docs/mixer-live.md @@ -0,0 +1,147 @@ +# Incremental Mixer Brush + +This change is the foundation of the requested Mixer Brush 2.0 work. It does not +implement the floating palette, harmony wheel, physical brush rack or bristle workshop. +Those must follow qualification of the painting and sampling foundation. These +original studio features do not change Photoshop parity counts. + +## Traced cause + +Baseline: upstream main `29f1ff9e90f2581ea2964e3276d85c71ab7e79a0`. +`ui-egui/src/canvas.rs::strokes_live` excludes Mixer Brush. `retouch_ui::finish_stroke` +dispatches `paint.mixerBrush` on pointer release. The engine's `mixer_brush` builds +all dabs and calls `apply_mixer_stroke` inside one document edit. The translucent +trail is therefore not the mixed result. This is deferred tool execution, not an +observed GPU invalidation failure. + +The old Sample All Layers path also rendered a single frozen composite spanning +the complete stroke. Later dabs could not sample the paint they just deposited. + +## Implemented path + +`brush_cmds::mixer_live::LiveMixer` uses the existing `DabGenerator`, `BrushContext`, +copy-on-write document/surfaces and native-channel Mixer Brush algorithm. Its float +working tiles avoid quantizing paint after every dab. The canvas displays that real +document copy and uses the existing damage rectangles and texture updates. + +An uncommitted smoothing/end-point tail is calculated on a copy of the incremental +state. It is displayed immediately and never contaminates the next input sample. +Dropping the live transaction cancels painting. Failed live strokes cancel rather +than reverting to a placeholder trail. The recorded command uses the same path and +creates one Undo edit. Release stages the already rendered document for the normal +command dispatcher, guarded by the original document and loaded-paint identity and +exact captured parameters. That one-use cache avoids repainting on release. Recorded +commands include the brush, seed, input samples and initial reservoir for deterministic +replay through the same incremental algorithm. Escape, focus loss, tool changes and +stale document context cancel the UI transaction. + +## Sampling semantics + +* Sample All Layers defaults on for new Mixer settings. Existing explicit false + preferences remain false. +* With it on, each dab reads the CPU compositor's visible document immediately + before depositing paint, including the active layer once. This includes visible + layers above the target as well as below it. Masks, adjustments, transforms, + opacity and blend modes follow the CPU compositor. Only the target surface changes. +* With it off, dabs read the evolving target working pixels. An empty target supplies + no pickup color. Masks/channels always use current-target semantics. +* Selection coverage is applied when the float working result is written to the + preview target. The next all-layer sample sees that selection-constrained result. +* Alt/Option + primary press loads a 64 x 64 spatial tip reservoir in document + coordinates. It does not create a document edit, rasterize a layer, or start a + paint drag. Coverage, tip shape, rotation, flips, aspect and projection are sampled. + The normalized spatial load follows subsequent dab poses. Resolution is bounded; + subpixel gaps smaller than a reservoir cell are not losslessly represented. +* Load Brush replaces the spatial load with foreground paint. Clean Brush empties + it and disables automatic reload. The pickup-clean and reload settings are part + of the existing serialized brush settings. The loaded reservoir persists during + the application session, including tool switches; restart persistence is not added. +* Native pixel conversion enters the document's CMYK profile scope, as the existing + brush engine does. The loaded-tip preview uses the document-to-monitor canvas + transform. Cross-document reservoir color-profile conversion is not implemented; + reload/sample in the destination document before painting between profiles. + +Commands: `brush.mixer.sample`, `brush.mixer.load`, `brush.mixer.clean`, +and the existing `paint.mixerBrush`. UI text uses the existing localization system; +new translations are not supplied for every catalog yet. + +## Resource envelope and limitations + +Live mixer size is limited to 512 pixels; dense footprints to 1,048,576 pixels; +one input segment to 2,048 pixels; one stroke to 250,000 dabs and 256 working tiles +(256 x 256 pixels each). Requests beyond these limits return errors. A spatial load +uses 64 KiB of float color data. Document snapshots share untouched tiles. These +limits are explicit initial engineering bounds, not hardware qualification. + +No fluid solver or grouped-bristle deformation model is introduced. Existing brush +tip/dynamics support is reused. Windows pen tilt/rotation remain limited by the +existing winit input path. Native Linux Wayland, X11/Wacom, macOS and WebAssembly +hardware interaction must be tested on those systems; simulated pen events do not +establish hardware qualification. + +## Verification + +`brush_cmds/mixer_live_tests.rs` covers live output, exact commit equivalence at +8/16/32-bit depth, reference-algorithm comparison, cancellation, Undo/Redo, spatial +loading, composite sampling, current-layer behavior, overlap feedback, floating +selection preservation, malformed reservoirs, depletion and reload. +`canvas.rs` covers the pointer-down/move/release and Alt-sampling routes. The existing +Mixer options test verifies the new default and persistence through `tools.setBrush`. + +`cargo run --release -p photocraft-engine --example mixer_probe` measures CPU engine +begin/move/prepared-commit costs on a 24 MP three-layer document. It is not an OS pointer-to- +display latency test. Run it under a recorded machine/build configuration and keep +its actual JSON results with the review evidence. + +## Upstream compatibility + +No PDF Binder #2011 or PDF integration #2751 code is modified. This branch starts +from upstream main, not the PDF branch. Relevant open work inspected on 2026-10-11: +#2131 (precomputed stroke commit), #3098/#2885/#2379 (brush performance), #2902 +(GPU tiles), #1911 (Windows pen input), #2516 (tip outline/persistence), #3039 +(Stroke Path), #3111 (mask locks). No upstream merge, force push or deployment is +performed. These PRs can change shared canvas/brush code and must be checked again +before integration. The private sibling craftrules checkout was not available. + +## Initial measured envelope (2026-10-11) + +Windows x64, Intel Core i7-9700K (8 cores), rustc 1.97.1, release with LTO disabled +and 16 codegen units. Synthetic 6000 x 4000 RGB8 document, three layers, 60 input +moves of roughly 10 pixels, spacing 25%, round brush, no simulated bristles. +Initial CPU-only results (milliseconds): + +| Brush | Begin | Move median | Move p95 | Move max | Prepared commit | Historical frozen-composite full stroke | +|---|---:|---:|---:|---:|---:|---:| +| 16 px | 1.83 | 0.43 | 1.43 | 2.85 | 0.58 | 12.00 | +| 64 px | 2.73 | 1.36 | 2.37 | 2.62 | 0.64 | 39.22 | +| 256 px | 24.88 | 0.003 | 24.70 | 24.97 | 0.95 | 164.46 | + +At 256 px, many 10 px moves emit no dab at 25% spacing, explaining the low median; +the p95/max matter for interaction. The baseline is the historical algorithm's +whole-stroke cost, not baseline frame latency. New live work is distributed over +input updates, so the commit column is not a claim of total algorithm speedup. +The 256 px p95 already exceeds a 16.67 ms / 60 Hz frame budget before GPU presentation. +These measurements do not qualify pen hardware or establish a worst-case memory bound. +The realtime mixer remains PARTIAL as a release qualification, despite the tested +functional slice. The expanded Creative Painting Studio task continues with dense +2K/4K/8K benchmarks, mixer optimization, then a dynamic stamp editor and procedural +tools. Palette, rack, AI guidance/result/comparison and grouped-bristle capabilities +are NOT IMPLEMENTED in this foundation commit. Expensive bristle simulation remains +gated on measured fast-mode performance. Larger brush optimization, full hardware +input qualification, reservoir profile tagging and restart persistence remain open. + +Native qualification: 1,207 engine, 84 paint and 1,828 UI unit tests passed, together +with the engine integration tests, command panic hunt, layering, Clippy and WASM +checks. The broad native run stopped at a concurrent GPU rotation-test access +violation on WARP, matching upstream #3212; serial isolation is recorded separately. +`xtask perf --quick` completed but reported over-budget existing canvas scenarios; +successful execution is not a painting-performance PASS. Photoshop menu parity +stays unchanged. + +Upstream refreshed on 2026-10-11 to `e44d96e1d4671b8d9fd64fbabdf88fa8abf0441e`. +Ordinary-brush prepared commits (#2131) have now landed and must be preserved on +integration. Open AI PRs #2878 (provider interface / Gemini), #730 (OpenAI), #2879 +(options UI), #2995 (subject selection) and #2649 (background removal) were identified. +Provider work must reconcile those existing contributions. No external AI provider +has been invoked, and publishing advanced palette/bristle/AI components requires +the owner's separate release approval after licensing review. From 92891b5908e3dd678629452c32831884c201924f Mon Sep 17 00:00:00 2001 From: durtyrobbin Date: Sun, 11 Oct 2026 12:40:22 -0400 Subject: [PATCH 017/229] studio: mixer optimization and baseline Preserve validated painting-studio phase 2 as its own source commit. Content tree: 803549d0cc3a688753c3561116cf3e7ccc76c107 Qualification details and remaining gates are retained in the phase documentation. Local commit authorized by durtyrobbin; no publication or upstream merge. --- crates/engine/examples/painting_baseline.rs | 111 +++++++++++++ crates/engine/examples/painting_live.rs | 126 +++++++++++++++ crates/engine/src/brush_cmds.rs | 2 +- crates/engine/src/brush_cmds/mixer_live.rs | 27 ++-- .../engine/src/brush_cmds/mixer_live_tests.rs | 16 ++ crates/paint/src/mixer.rs | 46 ++++-- crates/paint/src/mixer_reference_tests.rs | 152 ++++++++++++++++++ docs/creative-studio-validation.md | 123 ++++++++++++++ 8 files changed, 580 insertions(+), 23 deletions(-) create mode 100644 crates/engine/examples/painting_baseline.rs create mode 100644 crates/engine/examples/painting_live.rs create mode 100644 crates/paint/src/mixer_reference_tests.rs create mode 100644 docs/creative-studio-validation.md diff --git a/crates/engine/examples/painting_baseline.rs b/crates/engine/examples/painting_baseline.rs new file mode 100644 index 0000000000..a31c5375e1 --- /dev/null +++ b/crates/engine/examples/painting_baseline.rs @@ -0,0 +1,111 @@ +//! CPU baseline, one case per process: `painting_baseline `. +//! Run against the pinned upstream checkout too. Timings exclude fixture construction. +use photocraft_engine::{Session, brush_cmds::LiveStroke}; +use photocraft_geom::Rect; +use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; +use serde_json::json; +use std::time::Instant; + +fn ms(start: Instant) -> f64 { + start.elapsed().as_secs_f64() * 1000.0 +} + +fn stats(mut times: Vec) -> serde_json::Value { + if times.is_empty() { + return serde_json::Value::Null; + } + times.sort_by(f64::total_cmp); + let len = times.len(); + json!({"mean_ms":times.iter().sum::() / len as f64, + "p50_ms":times.get((len as f64 * 0.5).ceil() as usize - 1), + "p95_ms":times.get((len as f64 * 0.95).ceil() as usize - 1), + "p99_ms":times.get((len as f64 * 0.99).ceil() as usize - 1),"max_ms":times.last(),"samples":len}) +} + +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().collect(); + let dim: i32 = args.get(1).ok_or("missing dimension")?.parse()?; + let size: f32 = args.get(2).ok_or("missing diameter")?.parse()?; + let speed = args.get(3).ok_or("missing speed")?; + let mode = args.get(4).ok_or("missing mode")?; + if ![2048, 4096, 8192].contains(&dim) + || ![16.0, 64.0, 256.0, 1024.0].contains(&size) + || !["slow", "rapid"].contains(&speed.as_str()) + || !["stamp", "mixer"].contains(&mode.as_str()) + { + return Err("unsupported benchmark case".into()); + } + let mut s = Session::new(); + s.execute("file.new", json!({"width":dim,"height":dim,"background":"#808080"}))?; + s.execute("layer.new.layer", json!({"name":"Dense synthetic source"}))?; + s.edit("Fixture", |doc, id| { + if let Some(surface) = id.and_then(|id| doc.layer_mut(id)).and_then(|l| l.surface_mut()) { + let mut pixels = vec![0.0; 256 * 256 * 4]; + for ty in (0..dim).step_by(256) { + for tx in (0..dim).step_by(256) { + for (i, p) in pixels.chunks_exact_mut(4).enumerate() { + let x = tx + (i % 256) as i32; + let y = ty + (i / 256) as i32; + p.copy_from_slice(&[(x % 257) as f32 / 256.0, (y % 251) as f32 / 250.0, ((x ^ y) % 239) as f32 / 238.0, 0.85]); + } + surface.write_region(Rect::new(tx, ty, tx + 256, ty + 256), &pixels); + } + } + } + Ok(()) + })?; + s.execute("layer.new.layer", json!({"name":"Paint"}))?; + let source = s.active().ok_or("no document")?.doc.clone(); + let count = 96; + let step = if speed == "slow" { 4.0 } else { 48.0 }; + // A bounded out-and-back path exercises many interpolated dabs without a huge bounding box. + let points: Vec<_> = (0..=count) + .map(|i| { + let distance = i as f64 * step; + let phase = distance % 1600.0; + let x = if phase < 800.0 { phase } else { 1600.0 - phase }; + StrokePoint::new(dim as f64 / 2.0 - 400.0 + x, dim as f64 / 2.0 + (i as f64 * 0.17).sin() * 24.0, 1.0) + }) + .collect(); + let brush = BrushSettings { size, pressure_size: false, spacing: 0.25, seed: 42, ..Default::default() }; + let dab_count = photocraft_paint::dabs(&Stroke { brush: brush.clone(), points: points.clone() }).len(); + let mut params = json!({"points":[[points[0].x,points[0].y,1.0]],"brush":brush,"seed":42,"sampleAllLayers":true}); + let mut moves = Vec::new(); + let mut begin_ms = None; + let render_start = Instant::now(); + if mode == "stamp" { + let start = Instant::now(); + let mut live = LiveStroke::begin(&s, ¶ms)?; + begin_ms = Some(ms(start)); + for point in points.iter().skip(1) { + let start = Instant::now(); + live.push(&[*point])?; + moves.push(ms(start)); + } + std::hint::black_box(&live.doc); + } + let live_ms = ms(render_start); + params["points"] = json!(points.iter().map(|p| [p.x, p.y, 1.0]).collect::>()); + let start = Instant::now(); + s.execute(if mode == "stamp" { "paint.stroke" } else { "paint.mixerBrush" }, params)?; + let commit_ms = ms(start); + let painted = s.active().ok_or("no document")?.doc.clone(); + let start = Instant::now(); + s.execute("edit.undo", json!({}))?; + let undo_ms = ms(start); + let undo_exact = *s.active().ok_or("no document")?.doc == *source; + let start = Instant::now(); + s.execute("edit.redo", json!({}))?; + let redo_ms = ms(start); + let redo_exact = *s.active().ok_or("no document")?.doc == *painted; + println!( + "{}", + json!({"dimension":dim,"diameter":size,"speed":speed,"mode":mode,"input":"synthetic pressure=1, 97 samples", + "source":"dense synthetic RGBA8, three composited layers","begin_ms":begin_ms,"engine_updates":stats(moves), + "commit_ms":commit_ms,"undo_ms":undo_ms,"redo_ms":redo_ms,"undo_exact":undo_exact,"redo_exact":redo_exact, + "dabs":dab_count,"dabs_per_second":dab_count as f64 / (if mode == "stamp" {live_ms} else {commit_ms} / 1000.0), + "pointer_to_visible_ms":null,"display_frame_times":null,"gpu_peak_percent":null, + "scope":"CPU engine only; baseline stamp commit replays the recorded stroke; mixer has no live preview"}) + ); + Ok(()) +} diff --git a/crates/engine/examples/painting_live.rs b/crates/engine/examples/painting_live.rs new file mode 100644 index 0000000000..2e94d0ac4f --- /dev/null +++ b/crates/engine/examples/painting_live.rs @@ -0,0 +1,126 @@ +//! CPU baseline, one case per process: `painting_baseline `. +//! Run against the pinned upstream checkout too. Timings exclude fixture construction. +use photocraft_engine::{ + Session, + brush_cmds::{LiveStroke, mixer_live::LiveMixer}, +}; +use photocraft_geom::Rect; +use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; +use serde_json::json; +use std::time::Instant; + +fn ms(start: Instant) -> f64 { + start.elapsed().as_secs_f64() * 1000.0 +} + +fn stats(mut times: Vec) -> serde_json::Value { + if times.is_empty() { + return serde_json::Value::Null; + } + times.sort_by(f64::total_cmp); + let len = times.len(); + json!({"mean_ms":times.iter().sum::() / len as f64, + "p50_ms":times.get((len as f64 * 0.5).ceil() as usize - 1), + "p95_ms":times.get((len as f64 * 0.95).ceil() as usize - 1), + "p99_ms":times.get((len as f64 * 0.99).ceil() as usize - 1),"max_ms":times.last(),"samples":len}) +} + +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().collect(); + let dim: i32 = args.get(1).ok_or("missing dimension")?.parse()?; + let size: f32 = args.get(2).ok_or("missing diameter")?.parse()?; + let speed = args.get(3).ok_or("missing speed")?; + let mode = args.get(4).ok_or("missing mode")?; + if ![2048, 4096, 8192].contains(&dim) + || ![16.0, 64.0, 256.0, 1024.0].contains(&size) + || !["slow", "rapid"].contains(&speed.as_str()) + || !["stamp", "mixer"].contains(&mode.as_str()) + { + return Err("unsupported benchmark case".into()); + } + let mut s = Session::new(); + s.execute("file.new", json!({"width":dim,"height":dim,"background":"#808080"}))?; + s.execute("layer.new.layer", json!({"name":"Dense synthetic source"}))?; + s.edit("Fixture", |doc, id| { + if let Some(surface) = id.and_then(|id| doc.layer_mut(id)).and_then(|l| l.surface_mut()) { + let mut pixels = vec![0.0; 256 * 256 * 4]; + for ty in (0..dim).step_by(256) { + for tx in (0..dim).step_by(256) { + for (i, p) in pixels.chunks_exact_mut(4).enumerate() { + let x = tx + (i % 256) as i32; + let y = ty + (i / 256) as i32; + p.copy_from_slice(&[(x % 257) as f32 / 256.0, (y % 251) as f32 / 250.0, ((x ^ y) % 239) as f32 / 238.0, 0.85]); + } + surface.write_region(Rect::new(tx, ty, tx + 256, ty + 256), &pixels); + } + } + } + Ok(()) + })?; + s.execute("layer.new.layer", json!({"name":"Paint"}))?; + let source = s.active().ok_or("no document")?.doc.clone(); + let count = 96; + let step = if speed == "slow" { 4.0 } else { 48.0 }; + // A bounded out-and-back path exercises many interpolated dabs without a huge bounding box. + let points: Vec<_> = (0..=count) + .map(|i| { + let distance = i as f64 * step; + let phase = distance % 1600.0; + let x = if phase < 800.0 { phase } else { 1600.0 - phase }; + StrokePoint::new(dim as f64 / 2.0 - 400.0 + x, dim as f64 / 2.0 + (i as f64 * 0.17).sin() * 24.0, 1.0) + }) + .collect(); + let brush = BrushSettings { size, pressure_size: false, spacing: 0.25, seed: 42, ..Default::default() }; + let dab_count = photocraft_paint::dabs(&Stroke { brush: brush.clone(), points: points.clone() }).len(); + let mut params = json!({"points":[[points[0].x,points[0].y,1.0]],"brush":brush,"seed":42,"sampleAllLayers":true}); + let mut moves = Vec::new(); + let mut begin_ms = None; + let mut prepared = None; + let render_start = Instant::now(); + if mode == "stamp" { + let start = Instant::now(); + let mut live = LiveStroke::begin(&s, ¶ms)?; + begin_ms = Some(ms(start)); + for point in points.iter().skip(1) { + let start = Instant::now(); + live.push(&[*point])?; + moves.push(ms(start)); + } + std::hint::black_box(&live.doc); + } + if mode == "mixer" { + let start = Instant::now(); + let mut live = LiveMixer::begin(&s, ¶ms)?; + begin_ms = Some(ms(start)); + for point in points.iter().skip(1) { + let start = Instant::now(); + live.push(&[*point])?; + moves.push(ms(start)); + } + prepared = Some(live.prepare_commit(&mut s)?); + } + let live_ms = ms(render_start); + params["points"] = json!(points.iter().map(|p| [p.x, p.y, 1.0]).collect::>()); + let start = Instant::now(); + s.execute(if mode == "stamp" { "paint.stroke" } else { "paint.mixerBrush" }, prepared.unwrap_or(params))?; + let commit_ms = ms(start); + let painted = s.active().ok_or("no document")?.doc.clone(); + let start = Instant::now(); + s.execute("edit.undo", json!({}))?; + let undo_ms = ms(start); + let undo_exact = *s.active().ok_or("no document")?.doc == *source; + let start = Instant::now(); + s.execute("edit.redo", json!({}))?; + let redo_ms = ms(start); + let redo_exact = *s.active().ok_or("no document")?.doc == *painted; + println!( + "{}", + json!({"dimension":dim,"diameter":size,"speed":speed,"mode":mode,"input":"synthetic pressure=1, 97 samples", + "source":"dense synthetic RGBA8, three composited layers","begin_ms":begin_ms,"engine_updates":stats(moves), + "commit_ms":commit_ms,"undo_ms":undo_ms,"redo_ms":redo_ms,"undo_exact":undo_exact,"redo_exact":redo_exact, + "dabs":dab_count,"dabs_per_second":dab_count as f64 / (live_ms / 1000.0), + "pointer_to_visible_ms":null,"display_frame_times":null,"gpu_peak_percent":null, + "scope":"CPU engine only; stamp commit replays the recorded stroke; mixer commits prepared live pixels"}) + ); + Ok(()) +} diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index d984363bca..270fb8eb70 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -897,7 +897,7 @@ pub fn specs() -> Vec { spec!( "paint.mixerBrush", "Mixer Brush", - r##"{"points":[…] (max 65536 samples, 2048px per segment),"brush":{…}?,"preset":name?,"size":0.5..512 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke,"loadAfterStroke":bool=brush.mixer.reloadAfterStroke,"seed":u64?,"mixerState":{reservoir,amount,pickup,spatial} (optional captured replay state; spatial is null or 4096 RGBA colors)}"##, + r##"{"points":[…] (max 65536 samples, 2048px per segment),"brush":{…}?,"preset":name?,"size":0.5..1024 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke,"loadAfterStroke":bool=brush.mixer.reloadAfterStroke,"seed":u64?,"mixerState":{reservoir,amount,pickup,spatial} (optional captured replay state; spatial is null or 4096 RGBA colors)}"##, has_paintable, mixer_brush, true diff --git a/crates/engine/src/brush_cmds/mixer_live.rs b/crates/engine/src/brush_cmds/mixer_live.rs index 21f4c7e99e..21bed7d414 100644 --- a/crates/engine/src/brush_cmds/mixer_live.rs +++ b/crates/engine/src/brush_cmds/mixer_live.rs @@ -4,7 +4,7 @@ use super::*; use photocraft_color::{PixelFormat, SampleType}; use photocraft_doc::{Document, LayerId}; use photocraft_paint::dynamics::DabGenerator; -use photocraft_paint::mixer::{MixerState, apply_mixer_dab}; +use photocraft_paint::mixer::{MixerState, apply_mixer_dab_region}; use photocraft_paint::retouch::{Region, mix_back}; use photocraft_paint::{BrushContext, Dab}; use std::collections::BTreeSet; @@ -59,8 +59,8 @@ impl Core { continue; } let full = self.ctx.dab_rect(dab, false); - if u64::from(full.width()).saturating_mul(u64::from(full.height())) > 1_048_576 { - return Err(bad(CMD, "Mixer Brush footprint exceeds 1048576 pixels")); + if u64::from(full.width()).saturating_mul(u64::from(full.height())) > 4_194_304 { + return Err(bad(CMD, "Mixer Brush footprint exceeds 4194304 pixels")); } // Float working tiles avoid repeated integer-depth quantization. Initialize only // touched tiles, keeping unchanged source tiles shared by the document snapshots. @@ -80,13 +80,16 @@ impl Core { // deposition. Masks, transforms, blend modes and adjustments use the compositor. let sample = self.settings.sample_all_layers.then(|| { let buf = photocraft_compose::render(&self.doc, r); - let mut sample = Surface::new(PixelFormat::RGBA32F); - sample.write_region(r, &buf.px.iter().flatten().copied().collect::>()); + let fmt = self.pre.format(); + let mut sample = Region::new(r, fmt.channels()); + for (src, dst) in buf.px.iter().zip(sample.data.chunks_exact_mut(sample.ch)) { + photocraft_raster::from_rgba_into(&fmt, *src, dst); + } sample }); let fp = self.ctx.footprint(dab, index); let mut region = Region::read(&self.work, r); - apply_mixer_dab(&mut region, &fp, self.pre.format(), sample.as_ref(), &self.settings, &mut self.state, self.lock); + apply_mixer_dab_region(&mut region, &fp, self.pre.format(), sample.as_ref(), &self.settings, &mut self.state, self.lock); region.write(&mut self.work); let original = Region::read(&self.pre, r); let (target, _) = crate::channel_cmds::target_surface(Arc::make_mut(&mut self.doc), self.layer, &self.params)?; @@ -133,8 +136,8 @@ impl LiveMixer { let points = parse_points(p, CMD)?; let brush = resolve_brush(s, p, CMD)?; // Reject unsupported sizes before allocating a dense brush footprint. - if brush.size > 512.0 { - return Err(bad(CMD, "live Mixer Brush size is limited to 512 pixels")); + if brush.size > 1024.0 { + return Err(bad(CMD, "live Mixer Brush size is limited to 1024 pixels; choose Brush for larger tips")); } let bm = &brush.mixer; let settings = MixerSettings { @@ -274,7 +277,7 @@ impl LiveMixer { pub(super) fn run(s: &mut Session, p: &Value) -> Result { let cached = s.prepared_mixer.take().filter(|live| { - s.active().is_some_and(|st| Arc::ptr_eq(&st.doc, &live.original)) + s.active().is_some_and(|st| Arc::ptr_eq(&st.doc, &live.original) && (live.core.layer.is_none() || st.active_layer == live.core.layer)) && s.tools.mixer == live.original_state && live.commit_params().is_ok_and(|params| params == *p) }); @@ -308,15 +311,15 @@ pub(super) fn sample(s: &mut Session, p: &Value) -> Result { return Err(bad("brush.mixer.sample", "exactly one sample point is required")); } let mut brush = resolve_brush(s, p, "brush.mixer.sample")?; - if brush.size > 512.0 { - return Err(bad("brush.mixer.sample", "sampling size is limited to 512 pixels")); + if brush.size > 1024.0 { + return Err(bad("brush.mixer.sample", "sampling size is limited to 1024 pixels")); } brush.flow = 1.0; let dabs = photocraft_paint::dabs(&Stroke { brush: brush.clone(), points }); let dab = dabs.first().ok_or_else(|| bad("brush.mixer.sample", "brush has no contact"))?; let ctx = BrushContext::new(&brush); let rect = ctx.dab_rect(dab, false); - if u64::from(rect.width()).saturating_mul(u64::from(rect.height())) > 1_048_576 { + if u64::from(rect.width()).saturating_mul(u64::from(rect.height())) > 4_194_304 { return Err(bad("brush.mixer.sample", "sampling footprint is too large")); } let fp = ctx.footprint(dab, 0); diff --git a/crates/engine/src/brush_cmds/mixer_live_tests.rs b/crates/engine/src/brush_cmds/mixer_live_tests.rs index b13dc6ed3e..bac3a97446 100644 --- a/crates/engine/src/brush_cmds/mixer_live_tests.rs +++ b/crates/engine/src/brush_cmds/mixer_live_tests.rs @@ -224,3 +224,19 @@ fn mixer_rejects_malformed_load_and_retains_or_reloads_paint() { s.execute("paint.mixerBrush", json!({"points":[[40,40],[80,40]],"size":10,"loadAfterStroke":true,"load":10})).unwrap(); assert_eq!(s.tools.mixer.amount, 1.0); } + +#[test] +fn mixer_large_tip_retains_off_canvas_paint_and_commits_the_live_result() { + let mut s = fixture(PixelFormat::RGBA8); + let source = s.active().unwrap().doc.clone(); + let params = json!({"points":[[96,48]],"size":1024,"wet":0,"mix":0,"color":"#1122ee","brush":{"pressureSize":false}}); + let live = LiveMixer::begin(&s, ¶ms).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let shown = live.doc.layer(id).unwrap().surface().unwrap().clone(); + assert!(shown.rgba(-100, 48)[3] > 0.99, "off-canvas layer pixels remain paintable"); + let params = live.prepare_commit(&mut s).unwrap(); + s.execute("paint.mixerBrush", params).unwrap(); + assert_eq!(target(&s), shown); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.active().unwrap().doc, *source); +} diff --git a/crates/paint/src/mixer.rs b/crates/paint/src/mixer.rs index 4c35019cba..6682e83f6b 100644 --- a/crates/paint/src/mixer.rs +++ b/crates/paint/src/mixer.rs @@ -6,7 +6,7 @@ //! weight. The reservoir amount drains per dab unless `load` is 100 %, so low Load strokes dry out. //! A dry brush (`wet = 0`) paints only reservoir colour; an empty, wet brush smears canvas colour. -use photocraft_raster::{Surface, from_rgba, to_rgba}; +use photocraft_raster::{Surface, from_rgba, from_rgba_into, to_rgba}; use serde::{Deserialize, Serialize}; use crate::Stroke; @@ -119,6 +119,33 @@ pub fn apply_mixer_dab( m: &MixerSettings, state: &mut MixerState, lock_transparency: bool, +) { + let sample = sample.map(|surface| { + let source = crate::retouch::Region::read(surface, work.rect); + if surface.format().mode == fmt.mode && surface.format().alpha == fmt.alpha { + source + } else { + let mut native = crate::retouch::Region::new(work.rect, fmt.channels()); + for (src, dst) in source.data.chunks_exact(source.ch).zip(native.data.chunks_exact_mut(native.ch)) { + from_rgba_into(&fmt, to_rgba(&surface.format(), src), dst); + } + native + } + }); + apply_mixer_dab_region(work, fp, fmt, sample.as_ref(), m, state, lock_transparency); +} + +/// Contiguous native-channel sample variant. `sample` must have the same rectangle and +/// channel layout as `work`. The live compositor supplies it directly, avoiding a tiled +/// temporary surface and a heap allocation for every sampled/deposited pixel. +pub fn apply_mixer_dab_region( + work: &mut crate::retouch::Region, + fp: &crate::retouch::Footprint, + fmt: photocraft_color::PixelFormat, + sample: Option<&crate::retouch::Region>, + m: &MixerSettings, + state: &mut MixerState, + lock_transparency: bool, ) { let n = fmt.channels(); let a = alpha_index(&fmt); @@ -148,13 +175,7 @@ pub fn apply_mixer_dab( if w <= 0.0 { continue; } - let px: Vec = match sample { - Some(s) => { - let v = s.pixel(x, y); - if s.format() == fmt { v } else { from_rgba(&fmt, s.rgba(x, y)) } - } - None => work.px(x, y).to_vec(), - }; + let px = sample.unwrap_or(work).px(x, y); let al = a.map_or(1.0, |ai| px[ai]); for c in 0..nc { acc[c] += px[c] * w * al; @@ -181,17 +202,18 @@ pub fn apply_mixer_dab( paint[c] = (rv * wr + pv * wp) / total; } let dep = total.min(1.0) * flow; + let mut local = vec![0.0; n]; + let mut mixed = vec![0.0; n]; for y in r.y0..r.y1 { for x in r.x0..r.x1 { let k = fp.at(x, y) * dep; if k > 0.0 { if let Some(load) = &state.spatial { let loaded = load_at(load, &fp.dab, x as f32 + 0.5, y as f32 + 0.5); - let local = from_rgba(&fmt, loaded); + from_rgba_into(&fmt, loaded, &mut local); let local_wr = wr * loaded[3]; let local_total = local_wr + wp; if local_total > 1e-6 { - let mut mixed = paint.clone(); for (c, value) in mixed.iter_mut().enumerate() { *value = (local.get(c).copied().unwrap_or(0.0) * local_wr + pickup.as_ref().and_then(|p| p.get(c)).copied().unwrap_or(0.0) * wp) @@ -304,3 +326,7 @@ mod tests { assert!(run(1.0).is_some_and(|p| p[2] > 0.2)); } } + +#[cfg(test)] +#[path = "mixer_reference_tests.rs"] +mod reference_tests; diff --git a/crates/paint/src/mixer_reference_tests.rs b/crates/paint/src/mixer_reference_tests.rs new file mode 100644 index 0000000000..cdd86e2a48 --- /dev/null +++ b/crates/paint/src/mixer_reference_tests.rs @@ -0,0 +1,152 @@ +// Frozen pre-optimization dab kernel. Keep independent of the optimized implementation. +use super::*; +fn reference( + work: &mut crate::retouch::Region, + fp: &crate::retouch::Footprint, + fmt: photocraft_color::PixelFormat, + sample: Option<&Surface>, + m: &MixerSettings, + state: &mut MixerState, + lock_transparency: bool, +) { + let n = fmt.channels(); + let a = alpha_index(&fmt); + let nc = fmt.mode.color_channels(); + let native = |c: [f32; 4]| { + let mut v = from_rgba(&fmt, c); + if let Some(ai) = a { + v[ai] = 1.0; + } + v + }; + let res = state.reservoir.map(native); + let mut amount = if res.is_some() { state.amount.clamp(0.0, 1.0) } else { 0.0 }; + let mut pickup: Option> = state.pickup.map(native); + let (wet, mix, flow, load) = (m.wet.clamp(0.0, 1.0), m.mix.clamp(0.0, 1.0), m.flow.clamp(0.0, 1.0), m.load.clamp(0.0, 1.0)); + let mut acc = vec![0.0f32; n]; + let mut paint = vec![0.0f32; n]; + // Transfer › Wetness / Mix Jitter scale the settings per dab. + let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0)); + // Sample the canvas under the dab. + acc.iter_mut().for_each(|v| *v = 0.0); + let (mut wsum, mut asum) = (0.0f32, 0.0f32); + let r = fp.rect.intersect(&work.rect); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let w = fp.at(x, y); + if w <= 0.0 { + continue; + } + let px: Vec = match sample { + Some(s) => { + let v = s.pixel(x, y); + if s.format() == fmt { v } else { from_rgba(&fmt, s.rgba(x, y)) } + } + None => work.px(x, y).to_vec(), + }; + let al = a.map_or(1.0, |ai| px[ai]); + for c in 0..nc { + acc[c] += px[c] * w * al; + } + wsum += w; + asum += w * al; + } + } + if asum > 1e-6 && wet > 0.0 { + let ca = asum / wsum.max(1e-6); + let canvas: Vec = (0..n).map(|c| if c < nc { acc[c] / asum } else { 1.0 }).collect(); + pickup = Some(match pickup.take() { + None => canvas, + Some(p) => p.iter().zip(&canvas).map(|(p, c)| p + (c - p) * PICKUP_RATE * wet * ca).collect(), + }); + } + let wr = if res.is_some() { amount * (1.0 - mix * wet) } else { 0.0 }; + let wp = if pickup.is_some() { mix * wet } else { 0.0 }; + let total = wr + wp; + if total > 1e-6 { + for c in 0..n { + let rv = res.as_ref().map_or(0.0, |v| v[c]); + let pv = pickup.as_ref().map_or(0.0, |v| v[c]); + paint[c] = (rv * wr + pv * wp) / total; + } + let dep = total.min(1.0) * flow; + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let k = fp.at(x, y) * dep; + if k > 0.0 { + if let Some(load) = &state.spatial { + let loaded = load_at(load, &fp.dab, x as f32 + 0.5, y as f32 + 0.5); + let local = from_rgba(&fmt, loaded); + let local_wr = wr * loaded[3]; + let local_total = local_wr + wp; + if local_total > 1e-6 { + let mut mixed = paint.clone(); + for (c, value) in mixed.iter_mut().enumerate() { + *value = (local.get(c).copied().unwrap_or(0.0) * local_wr + + pickup.as_ref().and_then(|p| p.get(c)).copied().unwrap_or(0.0) * wp) + / local_total; + } + if let Some(ai) = a + && let Some(alpha) = mixed.get_mut(ai) + { + *alpha = 1.0; + } + over_native(&fmt, work.px_mut(x, y), &mixed, fp.at(x, y) * local_total.min(1.0) * flow, lock_transparency); + } + } else { + over_native(&fmt, work.px_mut(x, y), &paint, k, lock_transparency); + } + } + } + } + } + amount *= 1.0 - 0.08 * (1.0 - load); + state.amount = amount; + state.pickup = pickup.map(|p| to_rgba(&fmt, &p)); +} + +#[test] +fn contiguous_mixer_matches_frozen_kernel_across_models_depths_and_spatial_loads() { + use crate::retouch::Region; + use crate::{BrushContext, BrushSettings, Stroke, StrokePoint}; + use photocraft_color::{ColorMode, PixelFormat, SampleType}; + let rect = Rect::new(-16, -16, 32, 32); + for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for alpha in [false, true] { + let fmt = PixelFormat::new(mode, depth, alpha); + let mut surface = Surface::new(fmt); + let values: Vec<_> = (0..rect.width() * rect.height()).flat_map(|i| from_rgba(&fmt, [(i % 31) as f32 / 30.0, 0.3, 0.7, 0.6])).collect(); + surface.write_region(rect, &values); + let mut source = Surface::new(PixelFormat::RGBA32F); + source.fill_rect(rect, &[0.8, 0.2, 0.1, 0.75]); + let brush = BrushSettings { size: 24.0, angle: 33.0, roundness: 0.6, pressure_size: false, ..Default::default() }; + let dabs = crate::dabs(&Stroke { brush: brush.clone(), points: vec![StrokePoint::new(4.0, 4.0, 1.0)] }); + let ctx = BrushContext::new(&brush); + for spatial in [false, true] { + for sample in [None, Some(&source), Some(&surface)] { + for lock in [false, true] { + let mut state = MixerState::default(); + state.load([0.1, 0.8, 0.2, 1.0]); + if spatial { + state.spatial = + Some((0..4096).map(|i| [if i % 64 < 32 { 1.0 } else { 0.0 }, 0.2, 0.8, if i % 7 == 0 { 0.0 } else { 1.0 }]).collect()); + } + let mut expected_state = state.clone(); + let mut actual = Region::read(&surface, rect); + let mut expected = actual.clone(); + let settings = MixerSettings::default(); + for (index, dab) in dabs.iter().enumerate() { + let fp = ctx.footprint(dab, index); + reference(&mut expected, &fp, fmt, sample, &settings, &mut expected_state, lock); + apply_mixer_dab(&mut actual, &fp, fmt, sample, &settings, &mut state, lock); + } + assert_eq!(actual, expected, "{mode:?} {depth:?} alpha={alpha} spatial={spatial} lock={lock}"); + assert_eq!(state, expected_state); + } + } + } + } + } + } +} diff --git a/docs/creative-studio-validation.md b/docs/creative-studio-validation.md new file mode 100644 index 0000000000..07f3957f8f --- /dev/null +++ b/docs/creative-studio-validation.md @@ -0,0 +1,123 @@ +# Creative Painting Studio: validation ledger + +This branch contains independently scoped local work. It is not a completed studio +or a release. Original PhotoCraft additions do not change Photoshop parity counts. + +## Source and upstream coordination + +The mixer foundation started from `29f1ff9e90f2581ea2964e3276d85c71ab7e79a0`. +The 2026-10-11 upstream refresh resolved to +`e44d96e1d4671b8d9fd64fbabdf88fa8abf0441e`. The original baseline remains pinned. +Upstream #2131 now supplies prepared commits for ordinary Brush/Pencil and should +be retained when this branch integrates current main. #3098, #2885 and #2379 contain +overlapping brush performance work; no speedups from those branches are attributed +to this work. PDF Binder #2011 and PDF integration #2751 are untouched. + +Open #2878 supplies an `ImageProvider`/`HttpTransport` interface, bounded encoded +sRGB image/mask interchange and cancellable engine jobs. Its inspected head is +`4754d3c6edb9b37a7ad8516f15425abeca84987d`. #730 offers the OpenAI backend; +#2879 is dependent UI; #2995 and #2649 concern local segmentation. No provider or +network-generation functionality is added by this painting foundation. Later AI +result/guidance work must reconcile these existing contributions. + +## Gate 0 fixture and measurement scope + +`crates/engine/examples/painting_baseline.rs` is intentionally compatible with the +pinned original source. Copy this example into a detached checkout of the baseline +and build it there. `painting_live.rs` uses the same fixture and path for the new +live mixer. Do not compare the old full-stroke commit timer to a new individual +update as though they measure the same amount of work. + +Each case is a fresh process with three layers: a uniform background, a fully +allocated synthetic RGBA8 source with varying RGB and alpha, and an empty paint +layer. Dimensions are 2048², 4096² and 8192². Diameters are 16, 64, 256 and 1024 px; +spacing is 25%, seed 42, pressure one, smoothing off. Each path has 97 points, with +4 px or 48 px horizontal travel per update and a small sinusoidal vertical motion. +The rapid path travels back and forth, retaining a bounded painted region while +generating many interpolated dabs. Fixture construction is outside engine timers. + +The process runner records observed peak working set and host CPU utilization at +100 ms intervals. Those process metrics include fixture construction and validation; +brief peaks can be missed. JSON engine timers record begin, average/p50/p95/p99/max +input-update costs, completion, dab throughput and Undo/Redo. Exact Undo/Redo document +comparisons are outside those timers. These are CPU-engine measurements, not UI frame +times, OS pointer-to-display latency or tablet measurements. GPU utilization and +display timings remain explicitly null. No FPS performance gate passes from these +measurements alone. + +Build configuration used on Windows: rustc 1.97.1, release, LTO disabled, 16 codegen +units, four compiler jobs. Intel i7-9700K, eight logical processors. Avoid concurrent +compilation or other benchmarks when collecting comparative timings. + +```powershell +cargo build -p photocraft-engine --example painting_baseline --release --locked --offline -j4 --config profile.release.lto=false --config profile.release.codegen-units=16 +cargo build -p photocraft-engine --example painting_live --release --locked --offline -j4 --config profile.release.lto=false --config profile.release.codegen-units=16 +# One example case; repeat all dimension / diameter / speed combinations. +target/release/examples/painting_baseline.exe 4096 256 rapid mixer +target/release/examples/painting_live.exe 4096 256 rapid mixer +``` + +The original ordinary-stamp benchmark replays the command on release. It does not +include upstream's subsequently merged #2131 optimization. Its baseline must remain +labeled with its actual source revision. + +## Mixer optimization + +The original incremental implementation built a tiled RGBA32F sample Surface per +dab and allocated temporary pixel vectors in both sampling and spatial deposition. +The optimized path feeds the compositor's contiguous pixels into a native-channel +Region, borrows sampled slices, and reuses deposition scratch vectors. It preserves +the original arithmetic and full-resolution coverage; it does not reduce quality. +An independent frozen pre-optimization kernel checks exact pixels and reservoir +state across RGB/Gray/CMYK/Lab, 8/16/F32, alpha/no-alpha, spatial/uniform paint, +current/composite sampling and transparency lock. + +The size envelope now accepts 1024 px tips with at most 4,194,304 pixels per dense +footprint. The 256-tile working-set and input/dab limits remain. Larger sizes fail +explicitly with a suggestion to choose Brush; there is no silent mid-stroke quality +change. Acceptance of a size is not a responsiveness claim. + +## Measured CPU matrix + +All 96 baseline/live cases completed with exact Undo/Redo validation. The table gives +the worst mean/p95/p99 input-update times across the three canvas sizes and two +movement speeds in the new live implementation, in milliseconds. + +| Diameter | Stamp mean / p95 / p99 | Mixer mean / p95 / p99 | +|---|---|---| +| 16 | 0.112 / 0.173 / 0.207 | 0.582 / 1.218 / 2.002 | +| 64 | 0.371 / 0.463 / 1.005 | 1.417 / 1.856 / 3.076 | +| 256 | 1.337 / 1.940 / 2.374 | 6.193 / 9.491 / 10.480 | +| 1024 | 4.654 / 24.169 / 38.489 | 20.845 / 108.355 / 162.528 | + +Prepared mixer completion ranged from 0.674 to 3.451 ms. The original full-stroke +completion ranged from 9.451 to 4,085.397 ms; these completion measurements cover +different work because live rendering has moved that work into input updates. +Observed peak process working set was at most 400.6 MiB for the live matrix. +These results do not qualify 1024 px tips for the stated responsiveness targets. +Even the smaller engine-only timings do not prove display FPS or physical pen latency. + +## Functional and platform gates + +The mixer foundation unit/integration checks, live/committed screenshots and exact +pixel comparison are described in `mixer-live.md`. The first broad native test run +hit `0xc0000005` in concurrent `arbitrary_rotation_preview` tests on WARP. Both tests +passed serially. This matches the independently reported upstream #3212 issue; +no driver settings or host configuration were changed. Offscreen evidence uses the +Microsoft Basic Render Driver, not a verified physical RTX path. + +Relevant reproducible checks: + +```powershell +cargo test -p photocraft-paint -p photocraft-engine -p photocraft-ui-egui --lib --tests -- --test-threads=1 +cargo clippy -p photocraft-paint -p photocraft-engine -p photocraft-ui-egui --all-targets -- -D warnings +cargo xtask layers +cargo xtask wasm +cargo xtask parity +cargo test -p photocraft-engine --test panic_hunt -- --ignored +cargo xtask perf --quick +``` + +Native Linux/Wayland/X11, macOS, physical Wacom/Windows pen input, physical GPU +presentation and real pointer-to-display latency remain unqualified on this host. +WASM compile success is not browser interaction qualification. From a564ea2c76e29c1f07e9411d9df935d523d39d5c Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:43:05 +0200 Subject: [PATCH 018/229] History panel: snapshots (camera button, snapshot rows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #2966. The History panel's camera button takes a named snapshot of the document ("Snapshot N", not a history state). Snapshots are listed under the opening state with a thumbnail and scroll with the states; clicking one returns the document to it as one new, undoable state named after it. A double-click or right-click › Rename… renames it in place, and right-click › Delete removes it. Snapshots live for the session only, as in Photoshop. For agents: history.newSnapshot {name?, from?: fullDocument | mergedLayers | currentLayer}, history.selectSnapshot, renameSnapshot and deleteSnapshot, addressed by ids that are never reused, and doc.inspect reports snapshots. --- crates/engine/src/history_cmds.rs | 269 +++++++++++++++++++++++++++++- crates/engine/src/inspect.rs | 1 + crates/engine/src/lib.rs | 7 + crates/ui-egui/src/lib.rs | 27 +++ crates/ui-egui/src/panels.rs | 217 +++++++++++++++++++++++- docs/control-protocol.md | 2 +- docs/ui-parity.md | 4 +- 7 files changed, 517 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/history_cmds.rs b/crates/engine/src/history_cmds.rs index d9ca98f962..bb9cb34391 100644 --- a/crates/engine/src/history_cmds.rs +++ b/crates/engine/src/history_cmds.rs @@ -2,14 +2,34 @@ //! "Create new document from current state" button). Stepping to a state is Edit › Undo / Redo. //! //! States are indexed like [`photocraft_ops::History::entries`]: 0 is the oldest state still -//! held, the last index is the current document. Snapshots are not implemented. +//! held, the last index is the current document. +//! +//! Snapshots (#2966, the camera button) are named copies of the document that outlive the +//! states: `history.newSnapshot` takes one, `history.selectSnapshot` brings it back as a new, +//! undoable state. They live on [`crate::DocState::snapshots`] for the session only, as in +//! Photoshop, and are addressed by an id that is never reused. Their tiles are shared +//! copy-on-write with the document and its history, and are not counted in the history's pixel +//! budget. +use std::sync::Arc; + +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_ops::LayerTarget; use serde_json::{Value, json}; use crate::commands::{CommandSpec, int}; use crate::image_cmds::reidentify_copied_layers; use crate::{EngineError, Result, Session}; +/// A History panel snapshot: the document as it was when taken, and the layers it targeted. +#[derive(Clone, Debug)] +pub struct Snapshot { + pub id: u64, + pub name: String, + pub doc: Arc, + pub layers: LayerTarget, +} + fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } @@ -72,6 +92,105 @@ fn new_document(s: &mut Session, p: &Value) -> Result { Ok(json!({ "document": index, "name": name })) } +/// The `snapshot` param: the index of the active document's snapshot with that id. +fn snapshot_index(s: &Session, cmd: &str, p: &Value) -> Result { + let st = s.active().ok_or(EngineError::NoDocument)?; + let id = p.get("snapshot").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`snapshot` must be a snapshot id"))?; + st.snapshots.iter().position(|snap| snap.id == id).ok_or_else(|| bad(cmd, format!("no snapshot {id}"))) +} + +/// A non-blank `name` param, trimmed; `None` when absent. +fn name_param(cmd: &str, p: &Value) -> Result> { + match p.get("name") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(n)) if !n.trim().is_empty() => Ok(Some(n.trim().to_string())), + Some(_) => Err(bad(cmd, "`name` must be a non-empty string")), + } +} + +/// History panel › Create new snapshot (the camera button): a named copy of the document, +/// "Snapshot N" by default. `from` is the New Snapshot dialog's From: the full document (default), +/// its layers merged into one, or only the active layer. Not a history state. +fn new_snapshot(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "history.newSnapshot"; + let name = name_param(CMD, p)?; + let from = match p.get("from") { + None | Some(Value::Null) => "fullDocument", + Some(Value::String(f)) if matches!(f.as_str(), "fullDocument" | "mergedLayers" | "currentLayer") => f.as_str(), + Some(_) => return Err(bad(CMD, "`from` must be \"fullDocument\", \"mergedLayers\" or \"currentLayer\"")), + }; + let st = s.active().ok_or(EngineError::NoDocument)?; + let one_layer = |doc: &Document, layer: Layer| { + let mut doc = doc.clone(); + let id = layer.id; + doc.layers = vec![layer]; + (Arc::new(doc), LayerTarget { active: Some(id), selected: vec![id] }) + }; + let (doc, layers) = match from { + "mergedLayers" => { + let fmt = st.doc.pixel_format(); + one_layer(&st.doc, Layer::new("Merged Layers", LayerContent::Raster(crate::file_cmds::flattened(&st.doc, fmt)))) + } + "currentLayer" => { + let layer = st.active_layer.and_then(|id| st.doc.layer(id)).ok_or_else(|| bad(CMD, "no active layer"))?; + one_layer(&st.doc, layer.clone()) + } + _ => (st.doc.clone(), st.layer_target()), + }; + let st = s.active_mut().ok_or(EngineError::NoDocument)?; + let id = st.next_snapshot; + st.next_snapshot = id.saturating_add(1); + let name = name.unwrap_or_else(|| format!("Snapshot {id}")); + st.snapshots.push(Snapshot { id, name: name.clone(), doc, layers }); + Ok(json!({ "snapshot": id, "name": name })) +} + +/// Clicking a snapshot: the document returns to it as one new, undoable state named after it. +fn select_snapshot(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "history.selectSnapshot"; + let i = snapshot_index(s, CMD, p)?; + // A background job is computing from the current state: it must not move under it. + if s.active_job().is_some() { + return Err(EngineError::Other("the document is busy with a background job".into())); + } + let snap = s.active().and_then(|st| st.snapshots.get(i).cloned()).ok_or(EngineError::NoDocument)?; + s.edit(&snap.name, |doc, active| { + // Keep the identity (colour state, views and caches are keyed by it) and the tab name. + let (id, name) = (doc.id, doc.name.clone()); + *doc = (*snap.doc).clone(); + doc.id = id; + doc.name = name; + *active = snap.layers.active.filter(|l| doc.layer(*l).is_some()).or_else(|| doc.top_layer()); + Ok(()) + })?; + let st = s.active_mut().ok_or(EngineError::NoDocument)?; + if st.active_layer == snap.layers.active { + st.selected_layers.clone_from(&snap.layers.selected); + crate::fix_selection(st); + } + Ok(json!({ "snapshot": snap.id, "name": snap.name })) +} + +/// Renaming a snapshot (double-click its name). +fn rename_snapshot(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "history.renameSnapshot"; + let i = snapshot_index(s, CMD, p)?; + let name = name_param(CMD, p)?.ok_or_else(|| bad(CMD, "`name` is required"))?; + let st = s.active_mut().ok_or(EngineError::NoDocument)?; + let snap = st.snapshots.get_mut(i).ok_or_else(|| bad(CMD, "no such snapshot"))?; + snap.name.clone_from(&name); + Ok(json!({ "snapshot": snap.id, "name": name })) +} + +/// Deleting a snapshot; the document and its history are unchanged. +fn delete_snapshot(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "history.deleteSnapshot"; + let i = snapshot_index(s, CMD, p)?; + let st = s.active_mut().ok_or(EngineError::NoDocument)?; + let snap = st.snapshots.remove(i); + Ok(json!({ "snapshot": snap.id, "snapshots": st.snapshots.len() })) +} + fn can_delete(s: &Session) -> std::result::Result<(), String> { s.active().filter(|d| d.history.can_undo()).map(|_| ()).ok_or_else(|| "the oldest state can't be deleted".into()) } @@ -102,6 +221,46 @@ pub fn specs() -> Vec { run: new_document, journal: true, }, + CommandSpec { + id: "history.newSnapshot", + label: "New Snapshot", + menu: &[], + shortcut: None, + params: r##"{"name":str?="Snapshot N","from":"fullDocument"|"mergedLayers"|"currentLayer"="fullDocument"} → {snapshot,name}: a named copy of the document for the session (not a history state)"##, + enabled: has_doc, + run: new_snapshot, + journal: true, + }, + CommandSpec { + id: "history.selectSnapshot", + label: "Select Snapshot", + menu: &[], + shortcut: None, + params: r##"{"snapshot":id} → {snapshot,name}: the document returns to the snapshot as a new history state named after it"##, + enabled: has_doc, + run: select_snapshot, + journal: true, + }, + CommandSpec { + id: "history.renameSnapshot", + label: "Rename Snapshot", + menu: &[], + shortcut: None, + params: r##"{"snapshot":id,"name":str} → {snapshot,name}"##, + enabled: has_doc, + run: rename_snapshot, + journal: true, + }, + CommandSpec { + id: "history.deleteSnapshot", + label: "Delete Snapshot", + menu: &[], + shortcut: None, + params: r##"{"snapshot":id} → {snapshot,snapshots}: the document and its history are unchanged"##, + enabled: has_doc, + run: delete_snapshot, + journal: true, + }, ] } @@ -193,6 +352,114 @@ mod tests { assert_eq!(entries(&s), before); } + /// The top layer's pixel at (1, 1). + fn top_pixel(s: &Session) -> [f32; 4] { + s.active().unwrap().doc.layers.last().unwrap().surface().unwrap().rgba(1, 1) + } + + fn snapshots(s: &Session) -> Value { + crate::inspect::document(s.active().unwrap())["snapshots"].clone() + } + + #[test] + fn new_snapshot_keeps_the_document_and_is_not_a_history_state() { + let mut s = session(); + let before = entries(&s); + let r = s.execute("history.newSnapshot", json!({})).unwrap(); + assert_eq!(r, json!({"snapshot": 1, "name": "Snapshot 1"})); + assert_eq!(entries(&s), before, "taking a snapshot is not a step"); + let r = s.execute("history.newSnapshot", json!({"name": "Before green"})).unwrap(); + assert_eq!(r, json!({"snapshot": 2, "name": "Before green"})); + assert_eq!(snapshots(&s), json!([{"id": 1, "name": "Snapshot 1"}, {"id": 2, "name": "Before green"}])); + } + + #[test] + fn select_snapshot_returns_to_it_as_one_undoable_state() { + let mut s = session(); + s.execute("history.newSnapshot", json!({})).unwrap(); + let green = top_pixel(&s); + let id = s.active().unwrap().doc.id; + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + let blue = top_pixel(&s); + let before = entries(&s); + s.execute("history.selectSnapshot", json!({"snapshot": 1})).unwrap(); + assert_eq!(top_pixel(&s), green); + assert_eq!(entries(&s).len(), before.len() + 1, "one new state"); + assert_eq!(entries(&s).last().map(String::as_str), Some("Snapshot 1")); + assert_eq!(s.active().unwrap().doc.id, id, "the same document"); + assert!(s.active().unwrap().active_layer.is_some()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(top_pixel(&s), blue, "undo leaves the snapshot"); + assert_eq!(snapshots(&s).as_array().unwrap().len(), 1, "snapshots are not history"); + } + + #[test] + fn snapshots_from_merged_layers_or_the_current_layer() { + let mut s = session(); + assert_eq!(s.active().unwrap().doc.layers.len(), 2); + s.execute("history.newSnapshot", json!({"from": "mergedLayers"})).unwrap(); + s.execute("history.newSnapshot", json!({"from": "currentLayer"})).unwrap(); + s.execute("history.newSnapshot", json!({"from": "fullDocument"})).unwrap(); + s.execute("history.selectSnapshot", json!({"snapshot": 1})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + assert_eq!(doc.layers.len(), 1, "merged into one layer"); + assert_eq!(doc.layers[0].surface().unwrap().rgba(1, 1), [0.0, 1.0, 0.0, 1.0], "the visible result"); + s.execute("history.selectSnapshot", json!({"snapshot": 2})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), 1, "only the active layer"); + s.execute("history.selectSnapshot", json!({"snapshot": 3})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), 2); + } + + #[test] + fn rename_and_delete_snapshots_keep_the_other_ids() { + let mut s = session(); + for _ in 0..3 { + s.execute("history.newSnapshot", json!({})).unwrap(); + } + s.execute("history.renameSnapshot", json!({"snapshot": 2, "name": "Lesson start"})).unwrap(); + s.execute("history.deleteSnapshot", json!({"snapshot": 1})).unwrap(); + assert_eq!(snapshots(&s), json!([{"id": 2, "name": "Lesson start"}, {"id": 3, "name": "Snapshot 3"}])); + let r = s.execute("history.newSnapshot", json!({})).unwrap(); + assert_eq!(r["snapshot"], json!(4), "ids are never reused"); + } + + #[test] + fn snapshot_commands_refuse_bad_params() { + let mut s = Session::new(); + for (cmd, p) in [ + ("history.newSnapshot", json!({})), + ("history.selectSnapshot", json!({"snapshot": 1})), + ("history.renameSnapshot", json!({"snapshot": 1, "name": "x"})), + ("history.deleteSnapshot", json!({"snapshot": 1})), + ] { + assert!(s.execute(cmd, p).is_err(), "{cmd}: no document"); + } + let mut s = session(); + s.execute("history.newSnapshot", json!({})).unwrap(); + let (before, snaps) = (entries(&s), snapshots(&s)); + for (cmd, p) in [ + ("history.newSnapshot", json!({"from": "everything"})), + ("history.newSnapshot", json!({"from": 3})), + ("history.newSnapshot", json!({"name": 7})), + ("history.selectSnapshot", json!({})), + ("history.selectSnapshot", json!({"snapshot": 99})), + ("history.selectSnapshot", json!({"snapshot": -1})), + ("history.selectSnapshot", json!({"snapshot": "1"})), + ("history.selectSnapshot", json!({"snapshot": f64::MAX})), + ("history.renameSnapshot", json!({"snapshot": 1})), + ("history.renameSnapshot", json!({"snapshot": 1, "name": " "})), + ("history.renameSnapshot", json!({"snapshot": 99, "name": "x"})), + ("history.deleteSnapshot", json!({"snapshot": 99})), + ("history.deleteSnapshot", json!({"snapshot": [1]})), + ] { + assert!(s.execute(cmd, p.clone()).is_err(), "{cmd} {p}"); + assert_eq!((entries(&s), snapshots(&s)), (before.clone(), snaps.clone()), "{cmd} {p} changed nothing"); + } + // Current Layer needs an active layer. + s.execute("select.deselectLayers", json!({})).unwrap(); + assert!(s.execute("history.newSnapshot", json!({"from": "currentLayer"})).is_err()); + } + #[test] fn new_document_refuses_bad_params() { let mut s = Session::new(); diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 1d1c7e0c72..acfebc7f9f 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -39,6 +39,7 @@ pub fn document(d: &DocState) -> Value { "selectionBounds": doc.selection.as_ref().map(|s| { let r = s.content_bounds(); [r.x0, r.y0, r.width() as i32, r.height() as i32] }), "layers": doc.layers.iter().rev().map(|l| layer_sel(l, &selected)).collect::>(), "history": d.history.entries(), + "snapshots": d.snapshots.iter().map(|snap| json!({"id": snap.id, "name": snap.name})).collect::>(), "canUndo": d.history.can_undo(), "canRedo": d.history.can_redo(), "revision": d.revision, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1fffd0bdc1..9a926f86f1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -213,6 +213,11 @@ pub struct DocState { /// pixel coordinates. Per-document view state: discarded when the document closes, never saved /// to `.pcraft` or PSD, and not a history step. pub color_samplers: Vec<[f64; 2]>, + /// History panel snapshots, oldest first (`history.newSnapshot`, #2966). Kept for the + /// session only, as in Photoshop: not history states, and not saved with the document. + pub snapshots: Vec, + /// The id the next snapshot gets; ids are never reused, so deleting one keeps the others. + next_snapshot: u64, } impl DocState { @@ -239,6 +244,8 @@ impl DocState { show_only: None, floating: None, color_samplers: Vec::new(), + snapshots: Vec::new(), + next_snapshot: 1, } } /// The selected layers in bottom-to-top document order, always including the active layer. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 49a0332bcb..4beb2a8c35 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -548,6 +548,9 @@ pub struct PhotocraftApp { pub fps: f32, last_frame_time: f64, thumbs: HashMap<(photocraft_doc::LayerId, u8), (u64, egui::TextureHandle)>, + /// History snapshot thumbnails by (document, snapshot id): a snapshot never changes, so each + /// is made once (`snapshot_thumb`, #2966). + snapshot_thumbs: HashMap<(DocId, u64), egui::TextureHandle>, /// Snapshots whose live layer/mask keys were last used to prune thumbnail handles. thumb_documents: Vec<(DocId, std::sync::Weak)>, /// Content bounds cached per (key, revision): scanning a 36 MP layer every frame cost ~77 ms. @@ -722,6 +725,7 @@ impl PhotocraftApp { fps: 0.0, last_frame_time: 0.0, thumbs: HashMap::new(), + snapshot_thumbs: HashMap::new(), thumb_documents: Vec::new(), bounds_cache: HashMap::new(), proxy: None, @@ -1814,6 +1818,29 @@ impl PhotocraftApp { self.store_thumb(ctx, key, rev, img) } + /// The History panel thumbnail of snapshot `snap` of document `doc` (#2966): its composite, + /// centred in a square. Thumbnails of `doc`'s snapshots not in `live` are dropped. + pub fn snapshot_thumb(&mut self, ctx: &egui::Context, doc: DocId, snap: &photocraft_engine::history_cmds::Snapshot, live: &[u64]) -> egui::TextureId { + self.snapshot_thumbs.retain(|(d, id), _| *d != doc || live.contains(id)); + if let Some(tex) = self.snapshot_thumbs.get(&(doc, snap.id)) { + return tex.id(); + } + let comp = photocraft_compose::thumbnail(&snap.doc, 56); + let (w, h) = (comp.width as usize, comp.height as usize); + let side = w.max(h); + let (ox, oy) = ((side - w) / 2, (side - h) / 2); + let mut px = vec![egui::Color32::TRANSPARENT; side * side]; + for (i, &[r, g, b, a]) in comp.pixels.as_chunks::<4>().0.iter().enumerate() { + if let Some(out) = px.get_mut((i / w + oy) * side + i % w + ox) { + *out = egui::Color32::from_rgba_unmultiplied(r, g, b, a); + } + } + let tex = ctx.load_texture(format!("snapshot-{}", snap.id), egui::ColorImage::new([side, side], px), egui::TextureOptions::LINEAR); + let id = tex.id(); + self.snapshot_thumbs.insert((doc, snap.id), tex); + id + } + pub fn mask_thumb(&mut self, ctx: &egui::Context, doc: &Document, id: photocraft_doc::LayerId, mask: &photocraft_doc::LayerMask) -> egui::TextureId { let rev = surface_fingerprint(&mask.surface) ^ (doc.size.width as u64) << 40; let key = (id, mask_thumbs_ui::THUMB_MASK); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index b893521aa5..e85f5aa8db 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2840,12 +2840,16 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.painter().text(pos2(thumb.right() + 8.0, rect.center().y), Align2::LEFT_CENTER, &doc.name, egui::FontId::proportional(12.0), t.text); ui.painter().line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.separator)); } + let mut snap_cmd = None; let mut target: Option = None; let all = entries.iter().map(|e| (e.clone(), false)).chain(redo.iter().map(|e| (e.clone(), true))); // The dock gives History a fixed height: the rows scroll above the footer. let footer = if t.pro { widgets::footer_height(ui) + ui.spacing().item_spacing.y } else { 0.0 }; let max_h = (ui.available_height() - footer).max(40.0); egui::ScrollArea::vertical().id_salt("history-rows").max_height(max_h).auto_shrink([false, false]).show(ui, |ui| { + // Snapshots head the list and scroll with the states, so many of them never push the + // footer out of a short dock. + snap_cmd = snapshot_rows(app, ui); for (i, (e, is_redo)) in all.enumerate() { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); if i == current { @@ -2883,8 +2887,10 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ui.add_enabled_ui(can_delete, |ui| icons::button(ui, "trash", 26.0, false, tl!("Delete current state"))).inner.clicked() { footer_cmd = Some("history.deleteState"); } - // Snapshots are not implemented yet: shown greyed, as Photoshop's footer has the button. - ui.add_enabled_ui(false, |ui| icons::button(ui, "scan", 26.0, false, tl!("Create new snapshot"))); + let can_snap = app.session.is_enabled("history.newSnapshot"); + if ui.add_enabled_ui(can_snap, |ui| icons::button(ui, "scan", 26.0, false, tl!("Create new snapshot"))).inner.clicked() { + footer_cmd = Some("history.newSnapshot"); + } if ui.add_enabled_ui(can_new, |ui| icons::button(ui, "file-plus", 26.0, false, tl!("Create new document from current state"))).inner.clicked() { footer_cmd = Some("history.newDocument"); } @@ -2892,8 +2898,10 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } // An open Free Transform owns Undo (transform_tool::intercept): stepping the document's history under // its box would leave it transforming pixels that changed. - if let Some(cmd) = footer_cmd.filter(|_| app.ui.transform.is_none()) - && let Err(e) = app.run(cmd, json!({})) + // Selecting a snapshot changes the document, so it waits for an open transform like the rows. + let snap_cmd = snap_cmd.filter(|(cmd, _)| *cmd != "history.selectSnapshot" || app.ui.transform.is_none()); + if let Some((cmd, p)) = footer_cmd.map(|c| (c, json!({}))).filter(|_| app.ui.transform.is_none()).or(snap_cmd) + && let Err(e) = app.run(cmd, p) { app.ui.status = e; app.ui.status_error = true; @@ -2908,6 +2916,79 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// The History panel's snapshot rows (#2966), under the opening row, each with its thumbnail and +/// name: a click returns to the snapshot (a new state), a double-click or right-click › Rename… +/// edits the name in place (Enter or a click elsewhere keeps it, Escape cancels), and +/// right-click › Delete removes it. Returns the command a row asks for. +fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> Option<(&'static str, Value)> { + let t = Tokens::get(ui.ctx()); + let st = app.session.active()?; + let (doc_id, snaps) = (st.doc.id, st.snapshots.clone()); + let live: Vec = snaps.iter().map(|s| s.id).collect(); + // Per document: snapshot ids restart in each, and a tab switch must not carry the edit over. + let rename_id = egui::Id::new(("history-snapshot-rename", doc_id)); + let mut renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id)).flatten(); + let mut cmd = None; + let ctx = ui.ctx().clone(); + for snap in &snaps { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &snap.name)); + if resp.hovered() { + ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.5)); + } + let thumb = Rect::from_min_size(pos2(rect.left() + 28.0, rect.center().y - 14.0), vec2(28.0, 28.0)); + widgets::checker(ui.painter(), thumb, 4.0); + let tex = app.snapshot_thumb(&ctx, doc_id, snap, &live); + ui.painter().image(tex, thumb, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); + ui.painter().rect_stroke(thumb, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Outside); + match renaming.as_mut().filter(|(id, _)| *id == snap.id) { + Some((_, text)) => { + let field = Rect::from_min_max(pos2(thumb.right() + 6.0, rect.center().y - 10.0), pos2(rect.right() - 6.0, rect.center().y + 10.0)); + let r = ui.put(field, egui::TextEdit::singleline(text)); + if r.lost_focus() { + if !ui.input(|i| i.key_pressed(egui::Key::Escape)) { + cmd = Some(("history.renameSnapshot", json!({"snapshot": snap.id, "name": text.clone()}))); + } + renaming = None; + } else if !r.has_focus() { + // Starting to rename: focus the field with the whole name selected, so typing + // replaces it (Photoshop). + r.request_focus(); + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), r.id) { + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), r.id); + } + } + } + None => { + ui.painter().text(pos2(thumb.right() + 8.0, rect.center().y), Align2::LEFT_CENTER, &snap.name, egui::FontId::proportional(12.0), t.text); + } + } + if resp.double_clicked() { + renaming = Some((snap.id, snap.name.clone())); + } else if resp.clicked() { + cmd = Some(("history.selectSnapshot", json!({"snapshot": snap.id}))); + } + resp.context_menu(|ui| { + if ui.button(tl!("Rename…")).clicked() { + renaming = Some((snap.id, snap.name.clone())); + ui.close(); + } + if ui.button(tl!("Delete")).clicked() { + cmd = Some(("history.deleteSnapshot", json!({"snapshot": snap.id}))); + ui.close(); + } + }); + } + if !snaps.is_empty() { + let y = ui.cursor().top(); + ui.painter().hline(ui.max_rect().x_range(), y, Stroke::new(1.0, t.separator)); + } + ui.data_mut(|d| d.insert_temp(rename_id, renaming)); + cmd +} + // ----------------------------------------------------------------------------- properties /// Floating Properties card anchored to the canvas' top-right corner. @@ -4102,8 +4183,8 @@ mod history_transform_tests { } } - /// The Photoshop-theme footer (#1117): trash deletes the current state, file-plus makes a - /// new document from it, and the snapshot button is greyed (snapshots aren't implemented). + /// The Photoshop-theme footer (#1117): trash deletes the current state, the camera takes a + /// snapshot (#2966), and file-plus makes a new document from the current state. #[test] fn history_footer_buttons_run_their_commands() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); @@ -4124,7 +4205,12 @@ mod history_transform_tests { }; let b = buttons(&h); assert_eq!(b.len(), 3, "trash, snapshot, new document: {b:?}"); - assert!(b[0].1 && !b[1].1 && b[2].1, "only the snapshot button is disabled: {b:?}"); + assert!(b.iter().all(|b| b.1), "all enabled: {b:?}"); + let steps = h.state().session.active().unwrap().history.entries().len(); + click(&mut h, b[1].0.center()); + let st = h.state().session.active().unwrap(); + assert_eq!(st.snapshots.len(), 1, "the camera takes a snapshot"); + assert_eq!(st.history.entries().len(), steps, "which is not a history state"); let docs = h.state().session.documents().len(); click(&mut h, b[2].0.center()); assert_eq!(h.state().session.documents().len(), docs + 1, "file-plus makes a new document"); @@ -4139,6 +4225,123 @@ mod history_transform_tests { } } +#[cfg(test)] +mod snapshot_row_tests { + use egui::vec2; + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + use serde_json::json; + + use super::*; + + fn top_pixel(h: &Harness<'static, PhotocraftApp>) -> [f32; 4] { + h.state().session.active().unwrap().doc.layers.last().unwrap().surface().unwrap().rgba(1, 1) + } + + fn snapshot_names(h: &Harness<'static, PhotocraftApp>) -> Vec { + h.state().session.active().unwrap().snapshots.iter().map(|s| s.name.clone()).collect() + } + + /// Snapshot rows (#2966) sit under the opening row: clicking one returns to it as a new + /// state, and its right-click menu renames or deletes it. + #[test] + fn snapshot_rows_select_rename_and_delete() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("history.newSnapshot", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#0000ff"})).unwrap(); + // 60 fps steps, so two clicks a frame apart count as a double-click. + let mut h = + Harness::builder().with_size(vec2(300.0, 400.0)).with_step_dt(1.0 / 60.0).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app); + h.run_steps(3); + let steps = h.state().session.active().unwrap().history.entries().len(); + h.get_by_label("Snapshot 1").click(); + h.run_steps(2); + let st = h.state().session.active().unwrap(); + assert_eq!(st.history.entries().len(), steps + 1); + assert_eq!(st.history.entries().last().map(String::as_str), Some("Snapshot 1")); + assert_eq!(top_pixel(&h), [1.0, 0.0, 0.0, 1.0], "back to red"); + // Right-click › Rename…: type a name, Enter. + h.get_by_label("Snapshot 1").click_secondary(); + h.run_steps(2); + h.get_by_label("Rename…").click(); + h.run_steps(2); + h.get_by_role(egui::accesskit::Role::TextInput).type_text("Lesson start"); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert_eq!(snapshot_names(&h), ["Lesson start"]); + // A double-click renames too; Escape keeps the old name. + let steps_now = h.state().session.active().unwrap().history.entries().len(); + // A real double-click (accessibility clicks are single clicks). + let p = h.get_by_label("Lesson start").rect().center(); + let p = egui::pos2(p.x as f32, p.y as f32); + h.event(egui::Event::PointerMoved(p)); + // Wait out the double-click window of the earlier clicks. + h.run_steps(30); + for pressed in [true, false, true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + h.get_by_role(egui::accesskit::Role::TextInput).type_text("Oops"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(snapshot_names(&h), ["Lesson start"], "Escape cancels"); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps_now + 1, "the click selected it once"); + // Right-click › Delete. + h.get_by_label("Lesson start").click_secondary(); + h.run_steps(2); + h.get_by_label("Delete").click(); + h.run_steps(2); + assert!(snapshot_names(&h).is_empty()); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps + 2, "deleting leaves the history alone"); + } + + /// Snapshot rows scroll with the states: many snapshots in a short dock leave the footer and + /// its camera button in view. + #[test] + fn many_snapshots_keep_the_footer_in_a_short_panel() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + for _ in 0..6 { + app.run("history.newSnapshot", json!({})).unwrap(); + } + let mut h = Harness::builder().with_size(vec2(300.0, 250.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.state_mut().ui.theme = crate::theme::ThemeKind::ProMedium; + h.run_steps(3); + let view = h.ctx.input(|i| i.viewport_rect()); + let buttons: Vec = h + .ctx + .viewport(|v| v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(26.0, 26.0)).map(|w| w.rect).collect()); + assert_eq!(buttons.len(), 3, "{buttons:?}"); + assert!(buttons.iter().all(|b| view.contains_rect(*b)), "footer in view: {buttons:?} in {view:?}"); + } + + /// Renaming belongs to one document: snapshot ids restart in each, so switching tabs must not + /// carry the edit over to the other document's snapshot with the same id. + #[test] + fn a_rename_in_progress_stays_with_its_document() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + for _ in 0..2 { + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.run("history.newSnapshot", json!({})).unwrap(); + } + let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app); + h.run_steps(3); + h.get_by_label("Snapshot 1").click_secondary(); + h.run_steps(2); + h.get_by_label("Rename…").click(); + h.run_steps(2); + assert!(h.query_by_role(egui::accesskit::Role::TextInput).is_some(), "renaming in document 2"); + h.state_mut().session.set_active(0); + h.run_steps(2); + assert!(h.query_by_role(egui::accesskit::Role::TextInput).is_none(), "not in document 1"); + } +} + #[cfg(test)] #[path = "layer_pct_slider_tests.rs"] mod layer_pct_slider_tests; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b8d38b558a..db2251dfdc 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -101,7 +101,7 @@ store expose and persist the same setting; scripts keep using canonical command | `layer.newAdjustmentLayer.hueSaturation` | `{"hue":30,"saturation":10}` | | `paint.stroke` | `{"points":[[x,y,pressure],…],"size":20,"color":"#ff0000"}` | | `select.rect` | `{"x":0,"y":0,"width":100,"height":50,"mode":"add","ellipse":false}` | -| `document.inspect` | `{}`: layer tree, history, selection bounds | +| `document.inspect` | `{}`: layer tree, history, snapshots (`[{id, name}]`, for `history.selectSnapshot`), selection bounds | | `document.pixel` | `{"x":10,"y":10}`: composite RGBA | | `document.presets.list` | `{}` → `{presets:[{name,settings}]}`: saved New Document configurations | | `document.presets.save` | `{"name":"Product square","settings":{"width":1600,"height":1600,"background":"transparent"}}`: save a snapshot; names are trimmed, 1–255 characters, and ASCII case-insensitive duplicates are rejected | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 270214e1f9..af0992b6af 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Hand-tool flick panning) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (History snapshots) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -198,6 +198,7 @@ Paragraph; the rest open as in-app floating windows. | Command palette with recents | done | UI-217-7 | | Every preference takes effect | partial | 46 of 149 do nothing (scorecard) | | UI scale presets | missing | UI-217-9, #2492 | +| History snapshots: camera button, snapshot rows (click returns, rename, delete), `history.*Snapshot` commands | partial | `history_cmds.rs`, `snapshot_rows` in `panels.rs`; New Snapshot dialog, first snapshot and History Options to come (#2966) | | Themes: Photoshop's four greys | partial | five themes; #2661 | | Document tabs: overflow, context menu, reorder | done | UI-217-6; middle-click close #2570 | | Layers panel: wheel steps blend modes, drop on New/Trash | partial | UI-217-14 | @@ -209,3 +210,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-10 | major | First version, from a source audit against Photoshop 2026 27.11.0's shortcuts file and the open issues | | 2026-10-10 | minor | Fixed Crop live resize snapping feedback and outdated-camera rendering, applied ratios immediately to centred inscribed previews, and removed two-point options-bar overflow; added pointer, rendering, dropdown/Enter, Swap/X and scrolling regressions | | 2026-10-11 | minor | Flick panning done: releasing a moving Hand drag (tool, Space or middle button) glides and decays, gated by Enable Flick Panning | +| 2026-10-11 | minor | History snapshots: the camera button takes one, snapshot rows return to it as a new state, rename and delete it (#2966) | From b00adf4287d963b8ec43baad4ea1ef49fa61eefd Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:17:06 +0200 Subject: [PATCH 019/229] History panel: New Snapshot dialog and the first snapshot Part of #2966. Every document starts with a snapshot of itself as opened, named after it, as Photoshop does: the History panel's top row is now that snapshot, so clicking it brings the opened document back as one new state, even after History has dropped its oldest states. Option-click on the camera button opens New Snapshot: Name (suggested "Snapshot N", selected) and From (Full Document, Merged Layers, Current Layer). "From:", "Full Document" and "Merged Layers" are translated in all 16 catalogs, and "New Snapshot" in the 7 that lacked it. --- crates/engine/src/history_cmds.rs | 35 ++++++++- crates/engine/src/lib.rs | 16 ++-- crates/ui-egui/src/dialogs.rs | 2 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels.rs | 105 +++++++++++++++++++++----- crates/ui-egui/src/snapshot_dialog.rs | 71 +++++++++++++++++ docs/control-protocol.md | 2 +- docs/ui-parity.md | 3 +- 24 files changed, 260 insertions(+), 30 deletions(-) create mode 100644 crates/ui-egui/src/snapshot_dialog.rs diff --git a/crates/engine/src/history_cmds.rs b/crates/engine/src/history_cmds.rs index bb9cb34391..e49633e324 100644 --- a/crates/engine/src/history_cmds.rs +++ b/crates/engine/src/history_cmds.rs @@ -99,6 +99,12 @@ fn snapshot_index(s: &Session, cmd: &str, p: &Value) -> Result { st.snapshots.iter().position(|snap| snap.id == id).ok_or_else(|| bad(cmd, format!("no snapshot {id}"))) } +/// The name `history.newSnapshot` gives a snapshot without one: "Snapshot N" (the New Snapshot +/// dialog suggests it too). +pub fn next_snapshot_name(st: &crate::DocState) -> String { + format!("Snapshot {}", st.next_snapshot) +} + /// A non-blank `name` param, trimmed; `None` when absent. fn name_param(cmd: &str, p: &Value) -> Result> { match p.get("name") { @@ -138,9 +144,9 @@ fn new_snapshot(s: &mut Session, p: &Value) -> Result { _ => (st.doc.clone(), st.layer_target()), }; let st = s.active_mut().ok_or(EngineError::NoDocument)?; + let name = name.unwrap_or_else(|| next_snapshot_name(st)); let id = st.next_snapshot; st.next_snapshot = id.saturating_add(1); - let name = name.unwrap_or_else(|| format!("Snapshot {id}")); st.snapshots.push(Snapshot { id, name: name.clone(), doc, layers }); Ok(json!({ "snapshot": id, "name": name })) } @@ -370,7 +376,8 @@ mod tests { assert_eq!(entries(&s), before, "taking a snapshot is not a step"); let r = s.execute("history.newSnapshot", json!({"name": "Before green"})).unwrap(); assert_eq!(r, json!({"snapshot": 2, "name": "Before green"})); - assert_eq!(snapshots(&s), json!([{"id": 1, "name": "Snapshot 1"}, {"id": 2, "name": "Before green"}])); + let first = json!({"id": 0, "name": s.active().unwrap().doc.name}); + assert_eq!(snapshots(&s), json!([first, {"id": 1, "name": "Snapshot 1"}, {"id": 2, "name": "Before green"}])); } #[test] @@ -390,7 +397,7 @@ mod tests { assert!(s.active().unwrap().active_layer.is_some()); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(top_pixel(&s), blue, "undo leaves the snapshot"); - assert_eq!(snapshots(&s).as_array().unwrap().len(), 1, "snapshots are not history"); + assert_eq!(snapshots(&s).as_array().unwrap().len(), 2, "snapshots are not history"); } #[test] @@ -418,11 +425,31 @@ mod tests { } s.execute("history.renameSnapshot", json!({"snapshot": 2, "name": "Lesson start"})).unwrap(); s.execute("history.deleteSnapshot", json!({"snapshot": 1})).unwrap(); - assert_eq!(snapshots(&s), json!([{"id": 2, "name": "Lesson start"}, {"id": 3, "name": "Snapshot 3"}])); + s.execute("history.deleteSnapshot", json!({"snapshot": 0})).unwrap(); + assert_eq!(snapshots(&s), json!([{"id": 2, "name": "Lesson start"}, {"id": 3, "name": "Snapshot 3"}]), "the first snapshot can go too"); let r = s.execute("history.newSnapshot", json!({})).unwrap(); assert_eq!(r["snapshot"], json!(4), "ids are never reused"); } + /// Photoshop's first snapshot (#2966): every document starts with a snapshot of itself as + /// opened, named after it, so the opening state can always be brought back, even after + /// History has dropped its oldest states. + #[test] + fn a_document_starts_with_a_first_snapshot_of_itself() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 10})).unwrap(); + let name = s.active().unwrap().doc.name.clone(); + assert_eq!(snapshots(&s), json!([{"id": 0, "name": name}])); + let opened = top_pixel(&s); + for _ in 0..3 { + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + } + s.active_mut().unwrap().history.purge_last(); + s.execute("history.selectSnapshot", json!({"snapshot": 0})).unwrap(); + assert_eq!(top_pixel(&s), opened, "back to the document as opened"); + assert_eq!(entries(&s).last(), Some(&name), "a state named after the document"); + } + #[test] fn snapshot_commands_refuse_bad_params() { let mut s = Session::new(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9a926f86f1..4390575479 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -213,8 +213,9 @@ pub struct DocState { /// pixel coordinates. Per-document view state: discarded when the document closes, never saved /// to `.pcraft` or PSD, and not a history step. pub color_samplers: Vec<[f64; 2]>, - /// History panel snapshots, oldest first (`history.newSnapshot`, #2966). Kept for the - /// session only, as in Photoshop: not history states, and not saved with the document. + /// History panel snapshots, oldest first (`history.newSnapshot`, #2966): the first (id 0) is + /// the document as opened. Kept for the session only, as in Photoshop: not history states, + /// and not saved with the document. pub snapshots: Vec, /// The id the next snapshot gets; ids are never reused, so deleting one keeps the others. next_snapshot: u64, @@ -224,9 +225,14 @@ impl DocState { pub fn new(doc: Document, path: Option) -> Self { let active_layer = doc.top_layer(); let mut history = History::default(); - history.set_current_layers(LayerTarget { active: active_layer, selected: active_layer.into_iter().collect() }); + let target = LayerTarget { active: active_layer, selected: active_layer.into_iter().collect() }; + history.set_current_layers(target.clone()); + let doc = Arc::new(doc); + // Photoshop's first snapshot: the document as opened, named after it, so the History + // panel's top row can always bring it back (#2966). + let first = history_cmds::Snapshot { id: 0, name: doc.name.clone(), doc: doc.clone(), layers: target }; Self { - doc: Arc::new(doc), + doc, history, active_layer, selected_layers: active_layer.into_iter().collect(), @@ -244,7 +250,7 @@ impl DocState { show_only: None, floating: None, color_samplers: Vec::new(), - snapshots: Vec::new(), + snapshots: vec![first], next_snapshot: 1, } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e57c01b4c9..2f8215655f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -229,6 +229,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { DialogKind::Command if crate::fill_ui::owns(&fields) => crate::fill_ui::body(app, ui, &mut fields), DialogKind::Command if crate::pattern_fill_ui::owns(&fields) => crate::pattern_fill_ui::body(app, ui, &mut fields), DialogKind::Command if crate::stroke_ui::owns(&fields) => crate::stroke_ui::body(ui, &mut fields), + DialogKind::Command if crate::snapshot_dialog::owns(&fields) => crate::snapshot_dialog::body(ui, &mut fields), DialogKind::Command if crate::shape_dialog::owns(&fields) => crate::shape_dialog::body(app, ui, &mut fields), DialogKind::Command if crate::delete_layer_prompt::owns(&fields) => crate::delete_layer_prompt::body(ui, &mut fields), DialogKind::Command if crate::rasterize_prompt::owns(&fields) => crate::rasterize_prompt::body(ui, &fields), @@ -464,6 +465,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { DialogKind::Command if crate::fill_ui::owns(&d.fields) => crate::fill_ui::confirm(app, &d.fields), DialogKind::Command if crate::pattern_fill_ui::owns(&d.fields) => crate::pattern_fill_ui::confirm(app, &d.fields), DialogKind::Command if crate::stroke_ui::owns(&d.fields) => crate::stroke_ui::confirm(app, &d.fields), + DialogKind::Command if crate::snapshot_dialog::owns(&d.fields) => crate::snapshot_dialog::confirm(app, &d.fields), DialogKind::Command if crate::shape_dialog::owns(&d.fields) => crate::shape_dialog::confirm(app, &d.fields), DialogKind::Command if crate::delete_layer_prompt::owns(&d.fields) => crate::delete_layer_prompt::confirm(app, &d.fields), DialogKind::Command if crate::rasterize_prompt::owns(&d.fields) => crate::rasterize_prompt::confirm(app, &d.fields), diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 93f8fd5956..60068225a1 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -974,6 +974,10 @@ Create new pattern from the selection Vytvořit nový vzorek z výběru Create new shape from the current path Vytvořit nový tvar z aktuální cesty Create new snapshot Vytvořit nový snímek + From: Z: + Full Document Celý dokument + Merged Layers Sloučené vrstvy + New Snapshot Nový snímek Create new tool preset Vytvořit nové přednastavení nástroje Crop Factor Crop faktor Ctrl Ctrl diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 26871a1e68..b325827b05 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -952,6 +952,10 @@ Create new pattern from the selection Neues Muster aus der Auswahl erstellen Create new shape from the current path Neue Form aus dem aktuellen Pfad erstellen Create new snapshot Neuen Snapshot erstellen + From: Von: + Full Document Gesamtes Dokument + Merged Layers Zusammengefügte Ebenen + New Snapshot Neuer Snapshot Create new style (from the selected type) Neuen Stil erstellen (aus dem ausgewählten Text) Create new style from the selected layer Neuen Stil aus der ausgewählten Ebene erstellen Create new tool preset Neue Werkzeugvorgabe erstellen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index dc39a34150..22b56a00f9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -719,6 +719,9 @@ Create new pattern from the selection Δημιουργία μοτίβου από την επιλογή Create new shape from the current path Δημιουργία σχήματος από την τρέχουσα διαδρομή Create new snapshot Δημιουργία νέου στιγμιότυπου + From: Από: + Full Document Ολόκληρο έγγραφο + Merged Layers Συγχωνευμένα επίπεδα Create new style (from the selected type) Δημιουργία στυλ από τον επιλεγμένο τύπο Create new style from the selected layer Δημιουργία στυλ από το επιλεγμένο επίπεδο Current & Below Τρέχον και υποκείμενα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c65d4dd089..52a7f5799c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -960,6 +960,10 @@ Create new pattern from the selection Crear un motivo nuevo a partir de la selección Create new shape from the current path Crear una forma nueva a partir del trazado actual Create new snapshot Crear una instantánea nueva + From: De: + Full Document Documento completo + Merged Layers Capas combinadas + New Snapshot Instantánea nueva Create new tool preset Crear un ajuste preestablecido de herramienta nuevo Crop Factor Factor de recorte Ctrl Ctrl diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6a20c3521b..a4a6d86563 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -948,6 +948,9 @@ Create new pattern from the selection Créer un nouveau motif à partir de la sélection Create new shape from the current path Créer une nouvelle forme à partir du tracé actif Create new snapshot Créer un nouvel instantané + From: À partir de : + Full Document Document entier + Merged Layers Calques fusionnés Create new tool preset Créer un nouveau préréglage d'outil Crop Factor Facteur de recadrage Ctrl Ctrl diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9f1b970db2..061b2c7e11 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -942,6 +942,9 @@ Create new pattern from the selection Buat pola baru dari seleksi Create new shape from the current path Buat bentuk baru dari path saat ini Create new snapshot Buat snapshot baru + From: Dari: + Full Document Seluruh Dokumen + Merged Layers Layer Gabungan Create new tool preset Buat preset alat baru Crop Factor Faktor Crop Ctrl Ctrl diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 4924ecfc71..83bfe28abf 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -959,6 +959,10 @@ Create new pattern from the selection Crea nuovo pattern dalla selezione Create new shape from the current path Crea nuova forma dal tracciato corrente Create new snapshot Crea nuova istantanea + From: Da: + Full Document Documento intero + Merged Layers Livelli uniti + New Snapshot Nuova istantanea Create new style (from the selected type) Crea nuovo stile (dal testo selezionato) Create new style from the selected layer Crea nuovo stile dal livello selezionato Create new tool preset Crea nuovo predefinito strumento diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index be6169e806..dbef0bf2e5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -961,6 +961,9 @@ Create new pattern from the selection 選択範囲から新規パターンを作成 Create new shape from the current path 現在のパスから新規シェイプを作成 Create new snapshot 新規スナップショットを作成 + From: 作成元: + Full Document ドキュメント全体 + Merged Layers 結合されたレイヤー Create new tool preset 新規ツールプリセットを作成 Crop Factor 切り抜き係数 Ctrl Ctrl diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b9ee70a4e1..87937ad59e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -934,6 +934,10 @@ Create new pattern from the selection 선택 영역으로 새 패턴 만들기 Create new shape from the current path 현재 패스로 새 모양 만들기 Create new snapshot 새 스냅샷 만들기 + From: 원본: + Full Document 전체 문서 + Merged Layers 병합된 레이어 + New Snapshot 새 스냅샷 Create new style (from the selected type) 새 스타일 만들기(선택한 문자에서) Create new style from the selected layer 선택한 레이어로 새 스타일 만들기 Create new tool preset 새 도구 사전 설정 만들기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 00d793cbf5..908f761495 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -942,6 +942,9 @@ Create new pattern from the selection Nieuw patroon maken van de selectie Create new shape from the current path Nieuwe vorm maken van het huidige pad Create new snapshot Nieuwe momentopname maken + From: Van: + Full Document Volledig document + Merged Layers Samengevoegde lagen Create new style (from the selected type) Nieuwe stijl maken (van de geselecteerde tekst) Create new style from the selected layer Nieuwe stijl maken van de geselecteerde laag Create new tool preset Nieuwe gereedschapsvoorinstelling maken diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index d6018ec9a3..1681a8156c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -958,6 +958,9 @@ Create new pattern from the selection Utwórz nowy wzór z zaznaczenia Create new shape from the current path Utwórz nowy kształt z bieżącej ścieżki Create new snapshot Utwórz nową migawkę + From: Z: + Full Document Cały dokument + Merged Layers Scalone warstwy Create new style (from the selected type) Utwórz nowy styl (z zaznaczonego tekstu) Create new style from the selected layer Utwórz nowy styl z zaznaczonej warstwy Create new tool preset Utwórz nowe ustawienie wstępne narzędzia diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 7373a3a8a3..1896c8fddb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -944,6 +944,9 @@ Create new pattern from the selection Criar novo padrão a partir da seleção Create new shape from the current path Criar nova forma a partir do demarcador atual Create new snapshot Criar novo instantâneo + From: De: + Full Document Documento inteiro + Merged Layers Camadas mescladas Create new style (from the selected type) Criar novo estilo (a partir do texto selecionado) Create new style from the selected layer Criar novo estilo a partir da camada selecionada Create new tool preset Criar nova predefinição de ferramenta diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 25e240377b..4a0fcb8530 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2799,6 +2799,9 @@ fontWeight Italic Курсив Create new document from current state Создать новый документ из текущего состояния Create new fill or adjustment layer Создать новый слой-заливку или корректирующий слой Create new snapshot Создать новый снимок + From: Из: + Full Document Весь документ + Merged Layers Объединённые слои Delete Cropped Pixels Удалить отсечённые пикселы Delete current state Удалить текущее состояние Delete layer Удалить слой diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a28efb4a5f..c583cc9d99 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2656,6 +2656,9 @@ fontWeight Italic Курсивний Create new document from current state Створити новий документ із поточного стану Create new fill or adjustment layer Створити новий шар заливки або корекції Create new snapshot Створити новий знімок стану + From: З: + Full Document Увесь документ + Merged Layers Об'єднані шари Delete Cropped Pixels Видаляти обрізані пікселі Delete current state Видалити поточний стан diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 5c7caba0f8..6515a4aba7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -962,6 +962,10 @@ Create new pattern from the selection 从选区创建图案 Create new shape from the current path 从当前路径创建形状 Create new snapshot 创建快照 + From: 自: + Full Document 完整文档 + Merged Layers 合并的图层 + New Snapshot 新建快照 Create new tool preset 创建工具预设 Crop Factor 裁切系数 Ctrl Ctrl diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 08a7c3bee8..752c3ca44b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -965,6 +965,10 @@ Create new pattern from the selection 從選取範圍建立新圖樣 Create new shape from the current path 從目前路徑建立新形狀 Create new snapshot 建立新快照 + From: 自: + Full Document 完整文件 + Merged Layers 合併的圖層 + New Snapshot 新增快照 Create new tool preset 建立新工具預設集 Crop Factor 裁切係數 Ctrl Ctrl diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4beb2a8c35..496c68588c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -161,6 +161,7 @@ mod sizing; pub mod slice_ui; pub mod smart_ui; pub mod snap_ui; +pub mod snapshot_dialog; pub(crate) mod solid_fill_ui; pub mod state; pub mod stroke_constraint; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index e85f5aa8db..5c0992bfa5 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2822,24 +2822,9 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { empty(ui, tl!("No document")); return; }; - let doc = st.doc.clone(); let entries = st.history.entries(); let current = entries.len() - 1; let redo: Vec = st.history.redo_labels().map(str::to_string).collect(); - // Snapshot row (Photoshop shows the document's opening state with a thumbnail). - { - let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::hover()); - let thumb = Rect::from_min_size(pos2(rect.left() + 28.0, rect.center().y - 14.0), vec2(28.0, 28.0)); - let ctx = ui.ctx().clone(); - if let Some(layer) = doc.layers.first() { - widgets::checker(ui.painter(), thumb, 4.0); - let tex = app.layer_thumb(&ctx, &doc, layer); - ui.painter().image(tex, thumb, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); - } - ui.painter().rect_stroke(thumb, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Outside); - ui.painter().text(pos2(thumb.right() + 8.0, rect.center().y), Align2::LEFT_CENTER, &doc.name, egui::FontId::proportional(12.0), t.text); - ui.painter().line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.separator)); - } let mut snap_cmd = None; let mut target: Option = None; let all = entries.iter().map(|e| (e.clone(), false)).chain(redo.iter().map(|e| (e.clone(), true))); @@ -2880,6 +2865,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } }); let mut footer_cmd: Option<&str> = None; + let mut snapshot_dialog = false; if t.pro { let can_delete = app.session.is_enabled("history.deleteState"); let can_new = app.session.is_enabled("history.newDocument"); @@ -2888,8 +2874,13 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { footer_cmd = Some("history.deleteState"); } let can_snap = app.session.is_enabled("history.newSnapshot"); + // ⌥-click opens New Snapshot (name, From) instead, as in Photoshop. if ui.add_enabled_ui(can_snap, |ui| icons::button(ui, "scan", 26.0, false, tl!("Create new snapshot"))).inner.clicked() { - footer_cmd = Some("history.newSnapshot"); + if ui.input(|i| i.modifiers.alt) { + snapshot_dialog = true; + } else { + footer_cmd = Some("history.newSnapshot"); + } } if ui.add_enabled_ui(can_new, |ui| icons::button(ui, "file-plus", 26.0, false, tl!("Create new document from current state"))).inner.clicked() { footer_cmd = Some("history.newDocument"); @@ -2898,6 +2889,9 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } // An open Free Transform owns Undo (transform_tool::intercept): stepping the document's history under // its box would leave it transforming pixels that changed. + if snapshot_dialog { + crate::snapshot_dialog::open(app); + } // Selecting a snapshot changes the document, so it waits for an open transform like the rows. let snap_cmd = snap_cmd.filter(|(cmd, _)| *cmd != "history.selectSnapshot" || app.ui.transform.is_none()); if let Some((cmd, p)) = footer_cmd.map(|c| (c, json!({}))).filter(|_| app.ui.transform.is_none()).or(snap_cmd) @@ -4209,7 +4203,7 @@ mod history_transform_tests { let steps = h.state().session.active().unwrap().history.entries().len(); click(&mut h, b[1].0.center()); let st = h.state().session.active().unwrap(); - assert_eq!(st.snapshots.len(), 1, "the camera takes a snapshot"); + assert_eq!(st.snapshots.len(), 2, "the camera takes a snapshot, after the first one"); assert_eq!(st.history.entries().len(), steps, "which is not a history state"); let docs = h.state().session.documents().len(); click(&mut h, b[2].0.center()); @@ -4229,7 +4223,7 @@ mod history_transform_tests { mod snapshot_row_tests { use egui::vec2; use egui_kittest::Harness; - use egui_kittest::kittest::Queryable; + use egui_kittest::kittest::{NodeT, Queryable}; use serde_json::json; use super::*; @@ -4238,8 +4232,9 @@ mod snapshot_row_tests { h.state().session.active().unwrap().doc.layers.last().unwrap().surface().unwrap().rgba(1, 1) } + /// The names of the snapshots taken after the first one (the document as opened). fn snapshot_names(h: &Harness<'static, PhotocraftApp>) -> Vec { - h.state().session.active().unwrap().snapshots.iter().map(|s| s.name.clone()).collect() + h.state().session.active().unwrap().snapshots.iter().skip(1).map(|s| s.name.clone()).collect() } /// Snapshot rows (#2966) sit under the opening row: clicking one returns to it as a new @@ -4320,6 +4315,78 @@ mod snapshot_row_tests { assert!(buttons.iter().all(|b| view.contains_rect(*b)), "footer in view: {buttons:?} in {view:?}"); } + /// The top row is the first snapshot, the document as opened: clicking it brings that back + /// (the Discord report on #2966: "History original file click does not work"). + #[test] + fn the_top_row_returns_to_the_document_as_opened() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + let opened = app.session.active().unwrap().doc.layers[0].surface().unwrap().rgba(1, 1); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let name = app.session.active().unwrap().doc.name.clone(); + let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut PhotocraftApp| history(app, ui), app); + h.run_steps(3); + h.get_by_label(&name).click(); + h.run_steps(2); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.layers[0].surface().unwrap().rgba(1, 1), opened); + assert_eq!(st.history.entries().last(), Some(&name)); + } + + /// ⌥-click on the camera opens New Snapshot (#2966): the name, selected so typing replaces + /// it, and From (Full Document, Merged Layers, Current Layer); OK takes the snapshot. + #[test] + fn alt_click_on_the_camera_opens_new_snapshot() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 150})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + history(app, ui); + crate::dialogs::show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ProMedium); + h.state_mut().ui.theme = crate::theme::ThemeKind::ProMedium; + h.run_steps(4); + let mut buttons: Vec = h + .ctx + .viewport(|v| v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(26.0, 26.0)).map(|w| w.rect).collect()); + buttons.sort_by(|a, b| b.center().x.total_cmp(&a.center().x)); + let camera = buttons[1].center(); + let alt = egui::Modifiers::ALT; + h.event(egui::Event::ModifiersChanged(alt)); + h.event(egui::Event::PointerMoved(camera)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: camera, button: egui::PointerButton::Primary, pressed, modifiers: alt }); + h.run_steps(1); + } + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().snapshots.len(), 1, "⌥-click opens the dialog instead"); + assert!(h.state().ui.dialogs.last().is_some_and(|d| crate::snapshot_dialog::owns(&d.fields))); + h.get_by_role(egui::accesskit::Role::TextInput).type_text("Red layer only"); + h.run_steps(1); + h.get_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.accesskit_node().value().as_deref() == Some("Full Document")).unwrap().click(); + h.run_steps(3); + h.get_by_label("Current Layer").click(); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + let snap = st.snapshots.last().unwrap(); + assert_eq!(snap.name, "Red layer only"); + assert_eq!(snap.doc.layers.len(), 1, "only the current layer"); + assert!(h.state().ui.dialogs.is_empty()); + } + /// Renaming belongs to one document: snapshot ids restart in each, so switching tabs must not /// carry the edit over to the other document's snapshot with the same id. #[test] diff --git a/crates/ui-egui/src/snapshot_dialog.rs b/crates/ui-egui/src/snapshot_dialog.rs new file mode 100644 index 0000000000..3ee7553b84 --- /dev/null +++ b/crates/ui-egui/src/snapshot_dialog.rs @@ -0,0 +1,71 @@ +//! History › New Snapshot (⌥-click the History panel's camera button, #2966): Photoshop's dialog +//! for `history.newSnapshot`, with the snapshot's Name and what it is taken From (the full +//! document, its layers merged, or only the current layer). The command stays directly callable +//! with the same params by agents and Actions. + +use egui::{Align2, Sense, pos2, vec2}; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::theme::Tokens; + +pub const COMMAND: &str = "history.newSnapshot"; +const MARK: &str = "__newSnapshot"; + +pub fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Open the dialog on the active document, suggesting the name the camera button would give. +pub fn open(app: &mut PhotocraftApp) -> Option { + let name = photocraft_engine::history_cmds::next_snapshot_name(app.session.active()?); + let mut f = Map::new(); + f.insert(MARK.into(), json!(true)); + f.insert("__command".into(), json!(COMMAND)); + f.insert("__label".into(), json!("New Snapshot")); + f.insert("name".into(), json!(name)); + f.insert("from".into(), json!("fullDocument")); + Some(app.ui.open_dialog(crate::state::DialogKind::Command, f)) +} + +pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { + app.run(COMMAND, json!({"name": f.get("name"), "from": f.get("from")})) +} + +fn label(ui: &mut egui::Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(64.0, 22.0), Sense::hover()); + ui.painter().text(pos2(r.right() - 6.0, r.center().y), Align2::RIGHT_CENTER, text, egui::FontId::proportional(12.0), t.text_dim); +} + +pub fn body(ui: &mut egui::Ui, f: &mut Map) { + ui.spacing_mut().item_spacing.y = 6.0; + ui.horizontal(|ui| { + label(ui, tl!("Name:")); + let mut name = f.get("name").and_then(Value::as_str).unwrap_or_default().to_string(); + let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0)); + // Opens on the name, selected, so typing replaces it (Photoshop). + if !ui.is_sizing_pass() && !f.contains_key("__named") { + r.request_focus(); + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), r.id) { + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(name.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), r.id); + } + f.insert("__named".into(), json!(true)); + } + f.insert("name".into(), json!(name)); + }); + ui.horizontal(|ui| { + label(ui, tl!("From:")); + let mut from = f.get("from").and_then(Value::as_str).unwrap_or("fullDocument").to_string(); + let opts = [ + ("fullDocument".to_string(), tl!("Full Document")), + ("mergedLayers".to_string(), tl!("Merged Layers")), + ("currentLayer".to_string(), tl!("Current Layer")), + ]; + if crate::widgets::dropdown(ui, "new-snapshot-from", &mut from, &opts, 170.0) { + f.insert("from".into(), json!(from)); + } + }); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index db2251dfdc..0ebf13f340 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -101,7 +101,7 @@ store expose and persist the same setting; scripts keep using canonical command | `layer.newAdjustmentLayer.hueSaturation` | `{"hue":30,"saturation":10}` | | `paint.stroke` | `{"points":[[x,y,pressure],…],"size":20,"color":"#ff0000"}` | | `select.rect` | `{"x":0,"y":0,"width":100,"height":50,"mode":"add","ellipse":false}` | -| `document.inspect` | `{}`: layer tree, history, snapshots (`[{id, name}]`, for `history.selectSnapshot`), selection bounds | +| `document.inspect` | `{}`: layer tree, history, snapshots (`[{id, name}]`, for `history.selectSnapshot`; id 0 is the document as opened), selection bounds | | `document.pixel` | `{"x":10,"y":10}`: composite RGBA | | `document.presets.list` | `{}` → `{presets:[{name,settings}]}`: saved New Document configurations | | `document.presets.save` | `{"name":"Product square","settings":{"width":1600,"height":1600,"background":"transparent"}}`: save a snapshot; names are trimmed, 1–255 characters, and ASCII case-insensitive duplicates are rejected | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index af0992b6af..544cf381b4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -198,7 +198,7 @@ Paragraph; the rest open as in-app floating windows. | Command palette with recents | done | UI-217-7 | | Every preference takes effect | partial | 46 of 149 do nothing (scorecard) | | UI scale presets | missing | UI-217-9, #2492 | -| History snapshots: camera button, snapshot rows (click returns, rename, delete), `history.*Snapshot` commands | partial | `history_cmds.rs`, `snapshot_rows` in `panels.rs`; New Snapshot dialog, first snapshot and History Options to come (#2966) | +| History snapshots: camera button, New Snapshot dialog (⌥-click: Name, From), the first snapshot (the document as opened), snapshot rows (click returns, rename, delete), `history.*Snapshot` commands | partial | `history_cmds.rs`, `snapshot_rows` in `panels.rs`, `snapshot_dialog.rs`; the snapshot History Options and History Brush sources to come (#2966) | | Themes: Photoshop's four greys | partial | five themes; #2661 | | Document tabs: overflow, context menu, reorder | done | UI-217-6; middle-click close #2570 | | Layers panel: wheel steps blend modes, drop on New/Trash | partial | UI-217-14 | @@ -211,3 +211,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-10 | minor | Fixed Crop live resize snapping feedback and outdated-camera rendering, applied ratios immediately to centred inscribed previews, and removed two-point options-bar overflow; added pointer, rendering, dropdown/Enter, Swap/X and scrolling regressions | | 2026-10-11 | minor | Flick panning done: releasing a moving Hand drag (tool, Space or middle button) glides and decays, gated by Enable Flick Panning | | 2026-10-11 | minor | History snapshots: the camera button takes one, snapshot rows return to it as a new state, rename and delete it (#2966) | +| 2026-10-11 | minor | History snapshots: the New Snapshot dialog (⌥-click the camera), and the first snapshot of the document as opened as the panel's top row (#2966) | From 0fcce4a1486feba87d3b5550856aacc9d6863e44 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 09:12:47 +0200 Subject: [PATCH 020/229] fmt Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/channel_cmds.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index ca28a449ed..8c6cae1f35 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -572,7 +572,10 @@ pub(crate) fn read_target_surface<'a>(doc: &'a Document, layer: Option, } Target::FilterMask => { let id = layer.ok_or_else(|| EngineError::Other("no active layer".into()))?; - doc.layer(id).and_then(|l| crate::mask_view_cmds::viewed_mask(l, true)).map(|m| &m.surface).ok_or_else(|| EngineError::Other("the layer has no filter mask".into())) + doc.layer(id) + .and_then(|l| crate::mask_view_cmds::viewed_mask(l, true)) + .map(|m| &m.surface) + .ok_or_else(|| EngineError::Other("the layer has no filter mask".into())) } } } From 42519bb32dd5803b6174fb5d3bb7e09643e8d190 Mon Sep 17 00:00:00 2001 From: Erica Franco Date: Mon, 12 Oct 2026 04:50:56 -0600 Subject: [PATCH 021/229] Integrations: wire Allow Agent Control and Control Port (#204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both fields were in Preferences › Integrations and in prefs::HIDDEN_UNTIL_IMPLEMENTED: nothing read them, so the only way to open the control channel was --control (or PHOTOCRAFT_CONTROL_PORT). They now do what their doc comments describe. Control Port, when it is not 0, is the port this launch listens on; --control still wins over it. Allow Agent Control off refuses both, and when --control asked for a port it says so on stderr instead of dropping the flag silently — an ignored --control with no message is the shape #2449 was about. A port outside 1..=65535, which only a hand-edited file can hold, is refused and named the same way. integrations() reads the saved preferences before the window opens, the way custom_titlebar() already does for System Title Bar; a missing or unreadable file keeps the defaults. The defaults are unchanged (allow on, port 0), so a launch that touches neither preference behaves exactly as before. Checked against a release build, each case with its own PHOTOCRAFT_CONFIG_DIR: preferences --control listens on (none) - nothing controlPort 7878 - 7878 controlPort 7878 9000 9000 allowAgentControl false 9000 nothing, with the warning allowAgentControl false, port 7878 - nothing controlPort 70000 - nothing, with the warning Two tests cover the decision table and the file reading (absent, unparseable, valid). docs/scorecard.md regenerated: settings that do nothing, 30 -> 28. --- apps/photocraft/src/main.rs | 81 +++++++++++++++++++++++++++++++++++++ crates/engine/src/prefs.rs | 2 - docs/scorecard.md | 6 +-- 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index aee9eabca1..9aedda2a62 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -69,6 +69,36 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool { CUSTOM_TITLEBAR && !prefs.interface.system_title_bar } +/// Preferences › Integrations, read before the window opens the way [`custom_titlebar`] is: +/// whether agents may drive this launch at all, and the port to listen on when no `--control` +/// was given (0 = only with `--control`). A missing or unreadable file keeps the defaults. +fn integrations(prefs_file: Option<&std::path::Path>) -> photocraft_engine::prefs::Integrations { + let prefs: photocraft_engine::prefs::Preferences = + prefs_file.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default(); + prefs.integrations +} + +/// The control port this launch listens on, and the warning to print when a request was refused. +/// `--control` (or `PHOTOCRAFT_CONTROL_PORT`) wins over the preference; Allow Agent Control off +/// refuses both, and says so rather than ignoring the flag silently. A preferred port outside +/// 1..=65535 is refused the same way. +fn control_port_for(cli: Option, prefs: &photocraft_engine::prefs::Integrations) -> (Option, Option) { + if !prefs.allow_agent_control { + let warning = cli.map(|p| format!("photocraft: Preferences › Integrations › Allow Agent Control is off: not listening on port {p}\n")); + return (None, warning); + } + if let Some(port) = cli { + return (Some(port), None); + } + match prefs.control_port { + 0 => (None, None), + p => match u16::try_from(p).ok().filter(|p| *p != 0) { + Some(port) => (Some(port), None), + None => (None, Some(format!("photocraft: Preferences › Integrations › Control Port {p} is not a usable port: not listening\n"))), + }, + } +} + /// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame /// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without /// `centered`, Windows cascades each new window from the top-left corner, so it opened at a @@ -256,6 +286,10 @@ fn main() -> eframe::Result { std::process::exit(1); } + let (control_port, control_pref_warning) = control_port_for(control_port, &integrations(services::prefs_file().as_deref())); + if let Some(warning) = &control_pref_warning { + eprint!("{warning}"); + } let control = if let Some(port) = control_port { let (supplied, token_file) = photocraft_automation::security::token_inputs(control_token, control_token_file); let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) { @@ -645,6 +679,53 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn integrations_preferences_decide_the_control_port() { + use photocraft_engine::prefs::Integrations; + let off = Integrations { allow_agent_control: false, control_port: 0 }; + let on = Integrations { allow_agent_control: true, control_port: 0 }; + let preferred = Integrations { allow_agent_control: true, control_port: 7878 }; + + // Nothing asked for, nothing listening. + assert_eq!(super::control_port_for(None, &on), (None, None)); + // --control wins over the preference. + assert_eq!(super::control_port_for(Some(9000), &preferred), (Some(9000), None)); + // The preference opens the port when --control is absent. + assert_eq!(super::control_port_for(None, &preferred), (Some(7878), None)); + + // Allow Agent Control off refuses both, and says so when --control asked for one + // rather than ignoring the flag. + assert_eq!(super::control_port_for(None, &off), (None, None)); + let (port, warning) = super::control_port_for(Some(9000), &off); + assert_eq!(port, None); + assert!(warning.is_some_and(|w| w.contains("Allow Agent Control") && w.contains("9000")), "the refusal names the port"); + let off_with_port = Integrations { allow_agent_control: false, control_port: 7878 }; + assert_eq!(super::control_port_for(None, &off_with_port), (None, None), "off beats a preferred port"); + + // A port a hand-edited file could hold, outside 1..=65535, is refused and named. + let huge = Integrations { allow_agent_control: true, control_port: 70_000 }; + let (port, warning) = super::control_port_for(None, &huge); + assert_eq!(port, None); + assert!(warning.is_some_and(|w| w.contains("70000")), "the refusal names the value"); + } + + #[test] + fn integrations_fall_back_to_the_defaults_without_a_readable_file() { + let dir = std::env::temp_dir().join(format!("pc-integrations-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("prefs.json"); + let default = photocraft_engine::prefs::Integrations::default(); + + assert_eq!(super::integrations(None), default, "no preferences file"); + std::fs::write(&file, "not json").unwrap(); + assert_eq!(super::integrations(Some(&file)), default, "an unreadable file"); + std::fs::write(&file, r#"{"integrations":{"allowAgentControl":false,"controlPort":7878}}"#).unwrap(); + let read = super::integrations(Some(&file)); + assert!(!read.allow_agent_control); + assert_eq!(read.control_port, 7878); + 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] diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 93a52d2d10..e7ded41e12 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1058,8 +1058,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "rawDefaults.applyAutoTone", // Agent access is governed by the launch flags (`--control`, the automation roots), not // by these yet. - "integrations.allowAgentControl", - "integrations.controlPort", ]; /// Is the preference at `path` (`"section.key"`) hidden from the Preferences dialog? diff --git a/docs/scorecard.md b/docs/scorecard.md index 80cb52faae..71c9799a76 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 30 of 155 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 28 of 155 (target 0) | | [Type](#type) | 4 | 2 | 3 | | | [Automation](#automation) | 4 | 2 | 5 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -176,9 +176,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 30 of 155** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 28 of 155** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `file_handling.save_in_background`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.missing_glyph_protection`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `file_handling.save_in_background`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.missing_glyph_protection`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| From ab0b4080f557f1476db11b7a320da86905e49a69 Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:01:20 +0200 Subject: [PATCH 022/229] CLI orientation test: the first snapshot is named after the document The .pcraft round trip compares document.inspect of a.pcraft and b.pcraft with the per-file fields removed. The first snapshot (#2966) is named after the document, so it differs in the same way as the name: check each one's name, then compare the rest. --- apps/photocraft-cli/tests/orientation.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/photocraft-cli/tests/orientation.rs b/apps/photocraft-cli/tests/orientation.rs index 101195581f..3895df367a 100644 --- a/apps/photocraft-cli/tests/orientation.rs +++ b/apps/photocraft-cli/tests/orientation.rs @@ -125,7 +125,8 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() { let (a, b) = (d.join("a.pcraft"), d.join("b.pcraft")); ok(bin().arg("convert").arg(&jpg).arg(&a)); ok(bin().arg("convert").arg(&a).arg(&b)); - // Everything but the file's own path and saved document name must match. + // Everything but the file's own path and saved document name (which the first snapshot is + // named after) must match. let info = |p: &Path| { let mut v = serde_json::from_str::(&ok(bin().arg("info").arg(p))).unwrap(); v.as_object_mut().unwrap().remove("file"); @@ -136,8 +137,12 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() { 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["snapshots"][0]["name"], "a.pcraft"); + assert_eq!(ib["snapshots"][0]["name"], "b.pcraft"); + for v in [&mut ia, &mut ib] { + v.as_object_mut().unwrap().remove("name"); + v["snapshots"][0].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)); From 7db426981d8af28e9e02b7ef9f3dcd2de63f7d6c Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 18:40:45 +0200 Subject: [PATCH 023/229] i18n: hu/tr translations for vanishing point clone strings Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/tr.tsv | 2 ++ 2 files changed, 4 insertions(+) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 080e204aaa..8c30877039 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3954,6 +3954,8 @@ cameraRaw Yellow Sárga Draw to modify the curve Rajzoljon a görbe módosításához Edit points to modify the curve Szerkessze a pontokat a görbe módosításához Fix the red plane first: its corners must form a convex quad Először javítsa a piros síkot: sarkainak konvex négyszöget kell alkotniuk + Alt-click a plane to set the clone source Alt-kattintás egy síkra a klónforrás beállításához + Too many clone strokes: apply Vanishing Point first Túl sok klónvonás: először alkalmazza az Enyészpontot Grid Size: Rácsméret: Light Blue Világoskék Light Red Világospiros diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 617ad81fd9..16202a85ed 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3751,6 +3751,8 @@ cameraData Lens Lens Draw to modify the curve Eğriyi değiştirmek için çiz Edit points to modify the curve Eğriyi değiştirmek için noktaları düzenle Fix the red plane first: its corners must form a convex quad Önce kırmızı düzlemi düzeltin: köşeleri dışbükey bir dörtgen oluşturmalı + Alt-click a plane to set the clone source Klon kaynağını ayarlamak için bir düzleme Alt+tıklayın + Too many clone strokes: apply Vanishing Point first Çok fazla klon darbesi: önce Kaybolma Noktası'nı uygulayın Grid Size: Izgara Boyutu: Light Blue Açık Mavi Light Gray Açık Gri From 1a0e9972218927fa0f8829245eeb04fe5f91130c Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:15:29 +0200 Subject: [PATCH 024/229] i18n: New Snapshot dialog strings in the Hungarian and Turkish catalogs (#2966) --- crates/ui-egui/src/i18n/hu.tsv | 3 +++ crates/ui-egui/src/i18n/tr.tsv | 3 +++ 2 files changed, 6 insertions(+) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 080e204aaa..39fdea0e2f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3820,6 +3820,9 @@ cameraRaw Yellow Sárga New document… ⌘N Új dokumentum… ⌘N New Layer from Brush Új réteg ecsetből New Path Új útvonal + From: Forrás: + Full Document Teljes dokumentum + Merged Layers Egyesített rétegek New Snapshot Új pillanatkép New Video Group Új videócsoport Newspaper Újságpapír diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 617ad81fd9..49cce672fb 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2639,6 +2639,9 @@ cameraRaw Color Renk Color indicates Renk şunu gösterir: Create Snapshot Anlık Görüntü Oluştur Delete Snapshot Anlık Görüntüyü Sil + From: Kaynak: + Full Document Tam Belge + Merged Layers Birleştirilmiş Katmanlar New Snapshot Yeni Anlık Görüntü Snapshot 1 Anlık Görüntü 1 Nonlinear History Doğrusal Olmayan Geçmiş From dc8d468b6fedebd26b89872bc41d5817d808e240 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 23:49:53 +0200 Subject: [PATCH 025/229] docs: regenerate scorecard after merging main Co-Authored-By: Claude Opus 5.5 --- docs/scorecard.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 2d653afce6..745886de04 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | -| [Tools](#tools) | 28 | 14 | 1 | | +| [Tools](#tools) | 26 | 15 | 2 | | | [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 27 of 156 (target 0) | | [Type](#type) | 4 | 2 | 3 | | | [Automation](#automation) | 4 | 2 | 5 | 631 of 631 menu items live (100.0%) | @@ -133,11 +133,11 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-213-1 | Pencil tool: aliased 1 px painting, square pixel-aligned cursor | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::Pencil (B group): live aliased strokes on the pixel grid, square cursor, Auto Erase; pencil_tests.rs | | TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, command/control dispatch and selection-clipping tests | | TOOL-213-3 | Patch tool with a live preview of the patched area | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.patch (retouch_cmds/patch.rs, source/destination) + Tool::Patch (J group: lasso, then drag the selection); the drag shows the heal live (patch_preview.rs, coarse colour fit: 1000x1000 in 52 ms) | -| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). Missing: a live preview of the result (the drag and the box show the outline only) | +| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers; background job) + Tool::ContentAwareMove (J group: lasso, then drag). Missing: Transform On Drop, a live preview of the result (the drag shows the outline only) | | TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm | | TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | done | [#213](https://github.com/storytold/photocraft/issues/213) | fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opacity, preserve; remembered in prefs; Shift+F5 / Shift+Backspace | | TOOL-213-7 | Live stroke preview for every brush-based retouch tool | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Mixer Brush, Background Eraser, Pattern Stamp | -| TOOL-213-8 | Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Clone Source overlay drawn with opacity, inversion, circular clipping, auto-hide and the Normal/Darken/Lighten/Difference blend against the image under the brush (canvas.rs draw_clone_preview, clone_source::overlay_blend); W/H link (cloneSource.set linked, panel chain button, #3298); custom-tip clipping remains missing | +| TOOL-213-8 | Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Clone Source overlay drawn with opacity, inversion, circular clipping and auto-hide; W/H link, custom-tip clipping and overlay blend modes remain missing | | TOOL-213-9 | Eyedropper comparison ring (new colour above, current below) while sampling | done | [#213](https://github.com/storytold/photocraft/issues/213) | eyedropper_ring (canvas.rs): the sampled colour on the upper arc, the foreground below, drawn for the Eyedropper and Alt-sampling while the button is held; the Precise-cursor preference keeps the crosshair | | TOOL-213-10 | Ctrl+Alt drag resizes brush size/hardness with an on-canvas readout | done | [#213](https://github.com/storytold/photocraft/issues/213) | brush_resize.rs (#297, #370): Alt+right-drag, or Control+Option-drag on macOS (Control+Alt everywhere), sets diameter horizontally and hardness vertically, circle plus Diameter/Hardness readout, one coalesced tools.setBrush, no paint or history; 9 tests there | | TOOL-213-11 | Editable pressure curve, global and per control | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Global: Preferences › Tools › Pressure Curve (prefs tools.pressureCurve; monotone cubic, engine prefs::PressureCurve; applied to every pen sample in ui-egui stylus.rs before any brush; editor pressure_curve_ui.rs with Linear/Soft/Hard presets). Missing: per-control curves (each brush dynamic's own response to pressure, tilt, rotation) | @@ -150,15 +150,15 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Fuzziness-0 tests cover midtones/shadows only (selection_cmds.rs:444-447) | | TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs, layer.selectInRect, #2844) done; double-click fit and drag to a tab open | | TOOL-214-5 | Right-click menu during Free Transform (scale, rotate, skew, warp, flip, rotate 90/180) | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas_tool_menu.rs TRANSFORM_MENU: modes, Content-Aware Scale / Puppet Warp greyed, Rotate 180 / 90 CW / 90 CCW and Flip turn the open box (transform_tool::intercept, #2832) | -| TOOL-214-6 | Puppet Warp mesh follows the object outline (constrained triangulation) | done | [#214](https://github.com/storytold/photocraft/issues/214) | algo/puppet.rs build_mesh: traced outline of the opaque region grown by Expansion, triangulated with a Density-spaced lattice (conforming Delaunay); separate shapes get separate meshes (#3105) | -| TOOL-214-7 | Content-Aware Scale through the Free Transform box with a live proxy preview | done | [#214](https://github.com/storytold/photocraft/issues/214) | Edit > Content-Aware Scale opens a scale-only Free Transform box (cas_transform.rs) with Amount, Protect and Protect Skin Tones in the options bar; edit.contentAwareScale carves a 384 px copy in the background for the live preview, and Enter runs it with the box's size and x/y as one step (#3096) | +| TOOL-214-6 | Puppet Warp mesh follows the object outline (constrained triangulation) | missing | [#214](https://github.com/storytold/photocraft/issues/214) | algo/puppet.rs:136 builds a block-grid mesh | +| TOOL-214-7 | Content-Aware Scale through the Free Transform box with a live proxy preview | partial | [#214](https://github.com/storytold/photocraft/issues/214) | edit.contentAwareScale (edit_menu_cmds.rs:899) behind a parameter dialog | | TOOL-214-8 | A measured ruler angle prefills Image Rotation > Arbitrary (0.01 deg) | done | [#214](https://github.com/storytold/photocraft/issues/214) | analysis_ui.rs: image.rotation.arbitrary opens seeded with the ruler angle and the direction that straightens it; arbitrary_rotation_starts_at_the_ruler_angle checks 5.7106 deg within 1e-3 and cw/ccw for three measured lines | | TOOL-214-9 | Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ratio dropdown and fields, Straighten (TOOL-214-21); W x H x Resolution with units, built-in size presets and Front Image (crop_size.rs, image.crop targetWidth/targetHeight/resolution, #2443); New / Delete Crop Preset save and delete crop tool presets (crop_presets.rs, tool.presets.* with tool crop, persisted with the preferences) | | TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); Backspace/Delete retracts the last point while drawing (#1466); Alt during a drag breaks the handle pair, Alt-drag turns the last anchor's outgoing handle alone, and dragging on the first anchor closes with that anchor's handles (vector_ui.rs, #3306); no Shift 45 deg | -| TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | done | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool (Background Eraser, Clone/Pattern Stamp, Healing/Spot Healing Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Mixer Brush: their paint.* command along the path, one step) plus simulatePressure (taper both ends); Stroke Path dialog from the Pen menu, the Paths panel menu and Alt-click on its Stroke button (#2978) | +| TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | partial | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke brush, pencil, eraser (vector_cmds.rs:1170); brush-only button, no dialog | | TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.convertToShape with tolerance (vector_cmds.rs:1256); radius as a field only | | TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | -| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing | +| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | done | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas window (vp_ui.rs: Create/Edit Plane, edge nodes, Ctrl/Cmd-drag tear-off planes with depth and Alt-drag angle, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane, Stamp tool cloning in perspective with live preview, all applied on OK as one step); the window's Marquee tool and the Stamp's Heal modes are still missing | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | | TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo | | TOOL-213-17 | Red Eye tool: click a pupil to neutralize the red reflection | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.redEye (redeye_cmds.rs) + Tool::RedEye in the J flyout; Pupil Size and Darken Amount; catchlights kept; a miss is an error | From e494464a8c5316bf782c5161093d952e1c459741 Mon Sep 17 00:00:00 2001 From: Jeremy Yao Date: Mon, 12 Oct 2026 19:00:50 -0400 Subject: [PATCH 026/229] Interface: Animation Effects follows the system setting, or is on or off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preferences › Interface › Animation effects replaces the Enable animations checkbox with three choices: Use system setting (the default), On and Off (interface.animationEffects: system, on, off). A saved enableAnimations: false loads as off; prefs.set interface.enableAnimations is now an unknown preference. Use system setting reads the OS through the new Services::system_animations (no answer keeps animations on): - Windows: UISettings.AnimationsEnabled (Settings › Accessibility › Visual effects › Animation effects), a safe WinRT call through the windows crate already in Cargo.lock. - macOS: NSWorkspace.accessibilityDisplayShouldReduceMotion. - Linux: the settings portal (reduced-motion, then GNOME enable-animations and KDE AnimationDurationFactor), followed live through SettingChanged. - Linux without the portal, FreeBSD and the other BSDs: kdeglobals, gsettings (GNOME, MATE) and GTK settings.ini, read at start-up and when the window regains focus. - Web: prefers-reduced-motion. Off now also stops the mouse wheel glide. egui spreads a wheel notch over about 0.2 s with no switch, which the old checkbox never touched (only fades and scroll_to). wheel_nav::unsmooth, called from PhotocraftApp::logic, replaces the frame's smoothed scroll with what its wheel events add up to; trackpad gestures with begin and end phases are left to egui. The label and its choices are translated in all 19 catalogs, replacing the Enable animations entry. The scorecard row UI-217-10 note is updated. The Windows reader was run on a machine with Animation effects off. The macOS and Linux code is compile-checked and linted by cross-target builds only, and FreeBSD not at all locally (a variant without the portal module lints clean). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/photocraft-web/Cargo.toml | 2 +- apps/photocraft-web/src/web.rs | 6 + apps/photocraft/Cargo.toml | 13 +- apps/photocraft/src/animations.rs | 444 ++++++++++++++++++++++++++++ apps/photocraft/src/appearance.rs | 3 +- apps/photocraft/src/main.rs | 1 + apps/photocraft/src/services.rs | 1 + crates/engine/src/prefs.rs | 23 +- crates/engine/src/prefs/tests.rs | 40 +++ crates/ui-egui/src/i18n/cs.tsv | 3 +- crates/ui-egui/src/i18n/de.tsv | 4 +- crates/ui-egui/src/i18n/el.tsv | 3 +- crates/ui-egui/src/i18n/es.tsv | 4 +- crates/ui-egui/src/i18n/fr.tsv | 4 +- crates/ui-egui/src/i18n/hu.tsv | 3 +- crates/ui-egui/src/i18n/id.tsv | 4 +- crates/ui-egui/src/i18n/it.tsv | 3 +- crates/ui-egui/src/i18n/ja.tsv | 4 +- crates/ui-egui/src/i18n/ko.tsv | 4 +- crates/ui-egui/src/i18n/nl.tsv | 3 +- crates/ui-egui/src/i18n/pl.tsv | 4 +- crates/ui-egui/src/i18n/pt-br.tsv | 3 +- crates/ui-egui/src/i18n/ru.tsv | 4 +- crates/ui-egui/src/i18n/tr.tsv | 3 +- crates/ui-egui/src/i18n/uk.tsv | 4 +- crates/ui-egui/src/i18n/vi.tsv | 4 +- crates/ui-egui/src/i18n/zh-hans.tsv | 4 +- crates/ui-egui/src/i18n/zh-hant.tsv | 4 +- crates/ui-egui/src/input_tests.rs | 25 ++ crates/ui-egui/src/lib.rs | 13 + crates/ui-egui/src/prefs_ui.rs | 117 +++++++- crates/ui-egui/src/wheel_nav.rs | 228 +++++++++++++- docs/scorecard.md | 2 +- scorecard/ui.toml | 2 +- 35 files changed, 940 insertions(+), 50 deletions(-) create mode 100644 apps/photocraft/src/animations.rs diff --git a/Cargo.lock b/Cargo.lock index a0499ca1c8..2cf28244f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4223,6 +4223,7 @@ dependencies = [ "rfd", "ron", "serde_json", + "windows", "winit", "winresource", "winsafe", diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index a4ded64181..8653b5878b 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -34,7 +34,7 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator", "MediaQueryList"] } [dev-dependencies] serde_json = { workspace = true } diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 16781e42bb..6012c0658d 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -395,6 +395,12 @@ fn services(inbox: Inbox) -> Services { // Preferences live in the browser's localStorage. load_prefs: Some(Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten())), save_prefs: Some(Box::new(|text: &str| local_storage().ok_or("no localStorage")?.set_item(PREFS_KEY, text).map_err(|e| format!("{e:?}")))), + // Animation Effects › Use System Setting: the browser passes on the system's setting. One + // live query, so `matches` follows a change. + system_animations: Some({ + let reduce = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten()); + Box::new(move |_| reduce.as_ref().map(|query| !query.matches())) + }), ..Default::default() } } diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index 57b537931c..556f32feec 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -49,12 +49,13 @@ core-graphics2 = { version = "=0.6.1", default-features = false, features = ["di # registers the handler (Objective-C interop needs unsafe, which our workspace forbids) behind a # safe API. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" -# Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs) and find where the -# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position). Already in -# Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions. +# Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs), find where the +# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position) and read +# Reduce Motion (animations.rs: NSWorkspace, NSAccessibility). Already in Cargo.lock (winit, +# photocraft-tablet); pinned exactly to the same versions. objc2 = "=0.6.4" objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] } -objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow"] } +objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSAccessibility", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow", "NSWorkspace"] } # The macOS menu bar (mac_menu.rs): a standalone native menu crate (from the tauri-apps org, but # not Tauri: no webview, no JS), AppKit menus behind a safe API (no GTK with default features off). Pinned exactly: review the diff before bumping. muda = { version = "=0.21.1", default-features = false } @@ -82,6 +83,10 @@ winit = { version = "=0.30.13", default-features = false, features = ["x11"] } # Safe Windows screen-capture bindings; Linux uses the pure-Rust X11 client/portal. xcap = "=0.9.8" winsafe = { version = "=0.0.29", default-features = false, features = ["user"] } +# The system's Animation effects setting (animations.rs): `UISettings.AnimationsEnabled`, a safe +# WinRT call (winsafe's `SystemParametersInfo` is unsafe). Already in Cargo.lock (wgpu, xcap, +# accesskit); pinned exactly to the same version. +windows = { version = "=0.62.2", default-features = false, features = ["std", "UI_ViewManagement"] } # tests/mac_menu_appkit.rs: reads AppKit's menu bar back and sends it real key events. [target.'cfg(target_os = "macos")'.dev-dependencies] diff --git a/apps/photocraft/src/animations.rs b/apps/photocraft/src/animations.rs new file mode 100644 index 0000000000..62d138e756 --- /dev/null +++ b/apps/photocraft/src/animations.rs @@ -0,0 +1,444 @@ +//! The operating system's animation setting, for Preferences › Interface › Animation Effects › +//! Use System Setting (`Services::system_animations`). +//! +//! - **Windows**: Settings › Accessibility › Visual effects › Animation effects, through +//! `UISettings.AnimationsEnabled`. It answers for the session, so a Remote Desktop connection +//! that turns animations off counts too. +//! - **macOS**: System Settings › Accessibility › Display › Reduce motion +//! (`NSWorkspace.accessibilityDisplayShouldReduceMotion`). +//! - **Linux**: the settings portal: `reduced-motion`, then GNOME's `enable-animations` and KDE's +//! `AnimationDurationFactor` as the portal passes them on. Its change signal keeps the value +//! current, and it works inside a Flatpak sandbox. +//! - **Linux without the portal, FreeBSD and the other BSDs**: the desktop's setting where it is +//! stored: KDE's `kdeglobals`, GNOME's or MATE's `enable-animations` (`gsettings`), then GTK's +//! `gtk-enable-animations`. There is no change signal to follow, so it is read at start-up and +//! again whenever the window gets the focus back. +//! +//! `None` means the platform gave no answer; the shell then keeps animations on. +//! +//! No `unsafe`: every OS call goes through a safe binding. + +pub use platform::service; + +/// Windows and macOS answer at once, so the setting is simply asked for again: at most this +/// often, and only on frames that run anyway (coming back from the system's settings is one). +#[cfg(any(target_os = "windows", target_os = "macos"))] +const POLL: std::time::Duration = std::time::Duration::from_secs(1); + +#[cfg(any(target_os = "windows", target_os = "macos"))] +fn polled(read: fn() -> Option) -> photocraft_ui_egui::SystemAnimationsFn { + let last = std::cell::Cell::new(None::<(std::time::Instant, Option)>); + Box::new(move |_| { + if let Some((at, animations)) = last.get() + && at.elapsed() < POLL + { + return animations; + } + let animations = read(); + last.set(Some((std::time::Instant::now(), animations))); + animations + }) +} + +#[cfg(target_os = "windows")] +mod platform { + use photocraft_ui_egui::SystemAnimationsFn; + + pub fn service() -> Option { + Some(super::polled(read)) + } + + pub(super) fn read() -> Option { + windows::UI::ViewManagement::UISettings::new().and_then(|settings| settings.AnimationsEnabled()).ok() + } +} + +#[cfg(target_os = "macos")] +mod platform { + use photocraft_ui_egui::SystemAnimationsFn; + + pub fn service() -> Option { + Some(super::polled(read)) + } + + fn read() -> Option { + Some(!objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion()) + } +} + +#[cfg(not(any(unix, target_os = "windows")))] +mod platform { + pub fn service() -> Option { + None + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +mod platform { + use std::sync::atomic::{AtomicU8, Ordering}; + use std::sync::{Arc, OnceLock, mpsc}; + + use photocraft_ui_egui::SystemAnimationsFn; + + /// Follows the desktop's setting on a worker thread, so a slow session bus or `gsettings` + /// never holds up a frame. Until the first answer lands the setting is unknown. + pub fn service() -> Option { + let value = Arc::new(AtomicU8::new(super::code_of(None))); + let wake: Arc> = Arc::new(OnceLock::new()); + let (refresh, refreshes) = mpsc::channel::<()>(); + let publisher = Publisher { value: Arc::clone(&value), wake: Arc::clone(&wake) }; + std::thread::Builder::new().name("animations-setting".into()).spawn(move || worker(&publisher, &refreshes)).ok()?; + let focused = std::cell::Cell::new(true); + Some(Box::new(move |ctx| { + let _ = wake.set(ctx.clone()); + let now = ctx.input(|i| i.focused); + let was = focused.replace(now); + // Back from the desktop's settings, perhaps: the worker reads again unless it has + // the portal's change signal (then nobody listens, and the send fails quietly). + if now && !was { + let _ = refresh.send(()); + } + super::decode(value.load(Ordering::Relaxed)) + })) + } + + fn worker(publisher: &Publisher, refreshes: &mpsc::Receiver<()>) { + #[cfg(target_os = "linux")] + if portal::follow(publisher) { + return; + } + publisher.publish(desktop::read()); + for () in refreshes { + publisher.publish(desktop::read()); + } + } + + /// The worker's handle on the shared value. A change wakes the registered UI. + struct Publisher { + value: Arc, + wake: Arc>, + } + + impl Publisher { + fn publish(&self, animations: Option) { + let code = super::code_of(animations); + if self.value.swap(code, Ordering::Relaxed) != code + && let Some(ctx) = self.wake.get() + { + ctx.request_repaint(); + } + } + } + + #[cfg(target_os = "linux")] + mod portal { + use zbus::zvariant::{OwnedValue, Value}; + + use super::super::{duration_factor, enabled, reduced_motion}; + + /// Whether animations are on according to a setting's value. + type Reads = fn(&str) -> Option; + + /// The settings the portal may answer for and how each one's value reads, most general + /// first: the cross-desktop one, then the two desktops' own that older portals pass on. + const SETTINGS: [(&str, &str, Reads); 3] = [ + ("org.freedesktop.appearance", "reduced-motion", reduced_motion), + ("org.gnome.desktop.interface", "enable-animations", enabled), + ("org.kde.kdeglobals.KDE", "AnimationDurationFactor", duration_factor), + ]; + + /// Publishes the portal's answer, then every change to it. `false` when the portal has + /// none: no session bus, no portal, or one that knows none of the settings. + pub fn follow(publisher: &super::Publisher) -> bool { + let Some(proxy) = crate::appearance::portal_proxy() else { return false }; + // Subscribe before the first read, so a change during start-up isn't lost. + let signals = proxy.receive_signal("SettingChanged").ok(); + let Some(first) = read(&proxy) else { return false }; + publisher.publish(Some(first)); + for message in signals.into_iter().flatten() { + let Ok((namespace, key, _)) = message.body().deserialize::<(String, String, OwnedValue)>() else { + continue; + }; + // Read them all again: which setting answers first may have changed too. + if SETTINGS.iter().any(|(ns, k, _)| *ns == namespace && *k == key) { + publisher.publish(read(&proxy)); + } + } + true + } + + fn read(proxy: &zbus::blocking::Proxy<'_>) -> Option { + SETTINGS.iter().find_map(|(namespace, key, animations)| { + let value: OwnedValue = proxy.call("Read", &(*namespace, *key)).ok()?; + animations(&text(value)?) + }) + } + + /// A setting's value as the text a settings file would hold. + fn text(value: OwnedValue) -> Option { + let mut value: Value<'_> = value.into(); + // The Settings.Read result is a variant; some portals nest another variant inside it. + for _ in 0..4 { + match value { + Value::Value(inner) => value = *inner, + Value::Bool(on) => return Some(on.to_string()), + Value::U32(n) => return Some(n.to_string()), + Value::I32(n) => return Some(n.to_string()), + Value::F64(n) => return Some(n.to_string()), + Value::Str(text) => return Some(text.to_string()), + _ => return None, + } + } + None + } + + #[cfg(test)] + mod tests { + use super::*; + + fn owned(value: Value<'_>) -> OwnedValue { + OwnedValue::try_from(value).unwrap() + } + + #[test] + fn portal_values_read_as_text() { + let nested = Value::Value(Box::new(Value::Value(Box::new(Value::U32(1))))); + assert_eq!(text(owned(nested)).as_deref(), Some("1")); + assert_eq!(text(owned(Value::from(false))).as_deref(), Some("false")); + assert_eq!(text(owned(Value::from("0.5"))).as_deref(), Some("0.5")); + assert_eq!(text(owned(Value::from(vec![1u32, 2]))), None); + // Each setting reads its own type of value, the cross-desktop one first. + let [(_, motion, reduced), (_, _, on), (_, _, factor)] = SETTINGS; + assert_eq!(motion, "reduced-motion"); + assert_eq!(reduced(&text(owned(Value::U32(1))).unwrap()), Some(false)); + assert_eq!(reduced(&text(owned(Value::U32(0))).unwrap()), Some(true)); + assert_eq!(on(&text(owned(Value::from(false))).unwrap()), Some(false)); + assert_eq!(factor(&text(owned(Value::from("0"))).unwrap()), Some(false)); + assert_eq!(factor(&text(owned(Value::F64(0.5))).unwrap()), Some(true)); + } + } + } + + mod desktop { + use std::path::PathBuf; + + /// A settings file is a few kilobytes; a larger one isn't read. + const MAX_CONFIG_BYTES: u64 = 1 << 20; + + pub fn read() -> Option { + let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(); + super::super::desktop_setting(&desktop, &config_file, &gsettings) + } + + fn config_home() -> Option { + let dir = |key: &str| std::env::var_os(key).filter(|v| !v.is_empty()).map(PathBuf::from); + dir("XDG_CONFIG_HOME").filter(|path| path.is_absolute()).or_else(|| dir("HOME").map(|home| home.join(".config"))) + } + + fn config_file(name: &str) -> Option { + use std::io::Read as _; + let mut text = String::new(); + std::fs::File::open(config_home()?.join(name)).ok()?.take(MAX_CONFIG_BYTES).read_to_string(&mut text).ok()?; + Some(text) + } + + /// `gsettings get`, by absolute path (`/usr/local` and `/usr/pkg` are where the BSDs + /// install it). + fn gsettings(schema: &str, key: &str) -> Option { + let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings", "/usr/pkg/bin/gsettings"] + .into_iter() + .find(|path| std::path::Path::new(path).is_file())?; + let output = std::process::Command::new(exe).args(["get", schema, key]).output().ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout).ok() + } + } +} + +/// `true` or `false` (GSettings, the portal), `1` or `0` (a GTK settings file): animations are on. +#[cfg(any(test, all(unix, not(target_os = "macos"))))] +fn enabled(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "true" | "1" => Some(true), + "false" | "0" => Some(false), + _ => None, + } +} + +/// KDE's animation speed factor: 0 turns animations off. +#[cfg(any(test, all(unix, not(target_os = "macos"))))] +fn duration_factor(value: &str) -> Option { + value.trim().parse::().ok().filter(|factor| factor.is_finite()).map(|factor| factor > 0.0) +} + +/// The portal's `reduced-motion`: 1 asks for less motion; 0 and the values it may grow don't. +#[cfg(any(test, target_os = "linux"))] +fn reduced_motion(value: &str) -> Option { + value.trim().parse::().ok().map(|code| code != 1) +} + +/// The last value of `key` in `[section]` of an INI-style file (KDE's config, GTK's settings). +#[cfg(any(test, all(unix, not(target_os = "macos"))))] +fn ini_value<'a>(text: &'a str, section: &str, key: &str) -> Option<&'a str> { + let mut inside = false; + let mut found = None; + for line in text.lines() { + let line = line.trim(); + if let Some(name) = line.strip_prefix('[').and_then(|rest| rest.strip_suffix(']')) { + inside = name == section; + } else if inside && let Some((name, value)) = line.split_once('=') { + // KDE marks an entry the user may not change as `Key[$i]=value`. + let name = name.split_once('[').map_or(name, |(name, _)| name); + if name.trim() == key { + found = Some(value.trim()); + } + } + } + found +} + +/// The desktop's setting without the portal. `desktop` is `XDG_CURRENT_DESKTOP`, `file` reads a +/// file under the user's config directory and `gsettings` runs `gsettings get `. +#[cfg(any(test, all(unix, not(target_os = "macos"))))] +fn desktop_setting(desktop: &str, file: &dyn Fn(&str) -> Option, gsettings: &dyn Fn(&str, &str) -> Option) -> Option { + let running = |name: &str| desktop.split(':').any(|part| part.eq_ignore_ascii_case(name)); + let in_file = |name: &str, section: &str, key: &str, animations: fn(&str) -> Option| animations(ini_value(&file(name)?, section, key)?); + if running("KDE") + && let Some(animations) = in_file("kdeglobals", "KDE", "AnimationDurationFactor", duration_factor) + { + return Some(animations); + } + let schema = if running("MATE") { "org.mate.interface" } else { "org.gnome.desktop.interface" }; + let shell = gsettings(schema, "enable-animations").and_then(|out| enabled(&out)); + if shell == Some(false) { + return shell; + } + // `true` may only be the schema's default on a desktop that doesn't use it. GTK's own file + // is what a bare window manager's user writes, and what KDE keeps in step with its setting. + ["gtk-4.0/settings.ini", "gtk-3.0/settings.ini"].iter().find_map(|name| in_file(name, "Settings", "gtk-enable-animations", enabled)).or(shell) +} + +#[cfg(any(test, all(unix, not(target_os = "macos"))))] +fn code_of(animations: Option) -> u8 { + match animations { + None => 0, + Some(true) => 1, + Some(false) => 2, + } +} + +#[cfg(any(test, all(unix, not(target_os = "macos"))))] +fn decode(code: u8) -> Option { + match code { + 1 => Some(true), + 2 => Some(false), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_setting_reads_its_own_values() { + for (s, animations) in + [("true\n", Some(true)), ("false\n", Some(false)), ("1", Some(true)), ("0", Some(false)), ("FALSE", Some(false)), ("", None), ("maybe", None)] + { + assert_eq!(enabled(s), animations, "{s:?}"); + } + for (s, animations) in [ + ("0", Some(false)), + ("0.0", Some(false)), + ("0.5", Some(true)), + ("1", Some(true)), + ("-1", Some(false)), + ("", None), + ("fast", None), + ("NaN", None), + ("inf", None), + ] { + assert_eq!(duration_factor(s), animations, "{s:?}"); + } + // reduced-motion: only 1 asks for less motion; unknown values mean no preference. + for (s, animations) in [("0", Some(true)), ("1", Some(false)), ("2", Some(true)), ("true", None), ("-1", None), ("", None)] { + assert_eq!(reduced_motion(s), animations, "{s:?}"); + } + // The shared value round-trips, and a stray code is "unknown". + for animations in [None, Some(true), Some(false)] { + assert_eq!(decode(code_of(animations)), animations); + } + assert_eq!(decode(9), None); + } + + #[test] + fn ini_values_come_from_their_own_section() { + let kde = "[General]\nAnimationDurationFactor=3\n\n[KDE]\nSingleClick=false\n AnimationDurationFactor = 0 \n[KDE][Sub]\nAnimationDurationFactor=7\n"; + assert_eq!(ini_value(kde, "KDE", "AnimationDurationFactor"), Some("0")); + assert_eq!(ini_value(kde, "KDE", "Missing"), None); + assert_eq!(ini_value("[KDE]\nAnimationDurationFactor[$i]=0.5\n", "KDE", "AnimationDurationFactor"), Some("0.5"), "an immutable entry"); + assert_eq!( + ini_value("[Settings]\ngtk-enable-animations=1\ngtk-enable-animations=0\n", "Settings", "gtk-enable-animations"), + Some("0"), + "the last one wins" + ); + // Hostile text: no section, no `=`, stray brackets, non-ASCII. + for junk in ["", "AnimationDurationFactor=0", "[KDE", "[KDE]\n=\n[]\n]]][[[\n", "[KDE]\nклавиша=значение\n\u{0}\u{feff}"] { + assert_eq!(ini_value(junk, "KDE", "AnimationDurationFactor"), None, "{junk:?}"); + } + } + + #[test] + fn the_desktop_setting_is_read_where_each_desktop_keeps_it() { + let files = |kde: Option<&'static str>, gtk: Option<&'static str>| { + move |name: &str| match name { + "kdeglobals" => kde.map(String::from), + "gtk-3.0/settings.ini" => gtk.map(String::from), + _ => None, + } + }; + let none = |_: &str, _: &str| -> Option { None }; + let gnome = |on: &'static str| { + move |schema: &str, key: &str| (schema == "org.gnome.desktop.interface" && key == "enable-animations").then(|| format!("{on}\n")) + }; + let kde_off = Some("[KDE]\nAnimationDurationFactor=0\n"); + let gtk_off = Some("[Settings]\ngtk-enable-animations=false\n"); + + // KDE Plasma: its own file, only when Plasma is the desktop running. + assert_eq!(desktop_setting("KDE", &files(kde_off, None), &none), Some(false)); + assert_eq!(desktop_setting("KDE", &files(Some("[KDE]\nAnimationDurationFactor=0.5\n"), gtk_off), &none), Some(true)); + assert_eq!(desktop_setting("ubuntu:GNOME", &files(kde_off, None), &gnome("true")), Some(true), "a leftover kdeglobals is not GNOME's setting"); + // Plasma without the entry (animations at their default speed) falls through to GTK's file. + assert_eq!(desktop_setting("KDE", &files(Some("[KDE]\nSingleClick=false\n"), gtk_off), &none), Some(false)); + // GNOME and the desktops that share its schema. + assert_eq!(desktop_setting("GNOME", &files(None, None), &gnome("false")), Some(false)); + assert_eq!(desktop_setting("GNOME", &files(None, None), &gnome("true")), Some(true)); + // MATE keeps the same key in its own schema. + let mate = |schema: &str, _: &str| (schema == "org.mate.interface").then(|| "false\n".to_string()); + assert_eq!(desktop_setting("MATE", &files(None, None), &mate), Some(false)); + assert_eq!(desktop_setting("GNOME", &files(None, None), &mate), None); + // A bare window manager: GTK's settings file, which also outranks a schema default. + assert_eq!(desktop_setting("sway", &files(None, gtk_off), &none), Some(false)); + assert_eq!(desktop_setting("", &files(None, gtk_off), &gnome("true")), Some(false)); + assert_eq!(desktop_setting("", &files(None, Some("[Settings]\ngtk-enable-animations=1\n")), &none), Some(true)); + // Nothing to read, or nothing readable: unknown, never a panic. + assert_eq!(desktop_setting("", &files(None, None), &none), None); + assert_eq!( + desktop_setting("KDE:\u{0}::", &files(Some("\u{0}[[[=]]]"), Some("=\n[Settings]\ngtk-enable-animations=perhaps")), &|_, _| Some("\u{fffd}".into())), + None + ); + } + + /// The real setting of the machine the tests run on: whatever it is, reading it doesn't + /// panic and the service passes it on (`--nocapture` shows it). + #[cfg(target_os = "windows")] + #[test] + fn windows_reports_its_animation_setting() { + let animations = platform::read(); + println!("UISettings.AnimationsEnabled: {animations:?}"); + let service = platform::service().expect("service"); + assert_eq!(service(&egui::Context::default()), animations); + } +} diff --git a/apps/photocraft/src/appearance.rs b/apps/photocraft/src/appearance.rs index 8389ee83eb..e4c1e89e08 100644 --- a/apps/photocraft/src/appearance.rs +++ b/apps/photocraft/src/appearance.rs @@ -5,8 +5,9 @@ const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance"; #[cfg(target_os = "linux")] const PORTAL_KEY: &str = "color-scheme"; +/// The settings portal, also asked for the animation setting (`animations.rs`). #[cfg(target_os = "linux")] -fn portal_proxy() -> Option> { +pub(crate) fn portal_proxy() -> Option> { let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?; zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok() } diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index aee9eabca1..32b43e2bca 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -20,6 +20,7 @@ #![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod animations; mod app_dirs; mod app_icon; mod appearance; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index fea0d34541..c75c2ac694 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -433,6 +433,7 @@ pub fn native(automation: Option) -> photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))), save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))), system_theme: crate::appearance::service(), + system_animations: crate::animations::service(), append_text: Some(Box::new(|path: &str, text: &str| { use std::io::Write; let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path).map_err(|e| e.to_string())?; diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 3a8b7ce695..3d20f784ab 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -83,6 +83,12 @@ choice!( LinuxDisplayServer { Auto = "auto", X11 = "x11" } default Auto ); choice!(UiFontSize { Tiny = "tiny", Small = "small", Medium = "medium", Large = "large" } default Small); +choice!( + /// Interface animations: `system` follows the operating system's animation setting (Windows + /// "Animation effects", macOS "Reduce motion", the desktop's setting on Linux and the BSDs, + /// `prefers-reduced-motion` in a browser) and keeps them on where it cannot be read. + AnimationEffects { System = "system", On = "on", Off = "off" } default System +); choice!(LogDestination { Metadata = "metadata", TextFile = "textFile", Both = "both" } default Metadata); choice!(LogDetail { SessionsOnly = "sessionsOnly", Concise = "concise", Detailed = "detailed" } default Concise); choice!(Ask { Always = "always", Never = "never", Ask = "ask" } default Always); @@ -270,9 +276,9 @@ pub struct Interface { /// Draw menu item colours set with Edit › Menus. pub show_menu_colors: bool, pub show_tooltips: bool, - /// Menus, popups and collapsing sections fade and slide, and scroll areas glide to a new - /// position. Off, everything changes at once (a reduced-motion mode; #1218, #3273). - pub enable_animations: bool, + /// Menus, popups, dialogs and collapsing sections fade and slide, and scrolling glides to + /// its new position. Off, everything changes at once (a reduced-motion mode; #1218, #3273). + pub animation_effects: AnimationEffects, /// Move tool drags show only the layer's outline and an arrow, leaving its pixels in place /// until release. Off (the default), the pixels follow the pointer live inside the outline. pub show_bounding_box_when_dragging_layer: bool, @@ -306,7 +312,7 @@ impl Default for Interface { dynamic_color_sliders: true, show_menu_colors: true, show_tooltips: true, - enable_animations: true, + animation_effects: AnimationEffects::System, show_bounding_box_when_dragging_layer: false, system_title_bar: false, notification_auto_hide: true, @@ -1101,6 +1107,7 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> { "interface.canvasBorder" => CanvasBorder::NAMES, "interface.uiScale" => UiScale::NAMES, "interface.uiFontSize" => UiFontSize::NAMES, + "interface.animationEffects" => AnimationEffects::NAMES, "historyLog.destination" => LogDestination::NAMES, "historyLog.detail" => LogDetail::NAMES, "fileHandling.imagePreviews" | "fileHandling.maximizePsdCompatibility" => Ask::NAMES, @@ -1548,6 +1555,14 @@ impl Session { interface.insert("appearanceMode".into(), json!(mode)); interface.insert(slot.into(), json!(theme.name())); } + // Animation Effects was the Enable Animations checkbox (on by default) before it could + // follow the system: a saved "off" stays off. + if let Some(interface) = v.get_mut("interface").and_then(Value::as_object_mut) + && !interface.contains_key("animationEffects") + && interface.get("enableAnimations").and_then(Value::as_bool) == Some(false) + { + interface.insert("animationEffects".into(), json!(AnimationEffects::Off.name())); + } let color = v.as_object_mut().and_then(|m| m.remove("colorSettings")); let presets = v.as_object_mut().and_then(|m| m.remove("presets")); let prefs: Preferences = serde_json::from_value(v).map_err(|e| format!("preferences: {e}"))?; diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 6bb34a9706..46646c57f3 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -67,6 +67,46 @@ fn appearance_defaults_and_legacy_theme_migrate() { } } +/// Interface › Animation Effects: the system's setting by default, validated like any choice, and +/// the Enable Animations checkbox it replaced still means what it was saved as. +#[test] +fn animation_effects_default_to_the_system_and_keep_a_saved_off() { + assert_eq!(Preferences::default().interface.animation_effects, AnimationEffects::System); + assert_eq!(choices("interface.animationEffects"), Some(AnimationEffects::NAMES)); + assert_eq!(AnimationEffects::NAMES, ["system", "on", "off"]); + let mut s = Session::new(); + for (value, effects) in [("off", AnimationEffects::Off), ("on", AnimationEffects::On), ("system", AnimationEffects::System)] { + s.execute("prefs.set", json!({"path": "interface.animationEffects", "value": value})).unwrap(); + assert_eq!(s.prefs().interface.animation_effects, effects); + assert_eq!(s.execute("prefs.get", json!({"path": "interface.animationEffects"})).unwrap(), json!(value)); + let mut restarted = Session::new(); + restarted.load_prefs_json(&s.prefs_to_json()).unwrap(); + assert_eq!(restarted.prefs().interface.animation_effects, effects, "{value} survives a restart"); + } + // A value that is no choice is an error and changes nothing. + s.execute("prefs.set", json!({"path": "interface.animationEffects", "value": "off"})).unwrap(); + for bad in [json!("sometimes"), json!("Off"), json!(""), json!(true), json!(0), json!(null), json!(["off"]), json!({})] { + assert!(s.execute("prefs.set", json!({"path": "interface.animationEffects", "value": bad})).is_err(), "{bad}"); + assert_eq!(s.prefs().interface.animation_effects, AnimationEffects::Off); + } + s.execute("prefs.reset", json!({"path": "interface.animationEffects"})).unwrap(); + assert_eq!(s.prefs().interface.animation_effects, AnimationEffects::System); + // The checkbox was on by default: a saved "off" stays off, "on" now follows the system, and + // a choice made since wins over the old key left in the file. + for (saved, effects) in [ + (json!({"enableAnimations": false}), AnimationEffects::Off), + (json!({"enableAnimations": true}), AnimationEffects::System), + (json!({"enableAnimations": false, "animationEffects": "on"}), AnimationEffects::On), + (json!({"enableAnimations": "nope"}), AnimationEffects::System), + (json!({}), AnimationEffects::System), + ] { + let mut old = Session::new(); + old.load_prefs_json(&json!({"interface": saved}).to_string()).unwrap(); + assert_eq!(old.prefs().interface.animation_effects, effects, "{saved}"); + } + assert!(s.execute("prefs.set", json!({"path": "interface.enableAnimations", "value": false})).is_err(), "the old path is gone"); +} + #[test] fn appearance_choices_validate_and_legacy_theme_still_selects() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 36fc380769..1adb5b86e3 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1981,7 +1981,8 @@ Show only crosshair while painting Při malování zobrazit jen nitkový kříž Show slice numbers Zobrazit čísla řezů Show tooltips Zobrazit popisky nástrojů - Enable animations Povolit animace + Animation effects Efekty animace + Use system setting Použít nastavení systému Show highlight on rollover Zobrazit zvýraznění při přejetí Show bounding box when dragging layer Při přetahování vrstvy zobrazit ohraničovací rámeček System title bar Systémové záhlaví okna diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index dc366b4553..f1b676ed9d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1952,7 +1952,9 @@ Show only crosshair while painting Beim Malen nur Fadenkreuz zeigen Show slice numbers Slice-Nummern anzeigen Show tooltips Tooltips anzeigen - Enable animations Animationen aktivieren + Animation effects Animationseffekte + Use system setting Systemeinstellung verwenden + On Ein Show highlight on rollover Hervorhebung bei Rollover anzeigen Show bounding box when dragging layer Begrenzungsrahmen beim Ziehen einer Ebene anzeigen System title bar System-Titelleiste diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 3f693d543f..e23f2de96f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1468,7 +1468,8 @@ Show only crosshair while painting Εμφάνιση μόνο σταυρονήματος κατά τη ζωγραφική Show slice numbers Εμφάνιση αριθμών τομών Show tooltips Εμφάνιση επεξηγήσεων - Enable animations Ενεργοποίηση κινούμενων εφέ + Animation effects Εφέ κίνησης + Use system setting Χρήση ρύθμισης συστήματος Show highlight on rollover Εμφάνιση επισήμανσης κατά την κατάδειξη Show bounding box when dragging layer Εμφάνιση πλαισίου οριοθέτησης κατά τη μετακίνηση επιπέδου Show transformation values Εμφάνιση τιμών μετασχηματισμού diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3f001c478b..2c745d4a05 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1965,7 +1965,9 @@ Show only crosshair while painting Mostrar solo la retícula al pintar Show slice numbers Mostrar los números de sector Show tooltips Mostrar las descripciones emergentes - Enable animations Activar las animaciones + Animation effects Efectos de animación + Use system setting Usar la configuración del sistema + On Activado Show highlight on rollover Mostrar resaltado al pasar el cursor Show bounding box when dragging layer Mostrar el cuadro delimitador al arrastrar la capa System title bar Barra de título del sistema diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4dd927500c..48ba8375df 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1936,7 +1936,9 @@ Show only crosshair while painting N'afficher que le réticule pendant la peinture Show slice numbers Afficher les numéros de tranche Show tooltips Afficher les info-bulles - Enable animations Activer les animations + Animation effects Effets d'animation + Use system setting Utiliser le réglage du système + On Activé Show highlight on rollover Afficher la mise en surbrillance au survol Show transformation values Afficher les valeurs de transformation Slice color Couleur des tranches diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a84d19458b..3b46122e02 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2927,6 +2927,8 @@ cameraRaw Yellow Sárga Show rulers Vonalzók megjelenítése Show Rulers Vonalzók megjelenítése Show tooltips Eszköztippek megjelenítése + Animation effects Animációs effektusok + Use system setting Rendszerbeállítás használata Software Szoftver Solarized dark Solarized sötét Sort Rendezés @@ -3162,7 +3164,6 @@ cameraRaw Yellow Sárga Ellipse Tool Ellipszis Elliptical Marquee Tool Ellipszis-kijelölés Enable Bekapcsolás - Enable animations Animációk engedélyezése Enable flick panning Lendítéses pásztázás engedélyezése Enable floating document window docking Lebegő dokumentumablakok dokkolásának engedélyezése Enable narrow options bar Keskeny beállítósáv engedélyezése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index ea51c03775..1a01ea5c3d 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1930,7 +1930,9 @@ Show only crosshair while painting Tampilkan crosshair saja saat melukis Show slice numbers Tampilkan nomor slice Show tooltips Tampilkan tooltip - Enable animations Aktifkan animasi + Animation effects Efek animasi + Use system setting Gunakan pengaturan sistem + On Hidup Show highlight on rollover Tampilkan sorotan saat kursor melintas Show transformation values Tampilkan nilai transformasi Slice color Warna slice diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1aa22cc2aa..89b7f2455f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1963,7 +1963,8 @@ Show only crosshair while painting Mostra solo mirino durante pittura Show slice numbers Mostra numeri sezioni Show tooltips Mostra tooltip - Enable animations Attiva animazioni + Animation effects Effetti di animazione + Use system setting Usa impostazione di sistema Show highlight on rollover Mostra evidenziazione al passaggio del mouse Show bounding box when dragging layer Mostra riquadro selezione al trascinamento livello System title bar Barra del titolo di sistema diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 40749c2d0b..cb3cf18a56 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1964,7 +1964,9 @@ Show only crosshair while painting 描画中は十字線のみ表示 Show slice numbers スライス番号を表示 Show tooltips ツールチップを表示 - Enable animations アニメーションを有効にする + Animation effects アニメーション効果 + Use system setting システム設定を使用 + On オン Show highlight on rollover ロールオーバー時にハイライトを表示 Show bounding box when dragging layer レイヤーのドラッグ中にバウンディングボックスを表示 System title bar システムのタイトルバー diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index a93a20be59..04fb2944ea 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1927,7 +1927,9 @@ Show only crosshair while painting 칠하는 동안 십자선만 표시 Show slice numbers 분할 영역 번호 표시 Show tooltips 도구 설명 표시 - Enable animations 애니메이션 사용 + Animation effects 애니메이션 효과 + Use system setting 시스템 설정 사용 + On 켬 Show highlight on rollover 롤오버 시 강조 표시 Show transformation values 변형 값 표시 Slice color 분할 영역 색상 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index fc5809033c..4654b8196d 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1937,7 +1937,8 @@ Show only crosshair while painting Alleen dradenkruis tonen tijdens schilderen Show slice numbers Segmentnummers tonen Show tooltips Knopinfo tonen - Enable animations Animaties inschakelen + Animation effects Animatie-effecten + Use system setting Systeeminstelling gebruiken Show highlight on rollover Markering bij aanwijzen tonen Show bounding box when dragging layer Selectiekader tonen bij slepen van laag System title bar Titelbalk van systeem diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 9d3ecd9433..124e3ab6f3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1957,7 +1957,9 @@ Show only crosshair while painting Podczas malowania pokaż tylko celownik Show slice numbers Pokaż numery plasterków Show tooltips Pokaż podpowiedzi - Enable animations Włącz animacje + Animation effects Efekty animacji + Use system setting Użyj ustawienia systemowego + On Włączone Show highlight on rollover Pokaż podświetlenie po najechaniu Show bounding box when dragging layer Pokaż ramkę ograniczającą podczas przeciągania warstwy Show transformation values Pokaż wartości przekształcenia diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b26e9fd07e..86840fc789 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1932,7 +1932,8 @@ Show only crosshair while painting Mostrar apenas a mira ao pintar Show slice numbers Mostrar números das fatias Show tooltips Mostrar dicas de ferramentas - Enable animations Ativar animações + Animation effects Efeitos de animação + Use system setting Usar configuração do sistema Show highlight on rollover Mostrar realce ao passar o mouse Show transformation values Mostrar valores de transformação Slice color Cor das fatias diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e57d0686b9..f97672f416 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2380,7 +2380,9 @@ Show only crosshair while painting Только перекрестье при рисовании Show slice numbers Показывать номера фрагментов Show tooltips Показывать подсказки - Enable animations Включить анимацию + Animation effects Эффекты анимации + Use system setting Использовать системную настройку + On Вкл Show highlight on rollover Показывать подсветку при наведении Show bounding box when dragging layer Показывать рамку при перетаскивании слоя System title bar Системная строка заголовка diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 3f552460ca..d0bbf40504 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1929,6 +1929,8 @@ Show only crosshair while painting Boyama sırasında yalnızca artı işaretini göster Show slice numbers Dilim numaralarını göster Show tooltips Araç ipuçlarını göster + Animation effects Animasyon efektleri + Use system setting Sistem ayarını kullan Show highlight on rollover Rollover sırasında vurgulamayı göster Show bounding box when dragging layer Katmanı sürüklerken sınırlayıcı kutuyu göster System title bar Sistem başlık çubuğu @@ -2423,7 +2425,6 @@ cameraRaw Color Renk Remove All Tümünü Kaldır Unlock Kilidi aç Enable Etkinleştir - Enable animations Animasyonları etkinleştir Disable Devre dışı bırak On Açık Selected Seçilen diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f7feba19a7..eeec2df648 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2355,7 +2355,9 @@ Show only crosshair while painting Показувати лише перехрестя під час малювання Show slice numbers Показувати номери фрагментів Show tooltips Показувати підказки - Enable animations Увімкнути анімацію + Animation effects Ефекти анімації + Use system setting Використовувати системне налаштування + On Увімкнено Show highlight on rollover Показувати підсвічування під час наведення Show bounding box when dragging layer Показувати рамку під час перетягування шару System title bar Системний рядок заголовка diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 890533bab2..096eb66b7a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3512,7 +3512,9 @@ cameraData Lens Ống kính New Document With Mask Tài liệu mới có mặt nạ New Layer With Mask Lớp mới có mặt nạ Convert Point Tool Công cụ Chuyển đổi điểm - Enable animations Bật hiệu ứng chuyển động + Animation effects Hiệu ứng chuyển động + Use system setting Dùng cài đặt hệ thống + On Bật Gloss Contour Đường bao bóng Cone - Inverted Hình nón - Đảo ngược Cove - Deep Lõm - Sâu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2f13bc3bfb..485b3a3bc4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1974,7 +1974,9 @@ Show only crosshair while painting 绘画时仅显示十字线 Show slice numbers 显示切片编号 Show tooltips 显示工具提示 - Enable animations 启用动画 + Animation effects 动画效果 + Use system setting 使用系统设置 + On 开启 Show highlight on rollover 鼠标悬停时显示高亮 Show bounding box when dragging layer 拖动图层时显示边界框 System title bar 系统标题栏 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b72aa4509b..7dd463cf3c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1967,7 +1967,9 @@ Show only crosshair while painting 繪圖時僅顯示十字線 Show slice numbers 顯示切片編號 Show tooltips 顯示工具提示 - Enable animations 啟用動畫 + Animation effects 動畫效果 + Use system setting 使用系統設定 + On 開 Show highlight on rollover 滑鼠懸停時顯示醒目提示 Show bounding box when dragging layer 拖曳圖層時顯示邊界框 System title bar 系統標題列 diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 956a9de9ba..1120aa41d8 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -389,6 +389,31 @@ fn the_wheel_reaches_12800_percent_and_stops_dead_there() { assert!((d2[0] - d0[0]).abs() < 0.02 && (d2[1] - d0[1]).abs() < 0.02, "{d0:?} -> {d2:?}"); } +/// Preferences › Interface › Animation Effects (#3273): on, a wheel notch glides the canvas to +/// its new place over the frames that follow; off, it is there on the frame the notch arrives. +#[test] +fn animation_effects_off_pans_a_wheel_notch_at_once() { + for (choice, at_once) in [("on", false), ("off", true)] { + let mut h = app_window(); + h.state_mut().run("prefs.set", json!({"path": "interface.animationEffects", "value": choice})).unwrap(); + { + let v = &mut h.state_mut().ui.views[0]; + (v.zoom, v.center, v.fit_pending) = (8.0, [200.0, 150.0], false); + } + h.run_steps(4); + h.hover_at(h.state().last_canvas_rect.center()); + h.run_steps(2); + let c0 = h.state().ui.views[0].center; + wheel(&h, -1.0, Modifiers::NONE); + h.run_steps(1); + let c1 = h.state().ui.views[0].center; + h.run_steps(40); + let c2 = h.state().ui.views[0].center; + assert!((c2[1] - c0[1] - 5.0).abs() < 0.01 && c2[0] == c0[0], "{choice}: one notch is 40 points, 5 px at 800%: {c0:?} -> {c2:?}"); + assert_eq!(c1 == c2, at_once, "{choice}: {c0:?} -> {c1:?} -> {c2:?}"); + } +} + #[test] fn alt_scroll_zooms_in_steps_around_the_pointer() { let mut h = harness(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a144477cdb..57579e2654 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -334,6 +334,10 @@ pub type LoadTextFn = Box Option>; pub type SaveTextFn = Box Result<(), String>>; /// Platform appearance when egui cannot detect it (for example, Wayland without a theme event). pub type SystemThemeFn = Box Option>; +/// Whether the operating system animates its interface (Windows "Animation effects", macOS +/// "Reduce motion", the desktop's setting on Linux and the BSDs, `prefers-reduced-motion` in a +/// browser): `Some(false)` when the user turned animations off there, `None` when it can't tell. +pub type SystemAnimationsFn = Box Option>; /// Autosave a document snapshot for crash recovery: (snapshot, revision, original path). pub type AutosaveFn = Box, u64, Option<&str>) -> Result<(), String>>; /// Poll successful or failed background writes: (document id, revision, result). @@ -423,6 +427,10 @@ pub struct Services { /// Photoshop's live keyboard shortcut set, imported once at first launch (`kys_import`). pub photoshop_shortcuts: Option, pub system_theme: Option, + /// The system's animation setting, for Preferences › Interface › Animation Effects › Use + /// System Setting. Read every frame while that is the choice, so it must be cheap; without + /// it (or without an answer) animations stay on. + pub system_animations: Option, /// The native window is connected directly to a Wayland compositor. pub is_wayland: bool, /// On Wayland, the shell command that starts this install under XWayland, where native file @@ -1603,6 +1611,11 @@ impl eframe::App for PhotocraftApp { self.collect_screenshots(ctx); self.issue_screenshots(ctx); prefs_ui::tick(self, ctx); + // Animation Effects off: the wheel scrolls its whole distance at once, before anything + // reads this frame's scroll (wheel_nav.rs). + if self.prefs_rt.reduce_motion { + wheel_nav::unsmooth(ctx); + } monitor_status::poll_frame(self, ctx, frame); // Control requests and persisted preferences can change the language in this frame. i18n::sync_context(ctx, &self.session.prefs().interface.language); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 202944b58a..84c4c03e78 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -52,6 +52,8 @@ pub struct Runtime { show_rulers: Option, /// Tool options as last remembered in the preferences (see `tool_memory`). pub(crate) tool_options: Option, + /// Interface › Animation Effects came out as off this frame (see `sync_animations`). + pub(crate) reduce_motion: bool, } /// The GPU canvas colours from Preferences › Transparency & Gamut. @@ -384,11 +386,23 @@ fn sync_tooltips(app: &PhotocraftApp, ctx: &egui::Context) { } } -/// Interface › Enable Animations: off, egui's fades and slides take no time and scroll areas jump -/// straight to their target (#3273). Re-checked every frame because a theme change rebuilds the -/// style; both the dark and light styles are set, so a system appearance switch keeps it. -fn sync_animations(app: &PhotocraftApp, ctx: &egui::Context) { - let on = app.session.prefs().interface.enable_animations; +/// Interface › Animation Effects, resolved: on or off as chosen, or the operating system's own +/// setting for Use System Setting (on where the platform cannot tell). +fn animations_on(app: &PhotocraftApp, ctx: &egui::Context) -> bool { + match app.session.prefs().interface.animation_effects { + prefs::AnimationEffects::On => true, + prefs::AnimationEffects::Off => false, + prefs::AnimationEffects::System => app.services.system_animations.as_ref().and_then(|read| read(ctx)).unwrap_or(true), + } +} + +/// Interface › Animation Effects: off, egui's fades and slides take no time and scroll areas jump +/// straight to their target (#3273); the wheel's own glide is taken out by `wheel_nav::unsmooth`. +/// Re-checked every frame because a theme change rebuilds the style and the system's setting can +/// change; both the dark and light styles are set, so a system appearance switch keeps it. +fn sync_animations(app: &mut PhotocraftApp, ctx: &egui::Context) { + let on = animations_on(app, ctx); + app.prefs_rt.reduce_motion = !on; let time = if on { egui::Style::default().animation_time } else { 0.0 }; if ctx.global_style().animation_time != time { let scroll = if on { egui::style::ScrollAnimation::default() } else { egui::style::ScrollAnimation::none() }; @@ -1077,6 +1091,15 @@ fn choice_label(v: &str) -> String { } } +/// The label of choice `v` of the preference at `path`. Most choices read the same wherever +/// they appear; Animation Effects' `system` says what it does. +fn pref_choice_label(path: &str, v: &str) -> String { + match (path, v) { + ("interface.animationEffects", "system") => "Use system setting".into(), + _ => choice_label(v), + } +} + /// An editable `#rrggbb` field beside a colour swatch (#668): typing a valid value sets the /// colour; while it's being typed a partial value is kept, and it shows the colour otherwise. fn hex_field(ui: &mut egui::Ui, key: &str, rgb: &mut [u8; 3]) { @@ -1497,7 +1520,7 @@ fn section_fields( Value::String(s) if prefs::choices(&path).is_some() => { ui.label(RichText::new(tl!(&label)).color(t.text_dim)); let opts = prefs::choices(&path).unwrap_or(&[]); - let labels: Vec = opts.iter().map(|o| choice_label(o)).collect(); + let labels: Vec = opts.iter().map(|o| pref_choice_label(&path, o)).collect(); let pairs: Vec<(String, &str)> = opts.iter().map(|o| o.to_string()).zip(labels.iter().map(String::as_str)).collect(); let mut cur = s.clone(); crate::widgets::dropdown(ui, &format!("pref-{path}"), &mut cur, &pairs, 220.0); @@ -2350,7 +2373,8 @@ mod tests { continue; // Rendered as star controls beside the theme choices. } let mut labels = vec![humanize(k)]; - labels.extend(prefs::choices(&format!("{sec}.{k}")).into_iter().flatten().map(|c| choice_label(c))); + let path = format!("{sec}.{k}"); + labels.extend(prefs::choices(&path).into_iter().flatten().map(|c| pref_choice_label(&path, c))); missing.extend(labels.into_iter().filter(|l| !crate::i18n::has(lang, l))); } } @@ -2700,28 +2724,89 @@ mod tests { } #[test] - fn enable_animations_off_stops_fades_and_scroll_glides_and_is_remembered() { + fn animation_effects_follow_the_choice_or_the_system_and_are_remembered() { + use std::{cell::Cell, rc::Rc}; let on = egui::Style::default().animation_time; let both = |ctx: &egui::Context| [egui::Theme::Dark, egui::Theme::Light].map(|t| ctx.style_of(t).animation_time); // Saved off: the first frame after a restart applies it to both appearance styles. - let (mut app, _) = app_with_saved(Some(json!({"interface": {"enableAnimations": false}}).to_string())); + let (mut app, _) = app_with_saved(Some(json!({"interface": {"animationEffects": "off"}}).to_string())); let ctx = egui::Context::default(); tick(&mut app, &ctx); assert_eq!(both(&ctx), [0.0, 0.0]); assert_eq!(ctx.global_style().scroll_animation.duration.max, 0.0); + assert!(app.prefs_rt.reduce_motion); // A theme change rebuilds the style; the next frame turns animations off again. app.apply_theme(&ctx, crate::theme::ThemeKind::StudioLight); tick(&mut app, &ctx); assert_eq!(both(&ctx), [0.0, 0.0]); - app.run("prefs.set", json!({"values": {"interface.enableAnimations": true}})).unwrap(); + app.run("prefs.set", json!({"values": {"interface.animationEffects": "on"}})).unwrap(); tick(&mut app, &ctx); assert_eq!(both(&ctx), [on, on]); - assert!(on > 0.0 && ctx.global_style().scroll_animation.duration.max > 0.0); - // A hand-edited value of the wrong type doesn't crash: the defaults (animations on) stay. - let (mut bad, _) = app_with_saved(Some(json!({"interface": {"enableAnimations": "nope"}}).to_string())); - let ctx2 = egui::Context::default(); - tick(&mut bad, &ctx2); - assert_eq!(both(&ctx2), [on, on]); + assert!(on > 0.0 && ctx.global_style().scroll_animation.duration.max > 0.0 && !app.prefs_rt.reduce_motion); + + // Use System Setting, the default, follows the platform as it changes. + let system = Rc::new(Cell::new(Some(false))); + let (mut app, _) = app_with_store(); + assert_eq!(app.session.prefs().interface.animation_effects, prefs::AnimationEffects::System); + let read = system.clone(); + app.services.system_animations = Some(Box::new(move |_| read.get())); + let ctx = egui::Context::default(); + for (answer, time) in [(Some(false), 0.0), (Some(true), on), (None, on), (Some(false), 0.0)] { + system.set(answer); + tick(&mut app, &ctx); + assert_eq!(both(&ctx), [time, time], "the system says {answer:?}"); + assert_eq!(app.prefs_rt.reduce_motion, time == 0.0); + } + // On and Off hold whatever the system says. + for (answer, choice, time) in [(Some(false), "on", on), (Some(true), "off", 0.0), (Some(true), "system", on)] { + system.set(answer); + app.run("prefs.set", json!({"values": {"interface.animationEffects": choice}})).unwrap(); + tick(&mut app, &ctx); + assert_eq!(both(&ctx), [time, time], "{choice} while the system says {answer:?}"); + } + // A platform that can't tell (no service): Use System Setting keeps animations on. + let (mut plain, _) = app_with_store(); + let ctx = egui::Context::default(); + tick(&mut plain, &ctx); + assert_eq!(both(&ctx), [on, on]); + // The Enable Animations checkbox this replaced: a saved "off" is still off. + let (mut old, _) = app_with_saved(Some(json!({"interface": {"enableAnimations": false}}).to_string())); + let ctx = egui::Context::default(); + tick(&mut old, &ctx); + assert_eq!(both(&ctx), [0.0, 0.0]); + // A hand-edited value that is no choice doesn't crash: the defaults stay. + for junk in [json!("nope"), json!(7), json!(null)] { + let (mut bad, _) = app_with_saved(Some(json!({"interface": {"animationEffects": junk}}).to_string())); + let ctx = egui::Context::default(); + tick(&mut bad, &ctx); + assert_eq!(both(&ctx), [on, on]); + } + } + + /// Interface › Animation Effects is a dropdown of three choices, the system's first. + #[test] + fn animation_effects_is_a_three_way_choice_in_the_interface_section() { + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + assert_eq!(humanize("animationEffects"), "Animation effects"); + let labels: Vec = + prefs::choices("interface.animationEffects").unwrap().iter().map(|c| pref_choice_label("interface.animationEffects", c)).collect(); + assert_eq!(labels, ["Use system setting", "On", "Off"]); + assert_eq!(pref_choice_label("general.colorPicker", "system"), "System", "elsewhere `system` keeps its plain label"); + let obj = json!({"animationEffects": "system"}).as_object().unwrap().clone(); + let mut h = Harness::builder() + .with_size(egui::vec2(700.0, 700.0)) + .build_ui_state(|ui, obj: &mut Map| section_fields(ui, "interface", obj, &[], crate::i18n::Lang::from_pref("en"), None, ""), obj); + h.run_steps(4); + assert!(h.get_by_label("Animation effects").rect().is_positive()); + h.get_all_by_role(Role::ComboBox).last().unwrap().click(); + h.run_steps(4); + for choice in ["Use system setting", "On", "Off"] { + assert!(h.query_all_by_label(choice).any(|item| item.rect().is_positive()), "{choice} is offered"); + } + h.get_by_label("Off").click(); + h.run_steps(4); + assert_eq!(h.state()["animationEffects"], "off"); } #[test] diff --git a/crates/ui-egui/src/wheel_nav.rs b/crates/ui-egui/src/wheel_nav.rs index 4d07537df1..3735f53b7d 100644 --- a/crates/ui-egui/src/wheel_nav.rs +++ b/crates/ui-egui/src/wheel_nav.rs @@ -20,8 +20,11 @@ //! frames, so zooming reads the wheel events themselves and ignores that smoothed tail; panning //! keeps the smoothed delta. The Alt state is taken from the wheel events and kept until the next //! wheel event: releasing Alt while the tail is still settling must not turn it into a pan. +//! +//! Preferences › Interface › Animation Effects off takes that smoothing out everywhere, panels +//! and lists included: [`unsmooth`] scrolls a notch's whole distance the frame it arrives. -use egui::{Context, Event, Id, Modifiers, MouseWheelUnit, RawInput, Vec2}; +use egui::{Context, Event, Id, Modifiers, MouseWheelUnit, RawInput, TouchPhase, Vec2}; /// Zoom factor of one wheel notch: every step is exactly ×1.1. pub const NOTCH: f32 = 1.1; @@ -132,6 +135,67 @@ pub fn fold_legacy_pinch(ctx: &Context, raw: &mut RawInput) { } } +fn touch_id() -> Id { + Id::new("pc-wheel-touch") +} + +/// What this frame's wheel events scroll in all, in points: each one in its unit and folded +/// sideways or upright by its modifiers, as egui does before it smooths them. `touch` follows +/// trackpad gestures (the ones with a begin and an end phase). `None` leaves egui's own value +/// alone: for a frame with part of such a gesture, which egui applies as it arrives (and whose +/// modifiers it holds from the start), for a wheel event that zooms, and for a hostile sum. +fn arrived(events: &[Event], options: &egui::InputOptions, page: f32, touch: &mut bool) -> Option { + let mut gesture = *touch; + let mut sum = Vec2::ZERO; + for event in events { + let Event::MouseWheel { unit, delta, phase, modifiers } = event else { continue }; + match phase { + TouchPhase::Start => (*touch, gesture) = (true, true), + TouchPhase::End | TouchPhase::Cancel => (*touch, gesture) = (false, true), + TouchPhase::Move => { + if modifiers.matches_any(options.zoom_modifier) { + return None; + } + let delta = *delta + * match unit { + MouseWheelUnit::Point => 1.0, + MouseWheelUnit::Line => options.line_scroll_speed, + MouseWheelUnit::Page => page, + }; + let sideways = modifiers.matches_any(options.horizontal_scroll_modifier); + let upright = modifiers.matches_any(options.vertical_scroll_modifier); + sum += match (sideways, upright) { + (true, false) => Vec2::new(delta.x + delta.y, 0.0), + (false, true) => Vec2::new(0.0, delta.x + delta.y), + _ => delta, + }; + } + } + } + (!gesture && sum.x.is_finite() && sum.y.is_finite()).then_some(sum) +} + +/// Interface › Animation Effects off: scroll the whole distance of a wheel notch in the frame +/// it arrives. egui spreads a notch over the next several frames (a glide of about 0.2 s, with +/// no setting to turn it off), so this replaces the frame's smoothed scroll with everything its +/// wheel events add up to; the frames after it carry only the rest of that glide and no wheel +/// events, and so scroll nothing. Call it once a frame before anything reads the scroll +/// (`PhotocraftApp::logic`). Zooming already reads the wheel events themselves ([`read`]). +pub fn unsmooth(ctx: &Context) { + let options = ctx.options(|o| o.input_options); + let was = ctx.data(|d| d.get_temp::(touch_id())).unwrap_or(false); + let mut touch = was; + ctx.input_mut(|i| { + let page = i.viewport_rect().height(); + if let Some(scroll) = arrived(&i.events, &options, page, &mut touch) { + i.smooth_scroll_delta = scroll; + } + }); + if touch != was { + ctx.data_mut(|d| d.insert_temp(touch_id(), touch)); + } +} + fn alt_id() -> Id { Id::new("pc-wheel-alt") } @@ -180,8 +244,6 @@ pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool) -> Option Input { @@ -362,6 +424,166 @@ mod tests { assert!(matches!(ev[0], Event::MouseWheel { .. })); } + /// `smooth_scroll_delta` as the UI reads it on each of 30 frames at 60 fps, in a context set + /// up like the app's; `off` is Animation Effects off ([`unsmooth`] at the start of the frame). + fn scrolled(frames: Vec>, off: bool) -> Vec { + let ctx = Context::default(); + configure(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + let mut frames = frames.into_iter(); + (0..30) + .map(|n| { + let events = frames.next().unwrap_or_default(); + let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, screen_rect: Some(screen), events, ..Default::default() }; + let mut seen = Vec2::ZERO; + let mut out = ctx.run_ui(raw, |ui| { + if off { + unsmooth(ui.ctx()); + } + seen = ui.input(|i| i.smooth_scroll_delta); + }); + out.textures_delta.clear(); + seen + }) + .collect() + } + + fn total(frames: &[Vec2]) -> Vec2 { + frames.iter().fold(Vec2::ZERO, |sum, d| sum + *d) + } + + /// Animation Effects off: a wheel notch scrolls its whole distance the frame it arrives, + /// instead of gliding there over the frames that follow. + #[test] + fn animations_off_scrolls_a_wheel_notch_at_once() { + let notch = || vec![vec![], vec![wheel(Vec2::new(0.0, -1.0), Modifiers::NONE)]]; + // egui by itself: part of the notch on the frame it arrives, the rest over the next ones. + let glide = scrolled(notch(), false); + assert!(glide[1].y < 0.0 && glide[1].y > -30.0, "the notch starts gliding: {:?}", glide[1]); + assert!(glide[2..8].iter().all(|d| d.y < 0.0), "and keeps gliding: {:?}", &glide[2..8]); + assert!(near(total(&glide), Vec2::new(0.0, -40.0)), "{:?}", total(&glide)); + // Off: all of it at once, and nothing after. + let jump = scrolled(notch(), true); + assert_eq!(jump[1], Vec2::new(0.0, -40.0)); + assert!(jump.iter().enumerate().all(|(n, d)| n == 1 || *d == Vec2::ZERO), "{jump:?}"); + // Sideways (⇧ or ⌘/Ctrl + wheel), two notches in one frame, a fraction of a notch each + // way, a page and a coarse pixel step (a wheel on Wayland or the web) jump the same way. + let sent = |unit, y: f32| Event::MouseWheel { unit, delta: Vec2::new(0.0, y), phase: TouchPhase::Move, modifiers: Modifiers::NONE }; + for (events, want) in [ + (vec![wheel(Vec2::new(0.0, 1.0), Modifiers::SHIFT)], Vec2::new(40.0, 0.0)), + (vec![wheel(Vec2::new(0.0, 1.0), CTRL)], Vec2::new(40.0, 0.0)), + (vec![wheel(Vec2::new(0.0, 1.0), Modifiers::NONE), wheel(Vec2::new(0.0, 1.0), Modifiers::NONE)], Vec2::new(0.0, 80.0)), + (vec![wheel(Vec2::new(-0.5, 0.25), Modifiers::NONE)], Vec2::new(-20.0, 10.0)), + (vec![sent(MouseWheelUnit::Page, -1.0)], Vec2::new(0.0, -600.0)), + (vec![sent(MouseWheelUnit::Point, 53.0)], Vec2::new(0.0, 53.0)), + ] { + let jump = scrolled(vec![events.clone()], true); + assert!(near(jump[0], want) && jump[1..].iter().all(|d| *d == Vec2::ZERO), "{events:?}: {jump:?}"); + let glide = total(&scrolled(vec![events.clone()], false)); + assert!((glide - want).length() < 1.0, "the same distance as with animations on: {events:?}: {glide:?}"); + } + // The canvas pans by all of it on that frame too, and Alt + wheel still zooms one step. + let ctx = Context::default(); + configure(&ctx); + let mut seen = None; + for (time, modifiers) in [(0.0, Modifiers::NONE), (1.0, Modifiers::ALT)] { + let raw = RawInput { time: Some(time), predicted_dt: 1.0 / 60.0, events: vec![wheel(Vec2::new(0.0, 1.0), modifiers)], ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + unsmooth(ui.ctx()); + seen = read(ui.ctx(), false, true); + }); + out.textures_delta.clear(); + if modifiers.alt { + assert!(matches!(seen, Some(Wheel::Zoom(f)) if (f - 1.1).abs() < 1e-4), "{seen:?}"); + } else { + assert_eq!(seen, Some(Wheel::Pan(Vec2::new(0.0, 40.0)))); + } + } + } + + /// What the setting is for: an egui scroll area (panels, lists, long menus) is at its new + /// place on the frame the notch arrives, where it otherwise glides there. + #[test] + fn animations_off_scroll_areas_jump() { + let offsets = |off: bool| -> Vec { + let ctx = Context::default(); + configure(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0)); + (0..40) + .map(|n| { + let mut events = vec![Event::PointerMoved(egui::pos2(100.0, 100.0))]; + if n == 3 { + events.push(wheel(Vec2::new(0.0, -1.0), Modifiers::NONE)); + } + let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, screen_rect: Some(screen), events, ..Default::default() }; + let mut offset = 0.0; + let mut out = ctx.run_ui(raw, |ui| { + if off { + unsmooth(ui.ctx()); + } + let area = egui::ScrollArea::vertical().show(ui, |ui| ui.allocate_space(egui::vec2(100.0, 2000.0))); + offset = area.state.offset.y; + }); + out.textures_delta.clear(); + offset + }) + .collect() + }; + let (glide, jump) = (offsets(false), offsets(true)); + assert!(glide[3] > 0.0 && glide[3] < 30.0 && glide[4] > glide[3], "it glides: {:?}", &glide[..8]); + assert!((glide[39] - 40.0).abs() < 0.01, "to one notch down: {}", glide[39]); + assert_eq!(jump[..3], [0.0; 3]); + assert!(jump[3..].iter().all(|y| *y == 40.0), "off, it is there at once: {:?}", &jump[..8]); + } + + /// A trackpad gesture (begin and end phases) is already applied as it arrives, with its + /// modifiers held from the start: off leaves it exactly as egui has it. + #[test] + fn animations_off_leaves_trackpad_gestures_to_egui() { + let touch = |phase, y: f32, modifiers| Event::MouseWheel { unit: MouseWheelUnit::Point, delta: Vec2::new(0.0, y), phase, modifiers }; + let gesture = || { + vec![ + vec![touch(TouchPhase::Start, 0.0, Modifiers::SHIFT)], + vec![touch(TouchPhase::Move, 3.0, Modifiers::SHIFT)], + // Shift let go mid-gesture: egui keeps scrolling sideways. + vec![touch(TouchPhase::Move, 30.0, Modifiers::NONE)], + vec![touch(TouchPhase::Move, 2.0, Modifiers::NONE), touch(TouchPhase::End, 0.0, Modifiers::NONE)], + vec![], + // The wheel afterwards jumps again. + vec![wheel(Vec2::new(0.0, 1.0), Modifiers::NONE)], + ] + }; + let (on, off) = (scrolled(gesture(), false), scrolled(gesture(), true)); + assert_eq!(on[..5], off[..5], "the gesture reads the same"); + assert_eq!(on[2], Vec2::new(30.0, 0.0), "sideways for the whole gesture"); + assert_eq!(off[5], Vec2::new(0.0, 40.0)); + assert!(off[6..].iter().all(|d| *d == Vec2::ZERO)); + } + + /// A wheel event that zooms (egui's own zoom modifier, which the app turns off) and hostile + /// deltas leave the frame's scroll as egui computed it. + #[test] + fn unsmoothing_skips_zoom_and_hostile_numbers() { + let options = egui::InputOptions::default(); + let mut touch = false; + let zoom = [wheel(Vec2::new(0.0, 1.0), CTRL)]; + assert_eq!(arrived(&zoom, &options, 600.0, &mut touch), None, "egui's default: ⌘/Ctrl + wheel zooms"); + let plain = [wheel(Vec2::new(0.0, 1.0), Modifiers::NONE)]; + assert_eq!(arrived(&plain, &options, 600.0, &mut touch), Some(Vec2::new(0.0, options.line_scroll_speed))); + assert_eq!(arrived(&[], &options, 600.0, &mut touch), Some(Vec2::ZERO), "no wheel event: the rest of a glide is dropped"); + for bad in [f32::NAN, f32::INFINITY, -f32::INFINITY, f32::MAX] { + for page in [600.0, f32::NAN, f32::INFINITY, 0.0, -1.0] { + for unit in [MouseWheelUnit::Point, MouseWheelUnit::Line, MouseWheelUnit::Page] { + let events = [Event::MouseWheel { unit, delta: Vec2::new(bad, bad), phase: TouchPhase::Move, modifiers: Modifiers::SHIFT }]; + if let Some(sum) = arrived(&events, &options, page, &mut touch) { + assert!(sum.x.is_finite() && sum.y.is_finite(), "{sum:?} from {bad} {page} {unit:?}"); + } + } + } + } + assert!(!touch, "no gesture phase was seen"); + } + #[test] fn hostile_numbers_never_produce_a_bad_zoom() { for s in [f32::NAN, f32::INFINITY, -f32::INFINITY, 1e30, -1e30] { diff --git a/docs/scorecard.md b/docs/scorecard.md index d2ee641b1e..c6c93f46f6 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -191,7 +191,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | UI-217-7 | Command palette shows recently run commands on an empty query | done | [#217](https://github.com/storytold/photocraft/issues/217) | palette.rs: note_recent from menus::invoke keeps the last 8 commands (dedup, newest first); an empty query lists them via recent_hits; the_palette_records_and_replays_recent_commands_on_an_empty_query covers record -> empty query -> replay | | UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; spelling suggestions (edit.checkSpelling suggest) not in the text-field menu yet | | UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | done | [#217](https://github.com/storytold/photocraft/issues/217) | prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale, fractional and unscaled 4K included, #1072/#1278) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting | -| UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the Home screen lists recent files (canvas.rs home_recent, #2691); no canvas pattern toggle yet (#2264) and no option to hide the Home screen recents | +| UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | interface.animationEffects (system, on, off; prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles, and wheel_nav::unsmooth takes out egui's wheel glide; system, the default, follows the OS animation setting through Services::system_animations (apps/photocraft/src/animations.rs for Windows, macOS, Linux and the BSDs; prefers-reduced-motion on the web); the Home screen lists recent files (canvas.rs home_recent, #2691); no canvas pattern toggle yet (#2264) and no option to hide the Home screen recents | | UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | missing | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has twelve themes (the four Pro brightness levels, Studio and Studio Light, Classic, Adwaita and Adwaita Dark, Solarized Dark, Breeze Light and Breeze Dark); no Soft dark or Soft light, and no outlined knobs in the light themes | | UI-217-12 | Save custom New Document presets; a real Recent category | done | [#217](https://github.com/storytold/photocraft/issues/217) | document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent lists Clipboard, the last 20 documents created in the dialog (document.recent.*, persisted with the presets, newest first, deduplicated) and the uncovered defaults (#3131); created_documents_fill_the_recent_category | | UI-217-13 | Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider | done | [#217](https://github.com/storytold/photocraft/issues/217) | widgets::value_field: arithmetic plus units (1in, 5mm, 72pt, 50%) converted at the document resolution (#3099); widgets::scrub_label makes options-bar, slider-row and Character labels scrubby (Shift x10, Alt fine); slider rows with a known default reset on double-click | diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 3fa79b59fa..2542c2f16c 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -73,7 +73,7 @@ id = "UI-217-10" target = "Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents" status = "partial" issue = 217 -note = "interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the Home screen lists recent files (canvas.rs home_recent, #2691); no canvas pattern toggle yet (#2264) and no option to hide the Home screen recents" +note = "interface.animationEffects (system, on, off; prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles, and wheel_nav::unsmooth takes out egui's wheel glide; system, the default, follows the OS animation setting through Services::system_animations (apps/photocraft/src/animations.rs for Windows, macOS, Linux and the BSDs; prefers-reduced-motion on the web); the Home screen lists recent files (canvas.rs home_recent, #2691); no canvas pattern toggle yet (#2264) and no option to hide the Home screen recents" [[item]] id = "UI-217-11" From 051a4d6778b6f0a7e85491f77d863dbe3b7452df Mon Sep 17 00:00:00 2001 From: itsF4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:00:20 +0200 Subject: [PATCH 027/229] History panel: the snapshot strings in the Serbian catalog (#2966) --- crates/ui-egui/src/i18n/sr.tsv | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 9d8aebef63..54567192ce 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2236,6 +2236,9 @@ Create Snapshot Napravi snimak stanja Delete Snapshot Obriši snimak stanja New Snapshot Novi snimak stanja + From: Od: + Full Document Ceo dokument + Merged Layers Spojeni slojevi Snapshot 1 Snimak stanja 1 Step Forward Korak napred Step Backward Korak nazad From a128094e93e76732f247824b9cdd386f26754cae Mon Sep 17 00:00:00 2001 From: Jeremy Yao Date: Tue, 13 Oct 2026 04:26:42 -0400 Subject: [PATCH 028/229] chore: opt JeremyYao into public profile credit Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index bbda6719ec..d360d5525e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -154,3 +154,6 @@ sync_github_name = true [people.VanTan666] display_name = "VanTan" sync_github_name = false + +[people.JeremyYao] +sync_github_name = true From 7f4a5e37a9eb6fa84ddeeb50f1ee81088904be23 Mon Sep 17 00:00:00 2001 From: mighty-programmer <83077551+mighty-programmer@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:33:07 +0300 Subject: [PATCH 029/229] Wrap optional model guidance in translated preferences --- crates/ui-egui/src/prefs_ui.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 6bad4fee6f..f6f90efd04 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1253,10 +1253,10 @@ fn local_model_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, obj: &mut Map Date: Tue, 13 Oct 2026 13:56:51 +0300 Subject: [PATCH 030/229] Retain channel composite allocation across visibility changes --- crates/ui-egui/src/channel_view.rs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/channel_view.rs b/crates/ui-egui/src/channel_view.rs index 38116e58e1..c1df3f5562 100644 --- a/crates/ui-egui/src/channel_view.rs +++ b/crates/ui-egui/src/channel_view.rs @@ -22,6 +22,7 @@ const MAX_SIDE: u32 = 4096; const MAX_COMPOSITE_CACHE_PIXELS: usize = 8 * 1024 * 1024; struct SampledComposite { + doc: std::sync::Weak, revision: u64, preview_key: u64, factor: u32, @@ -273,9 +274,20 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio // A live preview can mutate the same Arc in place. Revision and preview_key, not // pointer identity, determine whether these flattened pixels are still current. if let Some(c) = app.channel_views.get_mut(&id) - && c.source.as_ref().is_some_and(|s| s.factor != factor || s.revision != revision || s.preview_key != preview_key) + && let Some(source) = c.source.as_mut() { - c.source = None; + // Channel visibility bumps the view revision without replacing the committed document. + // Keep its sampled pixels. Pixel edits replace the COW snapshot; live previews also + // require an unchanged revision/key because they can mutate a snapshot in place. + let same = source.factor == factor + && source.preview_key == preview_key + && source.doc.ptr_eq(&std::sync::Arc::downgrade(&doc)) + && (preview_key == 0 || source.revision == revision); + if same { + source.revision = revision; + } else { + c.source = None; + } } if let Some(c) = app.channel_views.get(&id) && c.key == key @@ -325,12 +337,12 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio c.factor = factor; c.valid = true; if let Some(pixels) = fresh.take() { - c.source = Some(SampledComposite { revision, preview_key, factor, pixels }); + c.source = Some(SampledComposite { doc: std::sync::Arc::downgrade(&doc), revision, preview_key, factor, pixels }); } } _ => { let tex = ctx.load_texture(format!("channel-view-{id}"), img, TextureOptions::LINEAR); - let source = fresh.map(|pixels| SampledComposite { revision, preview_key, factor, pixels }); + let source = fresh.map(|pixels| SampledComposite { doc: std::sync::Arc::downgrade(&doc), revision, preview_key, factor, pixels }); app.channel_views.insert(id, Cache { revision, key, preview_key, factor, valid: true, tex, source }); } } @@ -391,6 +403,11 @@ mod tests { app.run("channel.setVisible", json!({"channel": "red", "visible": false})).unwrap(); assert!(ensure(&mut app, &ctx, 0).is_some()); let id = app.session.active().unwrap().doc.id.0; + // Give the existing allocation spare capacity. Recomputing could accidentally reuse + // its freed address, but a fresh composite cannot retain this capacity. + let first = app.channel_views.get_mut(&id).unwrap().source.as_mut().unwrap(); + first.pixels.reserve_exact(1); + let capacity = first.pixels.capacity(); let first = app.channel_views.get(&id).unwrap().source.as_ref().unwrap(); let original_sample = first.pixels[0]; let ptr = first.pixels.as_ptr(); @@ -398,6 +415,7 @@ mod tests { app.run("channel.setVisible", json!({"channel": "green", "visible": false})).unwrap(); assert!(ensure(&mut app, &ctx, 0).is_some()); let reused = app.channel_views.get(&id).unwrap().source.as_ref().unwrap(); + assert_eq!(capacity, reused.pixels.capacity(), "visibility must retain the composite allocation"); assert_eq!(ptr, reused.pixels.as_ptr(), "visibility does not recompose the pixels"); app.run("edit.fill", json!({"color": "#e02010"})).unwrap(); From 42a03e2c97a21a6bea54f9ed3bd16a2c0f9a131e Mon Sep 17 00:00:00 2001 From: mighty-programmer <83077551+mighty-programmer@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:13:00 +0300 Subject: [PATCH 031/229] Constrain model guidance to the visible Preferences pane --- crates/ui-egui/src/prefs_ui.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index f6f90efd04..672c4f0873 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1248,6 +1248,16 @@ fn prefs_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { + // The Preferences grid can leave a wider layout than its visible scroll area. Bound this + // child to the clip rectangle so long translations wrap inside the actual dialog. + let width = ui.available_width().min((ui.clip_rect().right() - ui.next_widget_position().x - 8.0).max(1.0)); + ui.scope(|ui| { + ui.set_max_width(width); + local_model_rows_inner(app, ui, obj); + }); +} + +fn local_model_rows_inner(app: &mut PhotocraftApp, ui: &mut egui::Ui, obj: &mut Map) { let t = Tokens::get(ui.ctx()); let status = app.session.local_model_status(); let available = status["available"].as_bool().unwrap_or(false); From ce94ac430df12d301308d4068b5fda4d7f948bd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 14:24:55 +0000 Subject: [PATCH 032/229] test(algo): pin seam carving's tall-image output; bench_seam timing example The reduced-height seam search for tall images (P28's path) had no bit-exact test: output_bits_are_pinned and carver_matches_reference only cover images under 2048 rows. Pin five proxy-path carves (widths and, through the transposed pass, heights; with and without protection) from the current code, so the speed work that follows can't change which seams are chosen. bench_seam times the carver alone on a synthetic 24 MP photo (P28's width change, its height change) and two exact-path sizes, and prints a hash of each output. Before (4 cores, release): 2279 ms, 4872 ms, 1542 ms and 658 ms p50. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LELc5BQzgV5dA7dxdMejkK --- crates/algo/examples/bench_seam.rs | 78 ++++++++++++++++++++++++++++++ crates/algo/src/seam.rs | 29 +++++++++++ 2 files changed, 107 insertions(+) create mode 100644 crates/algo/examples/bench_seam.rs diff --git a/crates/algo/examples/bench_seam.rs b/crates/algo/examples/bench_seam.rs new file mode 100644 index 0000000000..e427a6b270 --- /dev/null +++ b/crates/algo/examples/bench_seam.rs @@ -0,0 +1,78 @@ +//! Timing of Content-Aware Scale's seam carving alone (no document plumbing), on a synthetic +//! photo-like RGBA image: perf scenario P28's 24 MP width change (#211), the same image's height +//! (the transposed pass) and two images short enough for the exact full-resolution seam search. +//! Prints an FNV-1a hash of each output's bits, so a speed-up can be checked to choose exactly the +//! same seams. +//! `cargo run --release -p photocraft-algo --example bench_seam [reps] [case filter]` +use std::time::Instant; + +use photocraft_algo::seam::carve; + +/// Smooth gradients, a few hard-edged shapes and fine noise: enough structure that seams are +/// steered by content, as in a photo. +fn photo(w: usize, h: usize) -> Vec { + let mut seed = 0x9e37_79b9u32; + let mut noise = move || { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + (seed % 1000) as f32 / 1000.0 - 0.5 + }; + let mut img = Vec::with_capacity(w * h * 4); + for y in 0..h { + for x in 0..w { + let (u, v) = (x as f32 / w as f32, y as f32 / h as f32); + let sky = 0.35 + 0.4 * v; + let disc = ((u - 0.3).powi(2) + (v - 0.55).powi(2)) < 0.02; + let bar = (0.62..0.66).contains(&u) && v > 0.3; + let hill = v > 0.7 + 0.08 * (u * 11.0).sin(); + let (r, g, b) = if disc { + (0.85, 0.3, 0.2) + } else if bar { + (0.1, 0.1, 0.12) + } else if hill { + (0.2, 0.45 + 0.1 * (u * 40.0).sin(), 0.15) + } else { + (sky * 0.6, sky * 0.8, sky) + }; + let n = noise() * 0.02; + img.extend_from_slice(&[r + n, g + n, b + n, 1.0]); + } + } + img +} + +fn fnv(v: &[f32]) -> u64 { + v.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, f| (h ^ u64::from(f.to_bits())).wrapping_mul(0x0100_0000_01b3)) +} + +fn main() { + let args: Vec = std::env::args().collect(); + let reps = args.get(1).and_then(|s| s.parse::().ok()).unwrap_or(1).clamp(1, 20); + let only = args.get(2).cloned(); + // (name, w, h, new w, new h) + let cases = [ + ("24 MP to 90 % width (P28, proxy rows)", 6000, 4000, 5400, 4000), + ("24 MP to 90 % height (transposed, proxy rows)", 6000, 4000, 6000, 3600), + ("5.4 MP to 90 % width (exact)", 3000, 1800, 2700, 1800), + ("1.5 MP to 70 % width (exact)", 1500, 1000, 1050, 1000), + ]; + for (name, w, h, nw, nh) in cases { + if only.as_ref().is_some_and(|o| !name.contains(o.as_str())) { + continue; + } + let img = photo(w, h); + let mut times = Vec::with_capacity(reps); + let mut hash = 0; + for _ in 0..reps { + let t = Instant::now(); + let out = carve(w, h, 4, &img, None, nw, nh); + times.push(t.elapsed().as_secs_f64() * 1000.0); + hash = fnv(&out); + } + times.sort_by(f64::total_cmp); + let p50 = times[times.len() / 2]; + let max = times[times.len() - 1]; + println!("{name:<48} p50 {p50:>9.1} ms max {max:>9.1} ms output {hash:016x}"); + } +} diff --git a/crates/algo/src/seam.rs b/crates/algo/src/seam.rs index 97619c7406..498e84a414 100644 --- a/crates/algo/src/seam.rs +++ b/crates/algo/src/seam.rs @@ -852,6 +852,35 @@ mod tests { 0x2157_9f86_27d6_b835, ]; + /// [`output_bits_are_pinned`] for the reduced-height seam search of tall images (rows at + /// least [`PROXY_SHRINK_MIN_HEIGHT`], at least [`PROXY_SHRINK_MIN_SEAMS`] seams): widths and, + /// through the transposed pass, heights, with and without protection, on tie-heavy and + /// fine-grained noise. + #[test] + fn tall_proxy_output_bits_are_pinned() { + let cases: [(usize, usize, usize, u32, bool, usize, usize); 5] = [ + (96, PROXY_SHRINK_MIN_HEIGHT, 3, 2, false, 30, PROXY_SHRINK_MIN_HEIGHT), + (130, 2_100, 4, 255, true, 64, 2_100), + (80, 2_600, 1, 65_536, false, 16, 2_600), + (PROXY_SHRINK_MIN_HEIGHT, 90, 3, 5, false, PROXY_SHRINK_MIN_HEIGHT, 20), + (2_050, 100, 4, 1000, true, 2_050, 36), + ]; + let got: Vec = cases + .iter() + .enumerate() + .map(|(i, &(w, h, ch, levels, prot, nw, nh))| { + let img = noise(w * h * ch, levels, 31 + i as u32); + let protect = prot.then(|| noise(w * h, 3, 131 + i as u32)); + let out = carve(w, h, ch, &img, protect.as_deref(), nw, nh); + assert_eq!(out.len(), nw * nh * ch); + fnv(&out) + }) + .collect(); + assert_eq!(got, TALL_PINNED); + } + + const TALL_PINNED: [u64; 5] = [0x5657_83ac_cb26_56a5, 0x81a6_b5f7_e299_fdfb, 0xcdf2_9df9_9b83_def5, 0xa713_544a_4dcc_d8d8, 0x3557_de9f_ffb0_881e]; + #[test] fn one_pixel_lines_enlarge_by_repeating() { // Width 1 has no seam to spare: its one column is repeated. Height 1 likewise (through From 67d32bcab592c27282cb60020553fefe8d905516 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 15:09:45 +0000 Subject: [PATCH 033/229] perf(algo): Content-Aware Scale keeps its seam search and moves no pixels (#211) Seam carving recomputed the whole dynamic programme for every seam (about 1.8 G serial cell updates for P28's 600 seams) and shifted every buffer, the image included, over each removed pixel. - The seam search keeps its path-cost map across seams. After a removal only the pixels near the seam and those whose parents' costs actually changed are recomputed, row by row; nothing else can change, so the map is bit-identical to a full pass (incremental_dp_matches_ full_dp checks it after every removal). The direction buffer is gone: the seam is traced back from the costs with the same tie rules. - The carver no longer copies or moves pixels: it keeps each remaining pixel's source column (u32) and reads energy neighbours and the output through it. A removal shifts the shorter side of each row in three 4-byte buffers instead of the longer tail in up to four, 16-byte pixels included. - The tall-image proxy build, its full-resolution rebuild (runs copied between removed columns), the height pass's transposes (blocked) and the plain resample run in parallel over rows. Output is unchanged: the pinned hashes (small and tall images), the 400-case reference comparison and bench_seam's hashes of 24 MP results all match. bench_seam, interleaved A/B over 5 rounds on 4 cores: 24 MP to 90 % width (P28) 2292 -> 612 ms, 24 MP to 90 % height 2795 -> 1076 ms, 5.4 MP exact path 1912 -> 797 ms, 1.5 MP 782 -> 428 ms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LELc5BQzgV5dA7dxdMejkK --- crates/algo/src/seam.rs | 580 +++++++++++++++++++++++++++++----------- 1 file changed, 424 insertions(+), 156 deletions(-) diff --git a/crates/algo/src/seam.rs b/crates/algo/src/seam.rs index 498e84a414..8b5befda80 100644 --- a/crates/algo/src/seam.rs +++ b/crates/algo/src/seam.rs @@ -33,7 +33,7 @@ pub fn energy(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec { } let row = |(y, out): (usize, &mut [f32])| { for (x, v) in out.iter_mut().enumerate() { - *v = energy_at(img, w, w, h, ch, x, y); + *v = energy_at(img, w, w, h, ch, x, y, |xx, _| xx); } }; #[cfg(not(target_arch = "wasm32"))] @@ -46,18 +46,21 @@ pub fn energy(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec { e } -/// [`energy`] of one pixel of a `cw`-wide image whose rows are `stride` pixels apart. Shared by -/// the full map and the per-seam updates so both give bit-identical values. +/// [`energy`] of pixel `x` of row `y` of a `cw`-wide, `h`-tall image whose pixel `xx` of row `yy` +/// is column `col(xx, yy)` of `img` (`w` pixels per row). Shared by the full map and the carver's +/// per-seam updates, so both give bit-identical values. +#[allow(clippy::too_many_arguments)] #[inline] -fn energy_at(img: &[f32], stride: usize, cw: usize, h: usize, ch: usize, x: usize, y: usize) -> f32 { +fn energy_at(img: &[f32], w: usize, cw: usize, h: usize, ch: usize, x: usize, y: usize, col: impl Fn(usize, usize) -> usize) -> f32 { let (xl, xr) = (x.saturating_sub(1), (x + 1).min(cw - 1)); let (yu, yd) = (y.saturating_sub(1), (y + 1).min(h - 1)); - let at = |xx: usize, yy: usize, c: usize| img[(yy * stride + xx) * ch + c]; + let at = |xx: usize, yy: usize| (yy * w + col(xx, yy)) * ch; + let (centre, right, left, down, up) = (at(x, y), at(xr, y), at(xl, y), at(x, yd), at(x, yu)); let mut s = 0.0; for c in 0..ch { - let v = at(x, y, c); - s += (at(xr, y, c) - v).abs() + (v - at(xl, y, c)).abs(); - s += (at(x, yd, c) - v).abs() + (v - at(x, yu, c)).abs(); + let v = img[centre + c]; + s += (img[right + c] - v).abs() + (v - img[left + c]).abs(); + s += (img[down + c] - v).abs() + (v - img[up + c]).abs(); } s * 0.5 } @@ -67,57 +70,177 @@ pub fn find_seam(w: usize, h: usize, energy: &[f32]) -> Vec { if w == 0 || h == 0 || energy.len() < w * h { return vec![0; h]; } - Dp::default().seam(w, w, h, energy) + let off = vec![0; h]; + let rows = Rows { stride: w, cw: w, off: &off }; + let mut dp = Dp::default(); + dp.full(rows, energy); + dp.seam(rows) } -/// Scratch buffers for the seam search, reused across seams. +/// Where the `cw` pixels in use of each of a carver's rows are, in buffers whose rows are +/// `stride` apart: row y's are `off[y]..off[y] + cw` of its `stride` slots. +#[derive(Clone, Copy)] +struct Rows<'o> { + stride: usize, + cw: usize, + off: &'o [usize], +} + +impl Rows<'_> { + fn h(&self) -> usize { + self.off.len() + } + + /// Row y's pixels in use, as a range of its buffers. + #[inline] + fn of(&self, y: usize) -> std::ops::Range { + let start = y * self.stride + self.off[y]; + start..start + self.cw + } +} + +/// The seam search's dynamic programme over an energy map laid out as [`Rows`] (the carver's own +/// layout, so a removal shifts it like the other buffers): for every pixel, the cost of the +/// cheapest 8-connected path down to it from the top row. [`Carver`] keeps it across seams and +/// recomputes only what each removal changed ([`Dp::update`]). #[derive(Default)] struct Dp { + /// Cost of the cheapest path from the top row to each pixel. cost: Vec, - next: Vec, - /// Per pixel, where the cheapest path came from: 0 = up-left, 1 = up, 2 = up-right. - from: Vec, +} + +/// Pixel `x` of a `cw`-wide row with energy `e`, given the path costs of the row above (`up`): +/// its path cost and where that path came from (0 = up-left, 1 = up, 2 = up-right). Ties go to +/// the pixel straight up, then up-left, then up-right. The cost doesn't depend on the tie-break +/// and the direction doesn't depend on `e`, so the seam can be traced back from the costs alone. +/// The full pass's edges, [`Dp::update`] and [`Dp::seam`] use it, and the full pass's interior +/// repeats it exactly, so every way of filling the map gives the same bits. +#[inline] +fn cell(up: &[f32], cw: usize, x: usize, e: f32) -> (f32, u8) { + let (b, d) = if x == 0 { + if cw > 1 && up[1] < up[0] { (up[1], 2) } else { (up[0], 1) } + } else if x + 1 == cw { + if up[x - 1] < up[x] { (up[x - 1], 0) } else { (up[x], 1) } + } else { + let (b, d) = if up[x - 1] < up[x] { (up[x - 1], 0) } else { (up[x], 1) }; + if up[x + 1] < b { (up[x + 1], 2) } else { (b, d) } + }; + (b + e, d) +} + +/// Path costs of pixels `lo..lo + out.len()` of a `cw`-wide row with energies `e` (the whole +/// row's), from the path costs of the row above (`up`), into `out`. [`cell`] at the ends of the +/// row; in between, the same selection without edge tests or directions, so it vectorises. +#[inline] +fn row_costs(up: &[f32], e: &[f32], cw: usize, lo: usize, out: &mut [f32]) { + let Some(last) = (lo + out.len()).checked_sub(1) else { return }; + let (mut a, mut b) = (lo, last); + if a == 0 { + out[0] = cell(up, cw, 0, e[0]).0; + a = 1; + } + if b + 1 == cw && b >= a { + out[b - lo] = cell(up, cw, b, e[b]).0; + b -= 1; + } + if a > b { + return; + } + // Every pixel in a..=b has all three parents: up-left, up and up-right. + let parents = up[a - 1..b].iter().zip(&up[a..=b]).zip(&up[a + 1..=b + 1]); + for ((n, &ev), ((&l, &u), &r)) in out[a - lo..=b - lo].iter_mut().zip(&e[a..=b]).zip(parents) { + let m = if l < u { l } else { u }; + let m = if r < m { r } else { m }; + *n = m + ev; + } } impl Dp { - /// The lowest-energy seam of a `cw`-wide, `h`-tall energy map with rows `stride` apart. - /// Ties go to the pixel straight up, then up-left, then up-right; at the bottom, to the - /// leftmost end. - fn seam(&mut self, stride: usize, cw: usize, h: usize, energy: &[f32]) -> Vec { - self.cost.clear(); - self.cost.extend_from_slice(&energy[..cw]); - self.next.resize(cw, 0.0); - self.from.resize(cw * h, 0); - for y in 1..h { - let (cost, next) = (&self.cost[..cw], &mut self.next[..cw]); - let e = &energy[y * stride..y * stride + cw]; - let from = &mut self.from[y * cw..(y + 1) * cw]; - if cw == 1 { - (next[0], from[0]) = (cost[0] + e[0], 1); - } else { - let (b, d) = if cost[1] < cost[0] { (cost[1], 2) } else { (cost[0], 1) }; - (next[0], from[0]) = (b + e[0], d); - // The interior has all three parents: no edge tests in the hot loop. - let inner = next[1..cw - 1].iter_mut().zip(&mut from[1..cw - 1]).zip(&e[1..cw - 1]).zip(cost.windows(3)); - for (((n, f), &ev), c) in inner { - let (b, d) = if c[0] < c[1] { (c[0], 0) } else { (c[1], 1) }; - let (b, d) = if c[2] < b { (c[2], 2) } else { (b, d) }; - (*n, *f) = (b + ev, d); - } - let (b, d) = if cost[cw - 2] < cost[cw - 1] { (cost[cw - 2], 0) } else { (cost[cw - 1], 1) }; - (next[cw - 1], from[cw - 1]) = (b + e[cw - 1], d); - } - std::mem::swap(&mut self.cost, &mut self.next); + /// Fill the map for the pixels in use of every row of `energy`. + fn full(&mut self, rows: Rows, energy: &[f32]) { + let (cw, h) = (rows.cw, rows.h()); + self.cost.resize(rows.stride * h, 0.0); + if cw == 0 || h == 0 { + return; } - let cost = &self.cost[..cw]; - let mut x = (0..cw).min_by(|a, b| cost[*a].total_cmp(&cost[*b])).unwrap_or(0); + let first = rows.of(0); + self.cost[first.clone()].copy_from_slice(&energy[first]); + for y in 1..h { + let (above, rest) = self.cost.split_at_mut(y * rows.stride); + let (r, base) = (rows.of(y), y * rows.stride); + row_costs(&above[rows.of(y - 1)], &energy[r.clone()], cw, 0, &mut rest[r.start - base..r.end - base]); + } + } + + /// Bring the map up to date after `seam` (one x per row, counted before the removal) was + /// removed from every row of the map and of `energy`, leaving `rows.cw` pixels per row. + /// + /// A pixel's path cost depends only on its energy and on the costs of its parents in the row + /// above. Where a pixel and all its parents are on the same side of the seam in their rows, + /// the removal moved them together; that leaves, in row y, only the pixels from one left of + /// min(seam[y-1], seam[y]) to max(seam[y-1], seam[y]) with new parents. The energy changed + /// only where [`Carver::update_energy`] recomputed it: from one left of the leftmost of + /// `seam[y-1..=y+1]` to the rightmost, which covers those pixels too. Every other pixel needs + /// recomputing only if a parent's cost changed, so the work follows the changed costs down + /// the map and narrows wherever they settle, instead of covering the whole map. Recomputed + /// pixels go through [`cell`], so the map matches a [`Dp::full`] pass over the same energy + /// bit for bit. + fn update(&mut self, rows: Rows, energy: &[f32], seam: &[usize]) { + let (cw, h) = (rows.cw, rows.h()); + if cw == 0 || h == 0 || seam.len() < h { + return; + } + // Pixels of the row above whose cost changed: `lo..=hi`, or none when `lo > hi`. + let (mut changed_lo, mut changed_hi) = (usize::MAX, 0); + let mut fresh = Vec::new(); + for y in 0..h { + let near = &seam[y.saturating_sub(1)..(y + 2).min(h)]; + let mut lo = near.iter().min().map_or(0, |&s| s.saturating_sub(1)); + let mut hi = near.iter().max().map_or(0, |&s| s.min(cw - 1)); + if changed_lo <= changed_hi { + lo = lo.min(changed_lo.saturating_sub(1)); + hi = hi.max((changed_hi + 1).min(cw - 1)); + } + let (above, rest) = self.cost.split_at_mut(y * rows.stride); + let (r, base) = (rows.of(y), y * rows.stride); + let e = &energy[r.clone()]; + if fresh.len() < hi + 1 - lo { + fresh.resize(hi + 1 - lo, 0.0); + } + let fresh = &mut fresh[..hi + 1 - lo]; + match y.checked_sub(1) { + Some(u) => row_costs(&above[rows.of(u)], e, cw, lo, fresh), + None => fresh.copy_from_slice(&e[lo..=hi]), + } + // Store only what changed, and remember where that was for the row below. + let row = &mut rest[r.start - base + lo..r.start - base + hi + 1]; + let differs = |(a, b): (&f32, &f32)| a.to_bits() != b.to_bits(); + (changed_lo, changed_hi) = (usize::MAX, 0); + if let Some(first) = fresh.iter().zip(row.iter()).position(differs) { + let last = fresh.iter().zip(row.iter()).rposition(differs).unwrap_or(first); + row[first..=last].copy_from_slice(&fresh[first..=last]); + (changed_lo, changed_hi) = (lo + first, lo + last); + } + } + } + + /// The lowest-energy seam of the map: one x per row. At the bottom, ties go to the leftmost + /// end; going up, [`cell`] tells where each pixel's path came from. + fn seam(&self, rows: Rows) -> Vec { + let (cw, h) = (rows.cw, rows.h()); let mut seam = vec![0usize; h]; + if cw == 0 || h == 0 { + return seam; + } + let last = &self.cost[rows.of(h - 1)]; + let mut x = (0..cw).min_by(|a, b| last[*a].total_cmp(&last[*b])).unwrap_or(0); for y in (0..h).rev() { seam[y] = x; if y > 0 { - x = match self.from[y * cw + x] { - 0 => x - 1, - 2 => x + 1, + // `cell` never points past either end of the row; saturate rather than trust it. + x = match cell(&self.cost[rows.of(y - 1)], cw, x, 0.0).1 { + 0 => x.saturating_sub(1), + 2 => (x + 1).min(cw - 1), _ => x, }; } @@ -126,118 +249,212 @@ impl Dp { } } -/// Shrinks an image one seam at a time in place. Rows keep their original stride, so removing a -/// seam shifts only the rest of each row (in parallel), and the energy map is updated only next -/// to the removed seam instead of being recomputed: the gradient of a pixel changes only when a -/// neighbour does, which happens within a pixel of the seam in its own row and the rows around it. -struct Carver { - stride: usize, - cw: usize, +/// Shrinks an image one seam at a time without moving its pixels: the carver keeps which source +/// column each remaining pixel is (`idx`) and reads pixels through it. A removal shifts, in each +/// row, whichever side of the seam is shorter in the three per-pixel buffers (in parallel over +/// rows), and nothing is recomputed in full: the gradient of a pixel changes only when a +/// neighbour does, which happens within a pixel of the seam in its own row and the rows around +/// it, and the seam search's path costs are updated from there down ([`Dp::update`]). +struct Carver<'a> { + /// The image, `w × h × ch`, and the protection, one value per pixel: as given, never moved. + src: &'a [f32], + prot: Option<&'a [f32]>, + w: usize, h: usize, ch: usize, - img: Vec, - prot: Option>, + /// Pixels left per row. + cw: usize, + /// Where each row's first remaining pixel is in the per-pixel buffers (see [`Rows`]). + off: Vec, + /// Source column of each remaining pixel (rows `w` apart; layer areas are at most i32::MAX + /// wide, so `u32` holds any column). + idx: Vec, /// Energy plus protection, as the seam search sees it. e: Vec, - /// Original column of each pixel, when the caller needs to know which ones were removed. - idx: Option>, + /// The seam search over `e`, kept up to date. dp: Dp, } -impl Carver { - fn new(w: usize, h: usize, ch: usize, img: Vec, prot: Option>, track: bool) -> Self { - let mut e = energy(w, h, ch, &img); - if let Some(p) = &prot { +impl<'a> Carver<'a> { + fn new(w: usize, h: usize, ch: usize, src: &'a [f32], prot: Option<&'a [f32]>) -> Self { + let mut e = energy(w, h, ch, src); + if let Some(p) = prot { for (e, p) in e.iter_mut().zip(p) { *e += p.clamp(0.0, 1.0) * PROTECT_ENERGY; } } - let idx = track.then(|| (0..h).flat_map(|_| 0..w).collect()); - Carver { stride: w, cw: w, h, ch, img, prot, e, idx, dp: Dp::default() } + let idx = (0..h).flat_map(|_| (0..w).map(|x| u32::try_from(x).unwrap_or(u32::MAX))).collect(); + let off = vec![0; h]; + let mut dp = Dp::default(); + dp.full(Rows { stride: w, cw: w, off: &off }, &e); + Carver { src, prot, w, h, ch, cw: w, off, idx, e, dp } } - /// Remove the lowest-energy seam. Returns, per row, the removed column (the original column - /// when tracking). + /// Remove the lowest-energy seam. Returns, per row, the source column removed. fn remove_seam(&mut self) -> Vec { - let (stride, cw, h, ch) = (self.stride, self.cw, self.h, self.ch); - let seam = self.dp.seam(stride, cw, h, &self.e); - let removed = match &self.idx { - Some(idx) => seam.iter().enumerate().map(|(y, &x)| idx[y * stride + x]).collect(), - None => seam.clone(), - }; - shift_rows(&mut self.img, stride * ch, &seam, ch, cw); - shift_rows(&mut self.e, stride, &seam, 1, cw); - if let Some(p) = &mut self.prot { - shift_rows(p, stride, &seam, 1, cw); - } - if let Some(idx) = &mut self.idx { - shift_rows(idx, stride, &seam, 1, cw); + if self.cw == 0 { + return Vec::new(); } + let rows = Rows { stride: self.w, cw: self.cw, off: &self.off }; + let seam = self.dp.seam(rows); + let removed = seam.iter().enumerate().map(|(y, &x)| self.idx[rows.of(y).start + x] as usize).collect(); + self.shift(&seam); self.cw -= 1; self.update_energy(&seam); + self.dp.update(Rows { stride: self.w, cw: self.cw, off: &self.off }, &self.e, &seam); removed } + /// Remove `seam[y]` from each row of the per-pixel buffers. + fn shift(&mut self, seam: &[usize]) { + let (w, cw) = (self.w, self.cw); + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + self.e + .par_chunks_mut(w) + .zip(self.idx.par_chunks_mut(w)) + .zip(self.dp.cost.par_chunks_mut(w)) + .zip(self.off.par_iter_mut()) + .zip(seam.par_iter()) + .for_each(|((((e, idx), cost), off), &x)| shift_row(e, idx, cost, off, x, cw)); + } + #[cfg(target_arch = "wasm32")] + self.e + .chunks_mut(w) + .zip(self.idx.chunks_mut(w)) + .zip(self.dp.cost.chunks_mut(w)) + .zip(self.off.iter_mut()) + .zip(seam) + .for_each(|((((e, idx), cost), off), &x)| shift_row(e, idx, cost, off, x, cw)); + } + /// Recompute the energy of the pixels whose neighbours changed when `seam` was removed: in /// row y, from one left of the leftmost of the seam's x in rows y-1..=y+1 to the rightmost. fn update_energy(&mut self, seam: &[usize]) { - let (stride, cw, h, ch) = (self.stride, self.cw, self.h, self.ch); + let (w, cw, h, ch) = (self.w, self.cw, self.h, self.ch); if cw == 0 { return; } - let (img, prot) = (&self.img, self.prot.as_deref()); + let (src, prot, off, idx) = (self.src, self.prot, &self.off, &self.idx); + let col = |xx: usize, yy: usize| idx[yy * w + off[yy] + xx] as usize; let row = |(y, e): (usize, &mut [f32])| { let near = &seam[y.saturating_sub(1)..(y + 2).min(h)]; let lo = near.iter().min().map_or(0, |&s| s.saturating_sub(1)); let hi = near.iter().max().map_or(0, |&s| s.min(cw - 1)); for x in lo..=hi { - let mut v = energy_at(img, stride, cw, h, ch, x, y); + let mut v = energy_at(src, w, cw, h, ch, x, y, col); if let Some(p) = prot { - v += p[y * stride + x].clamp(0.0, 1.0) * PROTECT_ENERGY; + v += p[y * w + col(x, y)].clamp(0.0, 1.0) * PROTECT_ENERGY; } - e[x] = v; + e[off[y] + x] = v; + } + }; + // A few pixels per row (seams move at most one column per row), but each waits on reads + // from three rows of the source: rows in parallel overlap the waits. + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + self.e.par_chunks_mut(w).enumerate().for_each(row); + } + #[cfg(target_arch = "wasm32")] + self.e.chunks_mut(w).enumerate().for_each(row); + } + + /// The carved pixels, `cw × h × ch`, rows packed. + fn into_pixels(self) -> Vec { + let (w, cw, ch) = (self.w, self.cw, self.ch); + let mut out = vec![0.0f32; cw * self.h * ch]; + if cw == 0 { + return out; + } + let (src, off, idx) = (self.src, &self.off, &self.idx); + let row = |(y, out): (usize, &mut [f32])| { + let cols = &idx[y * w + off[y]..y * w + off[y] + cw]; + for (px, &x) in out.chunks_exact_mut(ch).zip(cols) { + let s = (y * w + x as usize) * ch; + px.copy_from_slice(&src[s..s + ch]); } }; #[cfg(not(target_arch = "wasm32"))] { use rayon::prelude::*; - self.e.par_chunks_mut(stride).enumerate().for_each(row); + out.par_chunks_mut(cw * ch).enumerate().for_each(row); } #[cfg(target_arch = "wasm32")] - self.e.chunks_mut(stride).enumerate().for_each(row); - } - - /// The carved pixels, `cw × h × ch`, rows packed. - fn into_pixels(self) -> Vec { - let (row, used) = (self.stride * self.ch, self.cw * self.ch); - if row == used { - return self.img; - } - self.img.chunks(row).flat_map(|r| &r[..used]).copied().collect() + out.chunks_mut(cw * ch).enumerate().for_each(row); + out } } -/// Remove pixel `seam[y]` (of `n` values) from each `row_len`-long row whose first `cw` pixels -/// are in use, by shifting the rest of the row left. -fn shift_rows(buf: &mut [T], row_len: usize, seam: &[usize], n: usize, cw: usize) { - let shift = |(r, &x): (&mut [T], &usize)| r.copy_within((x + 1) * n..cw * n, x * n); - #[cfg(not(target_arch = "wasm32"))] - { - use rayon::prelude::*; - buf.par_chunks_mut(row_len).zip(seam.par_iter()).for_each(shift); +/// Remove pixel `x` of the `cw` in use, from `off` on, of one row of a carver's per-pixel +/// buffers, moving whichever side of it holds fewer pixels (the row then starts one later when +/// that is the left side). +fn shift_row(e: &mut [f32], idx: &mut [u32], cost: &mut [f32], off: &mut usize, x: usize, cw: usize) { + let o = *off; + if x < cw - 1 - x { + e.copy_within(o..o + x, o + 1); + idx.copy_within(o..o + x, o + 1); + cost.copy_within(o..o + x, o + 1); + *off = o + 1; + } else { + e.copy_within(o + x + 1..o + cw, o + x); + idx.copy_within(o + x + 1..o + cw, o + x); + cost.copy_within(o + x + 1..o + cw, o + x); } - #[cfg(target_arch = "wasm32")] - buf.chunks_mut(row_len).zip(seam).for_each(shift); } fn transpose(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec { let mut out = vec![0.0f32; img.len()]; - for y in 0..h { - for x in 0..w { - let (s, d) = ((y * w + x) * ch, (x * h + y) * ch); - out[d..d + ch].copy_from_slice(&img[s..s + ch]); - } + if w == 0 || h == 0 || ch == 0 { + return out; } + // Output rows (source columns) in blocks, so each source cache line serves several of them. + const BLOCK: usize = 16; + let block = |(b, out): (usize, &mut [f32])| { + let x0 = b * BLOCK; + let n = out.len() / (h * ch); + for y in 0..h { + for i in 0..n { + let (s, d) = ((y * w + x0 + i) * ch, (i * h + y) * ch); + out[d..d + ch].copy_from_slice(&img[s..s + ch]); + } + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_mut(BLOCK * h * ch).enumerate().for_each(block); + } + #[cfg(target_arch = "wasm32")] + out.chunks_mut(BLOCK * h * ch).enumerate().for_each(block); + out +} + +/// Each `step` rows of `src` (`h` rows of `row_len` values) averaged into one, the last group +/// possibly shorter: `h.div_ceil(step)` rows. +fn average_rows(src: &[f32], row_len: usize, h: usize, step: usize) -> Vec { + let mut out = vec![0.0; h.div_ceil(step) * row_len]; + if row_len == 0 { + return out; + } + let group = |(py, out_row): (usize, &mut [f32])| { + let y0 = py * step; + let y1 = (y0 + step).min(h); + let divisor = (y1 - y0) as f32; + for y in y0..y1 { + for (out, &sample) in out_row.iter_mut().zip(&src[y * row_len..(y + 1) * row_len]) { + *out += sample / divisor; + } + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_exact_mut(row_len).enumerate().for_each(group); + } + #[cfg(target_arch = "wasm32")] + out.chunks_exact_mut(row_len).enumerate().for_each(group); out } @@ -250,7 +467,8 @@ pub fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<& /// [`carve_width`] that checks `ctl` before every seam and reports progress per seam. pub fn carve_width_with(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, ctl: &Interrupt) -> Result, Cancelled> { let new_w = new_w.max(1); - if new_w == w || w == 0 || h == 0 { + // No channels: nothing to carve (and no pixels to size the work by). + if new_w == w || w == 0 || h == 0 || ch == 0 { return Ok(img.to_vec()); } let seams = new_w.abs_diff(w).max(1) as f32; @@ -258,7 +476,7 @@ pub fn carve_width_with(w: usize, h: usize, ch: usize, img: &[f32], protect: Opt if h >= PROXY_SHRINK_MIN_HEIGHT && w - new_w >= PROXY_SHRINK_MIN_SEAMS { return carve_width_proxy_height(w, h, ch, img, protect, new_w, ctl); } - let mut carver = Carver::new(w, h, ch, img.to_vec(), protect.map(<[f32]>::to_vec), false); + let mut carver = Carver::new(w, h, ch, img, protect); while carver.cw > new_w { ctl.check()?; ctl.progress((w - carver.cw) as f32 / seams); @@ -272,7 +490,7 @@ pub fn carve_width_with(w: usize, h: usize, ch: usize, img: &[f32], protect: Opt while cw < new_w { let k = (new_w - cw).min((cw / 2).max(1)); // Find k seams on a shrinking copy, mapping each back to original columns. - let mut carver = Carver::new(cw, h, ch, cur.clone(), prot.clone(), true); + let mut carver = Carver::new(cw, h, ch, &cur, prot.as_deref()); let mut dup: Vec> = vec![Vec::with_capacity(k); h]; for _ in 0..k { // A 1 px wide image's only seam is its one column: take it, so it is duplicated. @@ -329,37 +547,11 @@ fn carve_width_proxy_height(w: usize, h: usize, ch: usize, img: &[f32], protect: let step = h.div_ceil(SEAM_SEARCH_MAX_HEIGHT); let proxy_h = h.div_ceil(step); let row_len = w * ch; - let proxy_row_len = proxy_h * row_len; - let mut proxy = vec![0.0; proxy_row_len]; - for (py, out_row) in proxy.chunks_exact_mut(row_len).enumerate() { - let y0 = py * step; - let y1 = (y0 + step).min(h); - let divisor = (y1 - y0) as f32; - for y in y0..y1 { - let in_row = &img[y * row_len..(y + 1) * row_len]; - for (out, &sample) in out_row.iter_mut().zip(in_row) { - *out += sample / divisor; - } - } - } - let proxy_protect = protect.map(|mask| { - let mut reduced = vec![0.0; proxy_h * w]; - for (py, out_row) in reduced.chunks_exact_mut(w).enumerate() { - let y0 = py * step; - let y1 = (y0 + step).min(h); - let divisor = (y1 - y0) as f32; - for y in y0..y1 { - let in_row = &mask[y * w..(y + 1) * w]; - for (out, &sample) in out_row.iter_mut().zip(in_row) { - *out += sample / divisor; - } - } - } - reduced - }); + let proxy = average_rows(img, row_len, h, step); + let proxy_protect = protect.map(|mask| average_rows(mask, w, h, step)); let seam_count = w - new_w; - let mut carver = Carver::new(w, proxy_h, ch, proxy, proxy_protect, true); + let mut carver = Carver::new(w, proxy_h, ch, &proxy, proxy_protect.as_deref()); let mut removed = Vec::with_capacity(seam_count); while carver.cw > new_w { ctl.check()?; @@ -373,31 +565,44 @@ fn carve_width_proxy_height(w: usize, h: usize, ch: usize, img: &[f32], protect: columns.sort_unstable(); removed_by_row.push(columns); } - let mut out = Vec::with_capacity(new_w * h * ch); - let mut columns = vec![0; seam_count]; - for y in 0..h { + // Each full-resolution row removes the columns interpolated between its two proxy rows'. + let rebuild = |columns: &mut Vec, (y, out): (usize, &mut [f32])| -> Result<(), Cancelled> { ctl.check()?; let proxy_y = (y / step).min(proxy_h - 1); let next_proxy_y = (proxy_y + 1).min(proxy_h - 1); let blend = (y % step) as f32 / step as f32; let lower = &removed_by_row[proxy_y]; let upper = &removed_by_row[next_proxy_y]; + columns.clear(); let mut min_x = 0; - for (i, x) in columns.iter_mut().enumerate() { + for i in 0..seam_count { let interpolated = lower[i] as f32 + (upper[i] as f32 - lower[i] as f32) * blend; let max_x = w - (seam_count - i); - *x = (interpolated.round() as usize).clamp(min_x, max_x); + let x = (interpolated.round() as usize).clamp(min_x, max_x); + columns.push(x); min_x = x.saturating_add(1); } - let mut next = 0; + // The columns rise strictly within the row: copy the runs between them. let row = &img[y * row_len..(y + 1) * row_len]; - for (x, pixel) in row.chunks_exact(ch).enumerate() { - if columns.get(next) == Some(&x) { - next += 1; - } else { - out.extend_from_slice(pixel); - } + let (mut from, mut at) = (0, 0); + for &x in columns.iter() { + let run = (x - from) * ch; + out[at..at + run].copy_from_slice(&row[from * ch..x * ch]); + (from, at) = (x + 1, at + run); } + out[at..].copy_from_slice(&row[from * ch..]); + Ok(()) + }; + let mut out = vec![0.0f32; new_w * h * ch]; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_exact_mut(new_w * ch).enumerate().try_for_each_init(|| Vec::with_capacity(seam_count), rebuild)?; + } + #[cfg(target_arch = "wasm32")] + { + let mut columns = Vec::with_capacity(seam_count); + out.chunks_exact_mut(new_w * ch).enumerate().try_for_each(|r| rebuild(&mut columns, r))?; } ctl.progress(1.0); Ok(out) @@ -443,10 +648,10 @@ pub fn carve_with( /// Plain bilinear resize (the non-content-aware part of the Amount blend). pub fn resize_bilinear(w: usize, h: usize, ch: usize, img: &[f32], nw: usize, nh: usize) -> Vec { let mut out = vec![0.0f32; nw * nh * ch]; - if w == 0 || h == 0 { + if w == 0 || h == 0 || nw == 0 || nh == 0 { return out; } - for y in 0..nh { + let row = |(y, out): (usize, &mut [f32])| { let fy = ((y as f32 + 0.5) * h as f32 / nh as f32 - 0.5).clamp(0.0, (h - 1) as f32); let (y0, ty) = (fy.floor() as usize, fy.fract()); let y1 = (y0 + 1).min(h - 1); @@ -458,10 +663,17 @@ pub fn resize_bilinear(w: usize, h: usize, ch: usize, img: &[f32], nw: usize, nh let p = |xx: usize, yy: usize| img[(yy * w + xx) * ch + c]; let top = p(x0, y0) + (p(x1, y0) - p(x0, y0)) * tx; let bot = p(x0, y1) + (p(x1, y1) - p(x0, y1)) * tx; - out[(y * nw + x) * ch + c] = top + (bot - top) * ty; + out[x * ch + c] = top + (bot - top) * ty; } } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_mut(nw * ch).enumerate().for_each(row); } + #[cfg(target_arch = "wasm32")] + out.chunks_mut(nw * ch).enumerate().for_each(row); out } @@ -683,6 +895,62 @@ mod tests { } } + /// After every removal, the carver's incrementally updated seam search matches a full pass + /// over its current energy bit for bit (path costs and the seam traced from them), on images + /// where seams run through the edges, through flat areas full of ties and through protection. + /// Its energy matches a fresh map of the pixels left, and so do the pixels. + #[test] + fn incremental_dp_matches_full_dp() { + let mut ramp = vec![0.0f32; 120 * 90]; + for (i, v) in ramp.iter_mut().enumerate() { + let (x, y) = ((i % 120) as f32, (i / 120) as f32); + *v = ((x * 0.07).sin() * (y * 0.05).cos() * 0.5 + 0.5).clamp(0.0, 1.0); + } + // (w, h, channels, image, protection, seams to remove) + type Case = (usize, usize, usize, Vec, Option>, usize); + let cases: Vec = vec![ + (120, 90, 1, ramp, None, 40), + (97, 64, 3, noise(97 * 64 * 3, 2, 5), None, 90), + (80, 70, 4, noise(80 * 70 * 4, 1000, 6), Some(noise(80 * 70, 3, 7)), 60), + (64, 50, 3, bar(64, 50), None, 63), + (33, 1, 1, noise(33, 5, 8), None, 32), + (2, 40, 3, noise(2 * 40 * 3, 9, 9), None, 2), + (50, 40, 1, vec![0.5; 50 * 40], Some(noise(50 * 40, 2, 10)), 49), + ]; + let bits = |v: &[f32]| v.iter().map(|f| f.to_bits()).collect::>(); + for (case, (w, h, ch, img, prot, seams)) in cases.into_iter().enumerate() { + let mut c = Carver::new(w, h, ch, &img, prot.as_deref()); + for step in 0..=seams { + let rows = Rows { stride: c.w, cw: c.cw, off: &c.off }; + let mut fresh = Dp::default(); + fresh.full(rows, &c.e); + for y in 0..h { + assert_eq!(bits(&c.dp.cost[rows.of(y)]), bits(&fresh.cost[rows.of(y)]), "case {case} step {step} row {y}: costs"); + } + assert_eq!(c.dp.seam(rows), fresh.seam(rows), "case {case} step {step}: seam"); + // The pixels left, packed, and their energy (plus protection) from scratch. + let left: Vec = (0..h) + .flat_map(|y| c.idx[rows.of(y)].iter().flat_map(move |&x| (0..ch).map(move |k| (y * w + x as usize) * ch + k))) + .map(|i| img[i]) + .collect(); + let mut want = energy(c.cw, h, ch, &left); + if let Some(p) = &prot { + let kept = (0..h).flat_map(|y| c.idx[rows.of(y)].iter().map(move |&x| y * w + x as usize)); + for (e, i) in want.iter_mut().zip(kept) { + *e += p[i].clamp(0.0, 1.0) * PROTECT_ENERGY; + } + } + let have: Vec = (0..h).flat_map(|y| c.e[rows.of(y)].to_vec()).collect(); + assert_eq!(bits(&have), bits(&want), "case {case} step {step}: energy"); + if step == seams { + assert_eq!(bits(&c.into_pixels()), bits(&left), "case {case}: pixels"); + break; + } + c.remove_seam(); + } + } + } + #[test] fn cancelled_carve_stops() { let (w, h) = (40, 20); From d466da3d57153aec0e73f9a76e9aef765f5b4a49 Mon Sep 17 00:00:00 2001 From: Fabio Lissi Date: Tue, 13 Oct 2026 10:45:26 -0500 Subject: [PATCH 034/229] Camera Raw: Calibration panel, Auto, raw sidecars and live Camera Raw Defaults (FILE-215-11) Three pieces of G14 that needed no new data or dependencies. Calibration: Camera Raw's panel (Shadows tint, Red/Green/Blue Primary hue and saturation) as a develop stage in linear light. A pixel's hue turns by the weighted Hue of the primaries it is made of (triangular weights at 0, 120 and 240 degrees; +/-100 is +/-30 degrees, towards the next primary) and its saturation scales by their weighted Saturation; the luminance is kept, so neutrals are untouched. The shadow tint is magenta or green weighted by darkness, luminance kept, white untouched. The seven crs: keys load and save with the settings file, and filter.cameraRaw takes shadowTint, calibrationHue and calibrationSat. Auto: the Light panel's button (and "auto": true on the control channel) sets Exposure, Contrast, Highlights, Shadows, Whites and Blacks from the image's luminance histogram; the sliders show what it chose. The Apply auto tone preference opens a raw with it. Sidecars: a raw opened from a path reads IMG_0001.xmp beside it into the open-time dialog, naming in the status bar what the loader skipped (the absolute kelvin white balance has no counterpart), and Open writes the sidecar back when the settings aren't the defaults or one exists; never beside a DNG, off with the new rawDefaults.writeXmpSidecars preference. With "Open in Camera Raw" off, the sidecar still develops the file. Camera Raw Defaults: the six preferences the dialog hid as dead now act when a raw opens, with or without the dialog: colour space (a conversion from the develop's ProPhoto; Adobe RGB by default, as Photoshop's workflow), bit depth, resolution (300 ppi from 72), Sharpen For (folded into the Camera Raw step as 25/1.0, 35/1.2 or 40/1.0), Open as Smart Object and Apply auto tone; the dialog's reply lists what it applied, and opening still leaves the document unmodified. Tests: calibration maths and Auto (algo), the preset's RedHue now applied (io), calibration round trip, Auto and its preference, sidecar read / write / DNG / preference / no-dialog, and the defaults on open (ui-egui); the hidden-preference test lists the Camera Raw rows as live. Nine strings in every catalog. Scorecard FILE-215-11 missing -> partial with the evidence (lossy DNG from #5112 included); gaps G14 updated, with the CR3 assessment: the CRX codec has no public specification, only LGPL implementations, so a clean-room decoder is not possible. --- crates/algo/src/camera_raw.rs | 201 +++++++++++++++ crates/engine/src/lens_cmds.rs | 2 +- crates/engine/src/prefs.rs | 36 ++- crates/io/src/camera_raw_xmp.rs | 16 +- crates/ui-egui/src/camera_raw_ui.rs | 363 +++++++++++++++++++++++++++- crates/ui-egui/src/i18n/cs.tsv | 10 + crates/ui-egui/src/i18n/de.tsv | 10 + crates/ui-egui/src/i18n/el.tsv | 10 + crates/ui-egui/src/i18n/es.tsv | 10 + crates/ui-egui/src/i18n/fr.tsv | 10 + crates/ui-egui/src/i18n/hu.tsv | 10 + crates/ui-egui/src/i18n/id.tsv | 10 + crates/ui-egui/src/i18n/it.tsv | 10 + crates/ui-egui/src/i18n/ja.tsv | 10 + crates/ui-egui/src/i18n/ko.tsv | 10 + crates/ui-egui/src/i18n/nl.tsv | 10 + crates/ui-egui/src/i18n/pl.tsv | 10 + crates/ui-egui/src/i18n/pt-br.tsv | 10 + crates/ui-egui/src/i18n/ru.tsv | 10 + crates/ui-egui/src/i18n/sr.tsv | 10 + crates/ui-egui/src/i18n/tr.tsv | 10 + crates/ui-egui/src/i18n/uk.tsv | 10 + crates/ui-egui/src/i18n/vi.tsv | 10 + crates/ui-egui/src/i18n/zh-hans.tsv | 10 + crates/ui-egui/src/i18n/zh-hant.tsv | 10 + crates/ui-egui/src/jobs_ui.rs | 1 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/prefs_ui.rs | 7 +- docs/control-protocol.md | 12 +- docs/gaps.md | 15 +- docs/scorecard.md | 10 +- scorecard/file_compat.toml | 4 +- 32 files changed, 837 insertions(+), 33 deletions(-) diff --git a/crates/algo/src/camera_raw.rs b/crates/algo/src/camera_raw.rs index f855b08bfe..faab3731ee 100644 --- a/crates/algo/src/camera_raw.rs +++ b/crates/algo/src/camera_raw.rs @@ -172,6 +172,13 @@ pub struct CameraRaw { /// 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, + // Calibration (Camera Raw's Calibration panel) + /// Shadows › Tint: −100 (green) … 100 (magenta), strongest in the darkest tones. + pub shadow_tint: f32, + /// Red, Green and Blue Primary › Hue: −100 … 100, the primary turned towards its neighbours. + pub calibration_hue: [f32; 3], + /// Red, Green and Blue Primary › Saturation: −100 (grey) … 100 (purer). + pub calibration_sat: [f32; 3], } impl Default for CameraRaw { @@ -228,6 +235,9 @@ impl Default for CameraRaw { seed: 0, pixel_scale: 1.0, encoding_gamma: 0.0, + shadow_tint: 0.0, + calibration_hue: [0.0; 3], + calibration_sat: [0.0; 3], } } } @@ -235,6 +245,90 @@ impl Default for CameraRaw { /// Hue band centres of the Color Mixer (degrees). pub const HSL_BANDS: [f32; 8] = [0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 270.0, 300.0]; +/// How far a primary turns at Hue ±100 (degrees), as the Color Mixer's hue sliders do. +const CALIBRATION_TURN: f32 = 30.0; + +/// How much of each primary (red, green, blue) a hue is made of: triangular weights around +/// 0°, 120° and 240° that sum to one, so a colour between two primaries follows both. +pub fn calibration_weights(hue: f32) -> [f32; 3] { + let h = hue.rem_euclid(360.0); + let mut w = [0.0f32; 3]; + for (i, centre) in [0.0f32, 120.0, 240.0].iter().enumerate() { + let d = (h - centre).abs().min(360.0 - (h - centre).abs()); + w[i] = (1.0 - d / 120.0).max(0.0); + } + let sum: f32 = w.iter().sum(); + if sum > 1e-6 { w.map(|v| v / sum) } else { [1.0, 0.0, 0.0] } +} + +/// The Calibration panel's primaries on one linear-light pixel: its hue turns by the weighted +/// Hue of the primaries it is made of (±100 is ±30°, positive towards the next primary) and its +/// saturation scales by their weighted Saturation (−100 greys it); the luminance is kept, so +/// neutrals are untouched. +fn calibrate(c: [f32; 3], hue: [f32; 3], sat: [f32; 3]) -> [f32; 3] { + let [h, s, v] = rgb_to_hsv(c.map(|x| x.max(0.0))); + if s <= 1e-5 || v <= 1e-6 { + return c; + } + let w = calibration_weights(h); + let (mut dh, mut ds) = (0.0f32, 0.0f32); + for i in 0..3 { + dh += w[i] * hue[i].clamp(-100.0, 100.0); + ds += w[i] * sat[i].clamp(-100.0, 100.0); + } + let nh = h + dh / 100.0 * CALIBRATION_TURN; + let ns = (s * (1.0 + ds / 100.0)).clamp(0.0, 1.0); + let out = hsv_to_rgb(nh, ns, v); + let (l0, l1) = (luma(c), luma(out)); + if l1 > 1e-6 { out.map(|x| x * l0 / l1) } else { out } +} + +/// Shadows › Tint on one linear-light pixel: `tint` −1 (green) … 1 (magenta), weighted by how +/// dark the pixel is; the luminance is kept. +fn shadow_tint(c: [f32; 3], tint: f32) -> [f32; 3] { + let l = luma(c).max(0.0); + // Linear 0.18 is middle grey: full strength below it, fading out towards white. + let weight = (1.0 - (l / 0.18).sqrt().min(1.0)).powi(2) * 0.5 + (1.0 - l.min(1.0)).powi(4) * 0.5; + let k = tint * 0.12 * weight * (l + 0.02); + let o = [c[0] + k, c[1] - k, c[2] + k].map(|v| v.max(0.0)); + let l2 = luma(o); + if l2 > 1e-6 { o.map(|v| v * l / l2) } else { o } +} + +/// Camera Raw's Auto for the Light panel: Exposure, Contrast, Highlights, Shadows, Whites and +/// Blacks from the image's luminance histogram (`px` in the document's encoding). A well +/// exposed image gets near-zero settings; a dark one more exposure and lifted shadows; one +/// with a crushed or blown end gets Blacks or Whites back from the edge. The other panels are +/// left alone, as Camera Raw's Auto leaves them. +pub fn auto_tone(px: &[[f32; 4]], p: &mut CameraRaw) { + if px.is_empty() { + return; + } + let mut lum: Vec = px.iter().map(|q| luma([q[0], q[1], q[2]]).clamp(0.0, 1.0)).collect(); + lum.sort_by(|a, b| a.total_cmp(b)); + let at = |f: f32| lum[((lum.len() - 1) as f32 * f).round() as usize]; + let (black, shadow, median, light, white) = (at(0.001), at(0.1), at(0.5), at(0.9), at(0.999)); + // Exposure: the median towards middle grey (sRGB 0.46), damped so flat scenes aren't + // forced to it. + let median_lin = srgb_to_linear(median).max(1e-4); + p.exposure = (0.6 * (0.18f32 / median_lin).log2()).clamp(-2.0, 2.0); + // Blacks and Whites bring the ends to the edges without clipping more than Camera Raw does. + p.blacks = ((0.04 - black) * 400.0).clamp(-40.0, 40.0); + p.whites = ((0.96 - white) * 300.0).clamp(-40.0, 40.0); + // Shadows up when the darker tenth is crushed, highlights down when the brighter tenth is + // near white. + p.shadows = ((0.25 - shadow) * 200.0).clamp(0.0, 50.0); + p.highlights = ((light - 0.85) * -300.0).clamp(-50.0, 0.0); + // A little contrast for a flat histogram. + let spread = (light - shadow).max(0.0); + p.contrast = ((0.6 - spread) * 60.0).clamp(0.0, 25.0); + // Shown as Camera Raw shows them: Exposure to a hundredth of a stop, the others whole. + p.exposure = (p.exposure * 100.0).round() / 100.0; + for v in [&mut p.blacks, &mut p.whites, &mut p.shadows, &mut p.highlights, &mut p.contrast] { + *v = v.round(); + } +} + fn luma(c: [f32; 3]) -> f32 { 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] } @@ -540,6 +634,10 @@ impl CameraRaw { fn grade_neutral(&self) -> bool { self.grade_shadows.neutral() && self.grade_midtones.neutral() && self.grade_highlights.neutral() && self.grade_global.neutral() } + /// The Calibration panel at rest. + fn calibration_neutral(&self) -> bool { + self.shadow_tint == 0.0 && self.calibration_hue.iter().chain(&self.calibration_sat).all(|v| *v == 0.0) + } fn curves_neutral(&self) -> bool { [self.curve_highlights, self.curve_lights, self.curve_darks, self.curve_shadows].iter().all(|v| *v == 0.0) && [&self.point_curve, &self.point_curve_red, &self.point_curve_green, &self.point_curve_blue].iter().all(|c| is_linear(c)) @@ -547,6 +645,7 @@ impl CameraRaw { /// True when every control is neutral (the filter is an identity). pub fn is_identity(&self) -> bool { self.wb_neutral() + && self.calibration_neutral() && self.tone_neutral() && self.dehaze == 0.0 && self.vibrance == 0.0 @@ -598,6 +697,24 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo } }); } + // 1b. Calibration: the primaries turned and purer or greyer, and the shadows tinted, in + // linear light (the camera's primaries are what Camera Raw's panel calibrates). + if !p.calibration_neutral() { + let primaries = p.calibration_hue.iter().chain(&p.calibration_sat).any(|v| *v != 0.0); + let tint = p.shadow_tint.clamp(-100.0, 100.0) / 100.0; + par_rows(px, w, 1, |_, row| { + for q in row.iter_mut() { + let c = [srgb_to_linear(q[0]), srgb_to_linear(q[1]), srgb_to_linear(q[2])]; + let mut o = if primaries { calibrate(c, p.calibration_hue, p.calibration_sat) } else { c }; + if tint != 0.0 { + o = shadow_tint(o, tint); + } + for k in 0..3 { + q[k] = linear_to_srgb(o[k].max(0.0)); + } + } + }); + } // 2. Dehaze. if p.dehaze != 0.0 { dehaze(px, w, h, p.dehaze / 100.0); @@ -1150,6 +1267,90 @@ mod tests { assert!(v[0][0] < 0.35 && (v[(h / 2) * w + w / 2][0] - 0.5).abs() < 0.01, "{:?}", v[0]); } + #[test] + fn calibration_turns_primaries_keeps_neutrals_and_tints_shadows() { + let w = calibration_weights(0.0); + assert!((w[0] - 1.0).abs() < 1e-6, "pure red is all red primary: {w:?}"); + let w = calibration_weights(60.0); + assert!((w[0] - 0.5).abs() < 1e-6 && (w[1] - 0.5).abs() < 1e-6, "yellow is half red, half green: {w:?}"); + let w = calibration_weights(300.0); + assert!((w[2] - 0.5).abs() < 1e-6 && (w[0] - 0.5).abs() < 1e-6, "magenta wraps round: {w:?}"); + let (none, zero) = ([0.0f32; 3], [0.0f32; 3]); + let grey = calibrate([0.3, 0.3, 0.3], [100.0, -100.0, 50.0], [100.0, -100.0, 100.0]); + assert!(grey.iter().all(|v| (v - 0.3).abs() < 1e-5), "greys are untouched: {grey:?}"); + // Red turned towards green: it gains green, keeps its luminance. + let red = calibrate([0.5, 0.0, 0.0], [100.0, 0.0, 0.0], zero); + assert!(red[1] > 0.05 && (red[1] / red[0] - 0.5).abs() < 0.02 && red[2] < 1e-6, "red turns 30° towards green (orange): {red:?}"); + assert!((luma(red) - luma([0.5, 0.0, 0.0])).abs() < 1e-4, "luminance kept: {red:?}"); + // Blue at saturation −100 is grey; green at −50 is paler. + let blue = calibrate([0.0, 0.0, 0.8], none, [0.0, 0.0, -100.0]); + assert!((blue[0] - blue[1]).abs() < 1e-5 && (blue[1] - blue[2]).abs() < 1e-5, "blue at −100 saturation is grey: {blue:?}"); + let green = calibrate([0.2, 0.8, 0.2], none, [0.0, -50.0, 0.0]); + assert!(green[0] > 0.2 && green[1] < 0.8, "green at −50 is paler: {green:?}"); + // A primary's sliders leave the other primaries alone. + let unchanged = calibrate([0.0, 0.0, 0.8], [100.0, 100.0, 0.0], [100.0, 100.0, 0.0]); + assert!(unchanged.iter().zip([0.0, 0.0, 0.8]).all(|(a, b)| (a - b).abs() < 1e-5), "{unchanged:?}"); + // Shadows tint: magenta lifts red and blue over green in a dark pixel, keeps luminance, + // and leaves white alone. + let dark = shadow_tint([0.02, 0.02, 0.02], 1.0); + assert!(dark[0] > dark[1] && dark[2] > dark[1], "{dark:?}"); + assert!((luma(dark) - 0.02).abs() < 1e-4, "luminance kept: {dark:?}"); + let green = shadow_tint([0.02, 0.02, 0.02], -1.0); + assert!(green[1] > green[0], "{green:?}"); + let white = shadow_tint([1.0, 1.0, 1.0], 1.0); + assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-3), "white is not tinted: {white:?}"); + // Through develop: neutral settings are an identity, a tint changes dark pixels only. + let mut p = CameraRaw::default(); + assert!(p.is_identity()); + p.shadow_tint = 100.0; + assert!(!p.is_identity()); + let mut px = vec![[0.1, 0.1, 0.1, 1.0], [0.95, 0.95, 0.95, 1.0]]; + develop(&mut px, 2, 1, &p, false); + assert!(px[0][0] > px[0][1], "dark grey turns magenta: {:?}", px[0]); + assert!((px[1][0] - px[1][1]).abs() < 0.01, "near-white stays neutral: {:?}", px[1]); + p = CameraRaw::default(); + p.calibration_hue[2] = 100.0; + let mut px = vec![[0.0, 0.0, 0.8, 1.0]]; + develop(&mut px, 1, 1, &p, false); + assert!(px[0][0] > 0.05, "blue turned towards red: {:?}", px[0]); + } + + #[test] + fn auto_tone_brightens_dark_and_darkens_bright_images() { + let mut p = CameraRaw::default(); + auto_tone(&[], &mut p); + assert_eq!(p, CameraRaw::default(), "no pixels: nothing changes"); + let dark: Vec<[f32; 4]> = (0..4096) + .map(|i| { + let v = 0.05 + (i % 64) as f32 / 64.0 * 0.15; + [v, v, v, 1.0] + }) + .collect(); + auto_tone(&dark, &mut p); + assert!(p.exposure > 0.5, "a dark image gets exposure: {}", p.exposure); + assert!(p.shadows > 0.0, "and lifted shadows: {}", p.shadows); + let mut p = CameraRaw::default(); + let bright: Vec<[f32; 4]> = (0..4096) + .map(|i| { + let v = 0.8 + (i % 64) as f32 / 64.0 * 0.19; + [v, v, v, 1.0] + }) + .collect(); + auto_tone(&bright, &mut p); + assert!(p.exposure < -0.5, "a bright image loses exposure: {}", p.exposure); + assert!(p.highlights < 0.0, "and its highlights come down: {}", p.highlights); + let mut p = CameraRaw::default(); + let good: Vec<[f32; 4]> = (0..4096) + .map(|i| { + let v = 0.04 + (i % 64) as f32 / 63.0 * 0.92; + [v, v, v, 1.0] + }) + .collect(); + auto_tone(&good, &mut p); + assert!(p.exposure.abs() < 0.3 && p.blacks.abs() < 10.0 && p.whites.abs() < 10.0, "a full-range image is left nearly alone: {p:?}"); + assert!(p.saturation == 0.0 && p.hsl_neutral(), "other panels untouched"); + } + #[test] fn float_documents_keep_overrange() { let mut a = vec![[2.0f32, 1.5, 0.5, 1.0]; 16]; diff --git a/crates/engine/src/lens_cmds.rs b/crates/engine/src/lens_cmds.rs index 379ca2402a..17742bd091 100644 --- a/crates/engine/src/lens_cmds.rs +++ b/crates/engine/src/lens_cmds.rs @@ -463,7 +463,7 @@ pub fn specs() -> Vec { label: "Camera Raw Filter…", menu: &["Filter"], shortcut: Some("Cmd+Shift+A"), - params: r##"{"temperature":-100..100=0,"tint":-100..100=0,"exposure":-5..5=0,"contrast":-100..100=0,"highlights":-100..100=0,"shadows":-100..100=0,"whites":-100..100=0,"blacks":-100..100=0,"texture":-100..100=0,"clarity":-100..100=0,"dehaze":-100..100=0,"vibrance":-100..100=0,"saturation":-100..100=0,"curveHighlights":-100..100=0,"curveLights":-100..100=0,"curveDarks":-100..100=0,"curveShadows":-100..100=0,"curveSplits":[25,50,75],"pointCurve":[[in,out]],"pointCurveRed":[[in,out]],"pointCurveGreen":[[in,out]],"pointCurveBlue":[[in,out]],"hslHue":[8],"hslSat":[8],"hslLum":[8],"gradeShadows":{"hue":deg,"sat":0..100,"lum":-100..100},"gradeMidtones":{},"gradeHighlights":{},"gradeGlobal":{},"gradeBlending":0..100=50,"gradeBalance":-100..100=0,"sharpenAmount":0..150=0,"sharpenRadius":0.5..3=1,"sharpenDetail":0..100=25,"sharpenMasking":0..100=0,"noiseLuminance":0..100=0,"noiseLuminanceDetail":0..100=50,"noiseColor":0..100=0,"noiseColorDetail":0..100=50,"grainAmount":0..100=0,"grainSize":0..100=25,"grainRoughness":0..100=50,"vignetteAmount":-100..100=0,"vignetteMidpoint":0..100=50,"vignetteRoundness":-100..100=0,"vignetteFeather":0..100=50,"vignetteHighlights":0..100=0,"vignetteStyle":"highlightPriority|colorPriority|paintOverlay","seed":u32=0}"##, + params: r##"{"temperature":-100..100=0,"tint":-100..100=0,"exposure":-5..5=0,"contrast":-100..100=0,"highlights":-100..100=0,"shadows":-100..100=0,"whites":-100..100=0,"blacks":-100..100=0,"texture":-100..100=0,"clarity":-100..100=0,"dehaze":-100..100=0,"vibrance":-100..100=0,"saturation":-100..100=0,"curveHighlights":-100..100=0,"curveLights":-100..100=0,"curveDarks":-100..100=0,"curveShadows":-100..100=0,"curveSplits":[25,50,75],"pointCurve":[[in,out]],"pointCurveRed":[[in,out]],"pointCurveGreen":[[in,out]],"pointCurveBlue":[[in,out]],"hslHue":[8],"hslSat":[8],"hslLum":[8],"gradeShadows":{"hue":deg,"sat":0..100,"lum":-100..100},"gradeMidtones":{},"gradeHighlights":{},"gradeGlobal":{},"gradeBlending":0..100=50,"gradeBalance":-100..100=0,"shadowTint":-100..100=0,"calibrationHue":[red,green,blue] -100..100,"calibrationSat":[red,green,blue] -100..100 (the Calibration panel: Shadows tint and the primaries' hue and saturation),"sharpenAmount":0..150=0,"sharpenRadius":0.5..3=1,"sharpenDetail":0..100=25,"sharpenMasking":0..100=0,"noiseLuminance":0..100=0,"noiseLuminanceDetail":0..100=50,"noiseColor":0..100=0,"noiseColorDetail":0..100=50,"grainAmount":0..100=0,"grainSize":0..100=25,"grainRoughness":0..100=50,"vignetteAmount":-100..100=0,"vignetteMidpoint":0..100=50,"vignetteRoundness":-100..100=0,"vignetteFeather":0..100=50,"vignetteHighlights":0..100=0,"vignetteStyle":"highlightPriority|colorPriority|paintOverlay","seed":u32=0}"##, enabled: raw_enabled, run: camera_raw_cmd, journal: true, diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 378f71c18e..1d0a7c3696 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -914,6 +914,35 @@ pub struct RawDefaults { /// Photoshop does; off develops it with the defaults straight away. Automation opens never /// show the dialog. pub open_in_camera_raw: bool, + /// Keep a raw file's develop settings in a sidecar (`IMG_0001.xmp` beside `IMG_0001.CR2`), + /// as Camera Raw's "Save image settings in: Sidecar .xmp files"; the sidecar is read again + /// when the file is next opened. Off reads sidecars but never writes one. + pub write_xmp_sidecars: bool, +} + +impl RawColorSpace { + /// The built-in profile `edit.convertToProfile` knows this space as. + pub fn profile_id(self) -> &'static str { + match self { + RawColorSpace::Srgb => "srgb", + RawColorSpace::AdobeRgb => "adobe-rgb-compat", + RawColorSpace::ProPhoto => "prophoto-compat", + RawColorSpace::DisplayP3 => "display-p3", + } + } +} + +impl RawSharpen { + /// Camera Raw's output sharpening for this medium as the Detail panel's (amount, radius): + /// none, Screen (light, fine), Matte paper (stronger, wider: ink spreads), Glossy paper. + pub fn preset(self) -> Option<(f32, f32)> { + match self { + RawSharpen::None => None, + RawSharpen::Screen => Some((25.0, 1.0)), + RawSharpen::Matte => Some((35.0, 1.2)), + RawSharpen::Glossy => Some((40.0, 1.0)), + } + } } impl Default for RawDefaults { @@ -926,6 +955,7 @@ impl Default for RawDefaults { open_as_smart_object: false, apply_auto_tone: false, open_in_camera_raw: true, + write_xmp_sidecars: true, } } } @@ -1067,12 +1097,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "type.recentFonts", "enhancedControls.scrubbySliderAcceleration", "enhancedControls.touchGestures", - "rawDefaults.colorSpace", - "rawDefaults.bitDepth", - "rawDefaults.resolution", - "rawDefaults.sharpenFor", - "rawDefaults.openAsSmartObject", - "rawDefaults.applyAutoTone", // Agent access is governed by the launch flags (`--control`, the automation roots), not // by these yet. "integrations.allowAgentControl", diff --git a/crates/io/src/camera_raw_xmp.rs b/crates/io/src/camera_raw_xmp.rs index 159e298a06..80ec9e7ef0 100644 --- a/crates/io/src/camera_raw_xmp.rs +++ b/crates/io/src/camera_raw_xmp.rs @@ -3,8 +3,9 @@ //! `x:xmpmeta/rdf:RDF/rdf:Description`, as attributes or as child elements. //! //! Loading applies only the settings the file holds, over the current ones, as Camera Raw's -//! Load Settings does; settings PhotoCraft has no control for (lens corrections, calibration, -//! geometry, defringe, …) are ignored and named in the report. Saving writes every supported +//! Load Settings does; settings PhotoCraft has no control for (lens corrections, geometry, +//! defringe, …) are ignored and named in the report. The same reader fills the dialog from a +//! raw file's sidecar (`IMG_0001.xmp` beside `IMG_0001.CR2`), and the writer keeps it. Saving writes every supported //! control, so loading the file in Photoshop reproduces the dialog. //! //! White balance: a Camera Raw Filter on a rendered image uses the relative @@ -100,6 +101,14 @@ const SCALARS: &[(&str, Field, f32, f32)] = &[ ("PostCropVignetteRoundness", |p| &mut p.vignette_roundness, -100.0, 100.0), ("PostCropVignetteFeather", |p| &mut p.vignette_feather, 0.0, 100.0), ("PostCropVignetteHighlightContrast", |p| &mut p.vignette_highlights, 0.0, 100.0), + // Calibration + ("ShadowTint", |p| &mut p.shadow_tint, -100.0, 100.0), + ("RedHue", |p| &mut p.calibration_hue[0], -100.0, 100.0), + ("RedSaturation", |p| &mut p.calibration_sat[0], -100.0, 100.0), + ("GreenHue", |p| &mut p.calibration_hue[1], -100.0, 100.0), + ("GreenSaturation", |p| &mut p.calibration_sat[1], -100.0, 100.0), + ("BlueHue", |p| &mut p.calibration_hue[2], -100.0, 100.0), + ("BlueSaturation", |p| &mut p.calibration_sat[2], -100.0, 100.0), ]; type Curve = fn(&mut CameraRaw) -> &mut Vec<[f32; 2]>; @@ -357,7 +366,8 @@ mod tests { assert_eq!(p.point_curve_red, vec![[0.0, 0.0], [128.0, 140.0], [255.0, 255.0]]); assert_eq!(p.dehaze, 12.0); // Lens vignetting, calibration and geometry have no counterpart. - assert_eq!(r.ignored, vec!["VignetteAmount".to_string(), "RedHue".to_string()]); + assert_eq!(r.ignored, vec!["VignetteAmount".to_string()], "the Calibration panel's keys load now"); + assert_eq!(p.calibration_hue[0], -5.0); } #[test] diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index 59ab86092d..dd898b1c02 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -231,7 +231,32 @@ fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpen) -> Resul let index = document_index(app, raw.document).ok_or("the raw document was closed")?; app.session.set_active(index); let (layer, surf, _) = crate::distort_ui::active_pixels(app)?; - open_pixels(app, ctx, layer, surf, CameraRaw::default(), Coverage::None, Target::OpenRaw)?; + // The file's sidecar (`IMG_0001.xmp` beside it) fills the dialog, as Camera Raw's does; the + // status bar names what it could not apply. + let mut params = CameraRaw::default(); + let mut from_sidecar = false; + if let Some((loaded, report, file)) = raw.path.as_deref().and_then(read_sidecar) { + params = loaded; + from_sidecar = true; + app.ui.status = if report.ignored.is_empty() { + crate::i18n::fmt(tl!("Settings from {name}"), &[("name", &file)]) + } else { + crate::i18n::fmt( + tl!("Settings from {name}; {count} not supported: {names}"), + &[("name", &file), ("count", &report.ignored.len().to_string()), ("names", &report.ignored.join(", "))], + ) + }; + app.ui.status_error = false; + } + open_pixels(app, ctx, layer, surf, params, Coverage::None, Target::OpenRaw)?; + // Camera Raw Defaults › Apply auto tone: a file without its own settings opens with Auto. + if !from_sidecar + && app.session.prefs().raw_defaults.apply_auto_tone + && let Some(d) = app.camera_raw.as_mut() + { + photocraft_algo::camera_raw::auto_tone(&d.proxy, &mut d.params); + d.dirty = true; + } let curve = match (&raw.bytes, &raw.path) { (Some(b), _) => Some(photocraft_io::raw::tone_curve(b)), #[cfg(not(target_arch = "wasm32"))] @@ -292,6 +317,7 @@ fn raw_stage(p: &CameraRaw) -> photocraft_io::raw::Tone { /// (tests, the web) it runs inline. fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen) -> Result { let index = document_index(app, raw.document).ok_or("the raw document was closed")?; + write_sidecar(app, raw, params); let tuning = photocraft_io::raw::RawTuning { temperature: params.temperature, tint: params.tint, @@ -398,10 +424,14 @@ fn finish_redevelop(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen, /// The other settings as one Camera Raw step on document `index`, then the document is marked /// unmodified (developing is part of opening). The step runs like any filter (in the /// background in the desktop app); the document is marked unmodified when it lands. -fn finish_open_raw(app: &mut PhotocraftApp, index: usize, rest: CameraRaw, redeveloped: Option, raw: Option<&RawOpen>) -> Result { +fn finish_open_raw(app: &mut PhotocraftApp, index: usize, mut rest: CameraRaw, redeveloped: Option, raw: Option<&RawOpen>) -> Result { app.session.set_active(index); app.sync_views(); let mut result = json!({"document": index, "redeveloped": redeveloped.unwrap_or(false)}); + let defaults = apply_raw_defaults(app, index, &mut rest)?; + if !defaults.is_empty() { + result["defaults"] = json!(defaults); + } if !rest.is_identity() { let layer = app.session.active().and_then(|st| st.active_layer).ok_or("no layer to develop")?; let mut p = non_defaults(&rest); @@ -418,6 +448,151 @@ fn finish_open_raw(app: &mut PhotocraftApp, index: usize, rest: CameraRaw, redev Ok(result) } +/// The sidecar beside a raw file: `IMG_0001.xmp` for `IMG_0001.CR2`, as Camera Raw names it. +pub fn sidecar_path(path: &str) -> String { + match path.rsplit_once('.') { + Some((stem, ext)) if !ext.contains(['/', '\\']) => format!("{stem}.xmp"), + _ => format!("{path}.xmp"), + } +} + +/// A DNG carries its settings inside the file in Camera Raw; a sidecar is read for one too, +/// but never written beside it. +fn is_dng(path: &str) -> bool { + path.rsplit_once('.').is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("dng")) +} + +/// The settings of the sidecar beside `path`, when there is one that parses: the settings, what +/// the loader skipped, and the sidecar's file name. A sidecar that doesn't parse is ignored (the +/// raw still opens) and reported in the log. +#[cfg(not(target_arch = "wasm32"))] +fn read_sidecar(path: &str) -> Option<(CameraRaw, photocraft_io::camera_raw_xmp::LoadReport, String)> { + let sidecar = sidecar_path(path); + let bytes = std::fs::read(&sidecar).ok()?; + if bytes.len() > photocraft_io::camera_raw_xmp::MAX_XMP_BYTES { + log::warn!("{sidecar}: sidecar larger than {} bytes, ignored", photocraft_io::camera_raw_xmp::MAX_XMP_BYTES); + return None; + } + let mut params = CameraRaw::default(); + match photocraft_io::camera_raw_xmp::load(&mut params, &bytes) { + Ok(report) => Some((params, report, crate::file_open::display_name(&sidecar))), + Err(e) => { + log::warn!("{sidecar}: sidecar ignored: {e}"); + None + } + } +} +#[cfg(target_arch = "wasm32")] +fn read_sidecar(_: &str) -> Option<(CameraRaw, photocraft_io::camera_raw_xmp::LoadReport, String)> { + None +} + +/// Keep the develop settings beside the raw file (Camera Raw Defaults › Write xmp sidecars): +/// written when the settings aren't the defaults or a sidecar already exists, so a file reopens +/// as it was developed. Not for DNGs, and never fatal: a folder that can't be written to is +/// reported in the status bar. +fn write_sidecar(app: &mut PhotocraftApp, raw: &RawOpen, params: &CameraRaw) { + if !app.session.prefs().raw_defaults.write_xmp_sidecars { + return; + } + let Some(path) = raw.path.as_deref().filter(|p| !is_dng(p)) else { return }; + let sidecar = sidecar_path(path); + #[cfg(not(target_arch = "wasm32"))] + let exists = std::path::Path::new(&sidecar).exists(); + #[cfg(target_arch = "wasm32")] + let exists = false; + if params.is_identity() && !exists { + return; + } + let bytes = photocraft_io::camera_raw_xmp::save(params).into_bytes(); + let Some(write) = app.services.write.as_mut() else { return }; + if let Err(e) = write(&sidecar, &bytes) { + app.ui.status = crate::i18n::fmt(tl!("Could not write {name}: {error}"), &[("name", &crate::file_open::display_name(&sidecar)), ("error", &e)]); + app.ui.status_error = true; + } +} + +/// Camera Raw Defaults (Preferences): the colour space, bit depth and resolution the developed +/// document opens in, output sharpening (folded into the Camera Raw step `rest`, as the +/// workflow's Sharpen For is), and Open as Smart Object. Returns what it applied. +fn apply_raw_defaults(app: &mut PhotocraftApp, index: usize, rest: &mut CameraRaw) -> Result, String> { + use photocraft_engine::prefs::{RawColorSpace, RawDepth}; + let d = app.session.prefs().raw_defaults.clone(); + app.session.set_active(index); + let mut applied = Vec::new(); + let profile = d.color_space.profile_id(); + // Raws develop into ProPhoto-compatible RGB (`photocraft_io::raw`); the other spaces are a + // conversion. + if profile != RawColorSpace::ProPhoto.profile_id() { + app.session.execute("edit.convertToProfile", json!({"profile": profile, "intent": "relative"})).map_err(|e| e.to_string())?; + applied.push("colorSpace"); + } + if d.bit_depth == RawDepth::Eight { + app.session.execute("image.mode.bits8", json!({})).map_err(|e| e.to_string())?; + applied.push("bitDepth"); + } + let dpi = app.session.active().map(|st| st.doc.resolution_dpi).unwrap_or(72.0); + if d.resolution.is_finite() && d.resolution > 0.0 && (f64::from(dpi) - d.resolution).abs() > 1e-6 { + app.session.execute("image.imageSize", json!({"resolution": d.resolution})).map_err(|e| e.to_string())?; + applied.push("resolution"); + } + if let Some((amount, radius)) = d.sharpen_for.preset() + && rest.sharpen_amount == 0.0 + { + rest.sharpen_amount = amount; + rest.sharpen_radius = radius; + applied.push("sharpenFor"); + } + if d.open_as_smart_object { + app.session.execute("layer.smartObjects.convertToSmartObject", json!({})).map_err(|e| e.to_string())?; + applied.push("openAsSmartObject"); + } + Ok(applied) +} + +/// A raw that opened without the dialog (Camera Raw Defaults › Open in Camera Raw off): its +/// sidecar's settings develop it as the dialog's Open would, and the Camera Raw Defaults apply. +/// `warnings` are the import's; only a developed raw qualifies. +pub fn opened_without_dialog(app: &mut PhotocraftApp, index: usize, name: &str, path: Option<&str>, warnings: &[String]) { + if !warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE)) { + return; + } + let Some(document) = app.session.documents().get(index).map(|st| st.doc.id) else { return }; + let raw = RawOpen { document, name: name.to_string(), path: path.map(str::to_string), bytes: None }; + let mut params = CameraRaw::default(); + if let Some((loaded, _, _)) = path.and_then(read_sidecar) { + params = loaded; + } else if app.session.prefs().raw_defaults.apply_auto_tone + && let Some(px) = app.session.active().and_then(|st| st.doc.layer(st.active_layer?)).and_then(|l| l.surface()).map(|s| sample_pixels(s, 512)) + { + photocraft_algo::camera_raw::auto_tone(&px, &mut params); + } + let r = if params.is_identity() { finish_open_raw(app, index, params, None, Some(&raw)) } else { commit_open_raw(app, ¶ms, &raw) }; + if let Err(e) = r { + app.ui.status = e; + app.ui.status_error = true; + } +} + +/// Every k-th pixel of a surface's content, k chosen so the longer side has about `max_side` +/// samples: enough for a histogram (Auto tone) without reading a whole raw. +fn sample_pixels(surf: &photocraft_raster::Surface, max_side: usize) -> Vec<[f32; 4]> { + let area = surf.content_bounds(); + let (w, h) = (area.x1.saturating_sub(area.x0).max(0) as usize, area.y1.saturating_sub(area.y0).max(0) as usize); + if w == 0 || h == 0 { + return Vec::new(); + } + let k = w.max(h).div_ceil(max_side.max(1)).max(1); + let mut out = Vec::with_capacity(w.div_ceil(k) * h.div_ceil(k)); + let mut row = vec![[0.0f32; 4]; w]; + for y in (0..h).step_by(k) { + let yy = area.y0.saturating_add(y as i32); + surf.read_rgba_into(Rect::new(area.x0, yy, area.x1, yy.saturating_add(1)), &mut row); + out.extend(row.iter().step_by(k).copied()); + } + out +} + fn mark_unmodified(app: &mut PhotocraftApp, index: usize) { let active = app.session.active_index(); app.session.set_active(index); @@ -676,8 +851,8 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val for (key, value) in fields { match key.as_str() { "set" | "scope" | "view" => {} - "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" if value.is_boolean() => {} - "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" => return Some(Err(format!("Camera Raw {key} must be a boolean"))), + "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" | "auto" if value.is_boolean() => {} + "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" | "auto" => return Some(Err(format!("Camera Raw {key} must be a boolean"))), _ => return Some(Err(format!("unknown Camera Raw ui property {key}"))), } } @@ -712,6 +887,10 @@ fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Opti // occurs until every part of the request has passed validation. let d = app.camera_raw.as_ref()?; let mut next_params = d.params.clone(); + // Auto (the Light panel's button): the tone sliders from the image's histogram. + if ui.get("auto").and_then(Value::as_bool) == Some(true) { + photocraft_algo::camera_raw::auto_tone(&d.proxy, &mut next_params); + } if let Some(set) = ui.get("set") { if !set.is_object() { return Some(Err("bad Camera Raw settings: expected an object".into())); @@ -1073,9 +1252,16 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { egui::ScrollArea::vertical().id_salt("camera-raw-props").show(&mut props, |ui| { ui.spacing_mut().item_spacing.y = 4.0; let p = &mut d.params; + let proxy = &d.proxy; let alt = &mut d.scope.alt_tone; let (ts, tn) = (temp_stops(), tint_stops()); section(ui, "Light", true, |ui| { + // Auto: Camera Raw's one-click tone from the histogram; the sliders show what it + // chose, so each can be adjusted afterwards. + if ui.button(tl!("Auto")).on_hover_text(tl!("Set the tone sliders from the image")).clicked() { + photocraft_algo::camera_raw::auto_tone(proxy, p); + dirty = true; + } tone_row(ui, &mut dirty, alt, ToneZone::Exposure, &mut p.exposure); row(ui, &mut dirty, "Contrast", &mut p.contrast, -100.0..=100.0, None); tone_row(ui, &mut dirty, alt, ToneZone::Highlights, &mut p.highlights); @@ -1170,6 +1356,19 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { row(ui, &mut dirty, "Color", &mut p.noise_color, 0.0..=100.0, None); row(ui, &mut dirty, "Color Detail", &mut p.noise_color_detail, 0.0..=100.0, None); }); + // Camera Raw's Calibration panel: a tint for the shadows and each primary's hue and + // saturation (Process Version is implied: 2012 and later). + section(ui, "Calibration", false, |ui| { + widgets::section_label(ui, tl!("Shadows")); + row(ui, &mut dirty, "Tint", &mut p.shadow_tint, -100.0..=100.0, Some(&tn)); + for (i, name) in ["Red Primary", "Green Primary", "Blue Primary"].iter().enumerate() { + widgets::section_label(ui, crate::i18n::tr_ctx(crate::i18n::current(), "cameraRaw", name)); + ui.push_id(name, |ui| { + row(ui, &mut dirty, "Hue", &mut p.calibration_hue[i], -100.0..=100.0, None); + row(ui, &mut dirty, "Saturation", &mut p.calibration_sat[i], -100.0..=100.0, None); + }); + } + }); }); super::camera_raw_scope_ui::floating(ctx, d, scope, right); super::camera_raw_scope_ui::shortcuts(ctx, scope); @@ -1420,7 +1619,11 @@ mod tests { let (w, h) = (48, 32); let mut spec = DngSpec::cfa(w, h, mosaic(&scene(w, h), w, [0, 1, 1, 2], 0, 30000)); spec.as_shot_neutral = Some([0.5, 1.0, 0.7]); - (PhotocraftApp::new(photocraft_engine::Session::new(), services), spec.build()) + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + // The pixel comparisons below read the developed ProPhoto values: no conversion on open + // (Camera Raw Defaults › Color space is Adobe RGB otherwise). + app.run("prefs.set", json!({"path": "rawDefaults.colorSpace", "value": "proPhoto"})).unwrap(); + (app, spec.build()) } fn mean_red(app: &PhotocraftApp) -> f32 { @@ -1547,7 +1750,8 @@ mod tests { let raw = app.pending_raw_open.take().unwrap(); open_raw(&mut app, &ctx, raw).unwrap(); let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); - assert_eq!(r, json!({"document": 0, "redeveloped": false})); + assert_eq!((r["document"].as_u64(), r["redeveloped"].as_bool(), r.get("filter")), (Some(0), Some(false), None), "{r}"); + assert_eq!(r["defaults"], json!(["resolution"]), "Camera Raw Defaults: 300 ppi, from the develop's 72: {r}"); // Preference off: raws open straight away. app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": false})).unwrap(); app.open_bytes("c.dng", &dng).unwrap(); @@ -1556,6 +1760,151 @@ mod tests { assert!(open_raw(&mut app, &ctx, RawOpen { document: photocraft_doc::DocId(u64::MAX), name: "x".into(), path: None, bytes: None }).is_err()); } + #[test] + fn calibration_changes_the_preview_and_round_trips_through_xmp() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("IMG_0005.dng", &dng).unwrap(); + let raw = app.pending_raw_open.take().unwrap(); + open_raw(&mut app, &ctx, raw).unwrap(); + let before = app.camera_raw.as_ref().unwrap().processed.clone(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"shadowTint": 100, "calibrationHue": [100, 0, 0], "calibrationSat": [0, 0, -100]}}})) + .unwrap() + .unwrap(); + let d = app.camera_raw.as_mut().unwrap(); + d.render(&ctx); + assert!( + d.processed.iter().zip(&before).any(|(a, b)| (a[0] - b[0]).abs() > 0.01 || (a[1] - b[1]).abs() > 0.01), + "the Calibration panel changes the preview" + ); + let xmp = photocraft_io::camera_raw_xmp::save(&d.params); + assert!(xmp.contains("crs:ShadowTint=\"+100\"") && xmp.contains("crs:RedHue=\"+100\"") && xmp.contains("crs:BlueSaturation=\"-100\""), "{xmp}"); + let mut back = CameraRaw::default(); + photocraft_io::camera_raw_xmp::load(&mut back, xmp.as_bytes()).unwrap(); + assert_eq!((back.shadow_tint, back.calibration_hue, back.calibration_sat), (100.0, [100.0, 0.0, 0.0], [0.0, 0.0, -100.0])); + assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"auto": "yes"}})).unwrap().is_err(), "auto must be a boolean"); + } + + #[test] + fn auto_sets_the_light_sliders_from_the_image() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("IMG_0006.dng", &dng).unwrap(); + let raw = app.pending_raw_open.take().unwrap(); + open_raw(&mut app, &ctx, raw).unwrap(); + assert!(app.camera_raw.as_ref().unwrap().params.is_identity()); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"auto": true}})).unwrap().unwrap(); + let p = app.camera_raw.as_ref().unwrap().params.clone(); + assert!(!p.is_identity(), "Auto chose something: {p:?}"); + assert!(p.saturation == 0.0 && p.hsl_hue == [0.0; 8], "only the Light panel: {p:?}"); + // The preference does the same when a raw opens without a sidecar. + app.run("prefs.set", json!({"path": "rawDefaults.applyAutoTone", "value": true})).unwrap(); + app.open_bytes("IMG_0007.dng", &dng).unwrap(); + let raw = app.pending_raw_open.take().unwrap(); + open_raw(&mut app, &ctx, raw).unwrap(); + assert_eq!(app.camera_raw.as_ref().unwrap().params, p, "Apply auto tone opens the dialog with Auto's settings"); + } + + #[test] + fn a_sidecar_fills_the_dialog_and_open_writes_it_back() { + use std::sync::{Arc, Mutex}; + let (mut app, dng) = raw_app(); + let dir = std::env::temp_dir().join(format!("photocraft-sidecar-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let raw_path = dir.join("IMG_0008.cr2").to_string_lossy().into_owned(); + std::fs::write(&raw_path, &dng).unwrap(); + assert_eq!(sidecar_path(&raw_path), dir.join("IMG_0008.xmp").to_string_lossy()); + assert_eq!(sidecar_path("/a/b.c/noext"), "/a/b.c/noext.xmp"); + std::fs::write( + sidecar_path(&raw_path), + "", + ) + .unwrap(); + type Written = Arc)>>>; + let written: Written = Arc::default(); + let sink = written.clone(); + app.services.write = Some(Box::new(move |p: &str, b: &[u8]| { + sink.lock().unwrap().push((p.to_string(), b.to_vec())); + Ok(()) + })); + let ctx = egui::Context::default(); + app.open_bytes("IMG_0008.cr2", &dng).unwrap(); + let mut raw = app.pending_raw_open.take().unwrap(); + raw.path = Some(raw_path.clone()); + open_raw(&mut app, &ctx, raw).unwrap(); + let d = app.camera_raw.as_ref().unwrap(); + assert_eq!((d.params.exposure, d.params.shadow_tint), (1.5, 20.0), "the sidecar fills the dialog"); + assert!( + app.ui.status.contains("IMG_0008.xmp") && app.ui.status.contains("Temperature"), + "the absolute white balance is reported as not supported: {}", + app.ui.status + ); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 15}}})).unwrap().unwrap(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + let w = written.lock().unwrap().clone(); + assert_eq!(w.len(), 1, "Open writes the sidecar once: {:?}", w.iter().map(|(p, _)| p).collect::>()); + assert_eq!(w[0].0, sidecar_path(&raw_path)); + let text = String::from_utf8(w[0].1.clone()).unwrap(); + assert!(text.contains("crs:Exposure2012=\"+1.50\"") && text.contains("crs:Contrast2012=\"+15\"") && text.contains("crs:ShadowTint=\"+20\""), "{text}"); + // A DNG keeps its settings inside: no sidecar beside it. And the preference turns writing off. + written.lock().unwrap().clear(); + let dng_path = dir.join("IMG_0009.dng").to_string_lossy().into_owned(); + std::fs::write(&dng_path, &dng).unwrap(); + app.open_bytes("IMG_0009.dng", &dng).unwrap(); + let mut raw = app.pending_raw_open.take().unwrap(); + raw.path = Some(dng_path); + open_raw(&mut app, &ctx, raw).unwrap(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 15}, "commit": true}})).unwrap().unwrap(); + assert!(written.lock().unwrap().is_empty(), "no sidecar for a DNG"); + app.run("prefs.set", json!({"path": "rawDefaults.writeXmpSidecars", "value": false})).unwrap(); + app.open_bytes("IMG_0008.cr2", &dng).unwrap(); + let mut raw = app.pending_raw_open.take().unwrap(); + raw.path = Some(raw_path.clone()); + open_raw(&mut app, &ctx, raw).unwrap(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"contrast": 30}, "commit": true}})).unwrap().unwrap(); + assert!(written.lock().unwrap().is_empty(), "the preference stops the write"); + // Without the dialog, the sidecar still develops the file. + app.run("prefs.set", json!({"path": "rawDefaults.openInCameraRaw", "value": false})).unwrap(); + let before = app.session.documents().len(); + app.open_file(&raw_path, &dng).unwrap(); + assert!(app.pending_raw_open.is_none()); + assert_eq!(app.session.documents().len(), before + 1); + let st = app.session.active().unwrap(); + assert_eq!(st.saved_revision, st.revision, "opening with a sidecar leaves the document unmodified"); + assert!(mean_red(&app) > 0.0); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn camera_raw_defaults_apply_when_a_raw_opens() { + let (mut app, dng) = raw_app(); + app.run("prefs.set", json!({"values": {"rawDefaults.openInCameraRaw": false, "rawDefaults.colorSpace": "srgb", "rawDefaults.bitDepth": "8", "rawDefaults.resolution": 240.0, "rawDefaults.sharpenFor": "screen", "rawDefaults.openAsSmartObject": true}})).unwrap(); + app.open_bytes("IMG_0010.dng", &dng).unwrap(); + assert!(app.pending_raw_open.is_none()); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.depth, photocraft_color::SampleType::U8, "Bit depth 8"); + assert_eq!(st.doc.resolution_dpi, 240.0, "Resolution"); + let profile = st.doc.icc_profile.as_deref().and_then(|p| photocraft_cms::Profile::parse(p).ok()).map(|p| p.description).unwrap_or_default(); + assert!(profile.to_lowercase().contains("srgb"), "Color space: {profile}"); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap(); + assert!(matches!(layer.content, photocraft_doc::LayerContent::Smart(_)), "Open as Smart Object"); + assert_eq!(st.saved_revision, st.revision, "the defaults are part of opening: unmodified"); + // The dialog's Open applies them too, and reports what it applied. + app.run("prefs.set", json!({"values": {"rawDefaults.openInCameraRaw": true, "rawDefaults.openAsSmartObject": false, "rawDefaults.bitDepth": "16"}})) + .unwrap(); + let ctx = egui::Context::default(); + app.open_bytes("IMG_0011.dng", &dng).unwrap(); + let raw = app.pending_raw_open.take().unwrap(); + open_raw(&mut app, &ctx, raw).unwrap(); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["defaults"], json!(["colorSpace", "resolution", "sharpenFor"]), "{r}"); + assert!(r.get("filter").is_some(), "Sharpen For is a Camera Raw step: {r}"); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.depth, photocraft_color::SampleType::U16); + assert_eq!(st.saved_revision, st.revision); + } + #[test] fn save_and_load_settings_round_trip_through_xmp_files() { use crate::file_dialog::FileDialogAnswer; @@ -1564,7 +1913,7 @@ mod tests { let written: Written = Arc::default(); let w = written.clone(); let foreign = br#" - + "#; let answers = vec![ Some(FileDialogAnswer::SaveTo("/out/Look.xmp".into())), diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index cea55a1694..b1aa44a4c6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3734,3 +3734,13 @@ navigator Zoom Zvětšení @plural {n} action|{n} actions {n} akce|{n} akce|{n} akcí Auto Add/Delete Automaticky přidat/odstranit ICO is limited to 256 × 256 pixels ICO je omezen na 256 × 256 pixelů + Calibration Kalibrace + Red Primary Červená základní + Green Primary Zelená základní + Blue Primary Modrá základní + Write xmp sidecars Zapisovat přidružené soubory XMP + Set the tone sliders from the image Nastavit posuvníky tónů podle obrazu + Settings from {name} Nastavení z {name} + Settings from {name}; {count} not supported: {names} Nastavení z {name}; {count} nepodporováno: {names} + Could not write {name}: {error} Nelze zapsat {name}: {error} + Shadow Tint Odstín stínů diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b23510e12e..e7c43129b8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2768,3 +2768,13 @@ curves Smooth Glätten @plural {n} action|{n} actions {n} Aktion|{n} Aktionen Auto Add/Delete Autom. hinzuf./löschen ICO is limited to 256 × 256 pixels ICO ist auf 256 × 256 Pixel begrenzt + Calibration Kalibrierung + Red Primary Rot-Primärfarbe + Green Primary Grün-Primärfarbe + Blue Primary Blau-Primärfarbe + Write xmp sidecars XMP-Filialdateien schreiben + Set the tone sliders from the image Tonwert-Regler aus dem Bild setzen + Settings from {name} Einstellungen aus {name} + Settings from {name}; {count} not supported: {names} Einstellungen aus {name}; {count} nicht unterstützt: {names} + Could not write {name}: {error} {name} konnte nicht geschrieben werden: {error} + Shadow Tint Schattentönung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 1dd9ba3652..3eec523a21 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3823,3 +3823,13 @@ curves Smooth Εξομάλυνση @plural {n} action|{n} actions {n} ενέργεια|{n} ενέργειες Auto Add/Delete Αυτόματη προσθήκη/διαγραφή ICO is limited to 256 × 256 pixels Το ICO περιορίζεται σε 256 × 256 εικονοστοιχεία + Calibration Βαθμονόμηση + Red Primary Κόκκινο πρωτεύον + Green Primary Πράσινο πρωτεύον + Blue Primary Μπλε πρωτεύον + Write xmp sidecars Εγγραφή συνοδευτικών αρχείων XMP + Set the tone sliders from the image Ορισμός των ρυθμιστικών τόνων από την εικόνα + Settings from {name} Ρυθμίσεις από {name} + Settings from {name}; {count} not supported: {names} Ρυθμίσεις από {name}· {count} δεν υποστηρίζονται: {names} + Could not write {name}: {error} Αδυναμία εγγραφής του {name}: {error} + Shadow Tint Χροιά σκιών diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5d1ff71146..64884faa46 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2809,3 +2809,13 @@ curves Smooth Suavizar @plural {n} action|{n} actions {n} acción|{n} acciones Auto Add/Delete Añadir/eliminar automáticamente ICO is limited to 256 × 256 pixels ICO está limitado a 256 × 256 píxeles + Calibration Calibración + Red Primary Primario rojo + Green Primary Primario verde + Blue Primary Primario azul + Write xmp sidecars Escribir archivos XMP auxiliares + Set the tone sliders from the image Ajustar los controles de tono a partir de la imagen + Settings from {name} Ajustes de {name} + Settings from {name}; {count} not supported: {names} Ajustes de {name}; {count} no compatibles: {names} + Could not write {name}: {error} No se pudo escribir {name}: {error} + Shadow Tint Tinte de sombras diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6974616118..e0803628ab 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3127,3 +3127,13 @@ curves Smooth Lisser @plural {n} action|{n} actions {n} action|{n} actions Auto Add/Delete Ajout/suppr. auto ICO is limited to 256 × 256 pixels Le format ICO est limité à 256 × 256 pixels + Calibration Étalonnage + Red Primary Primaire rouge + Green Primary Primaire verte + Blue Primary Primaire bleue + Write xmp sidecars Écrire les fichiers annexes XMP + Set the tone sliders from the image Régler les curseurs de tonalité d’après l’image + Settings from {name} Réglages de {name} + Settings from {name}; {count} not supported: {names} Réglages de {name} ; {count} non pris en charge : {names} + Could not write {name}: {error} Impossible d’écrire {name} : {error} + Shadow Tint Teinte des ombres diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9aa29a7cb6..d7487f47ba 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4047,3 +4047,13 @@ cameraRaw Yellow Sárga Color Indicates: A szín jelzése: Ink Characteristics Festék jellemzői Solidity: Fedőképesség: + Calibration Kalibrálás + Red Primary Vörös alapszín + Green Primary Zöld alapszín + Blue Primary Kék alapszín + Write xmp sidecars XMP kísérőfájlok írása + Set the tone sliders from the image Tónuscsúszkák beállítása a kép alapján + Settings from {name} Beállítások innen: {name} + Settings from {name}; {count} not supported: {names} Beállítások innen: {name}; {count} nem támogatott: {names} + Could not write {name}: {error} Nem sikerült írni: {name}: {error} + Shadow Tint Árnyék színezet diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8182765c4a..e6f1dec68d 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3114,3 +3114,13 @@ curves Smooth Haluskan @plural {n} action|{n} actions {n} tindakan Auto Add/Delete Tambah/Hapus Otomatis ICO is limited to 256 × 256 pixels ICO dibatasi hingga 256 × 256 piksel + Calibration Kalibrasi + Red Primary Primer merah + Green Primary Primer hijau + Blue Primary Primer biru + Write xmp sidecars Tulis berkas pendamping XMP + Set the tone sliders from the image Atur penggeser nada dari gambar + Settings from {name} Pengaturan dari {name} + Settings from {name}; {count} not supported: {names} Pengaturan dari {name}; {count} tidak didukung: {names} + Could not write {name}: {error} Tidak dapat menulis {name}: {error} + Shadow Tint Rona bayangan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 08d8adcc14..fa86da7bfc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4090,3 +4090,13 @@ navigator Zoom Zoom @plural {n} action|{n} actions {n} azione|{n} azioni Auto Add/Delete Aggiungi/elimina automaticamente ICO is limited to 256 × 256 pixels ICO è limitato a 256 × 256 pixel + Calibration Calibrazione + Red Primary Primario rosso + Green Primary Primario verde + Blue Primary Primario blu + Write xmp sidecars Scrivi file sidecar XMP + Set the tone sliders from the image Imposta i cursori dei toni dall’immagine + Settings from {name} Impostazioni da {name} + Settings from {name}; {count} not supported: {names} Impostazioni da {name}; {count} non supportate: {names} + Could not write {name}: {error} Impossibile scrivere {name}: {error} + Shadow Tint Tinta ombre diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8e878c2c21..920756580e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3803,3 +3803,13 @@ curves Smooth スムーズ Reset All Locked Settings ロックされた設定をすべて初期化 Copy Texture to Other Tools テクスチャを他のツールにコピー ICO is limited to 256 × 256 pixels ICO は 256 × 256 ピクセルまでです + Calibration キャリブレーション + Red Primary レッド原色 + Green Primary グリーン原色 + Blue Primary ブルー原色 + Write xmp sidecars XMP サイドカーファイルを書き込む + Set the tone sliders from the image 画像からトーンスライダーを設定 + Settings from {name} {name} の設定 + Settings from {name}; {count} not supported: {names} {name} の設定; {count} 件は未対応: {names} + Could not write {name}: {error} {name} を書き込めません: {error} + Shadow Tint シャドウの色かぶり補正 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b7fe66cbb5..260eb2b9ea 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3299,3 +3299,13 @@ curves Smooth 매끄럽게 @plural {n} action|{n} actions 액션 {n}개 Auto Add/Delete 자동 추가/삭제 ICO is limited to 256 × 256 pixels ICO는 256 × 256픽셀로 제한됩니다 + Calibration 보정 + Red Primary 빨강 원색 + Green Primary 녹색 원색 + Blue Primary 파랑 원색 + Write xmp sidecars XMP 사이드카 파일 쓰기 + Set the tone sliders from the image 이미지에서 톤 슬라이더 설정 + Settings from {name} {name}의 설정 + Settings from {name}; {count} not supported: {names} {name}의 설정; {count}개 미지원: {names} + Could not write {name}: {error} {name}을(를) 쓸 수 없음: {error} + Shadow Tint 그림자 색조 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 9ffc6e89b8..776d3600d0 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3885,3 +3885,13 @@ curves Smooth Vloeiend maken @plural {n} action|{n} actions {n} handeling|{n} handelingen Auto Add/Delete Automatisch toevoegen/verwijderen ICO is limited to 256 × 256 pixels ICO is beperkt tot 256 × 256 pixels + Calibration Kalibratie + Red Primary Primair rood + Green Primary Primair groen + Blue Primary Primair blauw + Write xmp sidecars XMP-sidecarbestanden schrijven + Set the tone sliders from the image Toonschuifregelaars instellen op basis van de afbeelding + Settings from {name} Instellingen uit {name} + Settings from {name}; {count} not supported: {names} Instellingen uit {name}; {count} niet ondersteund: {names} + Could not write {name}: {error} Kan {name} niet schrijven: {error} + Shadow Tint Schaduwtint diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4601e90270..be7942739c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3723,3 +3723,13 @@ curves Smooth Wygładź @plural {n} action|{n} actions {n} operacja|{n} operacje|{n} operacji Auto Add/Delete Automatyczne dodawanie/usuwanie ICO is limited to 256 × 256 pixels ICO jest ograniczony do 256 × 256 pikseli + Calibration Kalibracja + Red Primary Czerwień podstawowa + Green Primary Zieleń podstawowa + Blue Primary Niebieski podstawowy + Write xmp sidecars Zapisuj pliki poboczne XMP + Set the tone sliders from the image Ustaw suwaki tonów na podstawie obrazu + Settings from {name} Ustawienia z {name} + Settings from {name}; {count} not supported: {names} Ustawienia z {name}; nieobsługiwane: {count}: {names} + Could not write {name}: {error} Nie można zapisać {name}: {error} + Shadow Tint Tinta cieni diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 21ce217463..aa9610d556 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3915,3 +3915,13 @@ layerLabel No Color Sem cor @plural {n} action|{n} actions {n} ação|{n} ações Auto Add/Delete Adicionar/excluir automaticamente ICO is limited to 256 × 256 pixels ICO é limitado a 256 × 256 pixels + Calibration Calibração + Red Primary Primária vermelha + Green Primary Primária verde + Blue Primary Primária azul + Write xmp sidecars Gravar arquivos auxiliares XMP + Set the tone sliders from the image Definir os controles de tom a partir da imagem + Settings from {name} Configurações de {name} + Settings from {name}; {count} not supported: {names} Configurações de {name}; {count} sem suporte: {names} + Could not write {name}: {error} Não foi possível gravar {name}: {error} + Shadow Tint Matiz das sombras diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ca950c2e05..5d0e98f662 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3071,3 +3071,13 @@ curves Smooth Сгладить @plural {n} action|{n} actions {n} операция|{n} операции|{n} операций Auto Add/Delete Автодобавление/удаление ICO is limited to 256 × 256 pixels ICO ограничен размером 256 × 256 пикселей + Calibration Калибровка + Red Primary Красный основной + Green Primary Зелёный основной + Blue Primary Синий основной + Write xmp sidecars Записывать сопутствующие файлы XMP + Set the tone sliders from the image Установить ползунки тона по изображению + Settings from {name} Настройки из {name} + Settings from {name}; {count} not supported: {names} Настройки из {name}; не поддерживается: {count}: {names} + Could not write {name}: {error} Не удалось записать {name}: {error} + Shadow Tint Оттенок теней diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 7a85f8d58f..60b17404b2 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4201,3 +4201,13 @@ shape Appearance Izgled Color Indicates: Boja označava: Ink Characteristics Karakteristike mastila Solidity: Punoća: + Calibration Kalibracija + Red Primary Crvena primarna + Green Primary Zelena primarna + Blue Primary Plava primarna + Write xmp sidecars Upisuj prateće XMP datoteke + Set the tone sliders from the image Postavi klizače tona prema slici + Settings from {name} Podešavanja iz {name} + Settings from {name}; {count} not supported: {names} Podešavanja iz {name}; {count} nije podržano: {names} + Could not write {name}: {error} Nije moguće upisati {name}: {error} + Shadow Tint Nijansa senki diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 12383b694b..338170e890 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3858,3 +3858,13 @@ cameraData Lens Lens Color Indicates: Rengin Gösterdiği: Ink Characteristics Mürekkep Özellikleri Solidity: Doluluk: + Calibration Kalibrasyon + Red Primary Kırmızı ana renk + Green Primary Yeşil ana renk + Blue Primary Mavi ana renk + Write xmp sidecars XMP yan dosyalarını yaz + Set the tone sliders from the image Ton kaydırıcılarını görüntüden ayarla + Settings from {name} {name} ayarları + Settings from {name}; {count} not supported: {names} {name} ayarları; {count} desteklenmiyor: {names} + Could not write {name}: {error} {name} yazılamadı: {error} + Shadow Tint Gölge tonu diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a9bae9eb27..b1977e72a3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3824,3 +3824,13 @@ curves Smooth Згладити @plural {n} action|{n} actions {n} операція|{n} операції|{n} операцій Auto Add/Delete Автододавання/видалення ICO is limited to 256 × 256 pixels ICO обмежено розміром 256 × 256 пікселів + Calibration Калібрування + Red Primary Червоний основний + Green Primary Зелений основний + Blue Primary Синій основний + Write xmp sidecars Записувати супутні файли XMP + Set the tone sliders from the image Встановити повзунки тону за зображенням + Settings from {name} Налаштування з {name} + Settings from {name}; {count} not supported: {names} Налаштування з {name}; не підтримується: {count}: {names} + Could not write {name}: {error} Не вдалося записати {name}: {error} + Shadow Tint Відтінок тіней diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index fc652eff83..c3391e59a1 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3611,3 +3611,13 @@ curves Smooth Làm mượt Choose a gradient file (.grd). Chọn tệp chuyển sắc (.grd). Auto Add/Delete Tự động thêm/xóa ICO is limited to 256 × 256 pixels ICO giới hạn ở 256 × 256 pixel + Calibration Hiệu chuẩn + Red Primary Màu đỏ cơ bản + Green Primary Màu lục cơ bản + Blue Primary Màu lam cơ bản + Write xmp sidecars Ghi tệp phụ XMP + Set the tone sliders from the image Đặt các thanh trượt tông từ ảnh + Settings from {name} Thiết lập từ {name} + Settings from {name}; {count} not supported: {names} Thiết lập từ {name}; {count} không được hỗ trợ: {names} + Could not write {name}: {error} Không thể ghi {name}: {error} + Shadow Tint Sắc độ bóng tối diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index fb6083985c..c16510782a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2734,3 +2734,13 @@ curves Smooth 平滑 @plural {n} action|{n} actions {n} 个动作 Auto Add/Delete 自动添加/删除 ICO is limited to 256 × 256 pixels ICO 最大为 256 × 256 像素 + Calibration 校准 + Red Primary 红原色 + Green Primary 绿原色 + Blue Primary 蓝原色 + Write xmp sidecars 写入 XMP 附属文件 + Set the tone sliders from the image 根据图像设置色调滑块 + Settings from {name} 来自 {name} 的设置 + Settings from {name}; {count} not supported: {names} 来自 {name} 的设置;{count} 项不支持:{names} + Could not write {name}: {error} 无法写入 {name}:{error} + Shadow Tint 阴影色调 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 760e390812..a96a16c821 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2719,3 +2719,13 @@ curves Smooth 平滑 @plural {n} action|{n} actions {n} 個動作 Auto Add/Delete 自動增加/刪除 ICO is limited to 256 × 256 pixels ICO 最大為 256 × 256 像素 + Calibration 校準 + Red Primary 紅原色 + Green Primary 綠原色 + Blue Primary 藍原色 + Write xmp sidecars 寫入 XMP 附屬檔案 + Set the tone sliders from the image 根據影像設定色調滑桿 + Settings from {name} 來自 {name} 的設定 + Settings from {name}; {count} not supported: {names} 來自 {name} 的設定;{count} 項不支援:{names} + Could not write {name}: {error} 無法寫入 {name}:{error} + Shadow Tint 陰影色調 diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 99479f4e32..babfcce497 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -549,6 +549,7 @@ fn finish_open(app: &mut PhotocraftApp, name: &str, path: Option<&str>, v: &Valu notices::io_warnings(app, &crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]), &crate::camera_raw_ui::without_develop_note(&warnings)); } else { notices::io_warnings(app, &crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]), &warnings); + crate::camera_raw_ui::opened_without_dialog(app, index, name, path, &warnings); } // Script events bound to "Open Document". photocraft_engine::automate_cmds::document_opened(&mut app.session); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6bffa8133f..a45a28ab30 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1229,6 +1229,9 @@ impl PhotocraftApp { notices::io_warnings(self, &crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]), &camera_raw_ui::without_develop_note(&warnings)); } else { notices::io_warnings(self, &crate::i18n::fmt(tl!("Opened {name}"), &[("name", name)]), &warnings); + if let Some(index) = self.session.active_index() { + camera_raw_ui::opened_without_dialog(self, index, name, None, &warnings); + } } // Script events bound to "Open Document". photocraft_engine::automate_cmds::document_opened(&mut self.session); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index cf0ff51969..8d5d15b783 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -2761,10 +2761,11 @@ mod tests { assert!(has_visible_fields(&values, "enhancedControls")); assert!(!prefs::is_hidden("enhancedControls.rotateViewWithTrackpad")); assert!(!prefs::is_hidden("enhancedControls.zoomWithTrackpadPinch")); - // Camera Raw Defaults shows only "Open in Camera Raw" so far. + // Camera Raw Defaults are all live: the dialog's Open applies them (camera_raw_ui). assert!(has_visible_fields(&values, "rawDefaults")); - assert!(!prefs::is_hidden("rawDefaults.openInCameraRaw")); - assert!(prefs::is_hidden("rawDefaults.applyAutoTone")); + for k in ["openInCameraRaw", "applyAutoTone", "colorSpace", "bitDepth", "resolution", "sharpenFor", "openAsSmartObject", "writeXmpSidecars"] { + assert!(!prefs::is_hidden(&format!("rawDefaults.{k}")), "{k}"); + } assert!(!prefs::is_hidden("general.autoShowHomeScreen")); assert!(!prefs::is_hidden("interface.uiScale")); assert!(!prefs::is_hidden("interface.uiFontSize")); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e571e94f8b..9e2a3b14b2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -419,15 +419,23 @@ viewports, so the dialog is drawn over the main window and appears in the image. RGB/Grayscale layer. `params: {"smartFilter": {"layer": id, "index": i}}` opens it on an existing Camera Raw smart filter instead (as double-clicking the filter in the Layers panel does): the stored settings over the pixels below that filter, previewed through the filter mask; commit -then runs `layer.smartFilter.setParams` with every setting. `params.ui` accepts `set` (filter settings), `before`, `scope`, `view`, `commit` and +then runs `layer.smartFilter.setParams` with every setting. `params.ui` accepts `set` (filter settings), `before`, `scope`, `view`, `auto` (true: the Light +sliders from the image's histogram, the panel's Auto button), `saveSettings`, `loadSettings`, `commit` and `cancel`. All parts of one request are validated before any is applied; a rejected request leaves the settings, view state, preferences and document unchanged (and closes a dialog it -opened). Unknown `ui` or settings properties, non-boolean `before` / `commit` / `cancel`, and +opened). Unknown `ui` or settings properties, non-boolean `before` / `auto` / `commit` / `cancel`, and `commit` together with `cancel` are errors. Point curves (`pointCurve`, `pointCurveRed`, `pointCurveGreen`, `pointCurveBlue`) are empty or 2–16 finite points in 0–255 with inputs at least one level apart. Commit dispatches one `filter.cameraRaw` engine command; a failed commit keeps the dialog open for correction. Nothing else writes document history. +A raw file opened from a path reads its sidecar (`IMG_0001.xmp` beside `IMG_0001.CR2`, Camera +Raw's `crs:` settings) into the open-time dialog, and Open writes it back (Preferences › Camera +Raw Defaults › Write xmp sidecars; never beside a DNG). Open also applies the other Camera Raw +Defaults (colour space, bit depth, resolution, Sharpen For as a Camera Raw step, Open as Smart +Object, Apply auto tone), reported as `defaults` in the commit's reply; with "Open in Camera Raw" +off the sidecar and the defaults apply without the dialog. + **Opening a raw file.** An interactive open (File › Open, Open Recent, drag and drop, the command line) of a camera raw developed from its sensor data shows this dialog first, titled "Camera Raw (name)", with **Open** and **Cancel**, as Photoshop opens raws in Adobe Camera Raw. diff --git a/docs/gaps.md b/docs/gaps.md index f7929c276d..6174868ee2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -116,9 +116,15 @@ what an agent can't finish alone. (AGENTS.md forbids JS in the app). ### G14. Camera Raw depth -- **Missing:** CR3 (detected, unsupported), compressed RAF, lossy DNG (FILE-215-11), .xmp - sidecars, masking and calibration in the Camera Raw dialog, per-camera colour for non-DNG - cameras, lens profiles. +- **Missing:** CR3 sensor data (the embedded preview opens; the CRX codec has no public + specification, only LGPL implementations, so a clean-room decoder is not possible), compressed + RAF, highlight recovery modes (FILE-215-11), masking in the Camera Raw dialog, per-camera + colour for non-DNG cameras, lens profiles. +- **Done 2026-10-13:** lossy DNG decodes (#5112); `.xmp` sidecars beside raw files fill the + open-time dialog and Open writes them back; the Camera Raw Defaults preferences (colour space, + bit depth, resolution, Sharpen For, Open as Smart Object, Apply auto tone, sidecars) all take + effect on open; the dialog has the Calibration panel (Shadows tint, primaries' hue and + saturation) and an Auto button, both in the `.xmp` settings and the `filter.cameraRaw` params. - **Evidence:** `crates/raw/src/lib.rs:228` (CR3), scorecard FILE-215-11/12, #50. - **Progress (2026-10-11):** on the embedded-JPEG rendering work in #2135, Camera Raw tuning retains the fitted look and applies user gains afterwards. Three D5100 NEFs @@ -255,6 +261,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | G14: lossy DNG (#5112), `.xmp` sidecars, Camera Raw Defaults wired, Calibration panel and Auto; CR3 sensor decode ruled out under the clean-room rule | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | | 2026-10-12 | minor | G11: `.atn` import maps 112 events (Select and Mask, Spatter, Ripple, Crystallize, Pattern Fill, mask targeting, Step Backward, points, Bitmap) | diff --git a/docs/scorecard.md b/docs/scorecard.md index af13a3f265..0b7b98f40a 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,9 +7,9 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 145/256, round trip ≥ 258/256 | +| [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 145/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 156 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 20 of 157 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -108,7 +108,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-8 | Large and BigTIFF import with banded decode across all IFDs | done | [#215](https://github.com/storytold/photocraft/issues/215) | codecs tiff_ifd.rs (bounds-checked IFD chain + SubIFD walk, classic and BigTIFF, cycle-safe; tiff_info / decode_tiff_page), tiff.rs banded parallel strip/tile decode, planar 1 and 2, BigTIFF write (tiff_bigtiff or > 4 GiB); io import_tiff_page; 30 MP RGB8 Deflate 345 -> 32 ms; no page picker in the UI yet | | FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | done | [#215](https://github.com/storytold/photocraft/issues/215) | io svg.rs: paths, groups, text outlines as shape layers (gradients approximated; clip/mask/filter/pattern/image subtrees rasterised); smart_cmds.rs renders an SVG source at its placement scale | | FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only print_cmds.rs:133 print_pdf (raster print page) | -| FILE-215-11 | RAW: lossy DNG, highlight modes, .xmp sidecars, RAW prefs wired, per-camera corpus | missing | [#215](https://github.com/storytold/photocraft/issues/215) | raw sensor.rs:210 lossy DNG unsupported; raw_defaults unread; no xmp | +| FILE-215-11 | RAW: lossy DNG, highlight modes, .xmp sidecars, RAW prefs wired, per-camera corpus | partial | [#215](https://github.com/storytold/photocraft/issues/215) | 2026-10-13: lossy DNG (34892) decodes stored 8-bit samples with padded edge tiles (raw sensor.rs lossy_jpeg, #5112); a raw's sidecar IMG.xmp fills the open-time dialog and Open writes it back (camera_raw_ui.rs read_sidecar/write_sidecar, pref rawDefaults.writeXmpSidecars); every Camera Raw Defaults preference is read (colour space, bit depth, resolution, Sharpen For, Open as Smart Object, Apply auto tone: apply_raw_defaults); Calibration panel (shadow tint, primaries hue/saturation) in the dialog, the filter and the crs: keys. Still missing: highlight recovery modes, the absolute (kelvin) white balance of raw sidecars, a per-camera corpus | | FILE-215-12 | Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Interactive RGB/Lab scopes. PSD WB/Light/Presence, curves, HSL, grading, sharpening/noise detail, grain and numeric vignette controls mapped with descriptor preservation. Active manual Optics and unverified vignette style keep the updated ACR 18.4 fixture opaque; PSD/.pcraft saves preserve it. HDR, remaining settings and Photoshop acceptance still open | | FILE-215-13 | Video layer frames persist in .pcraft; PSD video layers mapped | partial | [#215](https://github.com/storytold/photocraft/issues/215) | format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping | | FILE-215-14 | User swatch model with ACO/ASE import and export | done | [#215](https://github.com/storytold/photocraft/issues/215) | engine presets/swatches.rs (RGB/HSB/CMYK/Lab/Gray + verbatim colour books, persisted in prefs) + swatch_cmds.rs (swatches.*); psd aco.rs (v1/v2) and ase.rs (groups, spot/global) read+write; ui swatches_ui.rs, Color Picker Add to Swatches | @@ -176,9 +176,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 26 of 156** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 20 of 157** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 40b242ea53..561ed49103 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -78,9 +78,9 @@ note = "Only print_cmds.rs:133 print_pdf (raster print page)" [[item]] id = "FILE-215-11" target = "RAW: lossy DNG, highlight modes, .xmp sidecars, RAW prefs wired, per-camera corpus" -status = "missing" +status = "partial" issue = 215 -note = "raw sensor.rs:210 lossy DNG unsupported; raw_defaults unread; no xmp" +note = "2026-10-13: lossy DNG (34892) decodes stored 8-bit samples with padded edge tiles (raw sensor.rs lossy_jpeg, #5112); a raw's sidecar IMG.xmp fills the open-time dialog and Open writes it back (camera_raw_ui.rs read_sidecar/write_sidecar, pref rawDefaults.writeXmpSidecars); every Camera Raw Defaults preference is read (colour space, bit depth, resolution, Sharpen For, Open as Smart Object, Apply auto tone: apply_raw_defaults); Calibration panel (shadow tint, primaries hue/saturation) in the dialog, the filter and the crs: keys. Still missing: highlight recovery modes, the absolute (kelvin) white balance of raw sidecars, a per-camera corpus" [[item]] id = "FILE-215-12" From b8b8483bf9b5af858bb44ead80dafebc31f42d7a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 18:08:45 +0000 Subject: [PATCH 035/229] fix(algo): seam carving never panics on degenerate input; NaN never steers seams From an adversarial review of the Content-Aware Scale speed-up: - resize_bilinear with zero channels panicked (chunk size 0) since the previous commit; it, carve_with and carve_width_with now return early when there are no pixels or channels, or fewer values than the size says (sizes checked with checked_mul). carve with an empty width and a height change, and a protection map shorter than the image (now ignored), panicked before this series too. - With NaN in the energy map, which seam was chosen depended on the NaN payload, and that differed between builds. NaN samples now give the highest energy and NaN protection protects nothing, so costs are never NaN; outputs without NaN are unchanged (all pins match). Tests: degenerate_inputs_do_not_panic and nan_payloads_do_not_steer_seams (both failed before), proxy_rebuild_observes_cancellation (a cancel during the tall-image rebuild), and NaN/inf cases in incremental_dp_matches_full_dp. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LELc5BQzgV5dA7dxdMejkK --- crates/algo/src/seam.rs | 121 ++++++++++++++++++++++++++++++++++------ 1 file changed, 105 insertions(+), 16 deletions(-) diff --git a/crates/algo/src/seam.rs b/crates/algo/src/seam.rs index 8b5befda80..59e235bc05 100644 --- a/crates/algo/src/seam.rs +++ b/crates/algo/src/seam.rs @@ -9,13 +9,21 @@ //! //! A protect map (`0..=1`, e.g. an alpha channel or detected skin tones) adds a large energy so //! seams avoid protected pixels. Buffers are interleaved `w × h × ch` normalised floats of any -//! colour model and depth (alpha is carried like any other channel). Deterministic. +//! colour model and depth (alpha is carried like any other channel). Deterministic: a NaN sample +//! gives its pixels the highest energy and NaN protection protects nothing, so no NaN (whose +//! payload arithmetic does not keep consistently across builds) ever reaches the seam search. use photocraft_raster::{Cancelled, Interrupt}; /// Energy added per unit of protection: far above any gradient magnitude. const PROTECT_ENERGY: f32 = 1.0e4; +/// The energy protection `p` (`0..=1`) adds. NaN protects nothing. +#[inline] +fn protection(p: f32) -> f32 { + if p.is_nan() { 0.0 } else { p.clamp(0.0, 1.0) * PROTECT_ENERGY } +} + /// Keep full horizontal detail while reducing the height used to find vertical seams. At a /// 24 MP working size this cuts the dynamic-programming work by about four times without /// quantising the output pixels or changing the requested output dimensions. @@ -48,7 +56,7 @@ pub fn energy(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec { /// [`energy`] of pixel `x` of row `y` of a `cw`-wide, `h`-tall image whose pixel `xx` of row `yy` /// is column `col(xx, yy)` of `img` (`w` pixels per row). Shared by the full map and the carver's -/// per-seam updates, so both give bit-identical values. +/// per-seam updates, so both give bit-identical values. NaN samples give the highest energy. #[allow(clippy::too_many_arguments)] #[inline] fn energy_at(img: &[f32], w: usize, cw: usize, h: usize, ch: usize, x: usize, y: usize, col: impl Fn(usize, usize) -> usize) -> f32 { @@ -62,7 +70,8 @@ fn energy_at(img: &[f32], w: usize, cw: usize, h: usize, ch: usize, x: usize, y: s += (img[right + c] - v).abs() + (v - img[left + c]).abs(); s += (img[down + c] - v).abs() + (v - img[up + c]).abs(); } - s * 0.5 + let e = s * 0.5; + if e.is_nan() { f32::INFINITY } else { e } } /// The lowest-energy vertical seam: one x per row. @@ -114,7 +123,8 @@ struct Dp { /// the pixel straight up, then up-left, then up-right. The cost doesn't depend on the tie-break /// and the direction doesn't depend on `e`, so the seam can be traced back from the costs alone. /// The full pass's edges, [`Dp::update`] and [`Dp::seam`] use it, and the full pass's interior -/// repeats it exactly, so every way of filling the map gives the same bits. +/// repeats it exactly, so every way of filling the map gives the same bits (costs are never NaN: +/// see [`energy_at`] and [`protection`]). #[inline] fn cell(up: &[f32], cw: usize, x: usize, e: f32) -> (f32, u8) { let (b, d) = if x == 0 { @@ -279,8 +289,8 @@ impl<'a> Carver<'a> { fn new(w: usize, h: usize, ch: usize, src: &'a [f32], prot: Option<&'a [f32]>) -> Self { let mut e = energy(w, h, ch, src); if let Some(p) = prot { - for (e, p) in e.iter_mut().zip(p) { - *e += p.clamp(0.0, 1.0) * PROTECT_ENERGY; + for (e, &p) in e.iter_mut().zip(p) { + *e += protection(p); } } let idx = (0..h).flat_map(|_| (0..w).map(|x| u32::try_from(x).unwrap_or(u32::MAX))).collect(); @@ -345,7 +355,7 @@ impl<'a> Carver<'a> { for x in lo..=hi { let mut v = energy_at(src, w, cw, h, ch, x, y, col); if let Some(p) = prot { - v += p[y * w + col(x, y)].clamp(0.0, 1.0) * PROTECT_ENERGY; + v += protection(p[y * w + col(x, y)]); } e[off[y] + x] = v; } @@ -458,6 +468,11 @@ fn average_rows(src: &[f32], row_len: usize, h: usize, step: usize) -> Vec out } +/// No pixels or channels to work with, or `img` holds fewer than `w × h × ch` values. +fn unusable(w: usize, h: usize, ch: usize, img: &[f32]) -> bool { + w == 0 || h == 0 || ch == 0 || w.checked_mul(h).and_then(|n| n.checked_mul(ch)).is_none_or(|n| img.len() < n) +} + /// Change the width of `img` to `new_w` by removing or duplicating vertical seams. pub fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize) -> Vec { // Never cancelled; the fallback is unreachable. @@ -467,10 +482,12 @@ pub fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<& /// [`carve_width`] that checks `ctl` before every seam and reports progress per seam. pub fn carve_width_with(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, ctl: &Interrupt) -> Result, Cancelled> { let new_w = new_w.max(1); - // No channels: nothing to carve (and no pixels to size the work by). - if new_w == w || w == 0 || h == 0 || ch == 0 { + // Nothing to carve: no pixels or channels, or fewer values than the size says. + if new_w == w || unusable(w, h, ch, img) { return Ok(img.to_vec()); } + // A protection map that doesn't cover the image protects nothing. + let protect = protect.filter(|p| p.len() >= w * h); let seams = new_w.abs_diff(w).max(1) as f32; if new_w < w { if h >= PROXY_SHRINK_MIN_HEIGHT && w - new_w >= PROXY_SHRINK_MIN_SEAMS { @@ -628,6 +645,10 @@ pub fn carve_with( ctl: &Interrupt, ) -> Result, Cancelled> { let (new_w, new_h) = (new_w.max(1), new_h.max(1)); + if unusable(w, h, ch, img) { + return Ok(img.to_vec()); + } + let protect = protect.filter(|p| p.len() >= w * h); // Split progress between the passes by their seam counts. let (sw, sh) = (new_w.abs_diff(w) as f32, new_h.abs_diff(h) as f32); let split = if sw + sh > 0.0 { sw / (sw + sh) } else { 1.0 }; @@ -647,8 +668,11 @@ pub fn carve_with( /// Plain bilinear resize (the non-content-aware part of the Amount blend). pub fn resize_bilinear(w: usize, h: usize, ch: usize, img: &[f32], nw: usize, nh: usize) -> Vec { - let mut out = vec![0.0f32; nw * nh * ch]; - if w == 0 || h == 0 || nw == 0 || nh == 0 { + let Some(len) = nw.checked_mul(nh).and_then(|n| n.checked_mul(ch)) else { + return Vec::new(); + }; + let mut out = vec![0.0f32; len]; + if len == 0 || unusable(w, h, ch, img) { return out; } let row = |(y, out): (usize, &mut [f32])| { @@ -702,7 +726,7 @@ mod tests { /// The straightforward implementation the per-seam carver replaced (full energy map, fresh /// seam search and a new buffer per seam), kept as the oracle for `carver_matches_reference`. mod reference { - use super::super::{PROTECT_ENERGY, resize_bilinear, transpose}; + use super::super::{protection, resize_bilinear, transpose}; pub(super) fn energy(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec { let mut e = vec![0.0f32; w * h]; @@ -717,7 +741,8 @@ mod tests { s += (at(xr, y, c) - v).abs() + (v - at(xl, y, c)).abs(); s += (at(x, yd, c) - v).abs() + (v - at(x, yu, c)).abs(); } - e[y * w + x] = s * 0.5; + let v = s * 0.5; + e[y * w + x] = if v.is_nan() { f32::INFINITY } else { v }; } } e @@ -770,8 +795,8 @@ mod tests { } let protect_e = |e: &mut [f32], p: Option<&Vec>| { if let Some(p) = p { - for (e, p) in e.iter_mut().zip(p) { - *e += p.clamp(0.0, 1.0) * PROTECT_ENERGY; + for (e, &p) in e.iter_mut().zip(p) { + *e += protection(p); } } }; @@ -906,6 +931,12 @@ mod tests { let (x, y) = ((i % 120) as f32, (i / 120) as f32); *v = ((x * 0.07).sin() * (y * 0.05).cos() * 0.5 + 0.5).clamp(0.0, 1.0); } + // NaN and infinite samples and NaN protection: infinite energies, never NaN costs. + let mut odd = noise(40 * 30 * 3, 255, 11); + odd.iter_mut().step_by(13).for_each(|v| *v = f32::NAN); + odd.iter_mut().skip(5).step_by(17).for_each(|v| *v = f32::INFINITY); + let mut odd_prot = noise(40 * 30, 3, 12); + odd_prot.iter_mut().step_by(7).for_each(|v| *v = -f32::NAN); // (w, h, channels, image, protection, seams to remove) type Case = (usize, usize, usize, Vec, Option>, usize); let cases: Vec = vec![ @@ -916,6 +947,7 @@ mod tests { (33, 1, 1, noise(33, 5, 8), None, 32), (2, 40, 3, noise(2 * 40 * 3, 9, 9), None, 2), (50, 40, 1, vec![0.5; 50 * 40], Some(noise(50 * 40, 2, 10)), 49), + (40, 30, 3, odd, Some(odd_prot), 25), ]; let bits = |v: &[f32]| v.iter().map(|f| f.to_bits()).collect::>(); for (case, (w, h, ch, img, prot, seams)) in cases.into_iter().enumerate() { @@ -937,7 +969,7 @@ mod tests { if let Some(p) = &prot { let kept = (0..h).flat_map(|y| c.idx[rows.of(y)].iter().map(move |&x| y * w + x as usize)); for (e, i) in want.iter_mut().zip(kept) { - *e += p[i].clamp(0.0, 1.0) * PROTECT_ENERGY; + *e += protection(p[i]); } } let have: Vec = (0..h).flat_map(|y| c.e[rows.of(y)].to_vec()).collect(); @@ -951,6 +983,63 @@ mod tests { } } + /// Hostile but well-formed shapes return early instead of panicking: no channels, empty + /// sizes (also through the height pass), and images or protection maps shorter than their + /// size says (a short map protects nothing). + #[test] + fn degenerate_inputs_do_not_panic() { + assert!(carve(3, 3, 0, &[], None, 2, 2).is_empty()); + assert!(carve(3, 3, 0, &[], None, 5, 5).is_empty()); + assert!(transpose(3, 3, 0, &[]).is_empty()); + assert!(resize_bilinear(3, 3, 0, &[], 2, 2).is_empty()); + assert!(resize_bilinear(3, 3, 1, &[0.5; 9], 0, 2).is_empty()); + assert!(resize_bilinear(3, 3, 1, &[0.5; 9], 2, 0).is_empty()); + assert_eq!(resize_bilinear(3, 3, 1, &[0.5; 4], 2, 2), vec![0.0; 4]); + assert!(carve(0, 5, 3, &[], None, 3, 7).is_empty()); + assert!(carve(0, 5, 3, &[], None, 3, 2).is_empty()); + let img = noise(10 * 5, 7, 3); + assert_eq!(carve(10, 5, 1, &img, Some(&[0.0; 3]), 4, 5), carve(10, 5, 1, &img, None, 4, 5)); + assert_eq!(carve(10, 5, 1, &img, Some(&[0.0; 3]), 4, 3), carve(10, 5, 1, &img, None, 4, 3)); + assert_eq!(carve(10, 5, 3, &[0.5; 20], None, 4, 5), vec![0.5; 20]); + assert_eq!(carve_width(usize::MAX, 2, 4, &[0.5; 8], None, 1), vec![0.5; 8]); + } + + /// A cancel that arrives after the seam search, during the tall-image rebuild, still stops + /// the carve. + #[test] + fn proxy_rebuild_observes_cancellation() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let (w, h, ch, new_w) = (128, PROXY_SHRINK_MIN_HEIGHT, 3, 64); + let img = noise(w * h * ch, 7, 5); + let calls = AtomicUsize::new(0); + // One check per seam passes; the first one after the search cancels. + let cancel = || calls.fetch_add(1, Ordering::Relaxed) >= w - new_w; + assert_eq!(carve_width_with(w, h, ch, &img, None, new_w, &Interrupt::cancel_only(&cancel)), Err(Cancelled)); + assert!(calls.load(Ordering::Relaxed) > w - new_w, "the rebuild checked for cancellation"); + } + + /// NaN samples and NaN protection steer seams the same whatever their sign and payload + /// (which arithmetic does not preserve consistently across builds): the same pixels are kept. + #[test] + fn nan_payloads_do_not_steer_seams() { + let (w, h, ch) = (24, 18, 3); + let (base, prot_base) = (noise(w * h * ch, 255, 21), noise(w * h, 3, 22)); + let carve_with_nans = |pixel: f32, prot: f32| { + let (mut img, mut p) = (base.clone(), prot_base.clone()); + img.iter_mut().step_by(37).for_each(|v| *v = pixel); + p.iter_mut().step_by(29).for_each(|v| *v = prot); + carve(w, h, ch, &img, Some(&p), 15, 11) + }; + let canonical = |v: Vec| v.into_iter().map(|f| if f.is_nan() { u32::MAX } else { f.to_bits() }).collect::>(); + let nans = [f32::NAN, -f32::NAN, f32::from_bits(0x7fc0_1234), f32::from_bits(0xffc0_0001)]; + let want = canonical(carve_with_nans(nans[0], nans[0])); + for &a in &nans { + for &b in &nans { + assert_eq!(canonical(carve_with_nans(a, b)), want, "pixel NaN {:#x}, protection NaN {:#x}", a.to_bits(), b.to_bits()); + } + } + } + #[test] fn cancelled_carve_stops() { let (w, h) = (40, 20); From d1a1569a6cda9e6984ad2a6ebab2d980c43df047 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 18:20:06 +0000 Subject: [PATCH 036/229] bench(engine): bench_content_aware_scale times P28's command without the UI perf_scenarios needs the whole UI crate in release (about 25 min to build on 4 cores) to time edit.contentAwareScale. This example times the same command on the same synthetic 24 MP JPEG photo from the engine alone (about 6 min), undoing between runs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LELc5BQzgV5dA7dxdMejkK --- .../examples/bench_content_aware_scale.rs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 crates/engine/examples/bench_content_aware_scale.rs diff --git a/crates/engine/examples/bench_content_aware_scale.rs b/crates/engine/examples/bench_content_aware_scale.rs new file mode 100644 index 0000000000..dd137fefd5 --- /dev/null +++ b/crates/engine/examples/bench_content_aware_scale.rs @@ -0,0 +1,50 @@ +//! End-to-end timing of `edit.contentAwareScale` (perf scenario P28, #211) without the UI: a +//! synthetic 24 MP RGB photo opened from JPEG like `perf_scenarios`' (gradients, soft blobs, fine +//! noise and a high-contrast disc), scaled to 90 % width, undone between runs. +//! `cargo run --release -p photocraft-engine --example bench_content_aware_scale [reps] [width height]` +use std::time::Instant; + +use photocraft_engine::Session; +use serde_json::json; + +fn photo_jpeg(w: u32, h: u32) -> Result, String> { + let mut px = vec![0u8; w as usize * h as usize * 3]; + let (cx, cy, r) = (w as f32 * 0.55, h as f32 * 0.5, w.min(h) as f32 / 3.0); + for (y, row) in px.chunks_mut(w as usize * 3).enumerate() { + for x in 0..w as usize { + let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32); + let n = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 997) as f32 / 997.0 - 0.5; + let blob = (-(((fx - 0.4) * 3.0).powi(2) + ((fy - 0.55) * 4.0).powi(2))).exp(); + let inside = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt() < r; + let (r0, g0, b0) = if inside { (0.85, 0.3 + 0.2 * (fx * 40.0).sin(), 0.15) } else { (0.35, 0.45, 0.55) }; + let rgb = [r0 + 0.1 * blob + n * 0.08, g0 + 0.1 * blob + n * 0.08, b0 + 0.2 * fy + n * 0.08]; + for (c, v) in rgb.into_iter().enumerate() { + row[x * 3 + c] = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8; + } + } + } + let img = + photocraft_codecs::Image::from_raw(w, h, photocraft_codecs::ChannelLayout::Rgb, photocraft_codecs::SampleType::U8, px).map_err(|e| e.to_string())?; + photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).map_err(|e| e.to_string()) +} + +fn main() -> Result<(), String> { + let args: Vec = std::env::args().collect(); + let num = |i: usize, d: u32| args.get(i).and_then(|v| v.parse::().ok()).unwrap_or(d); + let (reps, w, h) = (num(1, 3).clamp(1, 20) as usize, num(2, 6000).clamp(16, 20_000), num(3, 4000).clamp(16, 20_000)); + let jpeg = photo_jpeg(w, h)?; + let doc = photocraft_io::import("photo.jpg", &jpeg).map_err(|e| e.to_string())?.document; + let mut s = Session::new(); + s.open_document(doc, Some("photo.jpg".into())); + let mut times = Vec::with_capacity(reps); + for _ in 0..reps { + let t = Instant::now(); + s.execute("edit.contentAwareScale", json!({"scaleX": 90, "scaleY": 100})).map_err(|e| e.to_string())?; + times.push(t.elapsed().as_secs_f64() * 1000.0); + s.execute("edit.undo", json!({})).map_err(|e| e.to_string())?; + } + times.sort_by(f64::total_cmp); + let (p50, max) = (times[times.len() / 2], times[times.len() - 1]); + println!("edit.contentAwareScale {w}x{h} to 90 % width: p50 {p50:.1} ms, max {max:.1} ms ({reps} runs)"); + Ok(()) +} From 615f9d66589cae9dc1e28469aaf9b69623414eb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 18:48:45 +0000 Subject: [PATCH 037/229] fix(algo): seam carving review follow-ups; infinite protection protects in the height pass From the second half of the adversarial review: - Correction to the previous commit: mapping energies that aren't numbers to +inf also changes output where the original computed NaN energies from NaN-free input (infinite samples at the border or next to each other, overflowing differences). The original's choice there went through NaN costs; keeping it would bring NaN back into the seam search, whose sign and payload aren't consistent across builds and wasm. So the contract is: bit-identical for every input whose energy stays a number (all finite images without overflow), deterministic everywhere else. infinite_samples_carve_deterministically pins it. - The height pass resampled the raw protection map, so an infinite value (or neighbours whose difference overflows) became NaN, which protects nothing: a row protected with +inf was carved away while 1.0 kept it. Where the plain resample gives NaN, the map clamped to 0..=1 is resampled instead (resample_protection); every other value is unchanged. infinite_protection_protects_in_both_passes failed before. - Values past w x h x ch are ignored through both passes. - Docs: row_costs is what recomputes pixels; the reduced-height search cuts the work 8-12 times at 24 MP, not about four; bench_seam's hash is FNV-style over 32-bit words. Carver::new reserves its index. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LELc5BQzgV5dA7dxdMejkK --- crates/algo/examples/bench_seam.rs | 4 +- crates/algo/src/seam.rs | 99 ++++++++++++++++++++++++------ 2 files changed, 83 insertions(+), 20 deletions(-) diff --git a/crates/algo/examples/bench_seam.rs b/crates/algo/examples/bench_seam.rs index e427a6b270..d25df5d8de 100644 --- a/crates/algo/examples/bench_seam.rs +++ b/crates/algo/examples/bench_seam.rs @@ -1,8 +1,8 @@ //! Timing of Content-Aware Scale's seam carving alone (no document plumbing), on a synthetic //! photo-like RGBA image: perf scenario P28's 24 MP width change (#211), the same image's height //! (the transposed pass) and two images short enough for the exact full-resolution seam search. -//! Prints an FNV-1a hash of each output's bits, so a speed-up can be checked to choose exactly the -//! same seams. +//! Prints an FNV-style hash of each output's bits (over 32-bit words), so a speed-up can be checked +//! to choose exactly the same seams. //! `cargo run --release -p photocraft-algo --example bench_seam [reps] [case filter]` use std::time::Instant; diff --git a/crates/algo/src/seam.rs b/crates/algo/src/seam.rs index 59e235bc05..b85fba5bab 100644 --- a/crates/algo/src/seam.rs +++ b/crates/algo/src/seam.rs @@ -9,9 +9,10 @@ //! //! A protect map (`0..=1`, e.g. an alpha channel or detected skin tones) adds a large energy so //! seams avoid protected pixels. Buffers are interleaved `w × h × ch` normalised floats of any -//! colour model and depth (alpha is carried like any other channel). Deterministic: a NaN sample -//! gives its pixels the highest energy and NaN protection protects nothing, so no NaN (whose -//! payload arithmetic does not keep consistently across builds) ever reaches the seam search. +//! colour model and depth (alpha is carried like any other channel). Deterministic on every +//! platform: an energy that isn't a number (from NaN samples, or infinite ones next to each other +//! or at the border) is the highest energy, and NaN protection protects nothing, so no NaN (whose +//! sign and payload arithmetic does not keep consistently across builds) reaches the seam search. use photocraft_raster::{Cancelled, Interrupt}; @@ -24,9 +25,10 @@ fn protection(p: f32) -> f32 { if p.is_nan() { 0.0 } else { p.clamp(0.0, 1.0) * PROTECT_ENERGY } } -/// Keep full horizontal detail while reducing the height used to find vertical seams. At a -/// 24 MP working size this cuts the dynamic-programming work by about four times without -/// quantising the output pixels or changing the requested output dimensions. +/// Keep full horizontal detail while reducing the height used to find vertical seams. On a +/// 6000×4000 image this cuts the dynamic-programming work eight times for a width change (4000 +/// rows to 500) and twelve times for a height change, without quantising the output pixels or +/// changing the requested output dimensions. const SEAM_SEARCH_MAX_HEIGHT: usize = 512; const PROXY_SHRINK_MIN_HEIGHT: usize = 2_048; const PROXY_SHRINK_MIN_SEAMS: usize = 64; @@ -56,7 +58,8 @@ pub fn energy(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec { /// [`energy`] of pixel `x` of row `y` of a `cw`-wide, `h`-tall image whose pixel `xx` of row `yy` /// is column `col(xx, yy)` of `img` (`w` pixels per row). Shared by the full map and the carver's -/// per-seam updates, so both give bit-identical values. NaN samples give the highest energy. +/// per-seam updates, so both give bit-identical values. An energy that isn't a number is the +/// highest energy (`+inf`). #[allow(clippy::too_many_arguments)] #[inline] fn energy_at(img: &[f32], w: usize, cw: usize, h: usize, ch: usize, x: usize, y: usize, col: impl Fn(usize, usize) -> usize) -> f32 { @@ -122,9 +125,9 @@ struct Dp { /// its path cost and where that path came from (0 = up-left, 1 = up, 2 = up-right). Ties go to /// the pixel straight up, then up-left, then up-right. The cost doesn't depend on the tie-break /// and the direction doesn't depend on `e`, so the seam can be traced back from the costs alone. -/// The full pass's edges, [`Dp::update`] and [`Dp::seam`] use it, and the full pass's interior -/// repeats it exactly, so every way of filling the map gives the same bits (costs are never NaN: -/// see [`energy_at`] and [`protection`]). +/// [`row_costs`] uses it at the ends of a row and repeats its selection in between, and +/// [`Dp::seam`] traces seams with it, so every way of filling the map gives the same bits (costs +/// are never NaN: see [`energy_at`] and [`protection`]). #[inline] fn cell(up: &[f32], cw: usize, x: usize, e: f32) -> (f32, u8) { let (b, d) = if x == 0 { @@ -193,8 +196,8 @@ impl Dp { /// `seam[y-1..=y+1]` to the rightmost, which covers those pixels too. Every other pixel needs /// recomputing only if a parent's cost changed, so the work follows the changed costs down /// the map and narrows wherever they settle, instead of covering the whole map. Recomputed - /// pixels go through [`cell`], so the map matches a [`Dp::full`] pass over the same energy - /// bit for bit. + /// pixels go through [`row_costs`], like a [`Dp::full`] pass's, so the map matches one over + /// the same energy bit for bit. fn update(&mut self, rows: Rows, energy: &[f32], seam: &[usize]) { let (cw, h) = (rows.cw, rows.h()); if cw == 0 || h == 0 || seam.len() < h { @@ -293,7 +296,10 @@ impl<'a> Carver<'a> { *e += protection(p); } } - let idx = (0..h).flat_map(|_| (0..w).map(|x| u32::try_from(x).unwrap_or(u32::MAX))).collect(); + let mut idx = Vec::with_capacity(w * h); + for _ in 0..h { + idx.extend((0..w).map(|x| u32::try_from(x).unwrap_or(u32::MAX))); + } let off = vec![0; h]; let mut dp = Dp::default(); dp.full(Rows { stride: w, cw: w, off: &off }, &e); @@ -483,7 +489,12 @@ pub fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<& pub fn carve_width_with(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, ctl: &Interrupt) -> Result, Cancelled> { let new_w = new_w.max(1); // Nothing to carve: no pixels or channels, or fewer values than the size says. - if new_w == w || unusable(w, h, ch, img) { + if unusable(w, h, ch, img) { + return Ok(img.to_vec()); + } + // Values past the size are not part of the image. + let img = img.get(..w * h * ch).unwrap_or(img); + if new_w == w { return Ok(img.to_vec()); } // A protection map that doesn't cover the image protects nothing. @@ -648,6 +659,7 @@ pub fn carve_with( if unusable(w, h, ch, img) { return Ok(img.to_vec()); } + let img = img.get(..w * h * ch).unwrap_or(img); let protect = protect.filter(|p| p.len() >= w * h); // Split progress between the passes by their seam counts. let (sw, sh) = (new_w.abs_diff(w) as f32, new_h.abs_diff(h) as f32); @@ -658,14 +670,30 @@ pub fn carve_with( if new_h == h { return Ok(wide); } - // Protection follows the width change by plain resampling (it only steers seams). - let prot_t = protect.map(|p| transpose(new_w, h, 1, &resize_bilinear(w, h, 1, p, new_w, h))); + let prot_t = protect.map(|p| transpose(new_w, h, 1, &resample_protection(w, h, p, new_w))); let t = transpose(new_w, h, ch, &wide); let second = |f: f32| ctl.progress(split + f * (1.0 - split)); let tall = carve_width_with(h, new_w, ch, &t, prot_t.as_deref(), new_h, &Interrupt::new(&cancel, &second))?; Ok(transpose(new_h, new_w, ch, &tall)) } +/// The protection map `p` (`w × h`) for the height pass, after the width changed to `new_w`: +/// plainly resampled (it only steers seams). Where values out of `0..=1` resample to NaN +/// (infinities, or neighbours whose difference overflows), the map clamped to `0..=1` (NaN to 0) +/// resampled instead, so an infinite protection still protects. +fn resample_protection(w: usize, h: usize, p: &[f32], new_w: usize) -> Vec { + let mut out = resize_bilinear(w, h, 1, p, new_w, h); + if out.iter().any(|v| v.is_nan()) { + let clamped: Vec = p.iter().map(|&v| if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }).collect(); + for (v, c) in out.iter_mut().zip(resize_bilinear(w, h, 1, &clamped, new_w, h)) { + if v.is_nan() { + *v = c; + } + } + } + out +} + /// Plain bilinear resize (the non-content-aware part of the Amount blend). pub fn resize_bilinear(w: usize, h: usize, ch: usize, img: &[f32], nw: usize, nh: usize) -> Vec { let Some(len) = nw.checked_mul(nh).and_then(|n| n.checked_mul(ch)) else { @@ -726,7 +754,7 @@ mod tests { /// The straightforward implementation the per-seam carver replaced (full energy map, fresh /// seam search and a new buffer per seam), kept as the oracle for `carver_matches_reference`. mod reference { - use super::super::{protection, resize_bilinear, transpose}; + use super::super::{protection, resample_protection, transpose}; pub(super) fn energy(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec { let mut e = vec![0.0f32; w * h]; @@ -879,7 +907,7 @@ mod tests { if new_h == h { return wide; } - let prot_t = protect.map(|p| transpose(new_w, h, 1, &resize_bilinear(w, h, 1, p, new_w, h))); + let prot_t = protect.map(|p| transpose(new_w, h, 1, &resample_protection(w, h, p, new_w))); let t = transpose(new_w, h, ch, &wide); let tall = carve_width(h, new_w, ch, &t, prot_t.as_deref(), new_h); transpose(new_h, new_w, ch, &tall) @@ -1002,6 +1030,41 @@ mod tests { assert_eq!(carve(10, 5, 1, &img, Some(&[0.0; 3]), 4, 3), carve(10, 5, 1, &img, None, 4, 3)); assert_eq!(carve(10, 5, 3, &[0.5; 20], None, 4, 5), vec![0.5; 20]); assert_eq!(carve_width(usize::MAX, 2, 4, &[0.5; 8], None, 1), vec![0.5; 8]); + // Values past the size are ignored, through both passes. + let long = noise(20, 7, 4); + assert_eq!(carve(5, 3, 1, &long, None, 3, 3), carve(5, 3, 1, &long[..15], None, 3, 3)); + assert_eq!(carve(5, 3, 1, &long, None, 5, 2), carve(5, 3, 1, &long[..15], None, 5, 2)); + } + + /// Infinite samples, and finite ones whose differences overflow, give energies that aren't + /// numbers; those are the highest energy (pinned deliberately: the code before NaN-free costs + /// fed NaN to the seam search and chose differently). + #[test] + fn infinite_samples_carve_deterministically() { + let inf = f32::INFINITY; + assert_eq!(carve(2, 1, 1, &[inf, 0.5], None, 1, 1), [0.5]); + assert_eq!(carve(2, 1, 1, &[f32::MAX, f32::MAX], None, 6, 1), [f32::MAX, inf, inf, inf, inf, f32::MAX]); + let mut img = noise(30 * 20 * 3, 255, 41); + img.iter_mut().step_by(11).for_each(|v| *v = inf); + img.iter_mut().skip(4).step_by(23).for_each(|v| *v = -inf); + let out = carve(30, 20, 3, &img, None, 21, 14); + assert_eq!(out.len(), 21 * 14 * 3); + assert_eq!(out, carve(30, 20, 3, &img, None, 21, 14)); + } + + /// Infinite protection protects like full protection in the height pass too, where the map + /// is resampled (and an infinity alone would resample to NaN). + #[test] + fn infinite_protection_protects_in_both_passes() { + let (w, h) = (6, 6); + let img: Vec = (0..w * h).map(|i| (i * 7 % 5) as f32 / 4.0).collect(); + let row = |v: f32| (0..w * h).map(|i| if i / w == 2 { v } else { 0.0 }).collect::>(); + let (full, inf) = (row(1.0), row(f32::INFINITY)); + for (nw, nh) in [(6, 3), (4, 3), (3, 6)] { + assert_eq!(carve(w, h, 1, &img, Some(&inf), nw, nh), carve(w, h, 1, &img, Some(&full), nw, nh), "{nw}x{nh}"); + } + let kept = carve(w, h, 1, &img, Some(&inf), 6, 3); + assert!(kept.chunks(6).any(|r| r == &img[12..18]), "the protected row survives"); } /// A cancel that arrives after the seam search, during the tall-image rebuild, still stops From e4dd35ce23550bd62b7103bea7e6d65c5d1b1bb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 19:00:14 +0000 Subject: [PATCH 038/229] docs: Content-Aware Scale (P28) measured within its budget docs/content-aware-scale-performance.md records what changed, the exactness contract (bit-identical wherever the energy stays a number; NaN and infinities now deterministic), and interleaved before/after timings on a 4-core VM: the carve of P28's 24 MP photo 2013 -> 556 ms, the P28 row 3281 -> 1639 ms against its 3 s budget, smaller images 1.65-2.3x faster, peak memory unchanged or lower. gaps.md G2 and target-app-parity.md get the dated measurement; budgets.toml's P28 comment says the target is met. Its enforce flag and the committed baseline stay as they are until the reference machine reruns cargo xtask perf --update-baseline. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LELc5BQzgV5dA7dxdMejkK --- docs/content-aware-scale-performance.md | 138 ++++++++++++++++++++++++ docs/gaps.md | 14 ++- docs/target-app-parity.md | 12 ++- perf/budgets.toml | 2 +- 4 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 docs/content-aware-scale-performance.md diff --git a/docs/content-aware-scale-performance.md b/docs/content-aware-scale-performance.md new file mode 100644 index 0000000000..5d15702a51 --- /dev/null +++ b/docs/content-aware-scale-performance.md @@ -0,0 +1,138 @@ +# Content-Aware Scale seam carving (2026-10-13) + +Perf scenario P28 ([#211](https://github.com/storytold/photocraft/issues/211)) scales a 24 MP +photo to 90 % width with Edit › Content-Aware Scale. Its budget is 3 s. + +- The committed baseline (2026-10-05) records 954 s. That predates the per-seam energy updates + and the reduced-height seam search already in `crates/algo/src/seam.rs`. +- Before this change, the row measured 3.3 s on the machine below. +- After it, the row measures 1.6 s and the carve itself is 3.6 times faster. The output is the + same. + +## What changed + +All changes are in `crates/algo/src/seam.rs`. The command, its parameters and its output are +unchanged. + +- **The seam search keeps its path-cost map across seams.** + - The search is the dynamic programme of Avidan and Shamir (2007). Before, it refilled the whole + map for every seam: for P28, 600 serial passes over about 3 M cells. + - After a removal, `Dp::update` recomputes row by row only two kinds of pixel: those next to the + seam, whose energies or parents changed, and those with a parent whose cost actually changed + (compared bit for bit). Nothing else can change, so the map stays identical to a full pass. + - On the bench images this recomputes 1.3 % of the map per seam on the reduced-height search, + and 6–10 % on the full-resolution one. 98–99 % of the recomputed pixels do change, so the work + is the removed seam's basin, not slack in the bounds. + - Directions are no longer stored. The seam is traced back from the costs with the same tie + rules (`cell`). Rows are computed by one shared, vectorised kernel (`row_costs`). +- **The carver moves no pixels.** + - It borrows the image, keeps each remaining pixel's source column (`u32`), and reads energy + neighbours and the result through it. + - A removal shifts whichever side of the seam is shorter, in three 4-byte per-pixel buffers + (index, energy, cost). Before, it shifted everything right of the seam in up to four buffers, + including the 16-byte RGBA pixels and, on the tracked paths, an 8-byte index. + - The image is no longer copied. +- **The full-resolution stages run in parallel over rows.** These are the tall-image proxy build, + its full-resolution rebuild (runs copied between removed columns), the height pass's transposes + (blocked) and the plain resample used by Amount. + +## Exactness + +**Inputs whose energy stays a number** (every finite image without overflow) produce output that +is bit-identical to the previous code. These tests pin it: + +- `output_bits_are_pinned`, for images under 2048 rows. +- `tall_proxy_output_bits_are_pinned`, for the reduced-height search. It was pinned from the + previous code before any change. +- `carver_matches_reference`, which runs 400 random cases against the original straightforward + implementation. +- `incremental_dp_matches_full_dp`, which after every removal checks the kept map, the seam, the + energy and the pixels against a recomputation from scratch. +- The hashes of the four `bench_seam` outputs. + +**An adversarial review** compared the previous and new code side by side over more than 37 000 +generated cases. Inputs included huge values, denormals, -0.0, out-of-range protection, enlarging +past 2× and proxy-size images. No difference appeared on any input whose energy stays a number. + +**NaN and infinities** are now deterministic on every platform: + +- An energy that isn't a number is the highest energy (`+inf`). Such energies come from NaN + samples, from infinite samples at the border or next to each other, and from overflowing + differences. +- NaN protection protects nothing. + +Before, these inputs fed NaN to the seam search. The chosen seam then depended on NaN payloads, +which arithmetic does not keep consistently across builds or on wasm. `nan_payloads_do_not_steer_seams` +and `infinite_samples_carve_deterministically` pin the new behaviour. + +**The height pass now keeps infinite protection.** It resamples the protection map; where that +gives NaN, the map clamped to 0..=1 is resampled instead. Before, a row protected with `+inf` +was carved away while one protected with `1.0` was kept. + +**Degenerate inputs now return early instead of panicking.** These are no channels, an empty +width with a height change, and images or protection maps shorter than their size. + +## Measurements + +**Machine and method:** + +- 4-vCPU cloud VM (Intel Xeon 2.1 GHz, Linux, no GPU), release builds. +- The VM's speed drifts by about 20 % over time, so before and after binaries ran interleaved in + the same window. Reported values are p50 with the [min–max] range. + +**The carver alone:** + +- Command: `cargo run --release -p photocraft-algo --example bench_seam`. +- Input: a synthetic photo-like RGBA image. +- Five interleaved rounds; the output hashes were identical in every run. + +| Case | Before | After | Speed-up | +|---|---|---|---| +| 24 MP to 90 % width (P28's carve, reduced-height search) | 2013 ms [1958–2149] | 556 ms [542–623] | 3.6× | +| 24 MP to 90 % height (transposed) | 2639 ms [2617–2809] | 993 ms [967–1017] | 2.7× | +| 5.4 MP to 90 % width (full-resolution search) | 1548 ms [1514–1648] | 677 ms [664–719] | 2.3× | +| 1.5 MP to 70 % width (full-resolution search) | 671 ms [658–766] | 406 ms [376–421] | 1.65× | + +**The P28 row:** + +- Command: `perf_scenarios --cpu --only "Content-Aware Scale"`. +- It also times reading the layer, the job and writing the result back. +- Five interleaved rounds, n = 1 per run: + +| P28 row | Before | After | +|---|---|---| +| Time | 3281 ms [3178–3364] | 1639 ms [1503–1728] | +| Peak RSS | 1368–1386 MB | 1305–1367 MB | + +`cargo run --release -p photocraft-engine --example bench_content_aware_scale` times the same +command without building the UI crate. After the change, one run split as follows: + +| Stage | Time | +|---|---| +| Reading the layer region | about 0.3 s | +| The carve | about 0.5 s | +| Writing the result back, with the undo step | about 0.2 s | + +**Memory inside the carver** is now 12 bytes per pixel. Before, it was the image copy (4 bytes per +channel), the energy and the stored directions, plus protection and an 8-byte index where used. + +- The only case that uses more is a one-channel shrink without protection: 9 → 12 bytes per pixel. + The review measured its peak 1.17× higher. +- Every other case uses less. + +## Limits and what is still open + +- **The scorecard and enforcement.** The scorecard keeps showing the 2026-10-05 baseline until + `cargo xtask perf --update-baseline` runs on the reference machine. `enforce` stays false for + P28 until then. +- **The rest of the P28 row** is now mostly plumbing outside the carver. `Surface::read_region` + converts the 24 MP layer pixel by pixel on one thread, and `write_region` also runs on one + thread. +- **Full-resolution searches.** Enlarging still runs them, since only shrinking tall images uses + the reduced-height search. The review measured a 24 MP 1 % enlarge going from 7.0 s to 1.8 s on a + loaded machine. What remains per seam is the removed seam's basin in the cost map (serial, row by + row) and the row shifts. +- **A worst case.** When every seam hugs the right edge of a one-sided smooth ramp, the old code + shifted nothing, but the new code's cost changes cover almost the whole map. The review measured + that case 1.3–1.5× slower than before. No other input it tried was slower. +- **wasm** builds run the same code on one thread. diff --git a/docs/gaps.md b/docs/gaps.md index 23b3751235..580a094f41 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -200,10 +200,19 @@ what an agent can't finish alone. default small blur and the 36 MP saturated-field gain are inconclusive. [Method and full ranges](tilt-shift-performance.md). Native GUI latency remains unmeasured; no scorecard budget or parity percentage is changed by this work. +- **Measured progress (2026-10-13):** Content-Aware Scale (P28) keeps its seam search across + seams and moves no pixels. On a 4-core cloud VM (CPU, interleaved runs), the carve of P28's + 24 MP photo to 90 % width takes 556 ms instead of 2013 ms (3.6×), and the whole P28 row takes + 1.64 s instead of 3.28 s, within its 3 s budget. Smaller images on the full-resolution search + are 1.65–2.3× faster. Output is bit-identical wherever the energy stays a number (pinned + hashes); NaN and infinities are now deterministic. The scorecard keeps the 2026-10-05 + baseline's 954 s, which predates the per-seam energy updates and the reduced-height search, + until the reference machine reruns it. Most of the row is now `read_region`/`write_region` + plumbing. [Method and ranges](content-aware-scale-performance.md). - **Missing:** 22 of 25 budgeted scenarios are over budget (baseline 2026-10-05, a heavily loaded machine): layer move with styles 321 ms p50 against 25 ms, opacity change 276 ms against 20 ms, paste 12 MP 1.2 s against 100 ms, layer move on 15000×10000 16-bit 2.7 s, - Content-Aware Scale 24 MP 954 s against 3 s, Select Subject 559 ms against 100 ms; the + Content-Aware Scale 24 MP 954 s against 3 s (older code; now met, see above), Select Subject 559 ms against 100 ms; the 150-layer nudge crashes the GPU compositor (P12); menus slow with thousands of layers (#2460); old machines lag (#2687). - **Estimate:** 200–350 h. Serial in places (compositor architecture). @@ -255,6 +264,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | G2: Content-Aware Scale (P28) measured: carve 2013 → 556 ms, row 3.28 → 1.64 s on a 4-core VM, output unchanged; baseline, budgets and estimate unchanged | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | | 2026-10-12 | minor | G11: `.atn` import maps 112 events (Select and Mask, Spatter, Ripple, Crystallize, Pattern Fill, mask targeting, Step Backward, points, Bitmap) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f6209c58db..e9a27beac9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (`.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (Content-Aware Scale measurements added; 2026-10-12: `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -367,6 +367,15 @@ The transform also corrects f32 envelope errors past coordinate 4096, including distances at selected pixels. The parallel path needs an additional four bytes per pixel; see [measurements, correctness and limits](selection-distance-performance.md). +2026-10-13: Content-Aware Scale keeps its seam search's path costs across seams (recomputing +only the removed seam's basin) and no longer moves pixels. On a 4-core cloud VM (CPU, release, +interleaved runs) the carve of P28's 24 MP photo to 90 % width takes 2013 → 556 ms (3.6×), the +same image's height 2639 → 993 ms, and full-resolution searches 1.65–2.3× less time. The P28 +row with its document plumbing takes 3.28 → 1.64 s, within its 3 s budget. Output bits are +unchanged wherever the energy stays a number (pinned hashes, reference comparison, a 37 000-case +differential review); NaN and infinities are now deterministic. The committed baseline (954 s, +older code) is unchanged until the reference machine reruns it; see [method and limits](content-aware-scale-performance.md). + **Bottom line.** Two days after 0.2.0 we had merged ~96 PRs and closed ~48 issues, but **real Photoshop parity is still well below 50%**. The biggest gaps are AI, missing tools, professional workflow depth and the plug-in ecosystem. Most fixes since 0.2.0 have passed our tests but have @@ -563,6 +572,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Added measured Content-Aware Scale gains (P28 under its budget on a 4-core VM, not yet in the baseline); percentages and budgets unchanged | | 2026-10-12 | minor | Free Transform options bar H/V skew fields; percentages unchanged | | 2026-10-12 | minor | `.atn` import: 112 events mapped (TrueGrit Texture Supply sets play end to end); percentages unchanged | | 2026-10-12 | minor | Photoshop Actions `.atn` load and save (90 events mapped both ways); percentages unchanged | diff --git a/perf/budgets.toml b/perf/budgets.toml index a95c1c83dc..348be1d65f 100644 --- a/perf/budgets.toml +++ b/perf/budgets.toml @@ -342,7 +342,7 @@ issue = 211 bench = "perf_scenarios" row = "Content-Aware Scale 24 MP to 90 % width" budget_ms = 3000 -enforce = false # over budget in the 2026-10-05 baseline +enforce = false # 2026-10-13: met on a 4-core VM (CPU), row 3.3 s -> 1.6 s (docs/content-aware-scale-performance.md); enforce once the baseline is refreshed [[scenario]] id = "P29" From 14a2844fad442c765b635b50e1588128fc9859cd Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 15 Oct 2026 22:31:11 +0200 Subject: [PATCH 039/229] Regenerate docs/scorecard.md Co-Authored-By: Claude Opus 5.5 --- docs/scorecard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 1e0576ba02..052558a8ef 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | +| [File compatibility](#file-compatibility) | 8 | 14 | 5 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 166 (target 0) | | [Type](#type) | 4 | 3 | 2 | | @@ -100,7 +100,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t |---|---|---|---|---| | FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded | | FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 14 filters plus a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | -| FILE-215-3 | Filter mask can be painted directly, with undo | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:382 mask only from the selection | +| FILE-215-3 | Filter mask can be painted directly, with undo | done | [#215](https://github.com/storytold/photocraft/issues/215) | Clicking the filter mask thumbnail on the Smart Filters row targets it (ui.filterMaskTarget, smart_ui.rs). Painting, fills, filters and adjustments with "target":"filterMask" then edit it (a white mask starts on the first stroke) and re-render the smart object, one undo step per stroke (channel_cmds Target::FilterMask, Session::edit). ⌥-click shows it (view.layerMask "mask":"filter"), ⇧-click disables it. Kept through .pcraft and PSD FEid (smart_cmds/tests/filter_mask_paint.rs) | | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | | FILE-215-6 | Smart Object corner skew and perspective (quad or homography transform) | done | [#215](https://github.com/storytold/photocraft/issues/215) | #426: SmartObject.perspective holds the projective map (transform keeps its affine approximation); Distort / Perspective on a smart object set it (transform_cmds.rs, smart_cmds::set_placement), it re-renders from the source through the homography, Warp composes with it, and the PSD placement reads and writes nonAffineTransform / Trnf (io/blocks.rs placed_projective, smart_map.rs); tests distort_survives_edit_contents, warp_on_a_distorted_smart_object_follows_the_corners, placement survives the save | From e9ad9e728e8345ecdd0e82edbc46ae8216c49a97 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 15 Oct 2026 22:34:57 +0200 Subject: [PATCH 040/229] Regenerate docs/scorecard.md Co-Authored-By: Claude Opus 5.5 --- docs/scorecard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 1e0576ba02..e91525ee49 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | -| [Tools](#tools) | 33 | 11 | 0 | | +| [Tools](#tools) | 34 | 10 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 166 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | @@ -158,7 +158,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | done | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool (Background Eraser, Clone/Pattern Stamp, Healing/Spot Healing Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Mixer Brush: their paint.* command along the path, one step) plus simulatePressure (taper both ends); Stroke Path dialog from the Pen menu, the Paths panel menu and Alt-click on its Stroke button (#2978) | | TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather | | TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | -| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing | +| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | done | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas window (vp_ui.rs: Create/Edit Plane, edge nodes, Ctrl/Cmd-drag tear-off planes with depth and Alt-drag angle, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane, Stamp tool cloning in perspective with live preview, all applied on OK as one step); the window's Marquee tool and the Stamp's Heal modes are still missing | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | | TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo | | TOOL-213-17 | Red Eye tool: click a pupil to neutralize the red reflection | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.redEye (redeye_cmds.rs) + Tool::RedEye in the J flyout; Pupil Size and Darken Amount; catchlights kept; a miss is an error | From 5d552d4a605fca75f7d0923829f89af4622f2cf2 Mon Sep 17 00:00:00 2001 From: Jeremy Yao Date: Sat, 17 Oct 2026 11:52:09 -0400 Subject: [PATCH 041/229] Animation Effects: one wheel fix, readers split by platform, clearer names - wheel_nav: unsmooth is the one place that stops the wheel glide. main's read_with(smooth) from #5883 did the same for the canvas only, and only for plain scrolling; with unsmooth running first in PhotocraftApp::logic it no longer changed anything, so read() is the single reader again and the canvas calls it as before. #5883's test now drives unsmooth and sits with the other Animation Effects tests. - wheel_nav: arrived and touch become unsmoothed_scroll and in_gesture, with plainer docs. - animations.rs: the Linux/BSD settings parsers move to animations/freedesktop.rs (plain functions, tested on every platform), so animations.rs reads as one short reader per platform under a table of where each one reads from. - prefs_ui: Runtime::reduce_motion becomes animations_off, to match animations_on(); the long animation test is split into three focused ones. No change in behaviour. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft/src/animations.rs | 341 +++++------------- apps/photocraft/src/animations/freedesktop.rs | 150 ++++++++ crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/lib.rs | 5 +- crates/ui-egui/src/prefs_ui.rs | 84 +++-- crates/ui-egui/src/wheel_nav.rs | 182 +++++----- 6 files changed, 377 insertions(+), 389 deletions(-) create mode 100644 apps/photocraft/src/animations/freedesktop.rs diff --git a/apps/photocraft/src/animations.rs b/apps/photocraft/src/animations.rs index 62d138e756..bae69370b0 100644 --- a/apps/photocraft/src/animations.rs +++ b/apps/photocraft/src/animations.rs @@ -1,36 +1,30 @@ //! The operating system's animation setting, for Preferences › Interface › Animation Effects › -//! Use System Setting (`Services::system_animations`). +//! Use System Setting (`Services::system_animations`). `None` means the platform gave no answer; +//! the app then keeps animations on. //! -//! - **Windows**: Settings › Accessibility › Visual effects › Animation effects, through -//! `UISettings.AnimationsEnabled`. It answers for the session, so a Remote Desktop connection -//! that turns animations off counts too. -//! - **macOS**: System Settings › Accessibility › Display › Reduce motion -//! (`NSWorkspace.accessibilityDisplayShouldReduceMotion`). -//! - **Linux**: the settings portal: `reduced-motion`, then GNOME's `enable-animations` and KDE's -//! `AnimationDurationFactor` as the portal passes them on. Its change signal keeps the value -//! current, and it works inside a Flatpak sandbox. -//! - **Linux without the portal, FreeBSD and the other BSDs**: the desktop's setting where it is -//! stored: KDE's `kdeglobals`, GNOME's or MATE's `enable-animations` (`gsettings`), then GTK's -//! `gtk-enable-animations`. There is no change signal to follow, so it is read at start-up and -//! again whenever the window gets the focus back. -//! -//! `None` means the platform gave no answer; the shell then keeps animations on. +//! | Platform | Read from | +//! |---|---| +//! | Windows | Settings › Accessibility › Visual effects › Animation effects (`UISettings.AnimationsEnabled`) | +//! | macOS | System Settings › Accessibility › Display › Reduce motion | +//! | Linux | The settings portal, followed as it changes; it also works inside a Flatpak | +//! | Linux without the portal, the BSDs | The desktop's own files and `gsettings` (`freedesktop.rs`), read again when the window gets the focus back | //! //! No `unsafe`: every OS call goes through a safe binding. +#[cfg(any(test, all(unix, not(target_os = "macos"))))] +mod freedesktop; + pub use platform::service; -/// Windows and macOS answer at once, so the setting is simply asked for again: at most this -/// often, and only on frames that run anyway (coming back from the system's settings is one). -#[cfg(any(target_os = "windows", target_os = "macos"))] -const POLL: std::time::Duration = std::time::Duration::from_secs(1); - +/// Windows and macOS answer at once, so the setting is asked for again on frames that run anyway +/// (coming back from the system's settings is one), at most once a second. #[cfg(any(target_os = "windows", target_os = "macos"))] fn polled(read: fn() -> Option) -> photocraft_ui_egui::SystemAnimationsFn { + const INTERVAL: std::time::Duration = std::time::Duration::from_secs(1); let last = std::cell::Cell::new(None::<(std::time::Instant, Option)>); Box::new(move |_| { if let Some((at, animations)) = last.get() - && at.elapsed() < POLL + && at.elapsed() < INTERVAL { return animations; } @@ -42,12 +36,12 @@ fn polled(read: fn() -> Option) -> photocraft_ui_egui::SystemAnimationsFn #[cfg(target_os = "windows")] mod platform { - use photocraft_ui_egui::SystemAnimationsFn; - - pub fn service() -> Option { + pub fn service() -> Option { Some(super::polled(read)) } + /// A safe WinRT call; it reports what Windows applies to this session, so a Remote Desktop + /// connection with animations off counts too. pub(super) fn read() -> Option { windows::UI::ViewManagement::UISettings::new().and_then(|settings| settings.AnimationsEnabled()).ok() } @@ -55,9 +49,7 @@ mod platform { #[cfg(target_os = "macos")] mod platform { - use photocraft_ui_egui::SystemAnimationsFn; - - pub fn service() -> Option { + pub fn service() -> Option { Some(super::polled(read)) } @@ -66,42 +58,37 @@ mod platform { } } -#[cfg(not(any(unix, target_os = "windows")))] -mod platform { - pub fn service() -> Option { - None - } -} - +/// Linux and the BSDs. Reading the setting can mean waiting for the session bus or running +/// `gsettings`, so a worker thread does it and the UI reads its latest answer. #[cfg(all(unix, not(target_os = "macos")))] mod platform { use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, OnceLock, mpsc}; - use photocraft_ui_egui::SystemAnimationsFn; + use super::freedesktop; - /// Follows the desktop's setting on a worker thread, so a slow session bus or `gsettings` - /// never holds up a frame. Until the first answer lands the setting is unknown. - pub fn service() -> Option { - let value = Arc::new(AtomicU8::new(super::code_of(None))); + pub fn service() -> Option { + let latest = Arc::new(AtomicU8::new(encode(None))); let wake: Arc> = Arc::new(OnceLock::new()); let (refresh, refreshes) = mpsc::channel::<()>(); - let publisher = Publisher { value: Arc::clone(&value), wake: Arc::clone(&wake) }; + let publisher = Publisher { latest: Arc::clone(&latest), wake: Arc::clone(&wake) }; std::thread::Builder::new().name("animations-setting".into()).spawn(move || worker(&publisher, &refreshes)).ok()?; - let focused = std::cell::Cell::new(true); + let last_focus = std::cell::Cell::new(true); Some(Box::new(move |ctx| { let _ = wake.set(ctx.clone()); - let now = ctx.input(|i| i.focused); - let was = focused.replace(now); - // Back from the desktop's settings, perhaps: the worker reads again unless it has - // the portal's change signal (then nobody listens, and the send fails quietly). - if now && !was { + let focused = ctx.input(|i| i.focused); + let was_focused = last_focus.replace(focused); + // Back from the desktop's settings, perhaps: read again. With the portal's change + // signal nobody listens for this, and the send fails quietly. + if focused && !was_focused { let _ = refresh.send(()); } - super::decode(value.load(Ordering::Relaxed)) + decode(latest.load(Ordering::Relaxed)) })) } + /// Follows the portal when it has an answer; otherwise reads the desktop's own setting now + /// and on every refresh request. fn worker(publisher: &Publisher, refreshes: &mpsc::Receiver<()>) { #[cfg(target_os = "linux")] if portal::follow(publisher) { @@ -113,16 +100,16 @@ mod platform { } } - /// The worker's handle on the shared value. A change wakes the registered UI. + /// The worker's side of the latest answer. A change wakes the UI. struct Publisher { - value: Arc, + latest: Arc, wake: Arc>, } impl Publisher { fn publish(&self, animations: Option) { - let code = super::code_of(animations); - if self.value.swap(code, Ordering::Relaxed) != code + let code = encode(animations); + if self.latest.swap(code, Ordering::Relaxed) != code && let Some(ctx) = self.wake.get() { ctx.request_repaint(); @@ -130,21 +117,37 @@ mod platform { } } + fn encode(animations: Option) -> u8 { + match animations { + None => 0, + Some(true) => 1, + Some(false) => 2, + } + } + + fn decode(code: u8) -> Option { + match code { + 1 => Some(true), + 2 => Some(false), + _ => None, + } + } + #[cfg(target_os = "linux")] mod portal { use zbus::zvariant::{OwnedValue, Value}; - use super::super::{duration_factor, enabled, reduced_motion}; + use super::freedesktop; - /// Whether animations are on according to a setting's value. - type Reads = fn(&str) -> Option; + /// Reads a setting's value as animations on or off. + type Parse = fn(&str) -> Option; - /// The settings the portal may answer for and how each one's value reads, most general - /// first: the cross-desktop one, then the two desktops' own that older portals pass on. - const SETTINGS: [(&str, &str, Reads); 3] = [ - ("org.freedesktop.appearance", "reduced-motion", reduced_motion), - ("org.gnome.desktop.interface", "enable-animations", enabled), - ("org.kde.kdeglobals.KDE", "AnimationDurationFactor", duration_factor), + /// The settings the portal may answer for, most general first: the cross-desktop one, + /// then GNOME's and KDE's own, which older portals pass on. + const SETTINGS: [(&str, &str, Parse); 3] = [ + ("org.freedesktop.appearance", "reduced-motion", freedesktop::from_reduced_motion), + ("org.gnome.desktop.interface", "enable-animations", freedesktop::from_enabled), + ("org.kde.kdeglobals.KDE", "AnimationDurationFactor", freedesktop::from_duration_factor), ]; /// Publishes the portal's answer, then every change to it. `false` when the portal has @@ -168,14 +171,14 @@ mod platform { } fn read(proxy: &zbus::blocking::Proxy<'_>) -> Option { - SETTINGS.iter().find_map(|(namespace, key, animations)| { + SETTINGS.iter().find_map(|(namespace, key, parse)| { let value: OwnedValue = proxy.call("Read", &(*namespace, *key)).ok()?; - animations(&text(value)?) + parse(&as_text(value)?) }) } /// A setting's value as the text a settings file would hold. - fn text(value: OwnedValue) -> Option { + fn as_text(value: OwnedValue) -> Option { let mut value: Value<'_> = value.into(); // The Settings.Read result is a variant; some portals nest another variant inside it. for _ in 0..4 { @@ -203,42 +206,36 @@ mod platform { #[test] fn portal_values_read_as_text() { let nested = Value::Value(Box::new(Value::Value(Box::new(Value::U32(1))))); - assert_eq!(text(owned(nested)).as_deref(), Some("1")); - assert_eq!(text(owned(Value::from(false))).as_deref(), Some("false")); - assert_eq!(text(owned(Value::from("0.5"))).as_deref(), Some("0.5")); - assert_eq!(text(owned(Value::from(vec![1u32, 2]))), None); - // Each setting reads its own type of value, the cross-desktop one first. - let [(_, motion, reduced), (_, _, on), (_, _, factor)] = SETTINGS; - assert_eq!(motion, "reduced-motion"); - assert_eq!(reduced(&text(owned(Value::U32(1))).unwrap()), Some(false)); - assert_eq!(reduced(&text(owned(Value::U32(0))).unwrap()), Some(true)); - assert_eq!(on(&text(owned(Value::from(false))).unwrap()), Some(false)); - assert_eq!(factor(&text(owned(Value::from("0"))).unwrap()), Some(false)); - assert_eq!(factor(&text(owned(Value::F64(0.5))).unwrap()), Some(true)); + assert_eq!(as_text(owned(nested)).as_deref(), Some("1")); + assert_eq!(as_text(owned(Value::from(false))).as_deref(), Some("false")); + assert_eq!(as_text(owned(Value::F64(0.5))).as_deref(), Some("0.5")); + assert_eq!(as_text(owned(Value::from("0"))).as_deref(), Some("0")); + assert_eq!(as_text(owned(Value::from(vec![1u32, 2]))), None); + assert_eq!(SETTINGS[0].1, "reduced-motion", "the cross-desktop setting is asked first"); } } } + /// The desktop's own setting, without the portal. mod desktop { use std::path::PathBuf; + use super::freedesktop; + /// A settings file is a few kilobytes; a larger one isn't read. const MAX_CONFIG_BYTES: u64 = 1 << 20; pub fn read() -> Option { let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(); - super::super::desktop_setting(&desktop, &config_file, &gsettings) - } - - fn config_home() -> Option { - let dir = |key: &str| std::env::var_os(key).filter(|v| !v.is_empty()).map(PathBuf::from); - dir("XDG_CONFIG_HOME").filter(|path| path.is_absolute()).or_else(|| dir("HOME").map(|home| home.join(".config"))) + freedesktop::without_portal(&desktop, &config_file, &gsettings) } fn config_file(name: &str) -> Option { use std::io::Read as _; + let dir = |key: &str| std::env::var_os(key).filter(|v| !v.is_empty()).map(PathBuf::from); + let config_home = dir("XDG_CONFIG_HOME").filter(|path| path.is_absolute()).or_else(|| dir("HOME").map(|home| home.join(".config")))?; let mut text = String::new(); - std::fs::File::open(config_home()?.join(name)).ok()?.take(MAX_CONFIG_BYTES).read_to_string(&mut text).ok()?; + std::fs::File::open(config_home.join(name)).ok()?.take(MAX_CONFIG_BYTES).read_to_string(&mut text).ok()?; Some(text) } @@ -257,188 +254,22 @@ mod platform { } } -/// `true` or `false` (GSettings, the portal), `1` or `0` (a GTK settings file): animations are on. -#[cfg(any(test, all(unix, not(target_os = "macos"))))] -fn enabled(value: &str) -> Option { - match value.trim().to_ascii_lowercase().as_str() { - "true" | "1" => Some(true), - "false" | "0" => Some(false), - _ => None, +#[cfg(not(any(unix, target_os = "windows")))] +mod platform { + pub fn service() -> Option { + None } } -/// KDE's animation speed factor: 0 turns animations off. -#[cfg(any(test, all(unix, not(target_os = "macos"))))] -fn duration_factor(value: &str) -> Option { - value.trim().parse::().ok().filter(|factor| factor.is_finite()).map(|factor| factor > 0.0) -} - -/// The portal's `reduced-motion`: 1 asks for less motion; 0 and the values it may grow don't. -#[cfg(any(test, target_os = "linux"))] -fn reduced_motion(value: &str) -> Option { - value.trim().parse::().ok().map(|code| code != 1) -} - -/// The last value of `key` in `[section]` of an INI-style file (KDE's config, GTK's settings). -#[cfg(any(test, all(unix, not(target_os = "macos"))))] -fn ini_value<'a>(text: &'a str, section: &str, key: &str) -> Option<&'a str> { - let mut inside = false; - let mut found = None; - for line in text.lines() { - let line = line.trim(); - if let Some(name) = line.strip_prefix('[').and_then(|rest| rest.strip_suffix(']')) { - inside = name == section; - } else if inside && let Some((name, value)) = line.split_once('=') { - // KDE marks an entry the user may not change as `Key[$i]=value`. - let name = name.split_once('[').map_or(name, |(name, _)| name); - if name.trim() == key { - found = Some(value.trim()); - } - } - } - found -} - -/// The desktop's setting without the portal. `desktop` is `XDG_CURRENT_DESKTOP`, `file` reads a -/// file under the user's config directory and `gsettings` runs `gsettings get `. -#[cfg(any(test, all(unix, not(target_os = "macos"))))] -fn desktop_setting(desktop: &str, file: &dyn Fn(&str) -> Option, gsettings: &dyn Fn(&str, &str) -> Option) -> Option { - let running = |name: &str| desktop.split(':').any(|part| part.eq_ignore_ascii_case(name)); - let in_file = |name: &str, section: &str, key: &str, animations: fn(&str) -> Option| animations(ini_value(&file(name)?, section, key)?); - if running("KDE") - && let Some(animations) = in_file("kdeglobals", "KDE", "AnimationDurationFactor", duration_factor) - { - return Some(animations); - } - let schema = if running("MATE") { "org.mate.interface" } else { "org.gnome.desktop.interface" }; - let shell = gsettings(schema, "enable-animations").and_then(|out| enabled(&out)); - if shell == Some(false) { - return shell; - } - // `true` may only be the schema's default on a desktop that doesn't use it. GTK's own file - // is what a bare window manager's user writes, and what KDE keeps in step with its setting. - ["gtk-4.0/settings.ini", "gtk-3.0/settings.ini"].iter().find_map(|name| in_file(name, "Settings", "gtk-enable-animations", enabled)).or(shell) -} - -#[cfg(any(test, all(unix, not(target_os = "macos"))))] -fn code_of(animations: Option) -> u8 { - match animations { - None => 0, - Some(true) => 1, - Some(false) => 2, - } -} - -#[cfg(any(test, all(unix, not(target_os = "macos"))))] -fn decode(code: u8) -> Option { - match code { - 1 => Some(true), - 2 => Some(false), - _ => None, - } -} - -#[cfg(test)] +#[cfg(all(test, target_os = "windows"))] mod tests { - use super::*; - - #[test] - fn each_setting_reads_its_own_values() { - for (s, animations) in - [("true\n", Some(true)), ("false\n", Some(false)), ("1", Some(true)), ("0", Some(false)), ("FALSE", Some(false)), ("", None), ("maybe", None)] - { - assert_eq!(enabled(s), animations, "{s:?}"); - } - for (s, animations) in [ - ("0", Some(false)), - ("0.0", Some(false)), - ("0.5", Some(true)), - ("1", Some(true)), - ("-1", Some(false)), - ("", None), - ("fast", None), - ("NaN", None), - ("inf", None), - ] { - assert_eq!(duration_factor(s), animations, "{s:?}"); - } - // reduced-motion: only 1 asks for less motion; unknown values mean no preference. - for (s, animations) in [("0", Some(true)), ("1", Some(false)), ("2", Some(true)), ("true", None), ("-1", None), ("", None)] { - assert_eq!(reduced_motion(s), animations, "{s:?}"); - } - // The shared value round-trips, and a stray code is "unknown". - for animations in [None, Some(true), Some(false)] { - assert_eq!(decode(code_of(animations)), animations); - } - assert_eq!(decode(9), None); - } - - #[test] - fn ini_values_come_from_their_own_section() { - let kde = "[General]\nAnimationDurationFactor=3\n\n[KDE]\nSingleClick=false\n AnimationDurationFactor = 0 \n[KDE][Sub]\nAnimationDurationFactor=7\n"; - assert_eq!(ini_value(kde, "KDE", "AnimationDurationFactor"), Some("0")); - assert_eq!(ini_value(kde, "KDE", "Missing"), None); - assert_eq!(ini_value("[KDE]\nAnimationDurationFactor[$i]=0.5\n", "KDE", "AnimationDurationFactor"), Some("0.5"), "an immutable entry"); - assert_eq!( - ini_value("[Settings]\ngtk-enable-animations=1\ngtk-enable-animations=0\n", "Settings", "gtk-enable-animations"), - Some("0"), - "the last one wins" - ); - // Hostile text: no section, no `=`, stray brackets, non-ASCII. - for junk in ["", "AnimationDurationFactor=0", "[KDE", "[KDE]\n=\n[]\n]]][[[\n", "[KDE]\nклавиша=значение\n\u{0}\u{feff}"] { - assert_eq!(ini_value(junk, "KDE", "AnimationDurationFactor"), None, "{junk:?}"); - } - } - - #[test] - fn the_desktop_setting_is_read_where_each_desktop_keeps_it() { - let files = |kde: Option<&'static str>, gtk: Option<&'static str>| { - move |name: &str| match name { - "kdeglobals" => kde.map(String::from), - "gtk-3.0/settings.ini" => gtk.map(String::from), - _ => None, - } - }; - let none = |_: &str, _: &str| -> Option { None }; - let gnome = |on: &'static str| { - move |schema: &str, key: &str| (schema == "org.gnome.desktop.interface" && key == "enable-animations").then(|| format!("{on}\n")) - }; - let kde_off = Some("[KDE]\nAnimationDurationFactor=0\n"); - let gtk_off = Some("[Settings]\ngtk-enable-animations=false\n"); - - // KDE Plasma: its own file, only when Plasma is the desktop running. - assert_eq!(desktop_setting("KDE", &files(kde_off, None), &none), Some(false)); - assert_eq!(desktop_setting("KDE", &files(Some("[KDE]\nAnimationDurationFactor=0.5\n"), gtk_off), &none), Some(true)); - assert_eq!(desktop_setting("ubuntu:GNOME", &files(kde_off, None), &gnome("true")), Some(true), "a leftover kdeglobals is not GNOME's setting"); - // Plasma without the entry (animations at their default speed) falls through to GTK's file. - assert_eq!(desktop_setting("KDE", &files(Some("[KDE]\nSingleClick=false\n"), gtk_off), &none), Some(false)); - // GNOME and the desktops that share its schema. - assert_eq!(desktop_setting("GNOME", &files(None, None), &gnome("false")), Some(false)); - assert_eq!(desktop_setting("GNOME", &files(None, None), &gnome("true")), Some(true)); - // MATE keeps the same key in its own schema. - let mate = |schema: &str, _: &str| (schema == "org.mate.interface").then(|| "false\n".to_string()); - assert_eq!(desktop_setting("MATE", &files(None, None), &mate), Some(false)); - assert_eq!(desktop_setting("GNOME", &files(None, None), &mate), None); - // A bare window manager: GTK's settings file, which also outranks a schema default. - assert_eq!(desktop_setting("sway", &files(None, gtk_off), &none), Some(false)); - assert_eq!(desktop_setting("", &files(None, gtk_off), &gnome("true")), Some(false)); - assert_eq!(desktop_setting("", &files(None, Some("[Settings]\ngtk-enable-animations=1\n")), &none), Some(true)); - // Nothing to read, or nothing readable: unknown, never a panic. - assert_eq!(desktop_setting("", &files(None, None), &none), None); - assert_eq!( - desktop_setting("KDE:\u{0}::", &files(Some("\u{0}[[[=]]]"), Some("=\n[Settings]\ngtk-enable-animations=perhaps")), &|_, _| Some("\u{fffd}".into())), - None - ); - } - /// The real setting of the machine the tests run on: whatever it is, reading it doesn't /// panic and the service passes it on (`--nocapture` shows it). - #[cfg(target_os = "windows")] #[test] fn windows_reports_its_animation_setting() { - let animations = platform::read(); + let animations = super::platform::read(); println!("UISettings.AnimationsEnabled: {animations:?}"); - let service = platform::service().expect("service"); + let service = super::platform::service().expect("service"); assert_eq!(service(&egui::Context::default()), animations); } } diff --git a/apps/photocraft/src/animations/freedesktop.rs b/apps/photocraft/src/animations/freedesktop.rs new file mode 100644 index 0000000000..437ab5c42a --- /dev/null +++ b/apps/photocraft/src/animations/freedesktop.rs @@ -0,0 +1,150 @@ +//! Where Linux and BSD desktops keep their animation setting, and how to read its value. +//! +//! Plain functions over text, so the tests run on every platform. Each one answers whether +//! animations are on, or `None` when the value doesn't parse (the next source is asked then). + +/// GSettings' and the portal's `true`/`false`, or GTK's `1`/`0`. +pub fn from_enabled(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "true" | "1" => Some(true), + "false" | "0" => Some(false), + _ => None, + } +} + +/// KDE's animation speed factor: 0 turns animations off. +pub fn from_duration_factor(value: &str) -> Option { + value.trim().parse::().ok().filter(|factor| factor.is_finite()).map(|factor| factor > 0.0) +} + +/// The settings portal's `reduced-motion`: 1 asks for less motion; 0 and any value added later +/// mean no preference. +#[cfg(any(test, target_os = "linux"))] +pub fn from_reduced_motion(value: &str) -> Option { + value.trim().parse::().ok().map(|code| code != 1) +} + +/// The desktop's setting when there is no settings portal to ask. `desktop` is +/// `XDG_CURRENT_DESKTOP`, `config_file` reads a file in the user's config directory and +/// `gsettings` runs `gsettings get `. +pub fn without_portal(desktop: &str, config_file: &dyn Fn(&str) -> Option, gsettings: &dyn Fn(&str, &str) -> Option) -> Option { + let running = |name: &str| desktop.split(':').any(|part| part.eq_ignore_ascii_case(name)); + let in_file = |file: &str, section: &str, key: &str, parse: fn(&str) -> Option| parse(ini_value(&config_file(file)?, section, key)?); + + // KDE Plasma keeps it in its own file. A kdeglobals left over from another desktop doesn't count. + if running("KDE") + && let Some(animations) = in_file("kdeglobals", "KDE", "AnimationDurationFactor", from_duration_factor) + { + return Some(animations); + } + // GNOME and the desktops built on it, or MATE's copy of the same key. + let schema = if running("MATE") { "org.mate.interface" } else { "org.gnome.desktop.interface" }; + let gnome = gsettings(schema, "enable-animations").and_then(|out| from_enabled(&out)); + if gnome == Some(false) { + return gnome; + } + // `true` may only be the schema's default on a desktop that doesn't use it, so GTK's own + // file comes first: it is what a bare window manager's user writes, and what KDE keeps in step. + ["gtk-4.0/settings.ini", "gtk-3.0/settings.ini"].iter().find_map(|file| in_file(file, "Settings", "gtk-enable-animations", from_enabled)).or(gnome) +} + +/// The last value of `key` in `[section]` of an INI-style file (KDE's config, GTK's settings). +pub fn ini_value<'a>(text: &'a str, section: &str, key: &str) -> Option<&'a str> { + let mut in_section = false; + let mut found = None; + for line in text.lines().map(str::trim) { + if let Some(name) = line.strip_prefix('[').and_then(|rest| rest.strip_suffix(']')) { + in_section = name == section; + } else if in_section && let Some((name, value)) = line.split_once('=') { + // KDE marks an entry the user may not change as `Key[$i]=value`. + let name = name.split_once('[').map_or(name, |(name, _)| name); + if name.trim() == key { + found = Some(value.trim()); + } + } + } + found +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_kind_of_value_reads_as_on_or_off() { + for (value, animations) in + [("true\n", Some(true)), ("false\n", Some(false)), ("1", Some(true)), ("0", Some(false)), ("FALSE", Some(false)), ("", None), ("maybe", None)] + { + assert_eq!(from_enabled(value), animations, "{value:?}"); + } + for (value, animations) in [ + ("0", Some(false)), + ("0.0", Some(false)), + ("0.5", Some(true)), + ("1", Some(true)), + ("-1", Some(false)), + ("", None), + ("fast", None), + ("NaN", None), + ("inf", None), + ] { + assert_eq!(from_duration_factor(value), animations, "{value:?}"); + } + for (value, animations) in [("0", Some(true)), ("1", Some(false)), ("2", Some(true)), ("true", None), ("-1", None), ("", None)] { + assert_eq!(from_reduced_motion(value), animations, "{value:?}"); + } + } + + #[test] + fn ini_values_come_from_their_own_section() { + let kde = "[General]\nAnimationDurationFactor=3\n\n[KDE]\nSingleClick=false\n AnimationDurationFactor = 0 \n[KDE][Sub]\nAnimationDurationFactor=7\n"; + assert_eq!(ini_value(kde, "KDE", "AnimationDurationFactor"), Some("0")); + assert_eq!(ini_value(kde, "KDE", "Missing"), None); + assert_eq!(ini_value("[KDE]\nAnimationDurationFactor[$i]=0.5\n", "KDE", "AnimationDurationFactor"), Some("0.5"), "an immutable entry"); + let gtk = "[Settings]\ngtk-enable-animations=1\ngtk-enable-animations=0\n"; + assert_eq!(ini_value(gtk, "Settings", "gtk-enable-animations"), Some("0"), "the last one wins"); + // Hostile text: no section, no `=`, stray brackets, non-ASCII. + for junk in ["", "AnimationDurationFactor=0", "[KDE", "[KDE]\n=\n[]\n]]][[[\n", "[KDE]\nклавиша=значение\n\u{0}\u{feff}"] { + assert_eq!(ini_value(junk, "KDE", "AnimationDurationFactor"), None, "{junk:?}"); + } + } + + #[test] + fn without_the_portal_each_desktop_is_read_where_it_keeps_the_setting() { + let files = |kdeglobals: Option<&'static str>, gtk3: Option<&'static str>| { + move |file: &str| match file { + "kdeglobals" => kdeglobals.map(String::from), + "gtk-3.0/settings.ini" => gtk3.map(String::from), + _ => None, + } + }; + let no_gsettings = |_: &str, _: &str| -> Option { None }; + let gnome = |on: &'static str| { + move |schema: &str, key: &str| (schema == "org.gnome.desktop.interface" && key == "enable-animations").then(|| format!("{on}\n")) + }; + let kde_off = Some("[KDE]\nAnimationDurationFactor=0\n"); + let gtk_off = Some("[Settings]\ngtk-enable-animations=false\n"); + + // KDE Plasma: its own file, only while Plasma is the desktop. + assert_eq!(without_portal("KDE", &files(kde_off, None), &no_gsettings), Some(false)); + assert_eq!(without_portal("KDE", &files(Some("[KDE]\nAnimationDurationFactor=0.5\n"), gtk_off), &no_gsettings), Some(true)); + assert_eq!(without_portal("ubuntu:GNOME", &files(kde_off, None), &gnome("true")), Some(true), "a leftover kdeglobals is not GNOME's setting"); + // Plasma without the entry (animations at their default speed) falls through to GTK's file. + assert_eq!(without_portal("KDE", &files(Some("[KDE]\nSingleClick=false\n"), gtk_off), &no_gsettings), Some(false)); + // GNOME and the desktops that share its schema. + assert_eq!(without_portal("GNOME", &files(None, None), &gnome("false")), Some(false)); + assert_eq!(without_portal("GNOME", &files(None, None), &gnome("true")), Some(true)); + // MATE keeps the same key in its own schema. + let mate = |schema: &str, _: &str| (schema == "org.mate.interface").then(|| "false\n".to_string()); + assert_eq!(without_portal("MATE", &files(None, None), &mate), Some(false)); + assert_eq!(without_portal("GNOME", &files(None, None), &mate), None); + // A bare window manager: GTK's settings file, which also outranks a schema default. + assert_eq!(without_portal("sway", &files(None, gtk_off), &no_gsettings), Some(false)); + assert_eq!(without_portal("", &files(None, gtk_off), &gnome("true")), Some(false)); + assert_eq!(without_portal("", &files(None, Some("[Settings]\ngtk-enable-animations=1\n")), &no_gsettings), Some(true)); + // Nothing to read, or nothing readable: unknown, never a panic. + assert_eq!(without_portal("", &files(None, None), &no_gsettings), None); + let unreadable = files(Some("\u{0}[[[=]]]"), Some("=\n[Settings]\ngtk-enable-animations=perhaps")); + assert_eq!(without_portal("KDE:\u{0}::", &unreadable, &|_, _| Some("\u{fffd}".into())), None); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 36dca3dbf5..50f3b47ae4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3260,14 +3260,12 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let bars_id = egui::Id::new(("pc-canvas-bars-hover", idx)); let over_bars = ctx.data(|d| d.get_temp::(bars_id)).unwrap_or(false); let nav_hovered = response.hovered() || free_hover || over_bars; - // Interface › Enable Animations off: the wheel moves the canvas without a glide (#5721). - let wheel = crate::wheel_nav::read_with( + let wheel = crate::wheel_nav::read( &ctx, app.session.prefs().general.zoom_with_scroll_wheel, app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch, response.id.with("wheel"), nav_hovered, - !app.prefs_rt.reduce_motion, ); if nav_hovered { let pointer = ui.input(|i| i.pointer.hover_pos()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 13bff3f225..46b15ba93a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1896,9 +1896,8 @@ impl eframe::App for PhotocraftApp { self.collect_screenshots(ctx); self.issue_screenshots(ctx); prefs_ui::tick(self, ctx); - // Animation Effects off: the wheel scrolls its whole distance at once, before anything - // reads this frame's scroll (wheel_nav.rs). - if self.prefs_rt.reduce_motion { + // Before anything reads this frame's scroll (see `wheel_nav::unsmooth`). + if self.prefs_rt.animations_off { wheel_nav::unsmooth(ctx); } monitor_status::poll_frame(self, ctx, frame); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index dc64c84297..4cb90dfafa 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -52,8 +52,8 @@ pub struct Runtime { show_rulers: Option, /// Tool options as last remembered in the preferences (see `tool_memory`). pub(crate) tool_options: Option, - /// Interface › Animation Effects came out as off this frame (see `sync_animations`). - pub(crate) reduce_motion: bool, + /// Interface › Animation Effects resolved to off this frame (see `sync_animations`). + pub(crate) animations_off: bool, pub(crate) selected_tool: Option, } @@ -412,8 +412,8 @@ fn sync_tooltips(app: &PhotocraftApp, ctx: &egui::Context) { } } -/// Interface › Animation Effects, resolved: on or off as chosen, or the operating system's own -/// setting for Use System Setting (on where the platform cannot tell). +/// Interface › Animation Effects, resolved: on or off as chosen, or for Use System Setting what +/// the operating system says (on when it can't tell). fn animations_on(app: &PhotocraftApp, ctx: &egui::Context) -> bool { match app.session.prefs().interface.animation_effects { prefs::AnimationEffects::On => true, @@ -422,13 +422,14 @@ fn animations_on(app: &PhotocraftApp, ctx: &egui::Context) -> bool { } } -/// Interface › Animation Effects: off, egui's fades and slides take no time and scroll areas jump -/// straight to their target (#3273); the wheel's own glide is taken out by `wheel_nav::unsmooth`. -/// Re-checked every frame because a theme change rebuilds the style and the system's setting can -/// change; both the dark and light styles are set, so a system appearance switch keeps it. +/// Interface › Animation Effects: off, egui's fades and slides take no time and `scroll_to` jumps +/// straight to its target (#3273); `PhotocraftApp::logic` takes out the wheel's glide +/// (`wheel_nav::unsmooth`). Checked every frame: a theme change rebuilds the style and the +/// system's setting can change. Both the dark and light styles are set, so an appearance switch +/// keeps it. fn sync_animations(app: &mut PhotocraftApp, ctx: &egui::Context) { let on = animations_on(app, ctx); - app.prefs_rt.reduce_motion = !on; + app.prefs_rt.animations_off = !on; let time = if on { egui::Style::default().animation_time } else { 0.0 }; if ctx.global_style().animation_time != time { let scroll = if on { egui::style::ScrollAnimation::default() } else { egui::style::ScrollAnimation::none() }; @@ -3470,63 +3471,76 @@ mod tests { } } + /// egui's `animation_time` in the dark and the light style. + fn animation_times(ctx: &egui::Context) -> [f32; 2] { + [egui::Theme::Dark, egui::Theme::Light].map(|t| ctx.style_of(t).animation_time) + } + #[test] - fn animation_effects_follow_the_choice_or_the_system_and_are_remembered() { - use std::{cell::Cell, rc::Rc}; + fn animation_effects_off_stops_egui_animations_and_is_remembered() { let on = egui::Style::default().animation_time; - let both = |ctx: &egui::Context| [egui::Theme::Dark, egui::Theme::Light].map(|t| ctx.style_of(t).animation_time); + assert!(on > 0.0, "egui animates by default"); // Saved off: the first frame after a restart applies it to both appearance styles. let (mut app, _) = app_with_saved(Some(json!({"interface": {"animationEffects": "off"}}).to_string())); let ctx = egui::Context::default(); tick(&mut app, &ctx); - assert_eq!(both(&ctx), [0.0, 0.0]); + assert_eq!(animation_times(&ctx), [0.0, 0.0]); assert_eq!(ctx.global_style().scroll_animation.duration.max, 0.0); - assert!(app.prefs_rt.reduce_motion); + assert!(app.prefs_rt.animations_off); // A theme change rebuilds the style; the next frame turns animations off again. app.apply_theme(&ctx, crate::theme::ThemeKind::StudioLight); tick(&mut app, &ctx); - assert_eq!(both(&ctx), [0.0, 0.0]); + assert_eq!(animation_times(&ctx), [0.0, 0.0]); app.run("prefs.set", json!({"values": {"interface.animationEffects": "on"}})).unwrap(); tick(&mut app, &ctx); - assert_eq!(both(&ctx), [on, on]); - assert!(on > 0.0 && ctx.global_style().scroll_animation.duration.max > 0.0 && !app.prefs_rt.reduce_motion); + assert_eq!(animation_times(&ctx), [on, on]); + assert!(ctx.global_style().scroll_animation.duration.max > 0.0 && !app.prefs_rt.animations_off); + } - // Use System Setting, the default, follows the platform as it changes. - let system = Rc::new(Cell::new(Some(false))); + #[test] + fn use_system_setting_follows_the_platform() { + use std::{cell::Cell, rc::Rc}; + let on = egui::Style::default().animation_time; + let system = Rc::new(Cell::new(None)); let (mut app, _) = app_with_store(); - assert_eq!(app.session.prefs().interface.animation_effects, prefs::AnimationEffects::System); let read = system.clone(); app.services.system_animations = Some(Box::new(move |_| read.get())); let ctx = egui::Context::default(); + assert_eq!(app.session.prefs().interface.animation_effects, prefs::AnimationEffects::System, "the default"); + // As the system's setting changes; no answer keeps animations on. for (answer, time) in [(Some(false), 0.0), (Some(true), on), (None, on), (Some(false), 0.0)] { system.set(answer); tick(&mut app, &ctx); - assert_eq!(both(&ctx), [time, time], "the system says {answer:?}"); - assert_eq!(app.prefs_rt.reduce_motion, time == 0.0); + assert_eq!(animation_times(&ctx), [time, time], "the system says {answer:?}"); } // On and Off hold whatever the system says. for (answer, choice, time) in [(Some(false), "on", on), (Some(true), "off", 0.0), (Some(true), "system", on)] { system.set(answer); app.run("prefs.set", json!({"values": {"interface.animationEffects": choice}})).unwrap(); tick(&mut app, &ctx); - assert_eq!(both(&ctx), [time, time], "{choice} while the system says {answer:?}"); + assert_eq!(animation_times(&ctx), [time, time], "{choice} while the system says {answer:?}"); } - // A platform that can't tell (no service): Use System Setting keeps animations on. + // A platform without the service can't tell: animations stay on. let (mut plain, _) = app_with_store(); let ctx = egui::Context::default(); tick(&mut plain, &ctx); - assert_eq!(both(&ctx), [on, on]); - // The Enable Animations checkbox this replaced: a saved "off" is still off. - let (mut old, _) = app_with_saved(Some(json!({"interface": {"enableAnimations": false}}).to_string())); - let ctx = egui::Context::default(); - tick(&mut old, &ctx); - assert_eq!(both(&ctx), [0.0, 0.0]); - // A hand-edited value that is no choice doesn't crash: the defaults stay. - for junk in [json!("nope"), json!(7), json!(null)] { - let (mut bad, _) = app_with_saved(Some(json!({"interface": {"animationEffects": junk}}).to_string())); + assert_eq!(animation_times(&ctx), [on, on]); + } + + #[test] + fn old_and_hand_edited_animation_settings_load_safely() { + let on = egui::Style::default().animation_time; + let after_restart = |interface: Value| { + let (mut app, _) = app_with_saved(Some(json!({"interface": interface}).to_string())); let ctx = egui::Context::default(); - tick(&mut bad, &ctx); - assert_eq!(both(&ctx), [on, on]); + tick(&mut app, &ctx); + animation_times(&ctx) + }; + // The Enable Animations checkbox this replaced: a saved "off" is still off. + assert_eq!(after_restart(json!({"enableAnimations": false})), [0.0, 0.0]); + // A value that is no choice doesn't crash: the defaults stay. + for junk in [json!("nope"), json!(7), json!(null)] { + assert_eq!(after_restart(json!({"animationEffects": junk})), [on, on], "{junk}"); } } diff --git a/crates/ui-egui/src/wheel_nav.rs b/crates/ui-egui/src/wheel_nav.rs index 900ea3779a..67e44ae608 100644 --- a/crates/ui-egui/src/wheel_nav.rs +++ b/crates/ui-egui/src/wheel_nav.rs @@ -170,28 +170,36 @@ pub fn fold_legacy_pinch(ctx: &Context, raw: &mut RawInput) { } } -fn touch_id() -> Id { - Id::new("pc-wheel-touch") +fn gesture_id() -> Id { + Id::new("pc-wheel-gesture") } -/// What this frame's wheel events scroll in all, in points: each one in its unit and folded -/// sideways or upright by its modifiers, as egui does before it smooths them. `touch` follows -/// trackpad gestures (the ones with a begin and an end phase). `None` leaves egui's own value -/// alone: for a frame with part of such a gesture, which egui applies as it arrives (and whose -/// modifiers it holds from the start), for a wheel event that zooms, and for a hostile sum. -fn arrived(events: &[Event], options: &egui::InputOptions, page: f32, touch: &mut bool) -> Option { - let mut gesture = *touch; +/// What this frame's wheel events scroll in all, in points, each folded sideways or upright by +/// its modifiers as egui folds it. `in_gesture` carries from frame to frame whether a trackpad +/// gesture (one with begin and end phases) is in progress. +/// +/// `None` keeps egui's own scroll: in a frame with any part of such a gesture, which egui applies +/// at once and with the modifiers it started with; for a wheel event that zooms; and for a sum +/// that isn't finite. +fn unsmoothed_scroll(events: &[Event], options: &egui::InputOptions, page: f32, in_gesture: &mut bool) -> Option { + let mut gesture_frame = *in_gesture; let mut sum = Vec2::ZERO; for event in events { let Event::MouseWheel { unit, delta, phase, modifiers } = event else { continue }; match phase { - TouchPhase::Start => (*touch, gesture) = (true, true), - TouchPhase::End | TouchPhase::Cancel => (*touch, gesture) = (false, true), + TouchPhase::Start => { + *in_gesture = true; + gesture_frame = true; + } + TouchPhase::End | TouchPhase::Cancel => { + *in_gesture = false; + gesture_frame = true; + } TouchPhase::Move => { if modifiers.matches_any(options.zoom_modifier) { return None; } - let delta = *delta + let points = *delta * match unit { MouseWheelUnit::Point => 1.0, MouseWheelUnit::Line => options.line_scroll_speed, @@ -200,35 +208,32 @@ fn arrived(events: &[Event], options: &egui::InputOptions, page: f32, touch: &mu let sideways = modifiers.matches_any(options.horizontal_scroll_modifier); let upright = modifiers.matches_any(options.vertical_scroll_modifier); sum += match (sideways, upright) { - (true, false) => Vec2::new(delta.x + delta.y, 0.0), - (false, true) => Vec2::new(0.0, delta.x + delta.y), - _ => delta, + (true, false) => Vec2::new(points.x + points.y, 0.0), + (false, true) => Vec2::new(0.0, points.x + points.y), + _ => points, }; } } } - (!gesture && sum.x.is_finite() && sum.y.is_finite()).then_some(sum) + (!gesture_frame && sum.x.is_finite() && sum.y.is_finite()).then_some(sum) } -/// Interface › Animation Effects off: scroll the whole distance of a wheel notch in the frame -/// it arrives. egui spreads a notch over the next several frames (a glide of about 0.2 s, with -/// no setting to turn it off), so this replaces the frame's smoothed scroll with everything its -/// wheel events add up to; the frames after it carry only the rest of that glide and no wheel -/// events, and so scroll nothing. Call it once a frame before anything reads the scroll -/// (`PhotocraftApp::logic`). Zooming already reads the wheel events themselves ([`read`]). +/// Interface › Animation Effects off: a wheel notch scrolls its whole distance in the frame it +/// arrives. egui spreads each notch over about 0.2 s and has no setting to stop it, so this +/// replaces the frame's smoothed scroll with what the frame's own wheel events add up to. The +/// frames after a notch carry only the rest of egui's glide, and so scroll nothing. +/// +/// Call it once a frame, before anything reads the scroll (`PhotocraftApp::logic` does). pub fn unsmooth(ctx: &Context) { let options = ctx.options(|o| o.input_options); - let was = ctx.data(|d| d.get_temp::(touch_id())).unwrap_or(false); - let mut touch = was; + let mut in_gesture = ctx.data(|d| d.get_temp::(gesture_id())).unwrap_or(false); ctx.input_mut(|i| { let page = i.viewport_rect().height(); - if let Some(scroll) = arrived(&i.events, &options, page, &mut touch) { + if let Some(scroll) = unsmoothed_scroll(&i.events, &options, page, &mut in_gesture) { i.smooth_scroll_delta = scroll; } }); - if touch != was { - ctx.data_mut(|d| d.insert_temp(touch_id(), touch)); - } + ctx.data_mut(|d| d.insert_temp(gesture_id(), in_gesture)); } fn alt_id() -> Id { @@ -262,13 +267,6 @@ const SNAP_IDLE_TIMEOUT: f64 = 0.5; /// accumulator per viewport. `hovered` gates accumulation: only the viewport receiving /// navigation may add raw input to the snap residual. pub fn read(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool) -> Option { - read_with(ctx, zoom_with_wheel, pinch_zooms, owner, hovered, true) -} - -/// [`read`], choosing how a plain wheel scroll pans: `smooth` glides it over a few frames (egui's -/// smoothed delta); off, as with Interface › Enable Animations off, the view moves the whole notch -/// in the frame the wheel turns and stops there (#5721), as scroll areas already do. -pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: Id, hovered: bool, smooth: bool) -> Option { let notch = ctx.options(|o| o.input_options.line_scroll_speed); // --- Modifier tracking (always runs for all callers) --- @@ -387,8 +385,6 @@ pub fn read_with(ctx: &Context, zoom_with_wheel: bool, pinch_zooms: bool, owner: d.remove::(snap_time_id(owner)); }); } - // Unsmoothed, a plain scroll pans by this frame's own wheel events (none: no tail). - let scroll = if smooth || alt || shift { scroll } else { raw }; classify(Input { scroll, raw, zoom_delta, alt, shift, zoom_with_wheel, pinch_zooms, notch }) } } @@ -441,42 +437,6 @@ mod tests { (a - b).length() < 0.01 } - /// #5721: with smoothing off a wheel notch pans the whole notch in the frame it arrives and - /// nothing after; smoothed (the default) it glides, reaching the same total over later frames. - #[test] - fn unsmoothed_wheel_pans_at_once_without_a_glide() { - let pans = |smooth: bool| -> Vec { - let owner = Id::new("test-unsmoothed"); - let ctx = Context::default(); - configure(&ctx); - (0..30) - .map(|n| { - let events = if n == 0 { vec![wheel(Vec2::new(0.0, -1.0), Modifiers::NONE)] } else { Vec::new() }; - let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() }; - let mut pan = Vec2::ZERO; - let mut out = ctx.run_ui(raw, |ui| { - if let Some(Wheel::Pan(d)) = read_with(ui.ctx(), false, true, owner, true, smooth) { - pan = d; - } - }); - out.textures_delta.clear(); - pan - }) - .collect() - }; - let notch = { - let ctx = Context::default(); - configure(&ctx); - ctx.options(|o| o.input_options.line_scroll_speed) - }; - let direct = pans(false); - assert!(near(direct[0], Vec2::new(0.0, -notch)), "the whole notch at once: {:?}", direct[0]); - assert!(direct[1..].iter().all(|p| *p == Vec2::ZERO), "no glide after it"); - let smooth = pans(true); - assert!(smooth[0].y.abs() < notch - 0.5, "smoothed, the first frame moves only part of it: {:?}", smooth[0]); - assert!(smooth[1..].iter().any(|p| p.y != 0.0), "and the rest follows"); - } - #[test] fn plain_scroll_pans() { assert_eq!(classify(input(Vec2::new(3.0, -40.0), false)), Some(Wheel::Pan(Vec2::new(3.0, -40.0)))); @@ -711,6 +671,48 @@ mod tests { assert!(matches!(ev[0], Event::MouseWheel { .. })); } + // --- Interface › Animation Effects off (`unsmooth`) + + /// #5721: with Animation Effects off ([`unsmooth`]) a wheel notch pans the canvas the whole + /// notch in the frame it arrives and nothing after; on, it glides, reaching the same total + /// over later frames. + #[test] + fn unsmoothed_wheel_pans_at_once_without_a_glide() { + let pans = |smooth: bool| -> Vec { + let owner = Id::new("test-unsmoothed"); + let ctx = Context::default(); + configure(&ctx); + (0..30) + .map(|n| { + let events = if n == 0 { vec![wheel(Vec2::new(0.0, -1.0), Modifiers::NONE)] } else { Vec::new() }; + let raw = RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() }; + let mut pan = Vec2::ZERO; + let mut out = ctx.run_ui(raw, |ui| { + if !smooth { + unsmooth(ui.ctx()); + } + if let Some(Wheel::Pan(d)) = read(ui.ctx(), false, true, owner, true) { + pan = d; + } + }); + out.textures_delta.clear(); + pan + }) + .collect() + }; + let notch = { + let ctx = Context::default(); + configure(&ctx); + ctx.options(|o| o.input_options.line_scroll_speed) + }; + let direct = pans(false); + assert!(near(direct[0], Vec2::new(0.0, -notch)), "the whole notch at once: {:?}", direct[0]); + assert!(direct[1..].iter().all(|p| *p == Vec2::ZERO), "no glide after it"); + let smooth = pans(true); + assert!(smooth[0].y.abs() < notch - 0.5, "smoothed, the first frame moves only part of it: {:?}", smooth[0]); + assert!(smooth[1..].iter().any(|p| p.y != 0.0), "and the rest follows"); + } + /// `smooth_scroll_delta` as the UI reads it on each of 30 frames at 60 fps, in a context set /// up like the app's; `off` is Animation Effects off ([`unsmooth`] at the start of the frame). fn scrolled(frames: Vec>, off: bool) -> Vec { @@ -769,23 +771,17 @@ mod tests { let glide = total(&scrolled(vec![events.clone()], false)); assert!((glide - want).length() < 1.0, "the same distance as with animations on: {events:?}: {glide:?}"); } - // The canvas pans by all of it on that frame too, and Alt + wheel still zooms one step. + // Alt + wheel still zooms one step, and doesn't pan as well. let ctx = Context::default(); configure(&ctx); + let raw = RawInput { time: Some(0.0), predicted_dt: 1.0 / 60.0, events: vec![wheel(Vec2::new(0.0, 1.0), Modifiers::ALT)], ..Default::default() }; let mut seen = None; - for (time, modifiers) in [(0.0, Modifiers::NONE), (1.0, Modifiers::ALT)] { - let raw = RawInput { time: Some(time), predicted_dt: 1.0 / 60.0, events: vec![wheel(Vec2::new(0.0, 1.0), modifiers)], ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| { - unsmooth(ui.ctx()); - seen = read(ui.ctx(), false, true, Id::new("test-unsmooth"), true); - }); - out.textures_delta.clear(); - if modifiers.alt { - assert!(matches!(seen, Some(Wheel::Zoom(f)) if (f - 1.1).abs() < 1e-4), "{seen:?}"); - } else { - assert_eq!(seen, Some(Wheel::Pan(Vec2::new(0.0, 40.0)))); - } - } + let mut out = ctx.run_ui(raw, |ui| { + unsmooth(ui.ctx()); + seen = read(ui.ctx(), false, true, Id::new("test-alt"), true); + }); + out.textures_delta.clear(); + assert!(matches!(seen, Some(Wheel::Zoom(f)) if (f - 1.1).abs() < 1e-4), "{seen:?}"); } /// What the setting is for: an egui scroll area (panels, lists, long menus) is at its new @@ -852,23 +848,23 @@ mod tests { #[test] fn unsmoothing_skips_zoom_and_hostile_numbers() { let options = egui::InputOptions::default(); - let mut touch = false; + let mut in_gesture = false; let zoom = [wheel(Vec2::new(0.0, 1.0), CTRL)]; - assert_eq!(arrived(&zoom, &options, 600.0, &mut touch), None, "egui's default: ⌘/Ctrl + wheel zooms"); + assert_eq!(unsmoothed_scroll(&zoom, &options, 600.0, &mut in_gesture), None, "egui's default: ⌘/Ctrl + wheel zooms"); let plain = [wheel(Vec2::new(0.0, 1.0), Modifiers::NONE)]; - assert_eq!(arrived(&plain, &options, 600.0, &mut touch), Some(Vec2::new(0.0, options.line_scroll_speed))); - assert_eq!(arrived(&[], &options, 600.0, &mut touch), Some(Vec2::ZERO), "no wheel event: the rest of a glide is dropped"); + assert_eq!(unsmoothed_scroll(&plain, &options, 600.0, &mut in_gesture), Some(Vec2::new(0.0, options.line_scroll_speed))); + assert_eq!(unsmoothed_scroll(&[], &options, 600.0, &mut in_gesture), Some(Vec2::ZERO), "no wheel event: the rest of a glide is dropped"); for bad in [f32::NAN, f32::INFINITY, -f32::INFINITY, f32::MAX] { for page in [600.0, f32::NAN, f32::INFINITY, 0.0, -1.0] { for unit in [MouseWheelUnit::Point, MouseWheelUnit::Line, MouseWheelUnit::Page] { let events = [Event::MouseWheel { unit, delta: Vec2::new(bad, bad), phase: TouchPhase::Move, modifiers: Modifiers::SHIFT }]; - if let Some(sum) = arrived(&events, &options, page, &mut touch) { + if let Some(sum) = unsmoothed_scroll(&events, &options, page, &mut in_gesture) { assert!(sum.x.is_finite() && sum.y.is_finite(), "{sum:?} from {bad} {page} {unit:?}"); } } } } - assert!(!touch, "no gesture phase was seen"); + assert!(!in_gesture, "no gesture phase was seen"); } #[test] From 89305c1d174eaa42dc2a93a7ba25fe8fc49190f7 Mon Sep 17 00:00:00 2001 From: deniskhud Date: Sat, 17 Oct 2026 23:13:00 +0200 Subject: [PATCH 042/229] Shape tools: gradient Fill and Stroke from the swatches, with an editable stops strip The shape Fill / Stroke swatches (options bar and Properties) offer No Color, Solid and Gradient, as Photoshop's popover does, for the next shape and the selected one. The Gradient tab (shape_gradient_ui.rs) has a Recent row, the preset library, a stops strip (click to add a colour or opacity stop, drag to move, drag off to delete, midpoints) with the selected stop's colour, opacity, location and Delete, and style, angle, scale, Reverse and Align with layer. Stop edits reuse the Gradient Editor's apply_stop. shape.create / shape.edit take and report every gradient field (opacityStops, midpoints, offset, dither, align), so editing an imported gradient keeps the rest. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/vector_cmds.rs | 38 +- crates/engine/src/vector_cmds/tests.rs | 29 ++ crates/ui-egui/src/gradient_ui.rs | 4 +- crates/ui-egui/src/shape_gradient_ui.rs | 470 +++++++++++++++++++++++ crates/ui-egui/src/state.rs | 11 + crates/ui-egui/src/tool_reset.rs | 2 +- crates/ui-egui/src/vector_paint_tests.rs | 219 +++++++++++ crates/ui-egui/src/vector_ui.rs | 121 +++++- docs/scorecard.md | 2 +- docs/shape-strokes.md | 20 +- scorecard/tools.toml | 2 +- 11 files changed, 886 insertions(+), 32 deletions(-) create mode 100644 crates/ui-egui/src/shape_gradient_ui.rs diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 14579e5954..34f2f260da 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -247,15 +247,17 @@ pub fn path_json(p: &Path) -> Value { fn fill_json(f: &Fill) -> Value { match f { Fill::Solid(c) => json!(hex(c)), - Fill::Gradient { stops, angle, scale, style, reverse, .. } => json!({ "gradient": { + Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align } => json!({ "gradient": { "stops": stops.iter().map(|(t, c)| json!([t, hex(c)])).collect::>(), - "angle": angle, "scale": scale * 100.0, "style": format!("{style:?}").to_ascii_lowercase(), "reverse": reverse } }), + "angle": angle, "scale": scale * 100.0, "style": format!("{style:?}").to_ascii_lowercase(), "reverse": reverse, + "opacityStops": opacity_stops, "midpoints": midpoints, "offset": [offset.0, offset.1], "dither": dither, "align": align } }), Fill::Pattern { name, scale, .. } => json!({ "pattern": name, "scale": scale * 100.0 }), } } -/// `"#rrggbb"` / `[r,g,b,a]` / `{"gradient":{"stops":[[t,"#hex"],…],"angle","scale","style","reverse"}}` / -/// `{"pattern":name,"scale"}`. `null` → `Ok(None)` (no fill). +/// `"#rrggbb"` / `[r,g,b,a]` / `{"gradient":{"stops":[[t,"#hex"|[r,g,b,a]],…],"angle","scale","style","reverse", +/// "opacityStops":[[t,0..1],…],"midpoints":[0..1,…],"offset":[x,y],"dither","align"}}` / `{"pattern":name,"scale"}`. +/// `null` → `Ok(None)` (no fill). fn parse_fill(v: &Value) -> std::result::Result, String> { if v.is_null() { return Ok(None); @@ -276,13 +278,35 @@ fn parse_fill(v: &Value) -> std::result::Result, String> { "diamond" => GradientStyle::Diamond, _ => GradientStyle::Linear, }; - return Ok(Some(Fill::gradient( + let mut fill = Fill::gradient( stops, f64p(g, "angle").unwrap_or(90.0) as f32, f64p(g, "scale").unwrap_or(100.0) as f32 / 100.0, style, g.get("reverse").and_then(Value::as_bool).unwrap_or(false), - ))); + ); + // The rest of a gradient, so a read-modify-write (the shape swatches) loses nothing. + if let Fill::Gradient { opacity_stops, midpoints, offset, dither, align, .. } = &mut fill { + let unit = |v: &Value| v.as_f64().map(|n| n.clamp(0.0, 1.0) as f32); + if let Some(a) = g.get("opacityStops").and_then(Value::as_array) { + *opacity_stops = a.iter().filter_map(|s| Some((unit(s.get(0)?)?, unit(s.get(1)?)?))).collect(); + opacity_stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + } + if let Some(a) = g.get("midpoints").and_then(Value::as_array) { + *midpoints = a.iter().filter_map(unit).collect(); + } + if let Some(o) = g.get("offset") { + let at = |i: usize| o.get(i).and_then(Value::as_f64).map(|n| n.clamp(-1000.0, 1000.0) as f32); + *offset = (at(0).ok_or("gradient offset must be [x, y]")?, at(1).ok_or("gradient offset must be [x, y]")?); + } + if let Some(d) = g.get("dither").and_then(Value::as_bool) { + *dither = d; + } + if let Some(a) = g.get("align").and_then(Value::as_bool) { + *align = a; + } + } + return Ok(Some(fill)); } if let Some(name) = v.get("pattern").and_then(Value::as_str) { return Ok(Some(Fill::Pattern { @@ -1676,7 +1700,7 @@ pub(crate) fn leak(s: String) -> &'static str { pub fn specs() -> Vec { let stroke = r##""stroke":{"width":px,"color":"#rrggbb"|fill,"opacity":0..100,"align":"inside|center|outside","cap":"butt|round|square","join":"miter|round|bevel","miterLimit":n,"dashes":[multiples of width],"dashOffset":n}|null"##; - let fill = r##""fill":"#rrggbb"|[r,g,b,a]|{"gradient":{"stops":[[t,"#hex"]],"angle":deg,"scale":%,"style":"linear|radial|angle|reflected|diamond","reverse":bool}}|{"pattern":name}|null (no fill)"##; + let fill = r##""fill":"#rrggbb"|[r,g,b,a]|{"gradient":{"stops":[[t,"#hex"|[r,g,b,a]]],"angle":deg,"scale":%,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"opacityStops":[[t,0..1]]?,"midpoints":[0..1]?,"offset":[x,y]?,"dither":bool?,"align":bool?}}|{"pattern":name}|null (no fill)"##; vec![ spec!( "shape.create", diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 7e1c871dff..bacd1b8869 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -27,6 +27,8 @@ fn stroke_validation_rejects_hostile_values_without_editing_history() { json!({"dashOffset": 1e100}), json!({"width": "4"}), json!({"width": 0.00001, "dashes": [0.01, 0.01]}), + json!({"color": {"gradient": {"offset": [0.5]}}}), + json!({"color": {"gradient": {"offset": "centre"}}}), ]; for stroke in invalid { assert!(s.execute("shape.edit", json!({"layer": id, "stroke": stroke})).is_err(), "{stroke}"); @@ -38,6 +40,33 @@ fn stroke_validation_rejects_hostile_values_without_editing_history() { assert_eq!(s.active().unwrap().doc, before); } +/// A gradient stroke keeps every gradient field through `shape.edit`, and `shape.info` reports +/// them, so the shape swatches can change one setting without dropping the rest. +#[test] +fn gradient_stroke_round_trips_every_gradient_field() { + let mut s = session(100, 80, 16); + let id = s.execute("shape.create", json!({"rect": [20, 20, 50, 30], "stroke": {"width": 6}})).unwrap()["layer"].as_u64().unwrap(); + let gradient = json!({"gradient": { + "stops": [[0.0, [0.25, 0.5, 0.75, 1.0]], [1.0, [1.0, 0.125, 0.0, 0.5]]], + "angle": 30.0, "scale": 50.0, "style": "radial", "reverse": true, + "opacityStops": [[1.0, 0.25], [0.0, 1.0]], "midpoints": [0.25], "offset": [0.125, -0.25], "dither": true, "align": false, + }}); + let r = s.execute("shape.edit", json!({"layer": id, "stroke": {"color": gradient}})).unwrap(); + let Some(Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align }) = shape(&s, id).stroke.map(|st| st.paint) else { + panic!("a gradient stroke") + }; + assert_eq!(stops, vec![(0.0, Color::rgba(0.25, 0.5, 0.75, 1.0)), (1.0, Color::rgba(1.0, 0.125, 0.0, 0.5))]); + assert_eq!((angle, scale, style, reverse), (30.0, 0.5, GradientStyle::Radial, true)); + assert_eq!(opacity_stops, vec![(0.0, 1.0), (1.0, 0.25)], "opacity stops are sorted"); + assert_eq!((midpoints, offset, dither, align), (vec![0.25], (0.125, -0.25), true, false)); + let g = &r["stroke"]["fill"]["gradient"]; + assert_eq!((g["style"].as_str(), g["midpoints"].clone(), g["align"].as_bool()), (Some("radial"), json!([0.25]), Some(false))); + // A partial gradient keeps the defaults it always had. + s.execute("shape.edit", json!({"layer": id, "stroke": {"color": {"gradient": {"stops": [[0, "#000000"], [1, "#ffffff"]]}}}})).unwrap(); + let Some(Fill::Gradient { opacity_stops, midpoints, offset, dither, align, .. }) = shape(&s, id).stroke.map(|st| st.paint) else { panic!("a gradient stroke") }; + assert_eq!((opacity_stops, midpoints, offset, dither, align), (Vec::new(), Vec::new(), (0.0, 0.0), false, true)); +} + #[test] fn complete_stroke_edits_preserve_paint_and_geometry_and_undo_at_every_depth() { for depth in [8, 16, 32] { diff --git a/crates/ui-egui/src/gradient_ui.rs b/crates/ui-egui/src/gradient_ui.rs index be35f66028..2ffcf0f1e7 100644 --- a/crates/ui-egui/src/gradient_ui.rs +++ b/crates/ui-egui/src/gradient_ui.rs @@ -446,7 +446,7 @@ fn line(painter: &egui::Painter, a: Pos2, b: Pos2) { painter.line_segment([a, b], Stroke::new(1.5, Color32::WHITE)); } -fn diamond(painter: &egui::Painter, c: Pos2, r: f32, fill: Color32, outline: Color32) { +pub(crate) fn diamond(painter: &egui::Painter, c: Pos2, r: f32, fill: Color32, outline: Color32) { let pts = vec![pos2(c.x, c.y - r), pos2(c.x + r, c.y), pos2(c.x, c.y + r), pos2(c.x - r, c.y)]; painter.add(egui::Shape::convex_polygon(pts, fill, Stroke::new(1.0, outline))); } @@ -508,7 +508,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform // ---------------------------------------------------------------- options bar /// A ramp of RGBA stops over a checkerboard in `r`. -fn paint_ramp(p: &egui::Painter, r: Rect, sample: impl Fn(f32) -> [f32; 4]) { +pub(crate) fn paint_ramp(p: &egui::Painter, r: Rect, sample: impl Fn(f32) -> [f32; 4]) { // The tessellator snaps the checker's rects to pixels but not the mesh: snap both alike, or a // half-pixel offset leaves a row of checker showing under the ramp (#4577). let r = r.round_to_pixels(p.pixels_per_point()); diff --git a/crates/ui-egui/src/shape_gradient_ui.rs b/crates/ui-egui/src/shape_gradient_ui.rs new file mode 100644 index 0000000000..d0556754ce --- /dev/null +++ b/crates/ui-egui/src/shape_gradient_ui.rs @@ -0,0 +1,470 @@ +//! The shape Fill / Stroke swatches' Gradient tab, as Photoshop's popover: the recently used +//! gradients and the preset library, a stops strip (click to add a stop, drag one to move it or +//! off the strip to delete it, drag a midpoint), the selected stop's colour, opacity and +//! location, and the gradient's style, angle, scale, reverse and Align with layer. Stop edits use +//! the Gradient Editor's grammar ([`apply_stop`]); the tab returns the edited gradient and the +//! swatch's caller applies it to the next shape or as one `shape.edit`. + +use egui::{Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use photocraft_compose::gradient_fill as gf; +use photocraft_doc::{Fill, GradientStyle}; +use photocraft_engine::gradient_fill_cmds::apply_stop; +use photocraft_engine::presets::Group; +use photocraft_engine::presets::gradients::GradientPreset; +use serde_json::{Value, json}; + +use super::{picker_color, picker_rgba, rgba_of}; +use crate::theme::Tokens; + +/// Width of the tab's rows (the strip, the swatch grids). +const WIDTH: f32 = 228.0; +/// How many gradients the Recent row keeps. +const RECENT: usize = 8; + +/// What a swatch's Gradient tab offers: the recently used gradients, the preset library, and the +/// Gradient tool's current gradient (what a colour switches to), with the tool colours that +/// resolve their Foreground / Background stops. +pub(super) struct GradientChoices<'a> { + pub presets: &'a [Group], + pub current: &'a GradientPreset, + /// Newest first ([`GradientChoices::remember`]). + pub recent: &'a mut Vec, + pub fg: [f32; 4], + pub bg: [f32; 4], +} + +impl GradientChoices<'_> { + /// Photoshop's shape gradient default: the current gradient, linear at 90°, 100%. + pub(super) fn default_fill(&self) -> Fill { + let mut fill = Fill::gradient(Vec::new(), 90.0, 1.0, GradientStyle::Linear, false); + take_preset(&mut fill, self.current, self.fg, self.bg); + fill + } + + /// Puts `gradient`'s stops at the front of the Recent row; a gradient already there moves up. + pub(super) fn remember(&mut self, gradient: &Fill) { + let Fill::Gradient { .. } = gradient else { return }; + self.recent.retain(|f| !same_stops(f, gradient)); + self.recent.insert(0, gradient.clone()); + self.recent.truncate(RECENT); + } +} + +/// Whether two gradients have the same colour stops, opacity stops and midpoints. +fn same_stops(a: &Fill, b: &Fill) -> bool { + match (a, b) { + (Fill::Gradient { stops: s1, opacity_stops: o1, midpoints: m1, .. }, Fill::Gradient { stops: s2, opacity_stops: o2, midpoints: m2, .. }) => { + s1 == s2 && o1 == o2 && m1 == m2 + } + _ => false, + } +} + +/// `fill` takes `preset`'s colour and opacity stops (live colours resolved now), as a Gradient +/// Fill layer made from it holds them; its old midpoints belonged to the old stops. +pub(super) fn take_preset(fill: &mut Fill, preset: &GradientPreset, fg: [f32; 4], bg: [f32; 4]) { + if let Fill::Gradient { stops, opacity_stops, midpoints, .. } = fill { + (*stops, *opacity_stops) = preset.fill_stops(fg, bg); + midpoints.clear(); + } +} + +/// `fill` takes `from`'s colour stops, opacity stops and midpoints (a Recent pick). +fn take_stops(fill: &mut Fill, from: &Fill) { + if let (Fill::Gradient { stops, opacity_stops, midpoints, .. }, Fill::Gradient { stops: s, opacity_stops: o, midpoints: m, .. }) = (fill, from) { + (*stops, *opacity_stops, *midpoints) = (s.clone(), o.clone(), m.clone()); + } +} + +/// The Gradient tab of the swatch `id`. Returns the edited gradient. +pub(super) fn gradient_tab(ui: &mut egui::Ui, fill: &Fill, choices: &GradientChoices, id: egui::Id) -> Option { + let Fill::Gradient { angle, scale, style, reverse, align, .. } = fill else { return None }; + let t = Tokens::get(ui.ctx()); + let mut out: Option = None; + egui::ScrollArea::vertical().id_salt(id.with("gradient-presets")).max_height(120.0).show(ui, |ui| { + ui.set_width(WIDTH); + let group_label = |ui: &mut egui::Ui, s: &str| ui.label(egui::RichText::new(s).color(t.text_dim).size(11.0)); + if !choices.recent.is_empty() { + group_label(ui, tl!("Recent")); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(3.0, 3.0); + for (i, recent) in choices.recent.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(32.0, 18.0), Sense::click()); + paint_fill_ramp(ui, r, recent, resp.hovered()); + let name = format!("{} {}", tl!("Recent"), i + 1); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)); + if resp.clicked() { + take_stops(edited(&mut out, fill), recent); + } + } + }); + } + for group in choices.presets { + // Group and preset names are the library's data, shown as they are. + group_label(ui, &group.name); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(3.0, 3.0); + for preset in &group.items { + let (r, resp) = ui.allocate_exact_size(vec2(32.0, 18.0), Sense::click()); + crate::preset_panels::paint_gradient(ui, r, &preset.resolve(choices.fg, choices.bg)); + let border = if resp.hovered() { t.text_dim } else { t.field_border }; + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, border), StrokeKind::Outside); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &preset.name)); + if resp.on_hover_text(&preset.name).clicked() { + take_preset(edited(&mut out, fill), preset, choices.fg, choices.bg); + } + } + }); + } + }); + ui.add_space(6.0); + if let Some(f) = stops_strip(ui, out.as_ref().unwrap_or(fill), id, choices.fg, choices.bg) { + out = Some(f); + } + if let Some(f) = stop_fields(ui, out.as_ref().unwrap_or(fill), id, choices.fg, choices.bg) { + out = Some(f); + } + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for (s, icon, tip) in [ + (GradientStyle::Linear, "gradient", tl!("Linear Gradient")), + (GradientStyle::Radial, "circle", tl!("Radial Gradient")), + (GradientStyle::Angle, "rotate-cw", tl!("Angle Gradient")), + (GradientStyle::Reflected, "arrow-left-right", tl!("Reflected Gradient")), + (GradientStyle::Diamond, "diamond", tl!("Diamond Gradient")), + ] { + if crate::icons::button(ui, icon, 22.0, *style == s, tip).clicked() + && *style != s + && let Fill::Gradient { style, .. } = edited(&mut out, fill) + { + *style = s; + } + } + }); + let label = |ui: &mut egui::Ui, s: &str| ui.label(egui::RichText::new(s).color(t.text_dim).size(12.0)); + ui.horizontal(|ui| { + label(ui, tl!("Angle")); + let mut a = *angle; + if crate::widgets::value_field(ui, &mut a, -180.0..=180.0, "°", 52.0).changed() + && let Fill::Gradient { angle, .. } = edited(&mut out, fill) + { + *angle = a; + } + label(ui, tl!("Scale")); + let mut sc = *scale * 100.0; + if crate::widgets::value_field(ui, &mut sc, 10.0..=150.0, "%", 52.0).changed() + && let Fill::Gradient { scale, .. } = edited(&mut out, fill) + { + *scale = sc / 100.0; + } + }); + ui.horizontal(|ui| { + let mut rev = *reverse; + if crate::widgets::checkbox(ui, &mut rev, tl!("Reverse")).changed() + && let Fill::Gradient { reverse, .. } = edited(&mut out, fill) + { + *reverse = rev; + } + let mut al = *align; + if crate::widgets::checkbox(ui, &mut al, tl!("Align with layer")).changed() + && let Fill::Gradient { align, .. } = edited(&mut out, fill) + { + *align = al; + } + }); + out +} + +/// The gradient being edited this frame: `fill` until a control changes it. +fn edited<'a>(out: &'a mut Option, fill: &Fill) -> &'a mut Fill { + out.get_or_insert_with(|| fill.clone()) +} + +/// A gradient's ramp in `r` (un-reversed, as the stops strip shows it), outlined. +fn paint_fill_ramp(ui: &egui::Ui, r: Rect, fill: &Fill, hovered: bool) { + let t = Tokens::get(ui.ctx()); + if let Some(ramp) = gf::Ramp::new(&unreversed(fill)) { + crate::gradient_ui::paint_ramp(ui.painter(), r, |u| ramp.sample(u)); + } + let border = if hovered { t.text_dim } else { t.field_border }; + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, border), StrokeKind::Outside); +} + +fn unreversed(fill: &Fill) -> Fill { + let mut f = fill.clone(); + if let Fill::Gradient { reverse, .. } = &mut f { + *reverse = false; + } + f +} + +/// A marker of the stops strip. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Marker { + Color(usize), + Opacity(usize), + /// The midpoint of the segment after colour stop `i`. + Mid(usize), +} + +/// A drag in progress: the marker and the gradient as it was when the drag began. Each frame +/// re-applies the move to that gradient, so a stop dragged past another stays the one dragged. +#[derive(Clone)] +struct Drag { + marker: Marker, + start: Fill, +} + +/// Colour stops, opacity stops and midpoints. +type Stops = (Vec<(f32, photocraft_doc::Color)>, Vec<(f32, f32)>, Vec); + +/// The colour stops (sorted), opacity stops (sorted; two opaque ones when the gradient has none, +/// as [`apply_stop`] counts them) and midpoints of `fill`. +fn sorted_stops(fill: &Fill) -> Stops { + let Fill::Gradient { stops, opacity_stops, midpoints, .. } = fill else { return (Vec::new(), Vec::new(), Vec::new()) }; + let mut cs = stops.clone(); + cs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut os = if opacity_stops.is_empty() { vec![(0.0, 1.0), (1.0, 1.0)] } else { opacity_stops.clone() }; + os.sort_by(|a, b| a.0.total_cmp(&b.0)); + (cs, os, midpoints.clone()) +} + +/// Where a stop moved to `location` lands once the stops are sorted again: the selection follows it. +fn index_after_move(locations: &[f32], moved: usize, location: f32) -> usize { + locations.iter().enumerate().filter(|(i, l)| *i != moved && (**l < location || (**l == location && *i < moved))).count() +} + +fn apply(fill: &Fill, edit: &Value, fg: [f32; 4], bg: [f32; 4]) -> Option { + apply_stop(fill, edit, fg, bg).ok() +} + +/// The stops strip: the ramp with opacity stops above and colour stops below (Photoshop's). +fn stops_strip(ui: &mut egui::Ui, fill: &Fill, id: egui::Id, fg: [f32; 4], bg: [f32; 4]) -> Option { + let t = Tokens::get(ui.ctx()); + let (outer, resp) = ui.allocate_exact_size(vec2(WIDTH, 58.0), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Gradient stops"))); + let top = outer.top() + 16.0; + let bar = Rect::from_min_max(pos2(outer.left() + 8.0, top), pos2(outer.right() - 8.0, top + 24.0)); + let x_of = |loc: f32| bar.left() + loc.clamp(0.0, 1.0) * bar.width(); + let loc_of = |x: f32| ((x - bar.left()) / bar.width().max(1.0)).clamp(0.0, 1.0); + if let Some(ramp) = gf::Ramp::new(&unreversed(fill)) { + crate::gradient_ui::paint_ramp(ui.painter(), bar, |u| ramp.sample(u)); + } + ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + let (cs, os, mids) = sorted_stops(fill); + let (drag_key, selection_key) = (id.with("stops-drag"), id.with("stops-selected")); + let drag: Option = ui.data(|d| d.get_temp(drag_key)); + let selected: Option = drag.as_ref().map(|d| d.marker).or_else(|| ui.data(|d| d.get_temp(selection_key))); + let p = ui.painter(); + let outline = |sel: bool| if sel { t.accent } else { t.text_dim }; + for (i, (loc, c)) in cs.iter().enumerate() { + let x = x_of(*loc); + let sel = selected == Some(Marker::Color(i)); + let body = Rect::from_center_size(pos2(x, bar.bottom() + 10.0), vec2(10.0, 10.0)); + p.add(egui::Shape::convex_polygon(vec![pos2(x, bar.bottom() + 1.0), pos2(x + 5.0, body.top()), pos2(x - 5.0, body.top())], outline(sel), Stroke::NONE)); + let [r, g, b, _] = rgba_of(c); + p.rect_filled(body, 1.0, super::rgba32([r, g, b, 1.0])); + p.rect_stroke(body, 1.0, Stroke::new(1.0, outline(sel)), StrokeKind::Outside); + } + let mid_x = |i: usize| -> Option { + let (a, b) = (cs.get(i)?.0, cs.get(i + 1)?.0); + (b - a > 1e-3).then(|| x_of(a + (b - a) * mids.get(i).copied().unwrap_or(0.5))) + }; + for i in 0..cs.len().saturating_sub(1) { + if let Some(x) = mid_x(i) { + let sel = selected == Some(Marker::Mid(i)); + crate::gradient_ui::diamond(p, pos2(x, bar.bottom() + 6.0), 3.0, if sel { t.accent } else { t.text_faint }, t.text_faint); + } + } + for (i, (loc, a)) in os.iter().enumerate() { + let x = x_of(*loc); + let sel = selected == Some(Marker::Opacity(i)); + let body = Rect::from_center_size(pos2(x, bar.top() - 10.0), vec2(10.0, 10.0)); + p.add(egui::Shape::convex_polygon(vec![pos2(x, bar.top() - 1.0), pos2(x + 5.0, body.bottom()), pos2(x - 5.0, body.bottom())], outline(sel), Stroke::NONE)); + p.rect_filled(body, 1.0, egui::Color32::from_gray((a.clamp(0.0, 1.0) * 255.0) as u8)); + p.rect_stroke(body, 1.0, Stroke::new(1.0, outline(sel)), StrokeKind::Outside); + } + let hit = |pos: Pos2| -> Option { + if pos.y > bar.bottom() { + if pos.y >= bar.bottom() + 3.0 + && let Some(i) = cs.iter().position(|(l, _)| (x_of(*l) - pos.x).abs() <= 6.0) + { + return Some(Marker::Color(i)); + } + return (0..cs.len().saturating_sub(1)).find(|i| mid_x(*i).is_some_and(|x| (x - pos.x).abs() <= 4.0)).map(Marker::Mid); + } + if pos.y < bar.top() { + return os.iter().position(|(l, _)| (x_of(*l) - pos.x).abs() <= 6.0).map(Marker::Opacity); + } + None + }; + let mut out = None; + if resp.drag_started() + && let Some(pos) = ui.input(|i| i.pointer.press_origin()) + && let Some(marker) = hit(pos) + { + ui.data_mut(|d| { + d.insert_temp(drag_key, Drag { marker, start: fill.clone() }); + d.insert_temp(selection_key, marker); + }); + } + if let Some(drag) = ui.data(|d| d.get_temp::(drag_key)) { + let (start_cs, start_os, _) = sorted_stops(&drag.start); + let pos = resp.interact_pointer_pos(); + if let Some(pos) = pos { + // Dragged well off the strip, a stop is deleted (while at least two remain), as in Photoshop. + let off = pos.y > bar.bottom() + 28.0 || pos.y < bar.top() - 26.0; + let loc = loc_of(pos.x); + let edit = match drag.marker { + Marker::Color(i) if off && start_cs.len() > 2 => json!({"action": "delete", "index": i}), + Marker::Color(i) => json!({"action": "move", "index": i, "location": loc}), + Marker::Opacity(i) if off && start_os.len() > 2 => json!({"action": "delete", "kind": "opacity", "index": i}), + Marker::Opacity(i) => json!({"action": "move", "kind": "opacity", "index": i, "location": loc}), + Marker::Mid(i) => { + let (a, b) = (start_cs.get(i).map_or(0.0, |s| s.0), start_cs.get(i + 1).map_or(1.0, |s| s.0)); + json!({"action": "midpoint", "index": i, "location": ((loc - a) / (b - a).max(1e-4)).clamp(0.05, 0.95)}) + } + }; + out = apply(&drag.start, &edit, fg, bg); + if resp.drag_stopped() { + let moved = edit.get("location").and_then(Value::as_f64).map(|l| l as f32); + let selection = match (drag.marker, edit.get("action").and_then(Value::as_str), moved) { + (Marker::Color(i), Some("move"), Some(l)) => Some(Marker::Color(index_after_move(&start_cs.iter().map(|s| s.0).collect::>(), i, l))), + (Marker::Opacity(i), Some("move"), Some(l)) => Some(Marker::Opacity(index_after_move(&start_os.iter().map(|s| s.0).collect::>(), i, l))), + (Marker::Mid(i), ..) => Some(Marker::Mid(i)), + _ => None, + }; + ui.data_mut(|d| match selection { + Some(m) => { + d.insert_temp(selection_key, m); + } + None => d.remove::(selection_key), + }); + } + } + if resp.drag_stopped() || pos.is_none() { + ui.data_mut(|d| d.remove::(drag_key)); + } + return out; + } + if resp.clicked() + && let Some(pos) = resp.interact_pointer_pos() + { + if let Some(marker) = hit(pos) { + ui.data_mut(|d| d.insert_temp(selection_key, marker)); + return None; + } + // A click on the ramp adds a stop there: an opacity stop above its middle, a colour stop below. + let loc = loc_of(pos.x); + let opacity = pos.y < bar.center().y; + let (edit, marker) = if opacity { + (json!({"action": "add", "kind": "opacity", "location": loc}), Marker::Opacity(os.iter().filter(|s| s.0 <= loc).count())) + } else { + (json!({"action": "add", "location": loc}), Marker::Color(cs.iter().filter(|s| s.0 <= loc).count())) + }; + out = apply(fill, &edit, fg, bg); + if out.is_some() { + ui.data_mut(|d| d.insert_temp(selection_key, marker)); + } + } + out +} + +/// The selected stop's fields under the strip: a colour stop's colour (an inline picker opened +/// from its swatch), an opacity stop's opacity, a midpoint's position; Location and Delete. +fn stop_fields(ui: &mut egui::Ui, fill: &Fill, id: egui::Id, fg: [f32; 4], bg: [f32; 4]) -> Option { + let t = Tokens::get(ui.ctx()); + let selection_key = id.with("stops-selected"); + let selected: Marker = ui.data(|d| d.get_temp(selection_key)).unwrap_or(Marker::Color(0)); + let (cs, os, mids) = sorted_stops(fill); + let label = |ui: &mut egui::Ui, s: &str| ui.label(egui::RichText::new(s).color(t.text_dim).size(12.0)); + let mut edit: Option = None; + let mut reselect: Option = None; + let (locations, index): (Vec, usize) = match selected { + Marker::Color(i) => (cs.iter().map(|s| s.0).collect(), i), + Marker::Opacity(i) => (os.iter().map(|s| s.0).collect(), i), + Marker::Mid(i) => (Vec::new(), i), + }; + let picker_key = id.with("stop-picker-open"); + let mut picker_open = ui.data(|d| d.get_temp::(picker_key)).unwrap_or(false); + ui.horizontal(|ui| match selected { + Marker::Color(i) => { + let Some((_, c)) = cs.get(i) else { return }; + label(ui, tl!("Color")); + let (r, resp) = ui.allocate_exact_size(vec2(40.0, 20.0), Sense::click()); + let [cr, cg, cb, _] = rgba_of(c); + ui.painter().rect_filled(r.shrink(1.0), t.radius_sm, super::rgba32([cr, cg, cb, 1.0])); + ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.0, if picker_open { t.accent } else { t.field_border }), StrokeKind::Inside); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ColorButton, true, tl!("Color"))); + if resp.on_hover_text(tl!("Color")).clicked() { + picker_open = !picker_open; + } + } + Marker::Opacity(i) => { + let Some((_, a)) = os.get(i) else { return }; + label(ui, tl!("Opacity")); + let mut v = a * 100.0; + if crate::widgets::value_field(ui, &mut v, 0.0..=100.0, "%", 52.0).changed() { + edit = Some(json!({"action": "opacity", "kind": "opacity", "index": i, "opacity": v})); + } + } + Marker::Mid(i) => { + if cs.get(i.saturating_add(1)).is_none() { + return; + } + label(ui, tl!("Location")); + let mut v = mids.get(i).copied().unwrap_or(0.5) * 100.0; + if crate::widgets::value_field(ui, &mut v, 5.0..=95.0, "%", 52.0).changed() { + edit = Some(json!({"action": "midpoint", "index": i, "location": v / 100.0})); + } + } + }); + if let Some(loc) = locations.get(index).copied() { + let kind = if matches!(selected, Marker::Opacity(_)) { "opacity" } else { "color" }; + ui.horizontal(|ui| { + label(ui, tl!("Location")); + let mut v = loc * 100.0; + if crate::widgets::value_field(ui, &mut v, 0.0..=100.0, "%", 52.0).changed() { + let to = v / 100.0; + edit = Some(json!({"action": "move", "kind": kind, "index": index, "location": to})); + reselect = Some(match selected { + Marker::Opacity(_) => Marker::Opacity(index_after_move(&locations, index, to)), + _ => Marker::Color(index_after_move(&locations, index, to)), + }); + } + // A gradient keeps at least two stops of each kind. + if locations.len() > 2 && crate::widgets::secondary_button(ui, tl!("Delete"), 0.0).clicked() { + edit = Some(json!({"action": "delete", "kind": kind, "index": index})); + reselect = Some(match selected { + Marker::Opacity(_) => Marker::Opacity(index.saturating_sub(1)), + _ => Marker::Color(index.saturating_sub(1)), + }); + } + }); + } + if let (Marker::Color(i), true) = (selected, picker_open) + && let Some((_, c)) = cs.get(i) + { + // The stop's colour keeps its own alpha; transparency belongs to the opacity stops. + let rgba = rgba_of(c); + let cache = id.with(("stop-hsva", i)); + let mut hsva = ui.data(|d| d.get_temp::<([f32; 4], egui::ecolor::Hsva)>(cache)).filter(|(previous, _)| *previous == rgba).map_or_else(|| picker_color(rgba), |(_, h)| h); + if egui::color_picker::color_picker_hsva_2d(ui, &mut hsva, egui::color_picker::Alpha::Opaque) { + let [r, g, b, _] = picker_rgba(hsva); + if [r, g, b].iter().zip(rgba).any(|(a, b)| (a - b).abs() > 0.000001) { + let color = [r, g, b, rgba[3]]; + edit = Some(json!({"action": "color", "index": i, "color": color})); + ui.data_mut(|d| d.insert_temp(cache, (color, hsva))); + } + } else { + ui.data_mut(|d| d.insert_temp(cache, (rgba, hsva))); + } + } + ui.data_mut(|d| d.insert_temp(picker_key, picker_open)); + let out = apply(fill, &edit?, fg, bg)?; + if let Some(m) = reselect { + ui.data_mut(|d| d.insert_temp(selection_key, m)); + } + Some(out) +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d464cb3cc8..4ecf504d72 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -586,6 +586,10 @@ pub struct ToolOptions { pub stroke_width: f32, #[serde(default)] pub shape_stroke: crate::shape_stroke_ui::StrokeOptions, + /// Shape tools: the gradient the next shape's fill / stroke takes (the swatches' Gradient + /// tab) instead of the foreground / background colour; `None` = the colour. + pub shape_fill_gradient: Option, + pub shape_stroke_gradient: Option, pub corner_radius: f32, pub polygon_sides: u32, pub line_weight: f32, @@ -812,6 +816,8 @@ impl Default for ToolOptions { shape_fill: true, stroke_width: 0.0, shape_stroke: Default::default(), + shape_fill_gradient: None, + shape_stroke_gradient: None, corner_radius: 0.0, polygon_sides: 5, line_weight: 3.0, @@ -1254,6 +1260,10 @@ pub struct UiState { pub selection_mode: u8, #[serde(default)] pub tool_options: ToolOptions, + /// The shape Fill / Stroke swatches' recently used gradients, newest first (the Gradient + /// tab's Recent row). + #[serde(default)] + pub recent_gradients: Vec, /// In-progress polygonal lasso vertices (document coordinates). #[serde(default)] pub polygon: Vec<[f64; 2]>, @@ -1377,6 +1387,7 @@ impl Default for UiState { brushes_panel: Default::default(), selection_mode: 0, tool_options: ToolOptions::default(), + recent_gradients: Vec::new(), polygon: Vec::new(), polygon_mode: String::new(), polygon_doc: None, diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 27e9622ab3..d567d64bcc 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -167,7 +167,7 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { Tool::CurvaturePen => reset!(vector_mode), Tool::FreeformPen => reset!(vector_mode, curve_fit), Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape => { - reset!(vector_mode, shape_fill, stroke_width, shape_stroke, corner_radius, polygon_sides, line_weight) + reset!(vector_mode, shape_fill, stroke_width, shape_stroke, shape_fill_gradient, shape_stroke_gradient, corner_radius, polygon_sides, line_weight) } Tool::ColorSampler | Tool::Ruler diff --git a/crates/ui-egui/src/vector_paint_tests.rs b/crates/ui-egui/src/vector_paint_tests.rs index c62277fb1e..8cb1c20c9d 100644 --- a/crates/ui-egui/src/vector_paint_tests.rs +++ b/crates/ui-egui/src/vector_paint_tests.rs @@ -316,3 +316,222 @@ fn options_bar_fill_swatch_recolours_the_shape_just_drawn() { h.state_mut().run("edit.undo", json!({})).unwrap(); assert_eq!(shape(h.state()).fill, original.fill); } + +fn test_gradient() -> photocraft_doc::Fill { + photocraft_doc::Fill::Gradient { + stops: vec![(0.0, photocraft_doc::Color::rgba(0.123456, 0.5, 0.75, 1.0)), (1.0, photocraft_doc::Color::rgba(0.9, 0.2, 0.1, 0.6))], + angle: 30.0, + scale: 0.5, + style: photocraft_doc::GradientStyle::Radial, + reverse: true, + opacity_stops: vec![(0.0, 1.0), (1.0, 0.25)], + midpoints: vec![0.3], + offset: (0.125, -0.25), + dither: true, + align: false, + } +} + +/// A gradient picked for the next shape's stroke and fill (the swatches' Gradient tab) reaches +/// the drag preview and the committed shape exactly, every gradient field kept; a colour picked +/// afterwards replaces it. +#[test] +fn tool_gradient_paints_reach_the_preview_and_the_next_shape() { + for (mode, depth) in [("rgb", 8), ("rgb", 32), ("gray", 16)] { + let mut app = app(mode, depth); + set_tool_paint(&mut app, true, PaintChange::Gradient(test_gradient())).unwrap(); + set_tool_paint(&mut app, false, PaintChange::Gradient(test_gradient())).unwrap(); + assert_eq!(app.ui.tool_options.stroke_width, 3.0, "a gradient enables the stroke"); + assert!(app.ui.tool_options.shape_fill); + let preview = shape_preview(&app, Tool::Rectangle, [20.0, 20.0], [100.0, 80.0], Modifiers::NONE).unwrap(); + finish_shape(&mut app, Tool::Rectangle, [20.0, 20.0], [100.0, 80.0], Modifiers::NONE); + let committed = shape(&app).clone(); + assert_eq!(preview.fill, committed.fill); + assert_eq!(preview.stroke, committed.stroke); + assert_eq!(committed.stroke.as_ref().unwrap().paint, test_gradient(), "{mode}/{depth}"); + assert_eq!(committed.fill, Some(test_gradient())); + let doc = &app.session.active().unwrap().doc; + let rendered = photocraft_vector::render_shape(&preview, doc.pixel_format(), doc.bounds()); + assert_eq!(committed.cache.as_ref().unwrap(), &rendered, "{mode}/{depth}"); + set_tool_paint(&mut app, true, PaintChange::Color([0.0, 1.0, 0.0, 1.0])).unwrap(); + assert!(app.ui.tool_options.shape_stroke_gradient.is_none()); + finish_shape(&mut app, Tool::Rectangle, [20.0, 20.0], [100.0, 80.0], Modifiers::NONE); + assert_eq!(shape(&app).stroke.as_ref().unwrap().paint, solid_paint([0.0, 1.0, 0.0, 1.0])); + assert_eq!(shape(&app).fill, Some(test_gradient()), "the fill keeps its gradient"); + } +} + +/// Options bar › Stroke › Gradient: the Rectangle tool's next stroke takes the current gradient, +/// a preset's colours and a style, without editing the document. +#[test] +fn stroke_swatch_gradient_tab_sets_the_next_shapes_stroke() { + let mut h = harness(app("rgb", 8), false); + h.get_by_label("Stroke color").click(); + h.run_steps(3); + h.get_by_label("Gradient").click(); + h.run_steps(3); + let stroke_gradient = |h: &Harness<'_, PhotocraftApp>| match h.state().ui.tool_options.shape_stroke_gradient.clone() { + Some(photocraft_doc::Fill::Gradient { stops, style, angle, .. }) => (stops, style, angle), + other => panic!("a stroke gradient, not {other:?}"), + }; + let (_, style, angle) = stroke_gradient(&h); + assert_eq!((style, angle), (photocraft_doc::GradientStyle::Linear, 90.0)); + assert_eq!(h.state().ui.tool_options.stroke_width, 3.0, "choosing a gradient enables the stroke"); + h.get_by_label("Blue 01").click(); + h.run_steps(2); + let presets = h.state().session.presets.gradients.clone(); + let blue = presets.iter().flat_map(|g| &g.items).find(|p| p.name == "Blue 01").unwrap(); + let (fg, bg) = (h.state().session.tools.foreground, h.state().session.tools.background); + let blue_stops = blue.fill_stops(fg, bg).0; + assert_eq!(stroke_gradient(&h).0, blue_stops); + h.get_by_label("Radial Gradient").click(); + h.run_steps(2); + let (stops, style, _) = stroke_gradient(&h); + assert_eq!((stops, style), (blue_stops, photocraft_doc::GradientStyle::Radial), "the style keeps the colours"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), 0, "tool defaults do not edit the document"); + h.get_by_label("Solid").click(); + h.run_steps(2); + assert!(h.state().ui.tool_options.shape_stroke_gradient.is_none(), "Solid goes back to the colour"); +} + +/// Properties › Stroke › Gradient gives the selected shape a gradient stroke in one undoable +/// step, keeping its width; a style change keeps its colours. +#[test] +fn properties_stroke_swatch_gives_the_selected_shape_a_gradient_stroke() { + let mut app = app("rgb", 16); + app.run("shape.create", json!({"rect": [20, 20, 60, 40], "stroke": {"width": 4, "color": [1.0, 0.0, 0.0, 1.0]}})).unwrap(); + let history = app.session.active().unwrap().history.past_len(); + let mut h = harness(app, true); + h.get_by_label("Set shape stroke type").click(); + h.run_steps(3); + h.get_by_label("Gradient").click(); + h.run_steps(3); + let stroke = shape(h.state()).stroke.clone().unwrap(); + let photocraft_doc::Fill::Gradient { stops, .. } = &stroke.paint else { panic!("a gradient stroke, not {:?}", stroke.paint) }; + assert_eq!(stroke.width, 4.0); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1); + h.get_by_label("Reflected Gradient").click(); + h.run_steps(3); + let photocraft_doc::Fill::Gradient { stops: after, style, .. } = shape(h.state()).stroke.clone().unwrap().paint else { panic!("still a gradient stroke") }; + assert_eq!((&after, style), (stops, photocraft_doc::GradientStyle::Reflected)); + while h.state().session.active().unwrap().history.past_len() > history { + h.state_mut().run("edit.undo", json!({})).unwrap(); + } + assert_eq!(shape(h.state()).stroke.as_ref().unwrap().paint, solid_paint([1.0, 0.0, 0.0, 1.0])); +} + +fn tool_stroke_gradient(h: &Harness<'_, PhotocraftApp>) -> photocraft_doc::Fill { + h.state().ui.tool_options.shape_stroke_gradient.clone().expect("a stroke gradient") +} + +fn gradient_stops(f: &photocraft_doc::Fill) -> photocraft_engine::presets::gradients::FillStops { + match f { + photocraft_doc::Fill::Gradient { stops, opacity_stops, .. } => (stops.clone(), opacity_stops.clone()), + other => panic!("a gradient, not {other:?}"), + } +} + +/// Opens Options bar › Stroke › Gradient and returns the stops strip's rect. +fn open_stroke_gradient(h: &mut Harness<'_, PhotocraftApp>) -> Rect { + h.get_by_label("Stroke color").click(); + h.run_steps(3); + if h.state().ui.tool_options.shape_stroke_gradient.is_none() || h.state().ui.tool_options.stroke_width <= 0.0 { + h.get_by_label("Gradient").click(); + h.run_steps(3); + } + h.get_by_label("Gradient stops").rect() +} + +fn click(h: &mut Harness<'_, PhotocraftApp>, pos: Pos2) { + h.hover_at(pos); + h.step(); + pointer(h, pos, true); + pointer(h, pos, false); + h.run_steps(2); +} + +/// The Gradient tab's strip edits the stops as Photoshop's popover does: a click below the ramp +/// adds a colour stop, above it an opacity stop; Delete removes the selected one; dragging a stop +/// moves it; the selected colour stop's swatch opens an inline picker that recolours only it. +#[test] +fn gradient_tab_strip_adds_moves_recolours_and_deletes_stops() { + let mut h = harness(app("rgb", 16), false); + let strip = open_stroke_gradient(&mut h); + let (bar_left, bar_right, bar_top, bar_bottom) = (strip.left() + 8.0, strip.right() - 8.0, strip.top() + 16.0, strip.top() + 40.0); + let x_at = |loc: f32| bar_left + (bar_right - bar_left) * loc; + let (stops, opacity) = gradient_stops(&tool_stroke_gradient(&h)); + assert_eq!((stops.len(), opacity.len()), (2, 0)); + // A colour stop added in the middle takes the colour the gradient has there. + click(&mut h, pos2(x_at(0.5), bar_bottom - 4.0)); + let (stops, _) = gradient_stops(&tool_stroke_gradient(&h)); + assert_eq!(stops.len(), 3, "{stops:?}"); + assert!(stops.iter().any(|(l, _)| (l - 0.5).abs() < 0.02), "{stops:?}"); + // It is selected: Delete removes it again. + h.get_by_label("Delete").click(); + h.run_steps(2); + assert_eq!(gradient_stops(&tool_stroke_gradient(&h)).0.len(), 2); + // An opacity stop above the ramp (the two implicit opaque ends become real stops). + click(&mut h, pos2(x_at(0.25), bar_top + 4.0)); + let (_, opacity) = gradient_stops(&tool_stroke_gradient(&h)); + assert_eq!(opacity.len(), 3, "{opacity:?}"); + // Drag the first colour stop to 30 %. + let from = pos2(x_at(0.0), bar_bottom + 10.0); + let to = pos2(x_at(0.3), bar_bottom + 10.0); + h.hover_at(from); + h.step(); + pointer(&mut h, from, true); + for k in 1..=4 { + h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))); + h.step(); + } + pointer(&mut h, to, false); + h.run_steps(2); + let (stops, _) = gradient_stops(&tool_stroke_gradient(&h)); + let mut locations: Vec = stops.iter().map(|s| s.0).collect(); + locations.sort_by(f32::total_cmp); + assert!((locations[0] - 0.3).abs() < 0.03 && locations[1] == 1.0, "{locations:?}"); + // The selected stop's colour: its swatch opens the picker; a drag in it recolours that stop. + let before = gradient_stops(&tool_stroke_gradient(&h)).0; + let swatch = h.get(egui_kittest::kittest::by().label("Color").role(egui::accesskit::Role::ColorWell)).rect(); + click(&mut h, swatch.center()); + h.run_steps(2); + // The inline picker opens under the stop's rows (readouts, preview, then the saturation / + // value field); a click in that field. + let field = pos2(swatch.left() + 30.0, h.get_by_label("Location").rect().bottom() + 120.0); + click(&mut h, field); + let after = gradient_stops(&tool_stroke_gradient(&h)).0; + assert_eq!(after.len(), before.len()); + let changed: Vec<_> = before.iter().zip(&after).filter(|(a, b)| a != b).collect(); + assert_eq!(changed.len(), 1, "only the selected stop changes colour: {before:?} → {after:?}"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), 0, "tool defaults do not edit the document"); +} + +/// Align with layer is a checkbox of the tab, and the gradient a popup closes on is offered again +/// in its Recent row. +#[test] +fn gradient_tab_align_with_layer_and_recent_row() { + let mut h = harness(app("rgb", 8), false); + open_stroke_gradient(&mut h); + let align = |h: &Harness<'_, PhotocraftApp>| matches!(tool_stroke_gradient(h), photocraft_doc::Fill::Gradient { align: true, .. }); + assert!(align(&h)); + h.get_by_label("Align with layer").click(); + h.run_steps(2); + assert!(!align(&h)); + h.get_by_label("Blue 01").click(); + h.run_steps(2); + let blue = gradient_stops(&tool_stroke_gradient(&h)); + assert!(h.query_by_label("Recent 1").is_none(), "nothing is recent yet"); + h.key_press(Key::Escape); + h.run_steps(3); + assert_eq!(h.state().ui.recent_gradients.len(), 1, "closing the popup remembers its gradient"); + open_stroke_gradient(&mut h); + h.get_by_label("Black, White").click(); + h.run_steps(2); + assert_ne!(gradient_stops(&tool_stroke_gradient(&h)), blue); + h.get_by_label("Recent 1").click(); + h.run_steps(2); + assert_eq!(gradient_stops(&tool_stroke_gradient(&h)), blue, "a Recent pick restores its stops"); + h.key_press(Key::Escape); + h.run_steps(3); + assert_eq!(h.state().ui.recent_gradients.len(), 1, "the same stops are remembered once"); +} diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 8b5e914af2..019b301834 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -63,10 +63,42 @@ fn stroke_param(app: &PhotocraftApp) -> Value { } let mut p = o.shape_stroke.params(); p["width"] = json!(o.stroke_width); - p["color"] = json!(app.session.tools.background); + p["color"] = tool_paint_param(app, true); p } +/// The paint the next shape's fill (`stroke` false) or stroke takes: the options bar's gradient, +/// else the foreground / background colour. +fn tool_paint(app: &PhotocraftApp, stroke: bool) -> photocraft_doc::Fill { + let (gradient, color) = tool_paint_source(app, stroke); + gradient.clone().unwrap_or_else(|| solid_paint(color)) +} + +/// [`tool_paint`] as `shape.create` takes it. +fn tool_paint_param(app: &PhotocraftApp, stroke: bool) -> Value { + let (gradient, color) = tool_paint_source(app, stroke); + gradient.as_ref().map_or_else(|| json!(color), paint_param) +} + +fn tool_paint_source(app: &PhotocraftApp, stroke: bool) -> (&Option, [f32; 4]) { + let o = &app.ui.tool_options; + if stroke { (&o.shape_stroke_gradient, app.session.tools.background) } else { (&o.shape_fill_gradient, app.session.tools.foreground) } +} + +/// A solid or gradient paint exactly as `shape.create` / `shape.edit` take it: float RGBA and +/// every gradient field, so a swatch edit (angle, style…) keeps the rest of the gradient. The +/// swatches never hand it a pattern (they don't offer one). +fn paint_param(f: &photocraft_doc::Fill) -> Value { + match f { + photocraft_doc::Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align } => json!({"gradient": { + "stops": stops.iter().map(|(t, c)| json!([t, rgba_of(c)])).collect::>(), + "angle": angle, "scale": scale * 100.0, "style": format!("{style:?}").to_ascii_lowercase(), "reverse": reverse, + "opacityStops": opacity_stops, "midpoints": midpoints, "offset": [offset.0, offset.1], "dither": dither, "align": align, + }}), + other => json!(color_of(other)), + } +} + /// Geometry of a Shape-tool drag as `shape.create` params, without fill and stroke (None when too /// small): ⇧ constrains proportions, ⌥ draws from the centre. Custom shapes give their box as a `rect`. fn shape_geometry(o: &ToolOptions, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) -> Option { @@ -135,7 +167,7 @@ pub fn finish_shape(app: &mut PhotocraftApp, tool: Tool, start: [f64; 2], end: [ } fn fill_param(app: &PhotocraftApp) -> Value { - if app.ui.tool_options.shape_fill { json!(app.session.tools.foreground) } else { Value::Null } + if app.ui.tool_options.shape_fill { tool_paint_param(app, false) } else { Value::Null } } /// Create a shape layer from `shape.create` geometry with the options bar's fill and stroke: the @@ -160,8 +192,8 @@ fn shape_preview(app: &PhotocraftApp, tool: Tool, start: [f64; 2], end: [f64; 2] }; Some(photocraft_doc::ShapeLayer { path, - fill: o.shape_fill.then(|| solid_paint(app.session.tools.foreground)), - stroke: (o.stroke_width > 0.0).then(|| o.shape_stroke.stroke(o.stroke_width, solid_paint(app.session.tools.background))), + fill: o.shape_fill.then(|| tool_paint(app, false)), + stroke: (o.stroke_width > 0.0).then(|| o.shape_stroke.stroke(o.stroke_width, tool_paint(app, true))), ..Default::default() }) } @@ -1180,8 +1212,14 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo let (fg, bg) = (app.session.tools.foreground, app.session.tools.background); let (mut has_fill, fill, stroke) = match &target { Some(s) => (s.fill.is_some(), s.fill.clone(), s.stroke.clone()), - None => (o.shape_fill, o.shape_fill.then(|| solid_paint(fg)), (o.stroke_width > 0.0).then(|| o.shape_stroke.stroke(o.stroke_width, solid_paint(bg)))), + None => { + let fill = o.shape_fill_gradient.clone().unwrap_or_else(|| solid_paint(fg)); + let stroke = o.shape_stroke_gradient.clone().unwrap_or_else(|| solid_paint(bg)); + (o.shape_fill, o.shape_fill.then_some(fill), (o.stroke_width > 0.0).then(|| o.shape_stroke.stroke(o.stroke_width, stroke))) + } }; + let mut gradients = + GradientChoices { presets: &app.session.presets.gradients, current: &app.session.presets.gradient, recent: &mut app.ui.recent_gradients, fg, bg }; let mut width = stroke.as_ref().map_or(0.0, |s| s.width); let style = stroke.as_ref().map_or_else(|| o.shape_stroke.clone(), crate::shape_stroke_ui::StrokeOptions::from); let key = |k: &str| target.as_ref().map(|s| format!("shape-options-{:?}-{}-{k}", s.doc, s.layer.0)); @@ -1189,11 +1227,11 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo lbl(ui, tl!("Fill:")); if crate::widgets::checkbox(ui, &mut has_fill, "").changed() { o.shape_fill = has_fill; - edit = Some(json!({"fill": if has_fill { json!(fg) } else { Value::Null }})); + edit = Some(json!({"fill": if has_fill { o.shape_fill_gradient.as_ref().map_or_else(|| json!(fg), paint_param) } else { Value::Null }})); } - let fill_color = swatch(ui, fill.as_ref(), fg, tl!("Fill color")); + let fill_color = swatch(ui, fill.as_ref(), fg, tl!("Fill color"), &mut gradients); lbl(ui, tl!("Stroke:")); - let stroke_color = swatch(ui, stroke.as_ref().map(|s| &s.paint), bg, tl!("Stroke color")); + let stroke_color = swatch(ui, stroke.as_ref().map(|s| &s.paint), bg, tl!("Stroke color"), &mut gradients); if crate::widgets::value_field(ui, &mut width, 0.0..=288.0, "px", 58.0).changed() { o.stroke_width = width; let mut p = json!({"width": width}); @@ -1262,15 +1300,17 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } for (is_stroke, change) in [(false, fill_color), (true, stroke_color)] { let Some(change) = change else { continue }; - if let Err(e) = set_tool_paint(app, is_stroke, change) { + if let Err(e) = set_tool_paint(app, is_stroke, change.clone()) { app.ui.status = e; app.ui.status_error = true; } edit = Some(match (is_stroke, change) { (false, PaintChange::None) => json!({"fill": null}), (false, PaintChange::Color(color)) => json!({"fill": color, "coalesce": key("fill")}), + (false, PaintChange::Gradient(g)) => json!({"fill": paint_param(&g), "coalesce": key("fill")}), (true, PaintChange::None) => json!({"stroke": null}), (true, PaintChange::Color(color)) => json!({"stroke": {"color": color, "width": enabled_stroke_width(width)}, "coalesce": key("stroke")}), + (true, PaintChange::Gradient(g)) => json!({"stroke": {"color": paint_param(&g), "width": enabled_stroke_width(width)}, "coalesce": key("stroke")}), }); } if let (Some(mut p), Some(s)) = (edit, &target) { @@ -1310,17 +1350,21 @@ fn solid_paint(c: [f32; 4]) -> photocraft_doc::Fill { fn color_of(f: &photocraft_doc::Fill) -> Option<[f32; 4]> { match f { - photocraft_doc::Fill::Solid(c) => { - let [r, g, b] = c.to_rgb(); - Some([r, g, b, c.alpha]) - } + photocraft_doc::Fill::Solid(c) => Some(rgba_of(c)), _ => None, } } -#[derive(Clone, Copy, Debug, PartialEq)] +fn rgba_of(c: &photocraft_doc::Color) -> [f32; 4] { + let [r, g, b] = c.to_rgb(); + [r, g, b, c.alpha] +} + +#[derive(Clone, Debug, PartialEq)] enum PaintChange { Color([f32; 4]), + /// A `Fill::Gradient` (the swatch's Gradient tab). + Gradient(photocraft_doc::Fill), None, } @@ -1330,6 +1374,14 @@ fn enabled_stroke_width(width: f32) -> f32 { /// Tool-bar paint changes affect the next shape; Properties edits the selected shape instead. fn set_tool_paint(app: &mut PhotocraftApp, stroke: bool, change: PaintChange) -> Result<(), String> { + // A colour takes the place of a gradient; No Color keeps it for when the paint comes back. + let o = &mut app.ui.tool_options; + let gradient = if stroke { &mut o.shape_stroke_gradient } else { &mut o.shape_fill_gradient }; + match &change { + PaintChange::Gradient(g) => *gradient = Some(g.clone()), + PaintChange::Color(_) => *gradient = None, + PaintChange::None => {} + } if let PaintChange::Color(color) = change { app.run("tools.setColors", if stroke { json!({"background": color}) } else { json!({"foreground": color}) })?; } @@ -1360,7 +1412,7 @@ fn picker_rgba(hsva: egui::ecolor::Hsva) -> [f32; 4] { /// The shared compact picker keeps the float RGB paint and its alpha. Color32 is only display /// data: opening the picker must never quantize an existing high-depth colour or enable paint. -fn swatch(ui: &mut egui::Ui, fill: Option<&photocraft_doc::Fill>, fallback: [f32; 4], tip: &str) -> Option { +fn swatch(ui: &mut egui::Ui, fill: Option<&photocraft_doc::Fill>, fallback: [f32; 4], tip: &str, gradients: &mut GradientChoices) -> Option { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(30.0, 22.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ColorButton, ui.is_enabled(), tip)); @@ -1419,7 +1471,9 @@ fn swatch(ui: &mut egui::Ui, fill: Option<&photocraft_doc::Fill>, fallback: [f32 if let Some(PaintChange::Color(color)) = out { hsva = picker_color(color); } - crate::widgets::swatch_popup(&resp).show(|ui| { + let gradient = fill.filter(|f| matches!(f, photocraft_doc::Fill::Gradient { .. })); + let popup_id = resp.id; + let shown = crate::widgets::swatch_popup(&resp).show(|ui| { ui.horizontal(|ui| { if ui.selectable_label(fill.is_none(), tl!("No Color")).clicked() && fill.is_some() { out = Some(PaintChange::None); @@ -1427,8 +1481,17 @@ fn swatch(ui: &mut egui::Ui, fill: Option<&photocraft_doc::Fill>, fallback: [f32 if ui.selectable_label(current.is_some(), tl!("Solid")).clicked() && current.is_none() { out = Some(PaintChange::Color(original)); } + if ui.selectable_label(gradient.is_some(), tl!("Gradient")).clicked() && gradient.is_none() { + out = Some(PaintChange::Gradient(gradients.default_fill())); + } }); ui.separator(); + if let Some(g) = gradient { + if let Some(edited) = shape_gradient::gradient_tab(ui, g, gradients, popup_id) { + out = Some(PaintChange::Gradient(edited)); + } + return; + } let before = picker_rgba(hsva); if egui::color_picker::color_picker_hsva_2d(ui, &mut hsva, egui::color_picker::Alpha::OnlyBlend) { let color = picker_rgba(hsva); @@ -1442,6 +1505,16 @@ fn swatch(ui: &mut egui::Ui, fill: Option<&photocraft_doc::Fill>, fallback: [f32 hsva = picker_color([rgb[0], rgb[1], rgb[2], original[3]]); } }); + // The gradient a swatch's popup closes on joins the Gradient tab's Recent row. + let open_key = popup_id.with("open"); + let was_open = ui.data(|d| d.get_temp::(open_key)).unwrap_or(false); + let open = shown.is_some(); + if was_open && !open + && let Some(g) = gradient + { + gradients.remember(g); + } + ui.data_mut(|d| d.insert_temp(open_key, open)); let cached_color = match out { Some(PaintChange::Color(color)) => color, _ => original, @@ -1450,6 +1523,10 @@ fn swatch(ui: &mut egui::Ui, fill: Option<&photocraft_doc::Fill>, fallback: [f32 out } +#[path = "shape_gradient_ui.rs"] +mod shape_gradient; +use shape_gradient::GradientChoices; + /// One corner of the live rectangle, preserving the other three radii. /// The engine takes the full [top-left, top-right, bottom-right, bottom-left] array. fn corner_radii_patch(radii: [f64; 4], corner: usize, radius: f32) -> Value { @@ -1501,19 +1578,25 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra if crate::props_layout::section(ui, "appearance", crate::i18n::tr_ctx(crate::i18n::current(), "shape", "Appearance")) { ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!("Fill")).color(t.text_dim).size(12.0)); - if let Some(change) = swatch(ui, sh.fill.as_ref(), app.session.tools.foreground, tl!("Set shape fill type")) { + let (fg, bg) = (app.session.tools.foreground, app.session.tools.background); + let mut gradients = + GradientChoices { presets: &app.session.presets.gradients, current: &app.session.presets.gradient, recent: &mut app.ui.recent_gradients, fg, bg }; + if let Some(change) = swatch(ui, sh.fill.as_ref(), fg, tl!("Set shape fill type"), &mut gradients) { let fill = match change { PaintChange::None => Value::Null, PaintChange::Color(color) => json!(color), + PaintChange::Gradient(g) => paint_param(&g), }; edit = Some(json!({"fill": fill, "coalesce": coalesce("fill")})); } ui.add_space(12.0); ui.label(egui::RichText::new(tl!("Stroke")).color(t.text_dim).size(12.0)); - if let Some(change) = swatch(ui, sh.stroke.as_ref().map(|s| &s.paint), app.session.tools.foreground, tl!("Set shape stroke type")) { + if let Some(change) = swatch(ui, sh.stroke.as_ref().map(|s| &s.paint), fg, tl!("Set shape stroke type"), &mut gradients) { + let width = enabled_stroke_width(sh.stroke.as_ref().map_or(0.0, |s| s.width)); let stroke = match change { PaintChange::None => Value::Null, - PaintChange::Color(color) => json!({"color": color, "width": enabled_stroke_width(sh.stroke.as_ref().map_or(0.0, |s| s.width))}), + PaintChange::Color(color) => json!({"color": color, "width": width}), + PaintChange::Gradient(g) => json!({"color": paint_param(&g), "width": width}), }; edit = Some(json!({"stroke": stroke, "coalesce": coalesce("stroke")})); } diff --git a/docs/scorecard.md b/docs/scorecard.md index 377ce8583d..a3e7bda59c 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -172,7 +172,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-21 | Crop: Straighten button in the options bar and Cmd/Ctrl-drag draw a line; the frame turns so the line ends up level (or plumb when nearer vertical), with an angle readout | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_straighten.rs: one-shot options-bar button, Cmd/Ctrl held is a temporary Straighten (ui.pointer command flag); the Ruler's straighten_angle convention; the frame is fitted to the largest of its proportions inside the image (Classic Mode); a pending edit committed by Enter | | TOOL-214-22 | Crop gear menu: Show Cropped Area (H), Enable Crop Shield with Match Canvas / custom colour, opacity and Auto Adjust Opacity | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_shield.rs: fill() resolves the shield (canvas.rs paints it around upright and turned frames), gear popup in the options bar, H ahead of the Hand key in shortcuts.rs, Custom… opens the Color Picker; ToolOptions crop_shield, ui.set cropShield. Use Classic Mode / Auto Center Preview: TOOL-214-23 | | TOOL-214-23 | Crop: Photoshop's default mode (the image turns and moves behind an upright box, Auto Center Preview keeps a resized box's centre in place and centres a drawn one) and Use Classic Mode (P) in the gear menu | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_mode.rs: the crop model (crop_rect + crop_angle) and image.crop are shared by both modes; default mode sets the view's rotation to -crop_angle while the tool shows a frame (picking the tool, in either mode, turns a Rotate View turn upright about the view centre, not restored after Enter/Esc or by P; Classic Mode keeps the camera upright with the frame drawn turned; as Photoshop 25.1), pivoting so the box stays put, moves view and frame together for drags inside and arrow keys, keeps a resized box's centre in place live (Auto Center Preview; a drawn box is centred on release, a moved one never), readout = the image's turn; gear menu order and Classic Mode greying as Photoshop 25.1; CropShield classic_mode / auto_center_preview (ui.set cropShield), P ahead of the Pen key | -| TOOL-1053-1 | Shape stroke controls: solid/dashed/dotted, custom dash/gap/offset, caps, joins, alignment, miter limit and opacity | done | [#1053](https://github.com/storytold/photocraft/issues/1053) | shape_stroke_ui.rs: shared staged editor, renderer samples, cached Properties canvas preview; existing shape.create/edit/info; serde tool defaults, ui.set shapeStroke; pcraft and vstk style round trips. Gradient fill UI and saved stroke libraries remain open; Line retains filled-bar geometry | +| TOOL-1053-1 | Shape stroke controls: solid/dashed/dotted, custom dash/gap/offset, caps, joins, alignment, miter limit and opacity | done | [#1053](https://github.com/storytold/photocraft/issues/1053) | shape_stroke_ui.rs: shared staged editor, renderer samples, cached Properties canvas preview; existing shape.create/edit/info; serde tool defaults, ui.set shapeStroke; pcraft and vstk style round trips. vector_ui.rs: the Fill and Stroke swatches (options bar and Properties) have a Gradient tab (shape_gradient_ui.rs: Recent row, presets, an editable stops strip with the selected stop's colour/opacity/location, style, angle, scale, reverse, Align with layer) for the next shape and the selected one. Pattern paint UI and saved stroke libraries remain open; Line retains filled-bar geometry | | TOOL-213-19 | Remove tool: paint over an object (or circle it) and it is filled from its surroundings | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Options bar: Sample All Layers, Create a new layer (the fill on a new layer above, `newLayer`), Remove after each stroke off (strokes collect as a tint and ↩ applies them as one `strokes` job, Esc drops them). Missing: generative AI mode, live preview of the result | | TOOL-2259-1 | Arrow-key nudges of one thing (layers, the selection outline, the Free Transform box) that follow each other are one History state, with a preference to turn it off | done | [#2259](https://github.com/storytold/photocraft/issues/2259) | nudge_bundle.rs: a run shares one `coalesce` key while each nudge comes within Tools > Nudge Bundle Pause (1 s) and nothing else touched the document (an edit, Undo/Redo, a layer selection, a save end it); move_mods.rs runs layer.translate and select.transformSelection through it, transform_tool.rs folds box nudges into one session step. Tools > Bundle Arrow-Key Nudges off gives every press its own state. Selected pixels float until dropped, which is one state already; ⌥-nudges (a copy each) never join a run | | TOOL-1045-1 | Color Replacement tool on the toolbar | done | [#1045](https://github.com/storytold/photocraft/issues/1045) | Tool::ColorReplacement in the Brush (B) flyout after the Mixer Brush; Mode/Sampling/Limits/Tolerance/Anti-alias options drive paint.colorReplacement with the foreground colour; live preview (LiveDab with the stroke's finishing dab), one History step; color_replacement_tests.rs | diff --git a/docs/shape-strokes.md b/docs/shape-strokes.md index 835eff76c2..a69ab79196 100644 --- a/docs/shape-strokes.md +++ b/docs/shape-strokes.md @@ -74,6 +74,23 @@ zero. Set tool colours with `tools.setColors`. `ui.inspect` reports `toolOptions `toolOptions.stroke_width`, and the transient `strokeEditor`. Keyboard focus, standard number fields, colour pickers, and ComboBoxes remain native egui controls. +## Paint + +The Fill and Stroke swatches (options bar and Properties) offer No Color, Solid and Gradient, +as Photoshop's shape Fill/Stroke popover does. Gradient starts from the Gradient tool's current +gradient (linear, 90°, 100%). Its tab (`shape_gradient_ui.rs`) has a Recent row (the gradients +the popups last closed on, `ui.recent_gradients`) above the preset library (a pick takes the +preset's colour and opacity stops, as a Gradient Fill layer made from it does), a stops strip +(click below the ramp to add a colour stop, above it an opacity stop; drag a stop to move it or +off the strip to delete it; drag a midpoint), the selected stop's colour (an inline picker), +opacity, location and Delete, and the style, angle, scale, Reverse and Align with layer. Stop +edits use the Gradient Editor's grammar (`gradient_fill_cmds::apply_stop`). With no +shape selected the choice is the next shape's (`toolOptions.shape_fill_gradient` / +`shape_stroke_gradient`; a colour replaces it, No Color keeps it); with one selected it is one +`shape.edit`. The swatches send every gradient field (`opacityStops`, `midpoints`, `offset`, +`dither`, `align`), so changing the angle of an imported gradient keeps the rest. Pattern paint is +not offered yet. + ## Upstream and public references - [PR #2218](https://github.com/storytold/photocraft/pull/2218) introduced these controls with @@ -82,7 +99,8 @@ fields, colour pickers, and ComboBoxes remain native egui controls. parity, including selected-shape editing from the options bar and additional fill types; those remain separate work. - [Issue #1053](https://github.com/storytold/photocraft/issues/1053) documents the missing - stroke controls and existing engine/model support. Gradient fill UI remains separate work. + stroke controls and existing engine/model support. The Fill and Stroke swatches now have a + Gradient tab (see Paint below); a pattern paint UI remains separate work. - [Issue #1931](https://github.com/storytold/photocraft/issues/1931) requests sharp stroke options. - [PR #233](https://github.com/storytold/photocraft/pull/233), [#397](https://github.com/storytold/photocraft/pull/397), and diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 51f6b1cf33..ae0e3e9af0 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -290,7 +290,7 @@ id = "TOOL-1053-1" target = "Shape stroke controls: solid/dashed/dotted, custom dash/gap/offset, caps, joins, alignment, miter limit and opacity" status = "done" issue = 1053 -note = "shape_stroke_ui.rs: shared staged editor, renderer samples, cached Properties canvas preview; existing shape.create/edit/info; serde tool defaults, ui.set shapeStroke; pcraft and vstk style round trips. Gradient fill UI and saved stroke libraries remain open; Line retains filled-bar geometry" +note = "shape_stroke_ui.rs: shared staged editor, renderer samples, cached Properties canvas preview; existing shape.create/edit/info; serde tool defaults, ui.set shapeStroke; pcraft and vstk style round trips. vector_ui.rs: the Fill and Stroke swatches (options bar and Properties) have a Gradient tab (shape_gradient_ui.rs: Recent row, presets, an editable stops strip with the selected stop's colour/opacity/location, style, angle, scale, reverse, Align with layer) for the next shape and the selected one. Pattern paint UI and saved stroke libraries remain open; Line retains filled-bar geometry" [[item]] id = "TOOL-213-19" From 7deeaeeba1638353a0fe6abdcd534a198c144cb0 Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:16:29 +0200 Subject: [PATCH 043/229] Brush: large tips rasterize, accumulate and composite in parallel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 2000 px soft tip is a 12 MP dab, and every dab ran on one thread: the tip's coverage (92 ms), its accumulation into the stroke's tiles (44 ms) and the composite of the dirty tiles into the layer (177 ms), about 300 ms a dab, so a stroke across a 24 MP photo lagged by a quarter second per frame. Photoshop keeps such strokes fluid by spreading them over the cores. Each stage is independent per row (the round tip's coverage) or per tile (the accumulation, the composite), so they now run on the thread pool from 256² pixels of work up, to the same pixels: the per-pixel maths is untouched, the dither and Dissolve hash positions, and a test renders a 700 px soft stroke, a Dissolve stroke and a per-dab-colour stroke (with a selection, 8-bit) both ways and compares every pixel. Below the threshold, and on the web, nothing changes. The composite's writes to the layer stay in order; its tiles are composited in batches so the pending regions stay small. `examples/big_dab_bench.rs` prints the stages of one dab. On a loaded 16-thread machine the 12 MP dab went from about 300 ms to 100–140 ms; the hard 2000 px tip from 75 to 35 ms. Co-Authored-By: Claude Fable 5.1 --- crates/paint/Cargo.toml | 4 + crates/paint/examples/big_dab_bench.rs | 44 ++++ crates/paint/src/brush_tests.rs | 53 +++++ crates/paint/src/render.rs | 307 ++++++++++++++++--------- 4 files changed, 297 insertions(+), 111 deletions(-) create mode 100644 crates/paint/examples/big_dab_bench.rs diff --git a/crates/paint/Cargo.toml b/crates/paint/Cargo.toml index afb9a196d0..4f4ca359e6 100644 --- a/crates/paint/Cargo.toml +++ b/crates/paint/Cargo.toml @@ -13,5 +13,9 @@ photocraft-color = { workspace = true } photocraft-raster = { workspace = true } serde = { workspace = true } +# Large tips rasterize, accumulate and composite in parallel (the same pixels as serially). +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rayon = { workspace = true } + [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/paint/examples/big_dab_bench.rs b/crates/paint/examples/big_dab_bench.rs new file mode 100644 index 0000000000..a31265224c --- /dev/null +++ b/crates/paint/examples/big_dab_bench.rs @@ -0,0 +1,44 @@ +//! Where a large brush's time goes: one dab of a 2000 px tip, by stage. +//! `cargo run --release -p photocraft-paint --example big_dab_bench [size] [hardness]` +use photocraft_color::PixelFormat; +use photocraft_geom::Rect; +use photocraft_paint::{BrushContext, BrushSettings, Dab, StrokePoint, StrokeRenderer}; +use photocraft_raster::Surface; +use std::time::Instant; + +fn main() { + let args: Vec = std::env::args().collect(); + let size: f32 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(2000.0); + let hardness: f32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0.0); + let brush = BrushSettings { size, hardness, spacing: 0.25, color: [1.0, 0.0, 0.0, 1.0], ..Default::default() }; + let fmt = PixelFormat::RGBA8; + let mut pre = Surface::new(fmt); + pre.fill_rect(Rect::new(0, 0, 4928, 3280), &[0.5, 0.5, 0.5, 1.0]); + let mut target = pre.clone(); + // Stage 1: the tip's coverage alone. + let ctx = BrushContext::new(&brush); + let dab = Dab::round(photocraft_geom::Point::new(2464.0, 1640.0), size / 2.0, 1.0); + let rect = ctx.dab_rect(&dab, false); + let mut cov = Vec::new(); + let t = Instant::now(); + ctx.rasterize(&dab, false, rect, &mut cov); + let raster = t.elapsed(); + println!("tip {size} px, hardness {hardness}: dab rect {}x{} = {:.1} MP", rect.width(), rect.height(), rect.width() as f64 * rect.height() as f64 / 1e6); + println!(" rasterize (coverage): {:>7.1} ms", raster.as_secs_f64() * 1e3); + // Stage 2+3: push through the renderer (rasterize + accumulate), then composite. + let mut r = StrokeRenderer::new(&brush, Some(fmt), 1.0); + let t = Instant::now(); + // Two points a pixel apart: the generator places the first dab at once (a lone point waits). + r.push(&[StrokePoint::new(2464.0, 1640.0, 1.0), StrokePoint::new(2465.0, 1640.0, 1.0)]); + let push = t.elapsed(); + println!(" push (rasterize+accumulate): {:>5.1} ms (accumulate ≈ {:.1} ms)", push.as_secs_f64() * 1e3, push.saturating_sub(raster).as_secs_f64() * 1e3); + let t = Instant::now(); + let dmg = r.composite(&pre, &mut target, None, false, false); + let comp = t.elapsed(); + println!(" composite (dirty tiles): {:>7.1} ms over {}x{}", comp.as_secs_f64() * 1e3, dmg.width(), dmg.height()); + // A second dab half a spacing away, as a stroke does: the same again. + let t = Instant::now(); + r.push(&[StrokePoint::new(2464.0 + f64::from(size) * 0.25, 1640.0, 1.0), StrokePoint::new(2466.0 + f64::from(size) * 0.25, 1640.0, 1.0)]); + let dmg = r.composite(&pre, &mut target, None, false, false); + println!(" next dab push+composite: {:>7.1} ms over {}x{}", t.elapsed().as_secs_f64() * 1e3, dmg.width(), dmg.height()); +} diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index d0d7de4110..90e1e64f50 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -1493,3 +1493,56 @@ fn an_aliased_stroke_at_partial_opacity_is_one_flat_colour() { assert_eq!(s.rgba(x, 5), first, "pixel {x} of the Pencil line"); } } + +/// Large tips rasterize, accumulate and composite in parallel (rows of a dab, tiles of a stroke); +/// the pixels are the ones the serial path gives, with 8-bit dithering, a selection, Dissolve and +/// per-dab colour included. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn large_tips_render_the_same_in_parallel_and_serially() { + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(Rect::new(0, 0, 900, 700), &[1.0]); + sel.fill_rect(Rect::new(300, 0, 400, 700), &[0.0]); + let strokes: Vec<(BrushSettings, Vec)> = vec![ + (BrushSettings { size: 700.0, hardness: 0.0, flow: 0.6, color: [0.9, 0.2, 0.1, 1.0], ..brush() }, line(200.0, 700.0, 350.0)), + ( + BrushSettings { size: 420.0, hardness: 1.0, opacity: 0.5, mode: BlendMode::Dissolve, color: [0.1, 0.5, 0.9, 1.0], ..brush() }, + line(150.0, 750.0, 300.0), + ), + ( + BrushSettings { + size: 500.0, + hardness: 0.3, + color_dynamics: ColorDynamics { enabled: true, hue_jitter: 0.5, per_tip: true, ..Default::default() }, + color: [0.2, 0.8, 0.3, 1.0], + ..brush() + }, + line(100.0, 800.0, 400.0), + ), + ]; + let render = |parallel_from: usize| -> Vec> { + crate::render::set_parallel_min_pixels(parallel_from); + strokes + .iter() + .map(|(b, pts)| { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 900, 700), &[0.5, 0.5, 0.5, 1.0]); + let (mut with_sel, mut plain) = (s.clone(), s); + render_stroke(&mut with_sel, b, pts, Some(&sel), false, 1.0); + render_stroke(&mut plain, b, pts, None, false, 1.0); + let mut px = with_sel.read_region(Rect::new(0, 0, 900, 700)); + px.extend(plain.read_region(Rect::new(0, 0, 900, 700))); + px + }) + .collect() + }; + let parallel = render(0); + let serial = render(usize::MAX); + crate::render::set_parallel_min_pixels(256 * 256); + for (i, (p, s)) in parallel.iter().zip(&serial).enumerate() { + assert_eq!(p.len(), s.len()); + let diff = p.iter().zip(s).filter(|(a, b)| a != b).count(); + assert_eq!(diff, 0, "stroke {i}: {diff} pixels differ between the parallel and the serial render"); + assert!(p.iter().any(|v| (*v - 0.5).abs() > 0.1), "stroke {i} painted something"); + } +} diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 5f59aafad1..b0fa7d64ea 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -39,6 +39,66 @@ fn dither8(px: &mut [f32], x: i32, y: i32) { /// Edge of a stroke-buffer tile. pub const COV_TILE: i32 = 64; + +/// Work on at least this many pixels runs in parallel (rows of a dab, tiles of a stroke): below +/// it the threads cost more than they save. A large soft tip is tens of megapixels per dab, and +/// every stage of it is independent per row or tile, so the pixels come out the same. +#[cfg(not(target_arch = "wasm32"))] +static PARALLEL_MIN_PIXELS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(256 * 256); + +/// Whether `pixels` of independent work are worth the thread pool (never on the web). +fn parallel(pixels: usize) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + pixels >= PARALLEL_MIN_PIXELS.load(std::sync::atomic::Ordering::Relaxed) + } + #[cfg(target_arch = "wasm32")] + { + let _ = pixels; + false + } +} + +/// Tests: the pixel count from which work runs in parallel (`usize::MAX` keeps everything serial). +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(crate) fn set_parallel_min_pixels(n: usize) { + PARALLEL_MIN_PIXELS.store(n, std::sync::atomic::Ordering::Relaxed); +} + +/// `f(row index, row)` over the rows of `out` (`w` values each), in parallel when worth it. +fn for_each_row(out: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32]) + Sync) { + if w == 0 { + return; + } + #[cfg(not(target_arch = "wasm32"))] + if parallel(out.len()) { + use rayon::prelude::*; + out.par_chunks_exact_mut(w).enumerate().for_each(|(i, row)| f(i, row)); + return; + } + out.chunks_exact_mut(w).enumerate().for_each(|(i, row)| f(i, row)); +} + +/// `f` over `items`, in parallel when `pixels` are worth it. +fn for_each_item(items: &mut [T], pixels: usize, f: impl Fn(&mut T) + Sync) { + #[cfg(not(target_arch = "wasm32"))] + if parallel(pixels) { + use rayon::prelude::*; + items.par_iter_mut().for_each(&f); + return; + } + items.iter_mut().for_each(&f); +} + +/// `f` over `items`, collecting its results in order, in parallel when `pixels` are worth it. +fn map_items(items: &[T], pixels: usize, f: impl Fn(&T) -> R + Sync) -> Vec { + #[cfg(not(target_arch = "wasm32"))] + if parallel(pixels) { + use rayon::prelude::*; + return items.par_iter().map(&f).collect(); + } + items.iter().map(&f).collect() +} /// Computed tips with a smaller radius (in pixels) are rasterised by area, not at pixel centres. const SMALL_TIP_RADIUS: f32 = 3.0; /// Sub-pixel sample offsets (a 4×4 grid) for the area coverage of small tips. @@ -466,61 +526,71 @@ impl CoverageMap { let (t0x, t0y) = (rect.x0.div_euclid(COV_TILE), rect.y0.div_euclid(COV_TILE)); let (t1x, t1y) = ((rect.x1 - 1).div_euclid(COV_TILE), (rect.y1 - 1).div_euclid(COV_TILE)); let want_col = color.is_some() && nc > 0; + // The tiles this dab touches (a tile with no value in it is skipped), each independent of + // the others: a large dab's tiles accumulate in parallel, to the same values. + let mut keys: Vec<((i32, i32), Rect)> = Vec::new(); for ty in t0y..=t1y { for tx in t0x..=t1x { let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&rect); if tr.is_empty() { continue; } - // Skip tiles this dab doesn't touch. - let any = (tr.y0..tr.y1).any(|y| { - let row = (y - rect.y0) as usize * w; - vals[row + (tr.x0 - rect.x0) as usize..row + (tr.x1 - rect.x0) as usize].iter().any(|&v| v > 0.0) - }); - if !any { - continue; - } - let tile = self.tiles.entry((tx, ty)).or_insert_with(|| CovTile { - cov: vec![0.0; (COV_TILE * COV_TILE) as usize], - depth: Vec::new(), - col: if want_col { vec![0.0; (COV_TILE * COV_TILE) as usize * nc] } else { Vec::new() }, - }); - if want_col && tile.col.is_empty() { - tile.col = vec![0.0; (COV_TILE * COV_TILE) as usize * nc]; - } - if depth.is_some() && tile.depth.is_empty() { - tile.depth = vec![0.0; (COV_TILE * COV_TILE) as usize]; - } - self.dirty.insert((tx, ty)); - for y in tr.y0..tr.y1 { - let row = (y - rect.y0) as usize * w; - let trow = ((y - ty * COV_TILE) * COV_TILE) as usize; - for x in tr.x0..tr.x1 { - let v = vals[row + (x - rect.x0) as usize]; - if v <= 0.0 { - continue; - } - let ti = trow + (x - tx * COV_TILE) as usize; - if let (Some(dv), Some(slot)) = (depth.and_then(|dp| dp.get(row + (x - rect.x0) as usize)), tile.depth.get_mut(ti)) { - *slot = slot.max(*dv); - } - let c = tile.cov[ti]; - let nv = if ceil > c { c + v * (ceil - c) } else { c }; - if nv <= c { - continue; - } - if let (true, Some(col)) = (want_col, color) { - let k = if c < 1.0 { (nv - c) / (1.0 - c) } else { 0.0 }; - let p = &mut tile.col[ti * nc..(ti + 1) * nc]; - for i in 0..nc { - p[i] = col[i] * k + p[i] * (1.0 - k); - } - } - tile.cov[ti] = nv; - } - } + keys.push(((tx, ty), tr)); } } + let touched = map_items(&keys, keys.len() * (COV_TILE * COV_TILE) as usize, |(_, tr)| { + (tr.y0..tr.y1).any(|y| { + let row = (y - rect.y0) as usize * w; + vals[row + (tr.x0 - rect.x0) as usize..row + (tr.x1 - rect.x0) as usize].iter().any(|&v| v > 0.0) + }) + }); + let keys: Vec<((i32, i32), Rect)> = keys.into_iter().zip(touched).filter(|(_, t)| *t).map(|(k, _)| k).collect(); + for (key, _) in &keys { + let tile = self.tiles.entry(*key).or_insert_with(|| CovTile { + cov: vec![0.0; (COV_TILE * COV_TILE) as usize], + depth: Vec::new(), + col: if want_col { vec![0.0; (COV_TILE * COV_TILE) as usize * nc] } else { Vec::new() }, + }); + if want_col && tile.col.is_empty() { + tile.col = vec![0.0; (COV_TILE * COV_TILE) as usize * nc]; + } + if depth.is_some() && tile.depth.is_empty() { + tile.depth = vec![0.0; (COV_TILE * COV_TILE) as usize]; + } + self.dirty.insert(*key); + } + let wanted: HashMap<(i32, i32), Rect> = keys.into_iter().collect(); + let mut tiles: Vec<(Rect, (i32, i32), &mut CovTile)> = self.tiles.iter_mut().filter_map(|(k, t)| Some((*wanted.get(k)?, *k, t))).collect(); + for_each_item(&mut tiles, wanted.len() * (COV_TILE * COV_TILE) as usize, |(tr, (tx, ty), tile)| { + let (tx, ty) = (*tx, *ty); + for y in tr.y0..tr.y1 { + let row = (y - rect.y0) as usize * w; + let trow = ((y - ty * COV_TILE) * COV_TILE) as usize; + for x in tr.x0..tr.x1 { + let v = vals[row + (x - rect.x0) as usize]; + if v <= 0.0 { + continue; + } + let ti = trow + (x - tx * COV_TILE) as usize; + if let (Some(dv), Some(slot)) = (depth.and_then(|dp| dp.get(row + (x - rect.x0) as usize)), tile.depth.get_mut(ti)) { + *slot = slot.max(*dv); + } + let c = tile.cov[ti]; + let nv = if ceil > c { c + v * (ceil - c) } else { c }; + if nv <= c { + continue; + } + if let (true, Some(col)) = (want_col, color) { + let k = if c < 1.0 { (nv - c) / (1.0 - c) } else { 0.0 }; + let p = &mut tile.col[ti * nc..(ti + 1) * nc]; + for i in 0..nc { + p[i] = col[i] * k + p[i] * (1.0 - k); + } + } + tile.cov[ti] = nv; + } + } + }); } fn take_dirty(&mut self) -> Vec<(i32, i32)> { @@ -989,75 +1059,88 @@ impl StrokeRenderer { // colour, as in Photoshop, not grain that a tolerance-0 Magic Wand or Paint Bucket trips on. let dither = fmt.sample == SampleType::U8 && !b.aliased; let mut dmg = Rect::EMPTY; - let mut region = Vec::new(); - for (tx, ty) in keys { - let Some(tile) = self.cov.tiles.get(&(tx, ty)) else { continue }; - let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&bounds); - if tr.is_empty() { - continue; - } - pre.read_region_into(tr, &mut region); - let sel = selection.map(|s| (s.channels(), s.read_region(tr))); - let w = tr.width() as usize; - for y in tr.y0..tr.y1 { - for x in tr.x0..tr.x1 { - let ti = ((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize; - let c = tile.cov[ti]; - if c <= 0.0 { - continue; - } - let i = (y - tr.y0) as usize * w + (x - tr.x0) as usize; - let m = self.ctx.stroke_mask_wet(c, tile.depth.get(ti).copied(), self.dual.as_ref().map(|d| d.get(x, y)), x, y); - let s = sel.as_ref().map_or(1.0, |(sc, v)| v[i * sc]); - let mut k = (m * opacity * s).min(1.0); - if k <= 0.0 { - continue; - } - if b.mode == BlendMode::Dissolve { - k = if hash2(x, y, b.seed) < k { 1.0 } else { 0.0 }; - if k == 0.0 { + // Each tile composites on its own (a large stroke's tiles in parallel, to the same + // pixels); the writes to the target go one by one. Batches keep the pending regions small. + const BATCH: usize = 256; + for batch in keys.chunks(BATCH) { + let regions: Vec<(Rect, Vec)> = map_items(batch, batch.len() * (COV_TILE * COV_TILE) as usize, |&(tx, ty)| { + let Some(tile) = self.cov.tiles.get(&(tx, ty)) else { return (Rect::EMPTY, Vec::new()) }; + let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&bounds); + if tr.is_empty() { + return (Rect::EMPTY, Vec::new()); + } + let mut region = Vec::new(); + pre.read_region_into(tr, &mut region); + let sel = selection.map(|s| (s.channels(), s.read_region(tr))); + // Per-dab colour rewrites the source per pixel: a copy per tile. + let mut src = src; + let w = tr.width() as usize; + for y in tr.y0..tr.y1 { + for x in tr.x0..tr.x1 { + let ti = ((y - ty * COV_TILE) * COV_TILE + (x - tx * COV_TILE)) as usize; + let c = tile.cov[ti]; + if c <= 0.0 { continue; } - } - let px = &mut region[i * n..(i + 1) * n]; - if b.erase { - if let (Some(a), false) = (a_idx, lock_transparency) { - px[a] *= 1.0 - k; + let i = (y - tr.y0) as usize * w + (x - tr.x0) as usize; + let m = self.ctx.stroke_mask_wet(c, tile.depth.get(ti).copied(), self.dual.as_ref().map(|d| d.get(x, y)), x, y); + let s = sel.as_ref().map_or(1.0, |(sc, v)| v[i * sc]); + let mut k = (m * opacity * s).min(1.0); + if k <= 0.0 { + continue; + } + if b.mode == BlendMode::Dissolve { + k = if hash2(x, y, b.seed) < k { 1.0 } else { 0.0 }; + if k == 0.0 { + continue; + } + } + let px = &mut region[i * n..(i + 1) * n]; + if b.erase { + if let (Some(a), false) = (a_idx, lock_transparency) { + px[a] *= 1.0 - k; + } + if dither { + dither8(px, x, y); + } + continue; + } + if lock_transparency && a_idx.is_some_and(|a| px[a] <= 0.0) { + continue; + } + if self.per_dab_color && !tile.col.is_empty() { + let p = &tile.col[ti * nc..(ti + 1) * nc]; + for ch in 0..nc { + src[ch] = p[ch] / c; + } + } + if matches!(b.mode, BlendMode::Normal | BlendMode::Dissolve) { + over_native(&fmt, px, &src[..n], k, lock_transparency); + } else { + let d = to_rgba(&fmt, px); + let sr = to_rgba(&fmt, &src[..n]); + let mut o = photocraft_color::blend::composite(b.mode, d, sr, k); + if lock_transparency { + o[3] = d[3]; + } + let mut enc = [0.0f32; 8]; + from_rgba_into(&fmt, o, &mut enc); + px.copy_from_slice(&enc[..n]); } if dither { dither8(px, x, y); } - continue; - } - if lock_transparency && a_idx.is_some_and(|a| px[a] <= 0.0) { - continue; - } - if self.per_dab_color && !tile.col.is_empty() { - let p = &tile.col[ti * nc..(ti + 1) * nc]; - for ch in 0..nc { - src[ch] = p[ch] / c; - } - } - if matches!(b.mode, BlendMode::Normal | BlendMode::Dissolve) { - over_native(&fmt, px, &src[..n], k, lock_transparency); - } else { - let d = to_rgba(&fmt, px); - let sr = to_rgba(&fmt, &src[..n]); - let mut o = photocraft_color::blend::composite(b.mode, d, sr, k); - if lock_transparency { - o[3] = d[3]; - } - let mut enc = [0.0f32; 8]; - from_rgba_into(&fmt, o, &mut enc); - px.copy_from_slice(&enc[..n]); - } - if dither { - dither8(px, x, y); } } + (tr, region) + }); + for (tr, region) in regions { + if tr.is_empty() { + continue; + } + target.write_region(tr, ®ion); + dmg = dmg.union(&tr); } - target.write_region(tr, ®ion); - dmg = dmg.union(&tr); } if all { bounds } else { dmg } } @@ -1091,23 +1174,25 @@ fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], hardness: f32, aliased: let w = rect.width() as usize; let reach = if aliased { rm } else { tip_reach(rm, hardness).max(rm) }; let reach2 = (reach + 0.5) * (reach + 0.5); - for (yy, row) in out.chunks_exact_mut(w).enumerate() { + let alpha = d.alpha; + // Rows are independent: a large tip's rows run in parallel, to the same values. + for_each_row(out, w, |yy, row| { let dy = (rect.y0 + yy as i32) as f32 + 0.5 - cy; if aliased { for (xx, dst) in row.iter_mut().enumerate() { let dx = (rect.x0 + xx as i32) as f32 + 0.5 - cx; let d2 = (dx * ro).powi(2) + dy * dy; - *dst = if d2 < reach2 && d2.sqrt() <= rm { d.alpha } else { 0.0 }; + *dst = if d2 < reach2 && d2.sqrt() <= rm { alpha } else { 0.0 }; } } else { for (xx, dst) in row.iter_mut().enumerate() { let dx = (rect.x0 + xx as i32) as f32 + 0.5 - cx; let d2 = (dx * ro).powi(2) + dy * dy; let val = if d2 >= reach2 { 0.0 } else { tip_falloff(d2.sqrt(), rm, hardness) }; - *dst = if val <= 0.0 { 0.0 } else { val * d.alpha }; + *dst = if val <= 0.0 { 0.0 } else { val * alpha }; } } - } + }); } #[cfg(test)] From 2a003d51ea11a2dbe7cbdfb1750aa87ea4087513 Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:24:26 +0200 Subject: [PATCH 044/229] Cargo.lock: rayon for photocraft-paint Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 43a4a44f65..2ed70ab879 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4913,6 +4913,7 @@ dependencies = [ "photocraft-color", "photocraft-geom", "photocraft-raster", + "rayon", "serde", "serde_json", ] From cfe8ae5c09ee88e7d19e37d16aa20e1e787fb296 Mon Sep 17 00:00:00 2001 From: Jeremy Yao Date: Sat, 17 Oct 2026 22:20:54 -0400 Subject: [PATCH 045/229] Animation Effects: plainer Linux/BSD reader - The worker thread shares its latest answer with the UI as a plain Option behind a mutex, instead of a byte that needed encode and decode helpers. - Finding the user's config directory is its own small function. - In freedesktop.rs, the two dense one-liners in without_portal are split into named steps, and the parsers are pub(super) (ini_value private): only the animations module uses them. No change in behaviour. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft/src/animations.rs | 40 ++++++++----------- apps/photocraft/src/animations/freedesktop.rs | 18 +++++---- 2 files changed, 27 insertions(+), 31 deletions(-) diff --git a/apps/photocraft/src/animations.rs b/apps/photocraft/src/animations.rs index bae69370b0..b6067fd25d 100644 --- a/apps/photocraft/src/animations.rs +++ b/apps/photocraft/src/animations.rs @@ -62,13 +62,12 @@ mod platform { /// `gsettings`, so a worker thread does it and the UI reads its latest answer. #[cfg(all(unix, not(target_os = "macos")))] mod platform { - use std::sync::atomic::{AtomicU8, Ordering}; - use std::sync::{Arc, OnceLock, mpsc}; + use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, mpsc}; use super::freedesktop; pub fn service() -> Option { - let latest = Arc::new(AtomicU8::new(encode(None))); + let latest = Arc::new(Mutex::new(None)); let wake: Arc> = Arc::new(OnceLock::new()); let (refresh, refreshes) = mpsc::channel::<()>(); let publisher = Publisher { latest: Arc::clone(&latest), wake: Arc::clone(&wake) }; @@ -83,7 +82,7 @@ mod platform { if focused && !was_focused { let _ = refresh.send(()); } - decode(latest.load(Ordering::Relaxed)) + *lock(&latest) })) } @@ -102,14 +101,14 @@ mod platform { /// The worker's side of the latest answer. A change wakes the UI. struct Publisher { - latest: Arc, + latest: Arc>>, wake: Arc>, } impl Publisher { fn publish(&self, animations: Option) { - let code = encode(animations); - if self.latest.swap(code, Ordering::Relaxed) != code + let previous = std::mem::replace(&mut *lock(&self.latest), animations); + if previous != animations && let Some(ctx) = self.wake.get() { ctx.request_repaint(); @@ -117,20 +116,9 @@ mod platform { } } - fn encode(animations: Option) -> u8 { - match animations { - None => 0, - Some(true) => 1, - Some(false) => 2, - } - } - - fn decode(code: u8) -> Option { - match code { - 1 => Some(true), - 2 => Some(false), - _ => None, - } + /// The latest answer; a panic elsewhere while holding it doesn't lose it. + fn lock(latest: &Mutex>) -> MutexGuard<'_, Option> { + latest.lock().unwrap_or_else(PoisonError::into_inner) } #[cfg(target_os = "linux")] @@ -230,12 +218,16 @@ mod platform { freedesktop::without_portal(&desktop, &config_file, &gsettings) } + /// `$XDG_CONFIG_HOME`, or `~/.config` when it isn't set. + fn config_home() -> Option { + let var = |key: &str| std::env::var_os(key).filter(|value| !value.is_empty()).map(PathBuf::from); + var("XDG_CONFIG_HOME").filter(|path| path.is_absolute()).or_else(|| Some(var("HOME")?.join(".config"))) + } + fn config_file(name: &str) -> Option { use std::io::Read as _; - let dir = |key: &str| std::env::var_os(key).filter(|v| !v.is_empty()).map(PathBuf::from); - let config_home = dir("XDG_CONFIG_HOME").filter(|path| path.is_absolute()).or_else(|| dir("HOME").map(|home| home.join(".config")))?; let mut text = String::new(); - std::fs::File::open(config_home.join(name)).ok()?.take(MAX_CONFIG_BYTES).read_to_string(&mut text).ok()?; + std::fs::File::open(config_home()?.join(name)).ok()?.take(MAX_CONFIG_BYTES).read_to_string(&mut text).ok()?; Some(text) } diff --git a/apps/photocraft/src/animations/freedesktop.rs b/apps/photocraft/src/animations/freedesktop.rs index 437ab5c42a..d5b4fbe626 100644 --- a/apps/photocraft/src/animations/freedesktop.rs +++ b/apps/photocraft/src/animations/freedesktop.rs @@ -4,7 +4,7 @@ //! animations are on, or `None` when the value doesn't parse (the next source is asked then). /// GSettings' and the portal's `true`/`false`, or GTK's `1`/`0`. -pub fn from_enabled(value: &str) -> Option { +pub(super) fn from_enabled(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { "true" | "1" => Some(true), "false" | "0" => Some(false), @@ -13,23 +13,26 @@ pub fn from_enabled(value: &str) -> Option { } /// KDE's animation speed factor: 0 turns animations off. -pub fn from_duration_factor(value: &str) -> Option { +pub(super) fn from_duration_factor(value: &str) -> Option { value.trim().parse::().ok().filter(|factor| factor.is_finite()).map(|factor| factor > 0.0) } /// The settings portal's `reduced-motion`: 1 asks for less motion; 0 and any value added later /// mean no preference. #[cfg(any(test, target_os = "linux"))] -pub fn from_reduced_motion(value: &str) -> Option { +pub(super) fn from_reduced_motion(value: &str) -> Option { value.trim().parse::().ok().map(|code| code != 1) } /// The desktop's setting when there is no settings portal to ask. `desktop` is /// `XDG_CURRENT_DESKTOP`, `config_file` reads a file in the user's config directory and /// `gsettings` runs `gsettings get `. -pub fn without_portal(desktop: &str, config_file: &dyn Fn(&str) -> Option, gsettings: &dyn Fn(&str, &str) -> Option) -> Option { +pub(super) fn without_portal(desktop: &str, config_file: &dyn Fn(&str) -> Option, gsettings: &dyn Fn(&str, &str) -> Option) -> Option { let running = |name: &str| desktop.split(':').any(|part| part.eq_ignore_ascii_case(name)); - let in_file = |file: &str, section: &str, key: &str, parse: fn(&str) -> Option| parse(ini_value(&config_file(file)?, section, key)?); + let in_file = |file: &str, section: &str, key: &str, parse: fn(&str) -> Option| { + let text = config_file(file)?; + parse(ini_value(&text, section, key)?) + }; // KDE Plasma keeps it in its own file. A kdeglobals left over from another desktop doesn't count. if running("KDE") @@ -45,11 +48,12 @@ pub fn without_portal(desktop: &str, config_file: &dyn Fn(&str) -> Option(text: &'a str, section: &str, key: &str) -> Option<&'a str> { +fn ini_value<'a>(text: &'a str, section: &str, key: &str) -> Option<&'a str> { let mut in_section = false; let mut found = None; for line in text.lines().map(str::trim) { From af08ca8df86dd3229e44f6af5e2100d8e150b995 Mon Sep 17 00:00:00 2001 From: deniskhud Date: Sun, 18 Oct 2026 08:32:05 +0200 Subject: [PATCH 046/229] Shape gradient paint: cargo fmt Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/vector_cmds/tests.rs | 7 +++++-- crates/ui-egui/src/shape_gradient_ui.rs | 15 ++++++++++++--- crates/ui-egui/src/vector_ui.rs | 12 +++++++++--- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index bacd1b8869..9329d8dd27 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -52,7 +52,8 @@ fn gradient_stroke_round_trips_every_gradient_field() { "opacityStops": [[1.0, 0.25], [0.0, 1.0]], "midpoints": [0.25], "offset": [0.125, -0.25], "dither": true, "align": false, }}); let r = s.execute("shape.edit", json!({"layer": id, "stroke": {"color": gradient}})).unwrap(); - let Some(Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align }) = shape(&s, id).stroke.map(|st| st.paint) else { + let Some(Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align }) = shape(&s, id).stroke.map(|st| st.paint) + else { panic!("a gradient stroke") }; assert_eq!(stops, vec![(0.0, Color::rgba(0.25, 0.5, 0.75, 1.0)), (1.0, Color::rgba(1.0, 0.125, 0.0, 0.5))]); @@ -63,7 +64,9 @@ fn gradient_stroke_round_trips_every_gradient_field() { assert_eq!((g["style"].as_str(), g["midpoints"].clone(), g["align"].as_bool()), (Some("radial"), json!([0.25]), Some(false))); // A partial gradient keeps the defaults it always had. s.execute("shape.edit", json!({"layer": id, "stroke": {"color": {"gradient": {"stops": [[0, "#000000"], [1, "#ffffff"]]}}}})).unwrap(); - let Some(Fill::Gradient { opacity_stops, midpoints, offset, dither, align, .. }) = shape(&s, id).stroke.map(|st| st.paint) else { panic!("a gradient stroke") }; + let Some(Fill::Gradient { opacity_stops, midpoints, offset, dither, align, .. }) = shape(&s, id).stroke.map(|st| st.paint) else { + panic!("a gradient stroke") + }; assert_eq!((opacity_stops, midpoints, offset, dither, align), (Vec::new(), Vec::new(), (0.0, 0.0), false, true)); } diff --git a/crates/ui-egui/src/shape_gradient_ui.rs b/crates/ui-egui/src/shape_gradient_ui.rs index d0556754ce..032fbfc24b 100644 --- a/crates/ui-egui/src/shape_gradient_ui.rs +++ b/crates/ui-egui/src/shape_gradient_ui.rs @@ -281,7 +281,11 @@ fn stops_strip(ui: &mut egui::Ui, fill: &Fill, id: egui::Id, fg: [f32; 4], bg: [ let x = x_of(*loc); let sel = selected == Some(Marker::Opacity(i)); let body = Rect::from_center_size(pos2(x, bar.top() - 10.0), vec2(10.0, 10.0)); - p.add(egui::Shape::convex_polygon(vec![pos2(x, bar.top() - 1.0), pos2(x + 5.0, body.bottom()), pos2(x - 5.0, body.bottom())], outline(sel), Stroke::NONE)); + p.add(egui::Shape::convex_polygon( + vec![pos2(x, bar.top() - 1.0), pos2(x + 5.0, body.bottom()), pos2(x - 5.0, body.bottom())], + outline(sel), + Stroke::NONE, + )); p.rect_filled(body, 1.0, egui::Color32::from_gray((a.clamp(0.0, 1.0) * 255.0) as u8)); p.rect_stroke(body, 1.0, Stroke::new(1.0, outline(sel)), StrokeKind::Outside); } @@ -331,7 +335,9 @@ fn stops_strip(ui: &mut egui::Ui, fill: &Fill, id: egui::Id, fg: [f32; 4], bg: [ let moved = edit.get("location").and_then(Value::as_f64).map(|l| l as f32); let selection = match (drag.marker, edit.get("action").and_then(Value::as_str), moved) { (Marker::Color(i), Some("move"), Some(l)) => Some(Marker::Color(index_after_move(&start_cs.iter().map(|s| s.0).collect::>(), i, l))), - (Marker::Opacity(i), Some("move"), Some(l)) => Some(Marker::Opacity(index_after_move(&start_os.iter().map(|s| s.0).collect::>(), i, l))), + (Marker::Opacity(i), Some("move"), Some(l)) => { + Some(Marker::Opacity(index_after_move(&start_os.iter().map(|s| s.0).collect::>(), i, l))) + } (Marker::Mid(i), ..) => Some(Marker::Mid(i)), _ => None, }; @@ -449,7 +455,10 @@ fn stop_fields(ui: &mut egui::Ui, fill: &Fill, id: egui::Id, fg: [f32; 4], bg: [ // The stop's colour keeps its own alpha; transparency belongs to the opacity stops. let rgba = rgba_of(c); let cache = id.with(("stop-hsva", i)); - let mut hsva = ui.data(|d| d.get_temp::<([f32; 4], egui::ecolor::Hsva)>(cache)).filter(|(previous, _)| *previous == rgba).map_or_else(|| picker_color(rgba), |(_, h)| h); + let mut hsva = ui + .data(|d| d.get_temp::<([f32; 4], egui::ecolor::Hsva)>(cache)) + .filter(|(previous, _)| *previous == rgba) + .map_or_else(|| picker_color(rgba), |(_, h)| h); if egui::color_picker::color_picker_hsva_2d(ui, &mut hsva, egui::color_picker::Alpha::Opaque) { let [r, g, b, _] = picker_rgba(hsva); if [r, g, b].iter().zip(rgba).any(|(a, b)| (a - b).abs() > 0.000001) { diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 019b301834..8bdf325071 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1509,7 +1509,8 @@ fn swatch(ui: &mut egui::Ui, fill: Option<&photocraft_doc::Fill>, fallback: [f32 let open_key = popup_id.with("open"); let was_open = ui.data(|d| d.get_temp::(open_key)).unwrap_or(false); let open = shown.is_some(); - if was_open && !open + if was_open + && !open && let Some(g) = gradient { gradients.remember(g); @@ -1579,8 +1580,13 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!("Fill")).color(t.text_dim).size(12.0)); let (fg, bg) = (app.session.tools.foreground, app.session.tools.background); - let mut gradients = - GradientChoices { presets: &app.session.presets.gradients, current: &app.session.presets.gradient, recent: &mut app.ui.recent_gradients, fg, bg }; + let mut gradients = GradientChoices { + presets: &app.session.presets.gradients, + current: &app.session.presets.gradient, + recent: &mut app.ui.recent_gradients, + fg, + bg, + }; if let Some(change) = swatch(ui, sh.fill.as_ref(), fg, tl!("Set shape fill type"), &mut gradients) { let fill = match change { PaintChange::None => Value::Null, From ce1d7576cf8a990a682b86d55964f4667d8a1f09 Mon Sep 17 00:00:00 2001 From: deniskhud Date: Sun, 18 Oct 2026 09:35:06 +0200 Subject: [PATCH 047/229] Shape gradient paint: cap gradient stop lists; keep the selected stop's colour row shape.create / shape.edit refuse more than 1024 colour stops, opacity stops or midpoints, as gradient.fill.stop does (an error, not a cut). The Gradient tab's selected stop was an index that could outlive the stops (a preset with fewer stops, another shape): the Color and Location rows vanished. An invalid selection falls back to the first colour stop, a preset or Recent pick starts there, and double-clicking a colour stop opens its colour, as in Photoshop. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/vector_cmds.rs | 17 +++++--- crates/engine/src/vector_cmds/tests.rs | 7 ++++ crates/ui-egui/src/shape_gradient_ui.rs | 53 +++++++++++++++++++++--- crates/ui-egui/src/vector_paint_tests.rs | 48 ++++++++++++++++++++- 4 files changed, 114 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 34f2f260da..b0eb24ee69 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -255,6 +255,10 @@ fn fill_json(f: &Fill) -> Value { } } +/// The most colour stops, opacity stops or midpoints a gradient fill takes, as the gradient stop +/// edits (`gradient.fill.stop`, `gradient.presets.stop`) cap them. +const MAX_GRADIENT_STOPS: usize = 1024; + /// `"#rrggbb"` / `[r,g,b,a]` / `{"gradient":{"stops":[[t,"#hex"|[r,g,b,a]],…],"angle","scale","style","reverse", /// "opacityStops":[[t,0..1],…],"midpoints":[0..1,…],"offset":[x,y],"dither","align"}}` / `{"pattern":name,"scale"}`. /// `null` → `Ok(None)` (no fill). @@ -266,9 +270,12 @@ fn parse_fill(v: &Value) -> std::result::Result, String> { return Ok(Some(Fill::Solid(c))); } if let Some(g) = v.get("gradient") { - let stops: Vec<(f32, Color)> = g - .get("stops") - .and_then(Value::as_array) + // Stop lists are capped as `gradient.fill.stop` caps them: more is an error, not a cut. + let list = |key: &str, what: &str| match g.get(key).and_then(Value::as_array) { + Some(a) if a.len() > MAX_GRADIENT_STOPS => Err(format!("too many {what} (max {MAX_GRADIENT_STOPS})")), + a => Ok(a), + }; + let stops: Vec<(f32, Color)> = list("stops", "colour stops")? .map(|a| a.iter().filter_map(|s| Some((s.get(0)?.as_f64()? as f32, color(s.get(1)?)?))).collect()) .unwrap_or_else(|| vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]); let style = match g.get("style").and_then(Value::as_str).unwrap_or("linear") { @@ -288,11 +295,11 @@ fn parse_fill(v: &Value) -> std::result::Result, String> { // The rest of a gradient, so a read-modify-write (the shape swatches) loses nothing. if let Fill::Gradient { opacity_stops, midpoints, offset, dither, align, .. } = &mut fill { let unit = |v: &Value| v.as_f64().map(|n| n.clamp(0.0, 1.0) as f32); - if let Some(a) = g.get("opacityStops").and_then(Value::as_array) { + if let Some(a) = list("opacityStops", "opacity stops")? { *opacity_stops = a.iter().filter_map(|s| Some((unit(s.get(0)?)?, unit(s.get(1)?)?))).collect(); opacity_stops.sort_by(|a, b| a.0.total_cmp(&b.0)); } - if let Some(a) = g.get("midpoints").and_then(Value::as_array) { + if let Some(a) = list("midpoints", "midpoints")? { *midpoints = a.iter().filter_map(unit).collect(); } if let Some(o) = g.get("offset") { diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 9329d8dd27..654e035c76 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -29,6 +29,10 @@ fn stroke_validation_rejects_hostile_values_without_editing_history() { json!({"width": 0.00001, "dashes": [0.01, 0.01]}), json!({"color": {"gradient": {"offset": [0.5]}}}), json!({"color": {"gradient": {"offset": "centre"}}}), + // Stop lists past the gradient stop edits' 1024 cap are refused, not cut. + json!({"color": {"gradient": {"stops": vec![json!([0.5, "#ff0000"]); 1025]}}}), + json!({"color": {"gradient": {"opacityStops": vec![json!([0.5, 1.0]); 1025]}}}), + json!({"color": {"gradient": {"midpoints": vec![0.5; 1025]}}}), ]; for stroke in invalid { assert!(s.execute("shape.edit", json!({"layer": id, "stroke": stroke})).is_err(), "{stroke}"); @@ -38,6 +42,9 @@ fn stroke_validation_rejects_hostile_values_without_editing_history() { } assert!(s.execute("shape.edit", json!({"layer": id, "rect": [1e100, 0, 20, 20]})).is_err()); assert_eq!(s.active().unwrap().doc, before); + // The cap itself is accepted. + let at_cap = json!({"color": {"gradient": {"opacityStops": vec![json!([0.5, 1.0]); 1024], "midpoints": vec![0.5; 1024]}}}); + s.execute("shape.edit", json!({"layer": id, "stroke": at_cap})).unwrap(); } /// A gradient stroke keeps every gradient field through `shape.edit`, and `shape.info` reports diff --git a/crates/ui-egui/src/shape_gradient_ui.rs b/crates/ui-egui/src/shape_gradient_ui.rs index 032fbfc24b..915b7161c4 100644 --- a/crates/ui-egui/src/shape_gradient_ui.rs +++ b/crates/ui-egui/src/shape_gradient_ui.rs @@ -81,6 +81,7 @@ pub(super) fn gradient_tab(ui: &mut egui::Ui, fill: &Fill, choices: &GradientCho let Fill::Gradient { angle, scale, style, reverse, align, .. } = fill else { return None }; let t = Tokens::get(ui.ctx()); let mut out: Option = None; + let mut picked = false; egui::ScrollArea::vertical().id_salt(id.with("gradient-presets")).max_height(120.0).show(ui, |ui| { ui.set_width(WIDTH); let group_label = |ui: &mut egui::Ui, s: &str| ui.label(egui::RichText::new(s).color(t.text_dim).size(11.0)); @@ -95,6 +96,7 @@ pub(super) fn gradient_tab(ui: &mut egui::Ui, fill: &Fill, choices: &GradientCho resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)); if resp.clicked() { take_stops(edited(&mut out, fill), recent); + picked = true; } } }); @@ -112,11 +114,16 @@ pub(super) fn gradient_tab(ui: &mut egui::Ui, fill: &Fill, choices: &GradientCho resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &preset.name)); if resp.on_hover_text(&preset.name).clicked() { take_preset(edited(&mut out, fill), preset, choices.fg, choices.bg); + picked = true; } } }); } }); + if picked { + // New stops: start from the first colour stop, its colour row showing. + ui.data_mut(|d| d.insert_temp(selection_key(id), Marker::Color(0))); + } ui.add_space(6.0); if let Some(f) = stops_strip(ui, out.as_ref().unwrap_or(fill), id, choices.fg, choices.bg) { out = Some(f); @@ -239,6 +246,29 @@ fn apply(fill: &Fill, edit: &Value, fg: [f32; 4], bg: [f32; 4]) -> Option apply_stop(fill, edit, fg, bg).ok() } +/// Where swatch `id` keeps its selected marker. +fn selection_key(id: egui::Id) -> egui::Id { + id.with("stops-selected") +} + +/// Where swatch `id` keeps whether the selected colour stop's inline picker is open. +fn picker_key(id: egui::Id) -> egui::Id { + id.with("stop-picker-open") +} + +/// The selected marker, when it still names a stop of `fill`; else the first colour stop. The +/// marker is an index, so a preset with fewer stops, a deleted stop or another shape would +/// otherwise leave it pointing past the stops (and the stop's fields would vanish). +fn selection(ui: &egui::Ui, id: egui::Id, fill: &Fill) -> Marker { + let (cs, os, _) = sorted_stops(fill); + let valid = |m: &Marker| match *m { + Marker::Color(i) => i < cs.len(), + Marker::Opacity(i) => i < os.len(), + Marker::Mid(i) => i.saturating_add(1) < cs.len(), + }; + ui.data(|d| d.get_temp::(selection_key(id))).filter(valid).unwrap_or(Marker::Color(0)) +} + /// The stops strip: the ramp with opacity stops above and colour stops below (Photoshop's). fn stops_strip(ui: &mut egui::Ui, fill: &Fill, id: egui::Id, fg: [f32; 4], bg: [f32; 4]) -> Option { let t = Tokens::get(ui.ctx()); @@ -253,9 +283,9 @@ fn stops_strip(ui: &mut egui::Ui, fill: &Fill, id: egui::Id, fg: [f32; 4], bg: [ } ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); let (cs, os, mids) = sorted_stops(fill); - let (drag_key, selection_key) = (id.with("stops-drag"), id.with("stops-selected")); + let (drag_key, selection_key) = (id.with("stops-drag"), selection_key(id)); let drag: Option = ui.data(|d| d.get_temp(drag_key)); - let selected: Option = drag.as_ref().map(|d| d.marker).or_else(|| ui.data(|d| d.get_temp(selection_key))); + let selected: Option = Some(drag.as_ref().map_or_else(|| selection(ui, id, fill), |d| d.marker)); let p = ui.painter(); let outline = |sel: bool| if sel { t.accent } else { t.text_dim }; for (i, (loc, c)) in cs.iter().enumerate() { @@ -354,6 +384,19 @@ fn stops_strip(ui: &mut egui::Ui, fill: &Fill, id: egui::Id, fg: [f32; 4], bg: [ } return out; } + // Double-clicking a colour stop opens its colour, as Photoshop's opens the Color Picker. egui + // counts a click within 0.6 s of the one before last as a triple click (a preset picked just + // before), which is a double click on the stop all the same. + if (resp.double_clicked() || resp.triple_clicked()) + && let Some(pos) = resp.interact_pointer_pos() + && let Some(marker @ Marker::Color(_)) = hit(pos) + { + ui.data_mut(|d| { + d.insert_temp(selection_key, marker); + d.insert_temp(picker_key(id), true); + }); + return None; + } if resp.clicked() && let Some(pos) = resp.interact_pointer_pos() { @@ -381,8 +424,8 @@ fn stops_strip(ui: &mut egui::Ui, fill: &Fill, id: egui::Id, fg: [f32; 4], bg: [ /// from its swatch), an opacity stop's opacity, a midpoint's position; Location and Delete. fn stop_fields(ui: &mut egui::Ui, fill: &Fill, id: egui::Id, fg: [f32; 4], bg: [f32; 4]) -> Option { let t = Tokens::get(ui.ctx()); - let selection_key = id.with("stops-selected"); - let selected: Marker = ui.data(|d| d.get_temp(selection_key)).unwrap_or(Marker::Color(0)); + let selection_key = selection_key(id); + let selected = selection(ui, id, fill); let (cs, os, mids) = sorted_stops(fill); let label = |ui: &mut egui::Ui, s: &str| ui.label(egui::RichText::new(s).color(t.text_dim).size(12.0)); let mut edit: Option = None; @@ -392,7 +435,7 @@ fn stop_fields(ui: &mut egui::Ui, fill: &Fill, id: egui::Id, fg: [f32; 4], bg: [ Marker::Opacity(i) => (os.iter().map(|s| s.0).collect(), i), Marker::Mid(i) => (Vec::new(), i), }; - let picker_key = id.with("stop-picker-open"); + let picker_key = picker_key(id); let mut picker_open = ui.data(|d| d.get_temp::(picker_key)).unwrap_or(false); ui.horizontal(|ui| match selected { Marker::Color(i) => { diff --git a/crates/ui-egui/src/vector_paint_tests.rs b/crates/ui-egui/src/vector_paint_tests.rs index 8cb1c20c9d..4a8f7765b5 100644 --- a/crates/ui-egui/src/vector_paint_tests.rs +++ b/crates/ui-egui/src/vector_paint_tests.rs @@ -35,7 +35,14 @@ fn assert_saved_path(saved: &Path, original: &Path) { } fn harness(app: PhotocraftApp, properties: bool) -> Harness<'static, PhotocraftApp> { - let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + // kittest's default step: 0.25 s a frame. + harness_stepped(app, properties, 0.25) +} + +/// [`harness`] advancing `step_dt` seconds a frame (1/60 lets two clicks fall within egui's +/// 0.3 s double-click window, as they do in the app). +fn harness_stepped(app: PhotocraftApp, properties: bool, step_dt: f32) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(step_dt).build_ui_state( move |ui, app: &mut PhotocraftApp| { crate::screen_picker::tick(app, ui.ctx()); if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { @@ -535,3 +542,42 @@ fn gradient_tab_align_with_layer_and_recent_row() { h.run_steps(3); assert_eq!(h.state().ui.recent_gradients.len(), 1, "the same stops are remembered once"); } + +/// #7176 review: the selected stop is an index; after taking a preset with fewer stops it pointed +/// past them and the Color / Location rows vanished, leaving no way to recolour a stop. They stay, +/// and double-clicking a colour stop opens its colour, as Photoshop's opens the Color Picker. +#[test] +fn gradient_tab_keeps_the_stop_colour_after_a_preset_with_fewer_stops() { + let mut h = harness_stepped(app("rgb", 8), false, 1.0 / 60.0); + let strip = open_stroke_gradient(&mut h); + let (bar_left, bar_right, bar_bottom) = (strip.left() + 8.0, strip.right() - 8.0, strip.top() + 40.0); + let x_at = |loc: f32| bar_left + (bar_right - bar_left) * loc; + // Three colour stops, the last one selected. + click(&mut h, pos2(x_at(0.5), bar_bottom - 4.0)); + click(&mut h, pos2(x_at(1.0), bar_bottom + 10.0)); + assert_eq!(gradient_stops(&tool_stroke_gradient(&h)).0.len(), 3); + h.get_by_label("Blue 01").click(); + h.run_steps(3); + assert_eq!(gradient_stops(&tool_stroke_gradient(&h)).0.len(), 2); + assert!(h.query_by_label("Location").is_some(), "the selected stop's rows stay after a preset with fewer stops"); + let color_well = egui_kittest::kittest::by().label("Color").role(egui::accesskit::Role::ColorWell); + assert!(h.query(color_well).is_some()); + // Double-click the right colour stop: its picker opens, and a click in it recolours only that stop. + let stop = pos2(x_at(1.0), bar_bottom + 10.0); + h.hover_at(stop); + // A second later, so the two clicks are a double click and not a triple with the preset pick. + h.run_steps(60); + for _ in 0..2 { + pointer(&mut h, stop, true); + pointer(&mut h, stop, false); + } + h.run_steps(2); + let before = gradient_stops(&tool_stroke_gradient(&h)).0; + let swatch = h.get(egui_kittest::kittest::by().label("Color").role(egui::accesskit::Role::ColorWell)).rect(); + let field = pos2(swatch.left() + 30.0, h.get_by_label("Location").rect().bottom() + 120.0); + click(&mut h, field); + let after = gradient_stops(&tool_stroke_gradient(&h)).0; + assert_eq!(after[0], before[0], "the left stop keeps its colour"); + assert_ne!(after[1].1, before[1].1, "the double-clicked right stop is recoloured"); + assert_eq!(after[1].0, before[1].0); +} From 4b06b0cc5a311ed595aaa3c0405d19ebca945d67 Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:53:07 +0200 Subject: [PATCH 048/229] Brush: small tips keep their speed next to the parallel large ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version slowed small brushes by about a third (reviewer's measurement: a 20 px soft zigzag 856 → 1736 ms over 6000 dabs), for three reasons, all gone: - `CoverageMap::accumulate` looked the dab's tiles up by walking the whole tile map (`iter_mut().filter_map`) on every dab, so a long stroke's thousands of tiles taxed every small dab. The touched tiles now leave the map (`remove`), accumulate, and go back: one to four lookups for a small tip. - The composite allocated a fresh 64 KB buffer per tile per frame (6872 `brk` calls per 3000 dabs against main's 542): the heap grew and trimmed every frame. The buffers live in a pool on the renderer (`RegionPool`, emptied on clone) and are reused from composite to composite. - The row / tile bodies passed as closures to the helpers cost a 20 px tip's `rasterize` half as much again (7.5 → 11.6 µs). They are `#[inline(always)]` steps on `Copy` structs (`RoundTip::row`, `DabValues::tile`), called from plain loops below the threshold and from rayon above it; `rasterize` is back to main's time. `examples/stroke_bench.rs` times a long zigzag stroke of one tip, push + composite per point (best of four, loaded machine; main → now): 20 px 667 → 706 ms, 50 px 1805 → 1734 ms, 150 px 11 017 → 5163 ms. Comments: the per-tile copy of `src` in the composite is the intended reading (a tile without per-dab colour only occurs in strokes without per-dab colour, where `src` is never rewritten), and `set_parallel_min_pixels` changes a process-wide static harmlessly. `parallel()` and the helpers' `pixels` are `cfg`'d so the wasm build has no unused warnings. Co-Authored-By: Claude Fable 5.1 --- crates/paint/examples/stroke_bench.rs | 64 ++++++ crates/paint/src/render.rs | 280 +++++++++++++++++--------- 2 files changed, 247 insertions(+), 97 deletions(-) create mode 100644 crates/paint/examples/stroke_bench.rs diff --git a/crates/paint/examples/stroke_bench.rs b/crates/paint/examples/stroke_bench.rs new file mode 100644 index 0000000000..2a6b59cfa7 --- /dev/null +++ b/crates/paint/examples/stroke_bench.rs @@ -0,0 +1,64 @@ +//! A long stroke of one tip, timed per dab: a zigzag over a 4928×3280 RGBA8 document that sweeps +//! the whole page, `push` then `composite` after every point, as painting does per frame. Small +//! tips are the everyday case; a long stroke grows the stroke's tile map to thousands of tiles, +//! and the time per dab must not grow with it. +//! `cargo run --release -p photocraft-paint --example stroke_bench -- [size] [hardness] [dabs]` +use photocraft_color::PixelFormat; +use photocraft_geom::Rect; +use photocraft_paint::{BrushSettings, StrokePoint, StrokeRenderer}; +use photocraft_raster::Surface; +use std::time::Instant; + +fn main() { + let args: Vec = std::env::args().collect(); + let size: f32 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(20.0); + let hardness: f32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0.0); + let dabs: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(6000); + let (w, h) = (4928.0f64, 3280.0f64); + let brush = BrushSettings { size, hardness, spacing: 0.25, color: [1.0, 0.0, 0.0, 1.0], ..Default::default() }; + let fmt = PixelFormat::RGBA8; + let mut pre = Surface::new(fmt); + pre.fill_rect(Rect::new(0, 0, w as i32, h as i32), &[0.5, 0.5, 0.5, 1.0]); + let mut target = pre.clone(); + // Segments from the top margin to the bottom one and back, advancing across the width so that + // `dabs` dabs at the brush's spacing sweep the document once. + let step = f64::from(size) * 0.25; + let margin = f64::from(size); + let span = h - 2.0 * margin; + let segments = ((dabs as f64 * step) / span).ceil().max(1.0) as usize; + let dx = (w - 2.0 * margin) / segments as f64; + let mut r = StrokeRenderer::new(&brush, Some(fmt), 1.0); + let (mut t_push, mut t_comp) = (0.0f64, 0.0f64); + let t_all = Instant::now(); + let mut pos = (margin, margin); + r.push(&[StrokePoint::new(pos.0, pos.1, 1.0)]); + let mut pushes = 0usize; + 'stroke: for seg in 0..segments { + let (x0, y0) = pos; + let (x1, y1) = (margin + (seg + 1) as f64 * dx, if seg % 2 == 0 { h - margin } else { margin }); + let n = (((x1 - x0).powi(2) + (y1 - y0).powi(2)).sqrt() / step).ceil().max(1.0) as usize; + for i in 1..=n { + let f = i as f64 / n as f64; + let t = Instant::now(); + r.push(&[StrokePoint::new(x0 + (x1 - x0) * f, y0 + (y1 - y0) * f, 1.0)]); + let tp = t.elapsed().as_secs_f64(); + r.composite(&pre, &mut target, None, false, false); + t_comp += t.elapsed().as_secs_f64() - tp; + t_push += tp; + pushes += 1; + if r.dab_count() >= dabs { + break 'stroke; + } + } + pos = (x1, y1); + } + let total = t_all.elapsed().as_secs_f64(); + let placed = r.dab_count(); + println!( + "tip {size} px, hardness {hardness}: {placed} dabs in {pushes} pushes over {segments} segments: {:.0} ms (push {:.0}, composite {:.0}), {:.1} µs per dab", + total * 1e3, + t_push * 1e3, + t_comp * 1e3, + total * 1e6 / placed.max(1) as f64 + ); +} diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index b0fa7d64ea..f58594c185 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -46,39 +46,22 @@ pub const COV_TILE: i32 = 64; #[cfg(not(target_arch = "wasm32"))] static PARALLEL_MIN_PIXELS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(256 * 256); -/// Whether `pixels` of independent work are worth the thread pool (never on the web). +/// Whether `pixels` of independent work are worth the thread pool (the web has none: there every +/// stage takes its serial path). +#[cfg(not(target_arch = "wasm32"))] fn parallel(pixels: usize) -> bool { - #[cfg(not(target_arch = "wasm32"))] - { - pixels >= PARALLEL_MIN_PIXELS.load(std::sync::atomic::Ordering::Relaxed) - } - #[cfg(target_arch = "wasm32")] - { - let _ = pixels; - false - } + pixels >= PARALLEL_MIN_PIXELS.load(std::sync::atomic::Ordering::Relaxed) } /// Tests: the pixel count from which work runs in parallel (`usize::MAX` keeps everything serial). +/// The static is process-wide, so brush tests on other threads see the change too; that is +/// harmless, because every stage gives the same pixels serially and in parallel (the point of +/// the test that uses this), so no test can tell which way it ran. #[cfg(all(test, not(target_arch = "wasm32")))] pub(crate) fn set_parallel_min_pixels(n: usize) { PARALLEL_MIN_PIXELS.store(n, std::sync::atomic::Ordering::Relaxed); } -/// `f(row index, row)` over the rows of `out` (`w` values each), in parallel when worth it. -fn for_each_row(out: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32]) + Sync) { - if w == 0 { - return; - } - #[cfg(not(target_arch = "wasm32"))] - if parallel(out.len()) { - use rayon::prelude::*; - out.par_chunks_exact_mut(w).enumerate().for_each(|(i, row)| f(i, row)); - return; - } - out.chunks_exact_mut(w).enumerate().for_each(|(i, row)| f(i, row)); -} - /// `f` over `items`, in parallel when `pixels` are worth it. fn for_each_item(items: &mut [T], pixels: usize, f: impl Fn(&mut T) + Sync) { #[cfg(not(target_arch = "wasm32"))] @@ -87,6 +70,8 @@ fn for_each_item(items: &mut [T], pixels: usize, f: impl Fn(&mut T) + S items.par_iter_mut().for_each(&f); return; } + #[cfg(target_arch = "wasm32")] + let _ = pixels; items.iter_mut().for_each(&f); } @@ -97,6 +82,8 @@ fn map_items(items: &[T], pixels: usize, f: impl Fn(&T) -> R + use rayon::prelude::*; return items.par_iter().map(&f).collect(); } + #[cfg(target_arch = "wasm32")] + let _ = pixels; items.iter().map(&f).collect() } /// Computed tips with a smaller radius (in pixels) are rasterised by area, not at pixel centres. @@ -544,53 +531,44 @@ impl CoverageMap { vals[row + (tr.x0 - rect.x0) as usize..row + (tr.x1 - rect.x0) as usize].iter().any(|&v| v > 0.0) }) }); - let keys: Vec<((i32, i32), Rect)> = keys.into_iter().zip(touched).filter(|(_, t)| *t).map(|(k, _)| k).collect(); - for (key, _) in &keys { - let tile = self.tiles.entry(*key).or_insert_with(|| CovTile { - cov: vec![0.0; (COV_TILE * COV_TILE) as usize], - depth: Vec::new(), - col: if want_col { vec![0.0; (COV_TILE * COV_TILE) as usize * nc] } else { Vec::new() }, - }); - if want_col && tile.col.is_empty() { - tile.col = vec![0.0; (COV_TILE * COV_TILE) as usize * nc]; - } - if depth.is_some() && tile.depth.is_empty() { - tile.depth = vec![0.0; (COV_TILE * COV_TILE) as usize]; - } - self.dirty.insert(*key); - } - let wanted: HashMap<(i32, i32), Rect> = keys.into_iter().collect(); - let mut tiles: Vec<(Rect, (i32, i32), &mut CovTile)> = self.tiles.iter_mut().filter_map(|(k, t)| Some((*wanted.get(k)?, *k, t))).collect(); - for_each_item(&mut tiles, wanted.len() * (COV_TILE * COV_TILE) as usize, |(tr, (tx, ty), tile)| { - let (tx, ty) = (*tx, *ty); - for y in tr.y0..tr.y1 { - let row = (y - rect.y0) as usize * w; - let trow = ((y - ty * COV_TILE) * COV_TILE) as usize; - for x in tr.x0..tr.x1 { - let v = vals[row + (x - rect.x0) as usize]; - if v <= 0.0 { - continue; - } - let ti = trow + (x - tx * COV_TILE) as usize; - if let (Some(dv), Some(slot)) = (depth.and_then(|dp| dp.get(row + (x - rect.x0) as usize)), tile.depth.get_mut(ti)) { - *slot = slot.max(*dv); - } - let c = tile.cov[ti]; - let nv = if ceil > c { c + v * (ceil - c) } else { c }; - if nv <= c { - continue; - } - if let (true, Some(col)) = (want_col, color) { - let k = if c < 1.0 { (nv - c) / (1.0 - c) } else { 0.0 }; - let p = &mut tile.col[ti * nc..(ti + 1) * nc]; - for i in 0..nc { - p[i] = col[i] * k + p[i] * (1.0 - k); - } - } - tile.cov[ti] = nv; + // The touched tiles leave the map while they accumulate and go back after: the dab's cost + // follows the tiles under it (one to four for a small tip), not the size of the map, which a + // long stroke grows to thousands of tiles. + let n = (COV_TILE * COV_TILE) as usize; + let mut tiles: Vec<(Rect, (i32, i32), CovTile)> = keys + .into_iter() + .zip(touched) + .filter(|(_, t)| *t) + .map(|((key, tr), _)| { + let mut tile = self.tiles.remove(&key).unwrap_or_else(|| CovTile { cov: vec![0.0; n], depth: Vec::new(), col: Vec::new() }); + if want_col && tile.col.is_empty() { + tile.col = vec![0.0; n * nc]; } + if depth.is_some() && tile.depth.is_empty() { + tile.depth = vec![0.0; n]; + } + self.dirty.insert(key); + (tr, key, tile) + }) + .collect(); + let dab = DabValues { vals, rect, w, ceil, color: if want_col { color } else { None }, depth, nc }; + // The serial path stays a plain loop over an inlined step (see `rasterize_round`). + #[cfg(not(target_arch = "wasm32"))] + if parallel(tiles.len() * n) { + use rayon::prelude::*; + tiles.par_iter_mut().for_each(|(tr, key, tile)| dab.tile(*tr, *key, tile)); + } else { + for (tr, key, tile) in &mut tiles { + dab.tile(*tr, *key, tile); } - }); + } + #[cfg(target_arch = "wasm32")] + for (tr, key, tile) in &mut tiles { + dab.tile(*tr, *key, tile); + } + for (_, key, tile) in tiles { + self.tiles.insert(key, tile); + } } fn take_dirty(&mut self) -> Vec<(i32, i32)> { @@ -657,6 +635,55 @@ impl UnionLive { } } +/// One dab's coverage (`vals` over `rect`, `w` wide) as [`CoverageMap::accumulate`] adds it to +/// a tile: the dab's opacity ceiling, its colour when the stroke has per-dab colour (`nc` channels), +/// its Wet Edges depth. +#[derive(Clone, Copy)] +struct DabValues<'a> { + vals: &'a [f32], + rect: Rect, + w: usize, + ceil: f32, + color: Option<&'a [f32]>, + depth: Option<&'a [f32]>, + nc: usize, +} + +impl DabValues<'_> { + /// Adds the dab over `tr` (within tile `(tx, ty)`) to `tile`. + #[inline(always)] + fn tile(self, tr: Rect, (tx, ty): (i32, i32), tile: &mut CovTile) { + let DabValues { vals, rect, w, ceil, color, depth, nc } = self; + for y in tr.y0..tr.y1 { + let row = (y - rect.y0) as usize * w; + let trow = ((y - ty * COV_TILE) * COV_TILE) as usize; + for x in tr.x0..tr.x1 { + let v = vals[row + (x - rect.x0) as usize]; + if v <= 0.0 { + continue; + } + let ti = trow + (x - tx * COV_TILE) as usize; + if let (Some(dv), Some(slot)) = (depth.and_then(|dp| dp.get(row + (x - rect.x0) as usize)), tile.depth.get_mut(ti)) { + *slot = slot.max(*dv); + } + let c = tile.cov[ti]; + let nv = if ceil > c { c + v * (ceil - c) } else { c }; + if nv <= c { + continue; + } + if let Some(col) = color { + let k = if c < 1.0 { (nv - c) / (1.0 - c) } else { 0.0 }; + let p = &mut tile.col[ti * nc..(ti + 1) * nc]; + for i in 0..nc { + p[i] = col[i] * k + p[i] * (1.0 - k); + } + } + tile.cov[ti] = nv; + } + } + } +} + /// Incremental stroke rendering: feed points, composite dirty tiles from the pre-stroke pixels. #[derive(Clone, Debug)] pub struct StrokeRenderer { @@ -673,6 +700,19 @@ pub struct StrokeRenderer { dab_buf: Vec, dual_buf: Vec, all_dabs: Option>, + regions: RegionPool, +} + +/// The tile buffers [`StrokeRenderer::composite`] works in, kept from one composite to the next: +/// a small stroke's few tiles reuse theirs instead of growing and trimming the heap every frame +/// (that cost a 50 px stroke a third of its time). A clone starts with none. +#[derive(Debug, Default)] +struct RegionPool(Vec>); + +impl Clone for RegionPool { + fn clone(&self) -> Self { + Self::default() + } } impl StrokeRenderer { @@ -765,6 +805,7 @@ impl StrokeRenderer { dab_buf: Vec::new(), dual_buf: Vec::new(), all_dabs: None, + regions: RegionPool::default(), }) } Some(m) => { @@ -816,6 +857,7 @@ impl StrokeRenderer { dab_buf: Vec::new(), dual_buf: Vec::new(), all_dabs: None, + regions: RegionPool::default(), } } @@ -946,6 +988,7 @@ impl StrokeRenderer { dab_buf: dabs, dual_buf: duals, all_dabs: None, + regions: RegionPool::default(), }; t.raster_pending(); // Tiles whose coverage the tail doesn't change still composite the same: redraw them all. @@ -1059,20 +1102,29 @@ impl StrokeRenderer { // colour, as in Photoshop, not grain that a tolerance-0 Magic Wand or Paint Bucket trips on. let dither = fmt.sample == SampleType::U8 && !b.aliased; let mut dmg = Rect::EMPTY; - // Each tile composites on its own (a large stroke's tiles in parallel, to the same - // pixels); the writes to the target go one by one. Batches keep the pending regions small. + // Each tile composites into a buffer of its own (a large stroke's tiles in parallel, to + // the same pixels); the writes to the target go one by one, in order. Batches keep the + // pending buffers few, and the pool keeps them from one composite to the next. const BATCH: usize = 256; + let mut pool = std::mem::take(&mut self.regions.0); for batch in keys.chunks(BATCH) { - let regions: Vec<(Rect, Vec)> = map_items(batch, batch.len() * (COV_TILE * COV_TILE) as usize, |&(tx, ty)| { - let Some(tile) = self.cov.tiles.get(&(tx, ty)) else { return (Rect::EMPTY, Vec::new()) }; + let mut items: Vec<((i32, i32), Rect, Vec)> = batch.iter().map(|&key| (key, Rect::EMPTY, pool.pop().unwrap_or_default())).collect(); + for_each_item(&mut items, batch.len() * (COV_TILE * COV_TILE) as usize, |(key, done, region)| { + let (tx, ty) = *key; + let Some(tile) = self.cov.tiles.get(&(tx, ty)) else { return }; let tr = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE).intersect(&bounds); if tr.is_empty() { - return (Rect::EMPTY, Vec::new()); + return; } - let mut region = Vec::new(); - pre.read_region_into(tr, &mut region); + pre.read_region_into(tr, region); let sel = selection.map(|s| (s.channels(), s.read_region(tr))); - // Per-dab colour rewrites the source per pixel: a copy per tile. + // Per-dab colour rewrites the source per pixel, so each tile works on its own copy + // that starts from the brush colour. (Before the tiles ran in parallel, one `src` + // carried over from tile to tile; a tile without per-dab colour then painted with + // whatever colour the previous tile's last pixel had left in it. Such a tile only + // occurs when the stroke has no per-dab colour at all, and then `src` is never + // rewritten, so the pixels are the same either way; the copy per tile is the + // intended reading.) let mut src = src; let w = tr.width() as usize; for y in tr.y0..tr.y1 { @@ -1132,16 +1184,17 @@ impl StrokeRenderer { } } } - (tr, region) + *done = tr; }); - for (tr, region) in regions { - if tr.is_empty() { - continue; + for (_, tr, region) in items { + if !tr.is_empty() { + target.write_region(tr, ®ion); + dmg = dmg.union(&tr); } - target.write_region(tr, ®ion); - dmg = dmg.union(&tr); + pool.push(region); } } + self.regions.0 = pool; if all { bounds } else { dmg } } } @@ -1172,27 +1225,60 @@ fn rasterize_round(d: &Dab, rect: Rect, out: &mut [f32], hardness: f32, aliased: let ro = d.roundness.max(0.5 / d.radius).min(1.0); let rm = (d.radius * ro).max(0.5); let w = rect.width() as usize; + if w == 0 { + return; + } let reach = if aliased { rm } else { tip_reach(rm, hardness).max(rm) }; - let reach2 = (reach + 0.5) * (reach + 0.5); - let alpha = d.alpha; - // Rows are independent: a large tip's rows run in parallel, to the same values. - for_each_row(out, w, |yy, row| { - let dy = (rect.y0 + yy as i32) as f32 + 0.5 - cy; - if aliased { + let tip = RoundTip { cx, cy, ro, rm, reach2: (reach + 0.5) * (reach + 0.5), hardness, alpha: d.alpha, aliased, x0: rect.x0, y0: rect.y0 }; + // Rows are independent: a large tip's rows run in parallel, to the same values. The serial + // path stays a plain loop over an inlined row: the same code as before the threads (a closure + // passed to a helper cost a small tip half its rasterization time). + #[cfg(not(target_arch = "wasm32"))] + if parallel(out.len()) { + use rayon::prelude::*; + out.par_chunks_exact_mut(w).enumerate().for_each(|(yy, row)| tip.row(yy, row)); + return; + } + for (yy, row) in out.chunks_exact_mut(w).enumerate() { + tip.row(yy, row); + } +} + +/// A round tip's coverage, computed row by row (see [`rasterize_round`]). +#[derive(Clone, Copy)] +struct RoundTip { + cx: f32, + cy: f32, + ro: f32, + rm: f32, + reach2: f32, + hardness: f32, + alpha: f32, + aliased: bool, + x0: i32, + y0: i32, +} + +impl RoundTip { + /// Row `yy` of the tip's rectangle into `row`. + #[inline(always)] + fn row(self, yy: usize, row: &mut [f32]) { + let dy = (self.y0 + yy as i32) as f32 + 0.5 - self.cy; + if self.aliased { for (xx, dst) in row.iter_mut().enumerate() { - let dx = (rect.x0 + xx as i32) as f32 + 0.5 - cx; - let d2 = (dx * ro).powi(2) + dy * dy; - *dst = if d2 < reach2 && d2.sqrt() <= rm { alpha } else { 0.0 }; + let dx = (self.x0 + xx as i32) as f32 + 0.5 - self.cx; + let d2 = (dx * self.ro).powi(2) + dy * dy; + *dst = if d2 < self.reach2 && d2.sqrt() <= self.rm { self.alpha } else { 0.0 }; } } else { for (xx, dst) in row.iter_mut().enumerate() { - let dx = (rect.x0 + xx as i32) as f32 + 0.5 - cx; - let d2 = (dx * ro).powi(2) + dy * dy; - let val = if d2 >= reach2 { 0.0 } else { tip_falloff(d2.sqrt(), rm, hardness) }; - *dst = if val <= 0.0 { 0.0 } else { val * alpha }; + let dx = (self.x0 + xx as i32) as f32 + 0.5 - self.cx; + let d2 = (dx * self.ro).powi(2) + dy * dy; + let val = if d2 >= self.reach2 { 0.0 } else { tip_falloff(d2.sqrt(), self.rm, self.hardness) }; + *dst = if val <= 0.0 { 0.0 } else { val * self.alpha }; } } - }); + } } #[cfg(test)] From 24d6150ed4f94dd4b27aa7c81530a050c7e25f48 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:30:52 +0200 Subject: [PATCH 049/229] Regenerate docs/scorecard.md after merging main Co-Authored-By: Claude Sonnet 5.5 --- docs/scorecard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index b320028936..d22ea47eeb 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 24 | 58 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 19 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | -| [Tools](#tools) | 33 | 11 | 0 | | +| [Tools](#tools) | 34 | 10 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 25 of 168 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | @@ -160,7 +160,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | done | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool (Background Eraser, Clone/Pattern Stamp, Healing/Spot Healing Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Mixer Brush: their paint.* command along the path, one step) plus simulatePressure (taper both ends); Stroke Path dialog from the Pen menu, the Paths panel menu and Alt-click on its Stroke button (#2978) | | TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather | | TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | -| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing | +| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | done | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas window (vp_ui.rs: Create/Edit Plane, edge nodes, Ctrl/Cmd-drag tear-off planes with depth and Alt-drag angle, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane, Stamp tool cloning in perspective with live preview, all applied on OK as one step); the window's Marquee tool and the Stamp's Heal modes are still missing | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | | TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo | | TOOL-213-17 | Red Eye tool: click a pupil to neutralize the red reflection | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.redEye (redeye_cmds.rs) + Tool::RedEye in the J flyout; Pupil Size and Darken Amount; catchlights kept; a miss is an error | From 7bf4cc0739307dc1a930c828cfa7080837abe874 Mon Sep 17 00:00:00 2001 From: ersoynarin-code Date: Tue, 20 Oct 2026 01:54:27 +0300 Subject: [PATCH 050/229] perf(photomerge): track multiband timing and peak memory --- crates/algo/examples/bench_multiband.rs | 181 ++++++++++++++++++++---- docs/scorecard.md | 3 +- perf/budgets.toml | 19 +++ 3 files changed, 176 insertions(+), 27 deletions(-) diff --git a/crates/algo/examples/bench_multiband.rs b/crates/algo/examples/bench_multiband.rs index 9d58b81a54..d3dcdc2f0b 100644 --- a/crates/algo/examples/bench_multiband.rs +++ b/crates/algo/examples/bench_multiband.rs @@ -1,42 +1,171 @@ -//! Time and peak memory of the Photomerge multi-band blend: `n` RGB sources of `mp` megapixels -//! (3:2), each overlapping the next by half its width. -//! `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]` (default 4 × 24 MP). -//! Peak memory is the growth of the process's high-water mark during the blend (Linux only). +//! Time and peak memory of Photomerge's multi-band blend on overlapping RGB sources. +//! Backwards compatible: `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]`. +//! `cargo xtask perf --bench bench_multiband --quick` also collects repeatable JSON timings. +//! The peak is the entire process's resident-set high-water mark, not an estimate of allocations +//! by the blend alone. Available on Linux and Windows; other platforms report no peak value. +use std::path::PathBuf; use std::time::Instant; use photocraft_algo::panorama::{self, RoiImage}; +use serde_json::json; -/// The process's peak resident set in MiB (`VmHWM`), where `/proc` has it. -fn peak_mib() -> Option { - let s = std::fs::read_to_string("/proc/self/status").ok()?; - let kb: f64 = s.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok()?; - Some(kb / 1024.0) +const ROW: &str = "Photomerge multiband blend"; + +#[derive(Debug, PartialEq)] +struct Options { + n: usize, + mp: f64, + reps: usize, + json: Option, } -fn main() { - let mut args = std::env::args().skip(1); - let n: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(4).max(1); - let mp: f64 = args.next().and_then(|a| a.parse().ok()).unwrap_or(24.0); - let sh = ((mp * 1e6 / 1.5).sqrt().round() as usize).max(2); +fn options(args: impl IntoIterator) -> Result { + let mut out = Options { n: 4, mp: 24.0, reps: 1, json: None }; + let mut positional = Vec::new(); + let mut it = args.into_iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "--json" => out.json = Some(PathBuf::from(it.next().ok_or("--json needs a file path")?)), + "--reps" => { + out.reps = it.next().ok_or("--reps needs an integer")?.parse().map_err(|_| "--reps needs an integer")?; + } + _ if !arg.starts_with('-') => positional.push(arg), + _ => return Err(format!("unknown option: {arg}")), + } + } + if positional.len() > 2 { + return Err("expected at most [n] [mp]".into()); + } + if let Some(n) = positional.first() { + out.n = n.parse().map_err(|_| "n must be an integer")?; + } + if let Some(mp) = positional.get(1) { + out.mp = mp.parse().map_err(|_| "mp must be a number")?; + } + if !(1..=16).contains(&out.n) || !(1..=25).contains(&out.reps) || !out.mp.is_finite() || !(0.01..=24.0).contains(&out.mp) { + return Err("expected 1..=16 images, 0.01..=24 MP per image and 1..=25 repetitions".into()); + } + Ok(out) +} + +/// Peak resident set size in bytes (Linux: VmHWM; Windows: PeakWorkingSet64). +/// Called outside the timed code. This metric includes input buffers and output buffers. +fn peak_rss_bytes() -> Option { + #[cfg(target_os = "linux")] + { + let s = std::fs::read_to_string("/proc/self/status").ok()?; + let kb: u64 = s.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok()?; + return kb.checked_mul(1024); + } + #[cfg(target_os = "windows")] + { + // A standard Windows system utility; no unsafe FFI or new crate is needed in this + // performance example. Its process-launch cost is outside the measurement window. + let cmd = format!("(Get-Process -Id {}).PeakWorkingSet64", std::process::id()); + let out = std::process::Command::new("powershell.exe").args(["-NoProfile", "-NonInteractive", "-Command", &cmd]).output().ok()?; + return out.status.success().then_some(out.stdout).and_then(|v| String::from_utf8(v).ok())?.trim().parse().ok(); + } + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + None +} + +fn run() -> Result<(), String> { + let o = options(std::env::args().skip(1))?; + let sh = ((o.mp * 1e6 / 1.5).sqrt().round() as usize).max(2); let sw = sh * 3 / 2; let step = sw / 2; - let (cw, ch) = (step * (n - 1) + sw, sh); - let rois: Vec = (0..n) + let (cw, ch) = (step * (o.n - 1) + sw, sh); + let rois: Vec = (0..o.n) .map(|k| { let px = (0..sw * sh * 3).map(|i| ((i / 3 % sw * 7 + i / 3 / sw * 3 + k * 31 + i % 3 * 50) % 251) as f32 / 251.0).collect(); RoiImage { x0: (step * k) as i32, y0: 0, w: sw, h: sh, ch: 3, px, alpha: vec![1.0; sw * sh] } }) .collect(); - // Each canvas column belongs to the image whose centre is nearest, as a seam would split it. - let wts: Vec> = (0..n) - .map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(n - 1) == k { 1.0 } else { 0.0 }).collect()) + let wts: Vec> = (0..o.n) + .map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(o.n - 1) == k { 1.0 } else { 0.0 }).collect()) .collect(); let levels = panorama::blend_levels(cw, ch).min(7); - let before = peak_mib(); - let t0 = Instant::now(); - let (px, cov) = panorama::multiband(cw, ch, &rois, &wts, levels); - let ms = t0.elapsed().as_secs_f64() * 1000.0; - let peak = peak_mib().zip(before).map_or("n/a".to_string(), |(a, b)| format!("{:.0} MiB", a - b)); - println!("{n} × {sw}×{sh} ({mp} MP) RGB, canvas {cw}×{ch}, {levels} levels: {ms:.0} ms, peak +{peak} during the blend"); - std::hint::black_box((px, cov)); + let before = peak_rss_bytes(); + let mut samples_ms = Vec::with_capacity(o.reps); + for _ in 0..o.reps { + let t0 = Instant::now(); + let output = panorama::multiband(cw, ch, &rois, &wts, levels); + samples_ms.push(t0.elapsed().as_secs_f64() * 1000.0); + std::hint::black_box(&output); + } + let peak = peak_rss_bytes(); + let delta = before.zip(peak).map(|(a, b)| b.saturating_sub(a) as f64 / (1024.0 * 1024.0)); + let med = { + let mut sorted = samples_ms.clone(); + sorted.sort_by(f64::total_cmp); + sorted[sorted.len() / 2] + }; + println!( + "{} × {}×{} ({:.2} MP) RGB, canvas {}×{}, {} levels: {:.1} ms median ({} runs), peak +{}", + o.n, + sw, + sh, + o.mp, + cw, + ch, + levels, + med, + o.reps, + delta.map_or("n/a".to_string(), |m| format!("{m:.0} MiB")) + ); + if let Some(path) = o.json { + let report = json!({ + "context": {"sources": o.n, "mp_per_source": o.mp, "canvas_width": cw, "canvas_height": ch, "levels": levels}, + "process_peak_rss_bytes": peak, + "rows": [{"name": ROW, "samples_ms": samples_ms, "peak_rss_bytes": peak}] + }); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; + } + std::fs::write(&path, serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?).map_err(|e| format!("write {}: {e}", path.display()))?; + } + Ok(()) +} + +fn main() { + if let Err(e) = run() { + eprintln!("bench_multiband: {e}"); + std::process::exit(2); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Result { + options(args.iter().map(|s| s.to_string())) + } + + #[test] + fn parses_old_positional_args_and_perf_runner_args() { + assert_eq!(parse(&[]).unwrap().n, 4); + assert_eq!( + parse(&["2", "0.25", "--reps", "3", "--json", "out.json"]).unwrap(), + Options { n: 2, mp: 0.25, reps: 3, json: Some(PathBuf::from("out.json")) } + ); + } + + #[test] + fn rejects_bad_benchmark_args() { + for args in [ + vec!["0"], + vec!["17"], + vec!["2", "0"], + vec!["2", "NaN"], + vec!["2", "inf"], + vec!["--reps", "0"], + vec!["--reps", "26"], + vec!["--json"], + vec!["--reps"], + vec!["--unknown"], + ] { + assert!(parse(&args).is_err(), "{args:?}"); + } + } } diff --git a/docs/scorecard.md b/docs/scorecard.md index c4b53a20f8..b9902151c8 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -6,7 +6,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| -| [Performance](#performance) | 3 | 22 | 24 | 58 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 19 not run, 5 not measurable yet (baseline 2026-10-05) | +| [Performance](#performance) | 3 | 22 | 25 | 59 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 20 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 25 of 169 (target 0) | @@ -24,6 +24,7 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr | Id | Scenario | Target | p50 ms | p95 ms | max ms | Peak RSS | GPU | Status | Issue | |---|---|---|---:|---:|---:|---:|---:|---|---| +| P62 | Photomerge multi-band blend, synthetic | track timing and peak RSS; no calibrated performance budget | – | – | – | – | – | not run | [#7758](https://github.com/storytold/photocraft/issues/7758) | | P1 | Move a layer with layer styles (per drag update) | ≤ 25 ms p95 | 321 | 535 | 535 | 825 MB | 409 MB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) | | P2 | Change layer opacity | ≤ 20 ms p95 | 276 | 443 | 443 | 639 MB | 409 MB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) | | P3 | Change layer fill | ≤ 20 ms p95 | 279 | 467 | 467 | 506 MB | 409 MB | over budget (target not met yet) | [#209](https://github.com/storytold/photocraft/issues/209) | diff --git a/perf/budgets.toml b/perf/budgets.toml index da47aa0503..86ab5a7b4f 100644 --- a/perf/budgets.toml +++ b/perf/budgets.toml @@ -69,6 +69,25 @@ requires = "crates/ui-egui/examples/layout_bench.rs" quick_args = [] note = "layout PSDs with many styled layers (#125/#128)" +# Photomerge (#7758): the bounded-band algorithm must remain fast and its process-level +# resident-set high-water mark is tracked (on Linux and Windows). No uncalibrated budget is +# enforced. Full mode uses 4 x 2 MP so ordinary CI machines do not allocate multi-GB blends; +# use the stand-alone example for the 4 x 24 MP stress test. +[[bench]] +name = "bench_multiband" +package = "photocraft-algo" +args = ["4", "2", "--reps", "5"] +quick_args = ["2", "0.25", "--reps", "3"] +note = "synthetic Photomerge multi-band blend; resident-set peak includes input/output buffers" + +[[scenario]] +id = "P62" +title = "Photomerge multi-band blend, synthetic" +issue = 7758 +bench = "bench_multiband" +row = "Photomerge multiband blend" +target = "track timing and peak RSS; no calibrated performance budget" + # ---- #209: interactive edits on layered documents (20 MP, styled layers) --------------------- [[scenario]] From 037177abde9c58c77680247cc9272650c87ac42b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 20 Oct 2026 01:21:45 +0200 Subject: [PATCH 051/229] docs: regenerate the scorecard after merging main Co-Authored-By: Claude Sonnet 5.5 --- docs/scorecard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index c4b53a20f8..8bdea9ae72 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 24 | 58 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 19 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | -| [Tools](#tools) | 33 | 11 | 0 | | +| [Tools](#tools) | 34 | 10 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 25 of 169 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | @@ -160,7 +160,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | done | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool (Background Eraser, Clone/Pattern Stamp, Healing/Spot Healing Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Mixer Brush: their paint.* command along the path, one step) plus simulatePressure (taper both ends); Stroke Path dialog from the Pen menu, the Paths panel menu and Alt-click on its Stroke button (#2978) | | TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather | | TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | -| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing | +| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | done | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas window (vp_ui.rs: Create/Edit Plane, edge nodes, Ctrl/Cmd-drag tear-off planes with depth and Alt-drag angle, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane, Stamp tool cloning in perspective with live preview, all applied on OK as one step); the window's Marquee tool and the Stamp's Heal modes are still missing | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | | TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo | | TOOL-213-17 | Red Eye tool: click a pupil to neutralize the red reflection | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.redEye (redeye_cmds.rs) + Tool::RedEye in the J flyout; Pupil Size and Darken Amount; catchlights kept; a miss is an error | From c9cbe6a416a9dba53f8e8be1f79b1b82eeb9f402 Mon Sep 17 00:00:00 2001 From: ersoynarin-code Date: Tue, 20 Oct 2026 02:22:44 +0300 Subject: [PATCH 052/229] docs(photomerge): document benchmark helpers --- crates/algo/examples/bench_multiband.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/algo/examples/bench_multiband.rs b/crates/algo/examples/bench_multiband.rs index d3dcdc2f0b..064abeba51 100644 --- a/crates/algo/examples/bench_multiband.rs +++ b/crates/algo/examples/bench_multiband.rs @@ -19,6 +19,8 @@ struct Options { json: Option, } +/// Parses legacy `[n] [mp]` arguments and the perf runner's `--reps`/`--json` flags. +/// Rejects missing, out-of-range, or non-finite values before allocating images. fn options(args: impl IntoIterator) -> Result { let mut out = Options { n: 4, mp: 24.0, reps: 1, json: None }; let mut positional = Vec::new(); @@ -69,6 +71,8 @@ fn peak_rss_bytes() -> Option { None } +/// Builds deterministic overlapping RGB inputs and times each multi-band blend. +/// Writes individual samples and whole-process peak RSS to JSON when requested. fn run() -> Result<(), String> { let o = options(std::env::args().skip(1))?; let sh = ((o.mp * 1e6 / 1.5).sqrt().round() as usize).max(2); @@ -127,6 +131,7 @@ fn run() -> Result<(), String> { Ok(()) } +/// Runs the benchmark, reporting argument or output errors with a failing exit status. fn main() { if let Err(e) = run() { eprintln!("bench_multiband: {e}"); @@ -138,10 +143,12 @@ fn main() { mod tests { use super::*; + /// Converts borrowed argument slices for the owned-string command-line parser. fn parse(args: &[&str]) -> Result { options(args.iter().map(|s| s.to_string())) } + /// Confirms backwards compatibility with the positional and perf-runner forms. #[test] fn parses_old_positional_args_and_perf_runner_args() { assert_eq!(parse(&[]).unwrap().n, 4); @@ -151,6 +158,7 @@ mod tests { ); } + /// Checks invalid counts, non-finite sizes, repetitions, and missing flag values. #[test] fn rejects_bad_benchmark_args() { for args in [ From 4d3af32e0f85f1e3f6b370ea4a1a6ae809ae63a3 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 20 Oct 2026 01:35:56 +0200 Subject: [PATCH 053/229] fix(mask-view): turning one mask view off keeps the other mask's view --- crates/engine/src/mask_view_cmds.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/mask_view_cmds.rs b/crates/engine/src/mask_view_cmds.rs index 8414fa71c8..4312f92f6c 100644 --- a/crates/engine/src/mask_view_cmds.rs +++ b/crates/engine/src/mask_view_cmds.rs @@ -135,7 +135,7 @@ fn set_view(s: &mut Session, p: &Value) -> Result { let st = s.active_mut().ok_or(EngineError::NoDocument)?; let next = want.map(|mode| LayerMaskView { layer, mode, filter }); // "off" for a layer whose mask isn't the one shown leaves the other view alone. - let applies = next.is_some() || st.channel_view.layer_mask.is_some_and(|v| v.layer == layer); + let applies = next.is_some() || st.channel_view.layer_mask.is_some_and(|v| v.layer == layer && v.filter == filter); if applies && st.channel_view.layer_mask != next { st.channel_view.layer_mask = next; // Hiding the mask while every colour channel is off would leave a blank canvas. From 0741b128ff6c0c027681f7a5552e97aab1469467 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 20 Oct 2026 01:37:13 +0200 Subject: [PATCH 054/229] fix(vp): keep the Vanishing Point session when commit fails --- crates/ui-egui/src/vp_ui.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/vp_ui.rs b/crates/ui-egui/src/vp_ui.rs index 1a466e7bc4..c3dc645dd9 100644 --- a/crates/ui-egui/src/vp_ui.rs +++ b/crates/ui-egui/src/vp_ui.rs @@ -750,11 +750,18 @@ pub fn commit(app: &mut PhotocraftApp) -> Result { return Err(msg.into()); } let params = s.params(); - app.distort.vanishing = None; + let session = app.distort.vanishing.take(); if params.get("paste").is_none() && params.get("clone").is_none() { return Ok(json!({"committed": false})); } - app.run(VP, params).map(|r| json!({"committed": true, "result": r})) + match app.run(VP, params) { + Ok(r) => Ok(json!({"committed": true, "result": r})), + Err(e) => { + // Keep the planes, source and strokes so the user can fix the cause and retry. + app.distort.vanishing = session; + Err(e) + } + } } /// Control channel: `filter.vanishingPoint {"ui": {...}}` while the mode is active. From 38826b83691c3c176929825a0b586b7a55fce307 Mon Sep 17 00:00:00 2001 From: durtyrobbin <76844303+durtyrobbin@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:07:21 -0400 Subject: [PATCH 055/229] Keep mixer preview limits out of commands and discard stale prepared strokes --- Cargo.lock | 14 ++--- crates/engine/src/brush_cmds.rs | 2 +- crates/engine/src/brush_cmds/mixer_live.rs | 45 ++++++++++---- .../engine/src/brush_cmds/mixer_live_tests.rs | 59 +++++++++++++++++++ crates/engine/src/jobs.rs | 8 +++ crates/ui-egui/src/canvas.rs | 50 ++++++++++++---- docs/mixer-live.md | 16 +++-- 7 files changed, 159 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6b5a7bbf9b..33dcbfda3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6033,9 +6033,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "once_cell", @@ -6096,9 +6096,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -6950,7 +6950,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", "windows-sys 0.61.2", @@ -7905,9 +7905,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 6be1ef8428..725e246cab 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -1103,7 +1103,7 @@ pub fn specs() -> Vec { spec!( "paint.mixerBrush", "Mixer Brush", - r##"{"points":[…] (max 65536 samples, 2048px per segment),"brush":{…}?,"preset":name?,"size":0.5..1024 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke,"loadAfterStroke":bool=brush.mixer.reloadAfterStroke,"seed":u64?,"mixerState":{reservoir,amount,pickup,spatial} (optional captured replay state; spatial is null or 4096 RGBA colors)}"##, + r##"{"points":[…] (max 65536 samples; long segments are interpolated),"brush":{…}?,"preset":name?,"size":0.5..1024 px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=brush.mixer.cleanAfterStroke,"loadAfterStroke":bool=brush.mixer.reloadAfterStroke,"seed":u64?,"mixerState":{reservoir,amount,pickup,spatial} (optional captured replay state; spatial is null or 4096 RGBA colors)}"##, has_paintable, mixer_brush, true diff --git a/crates/engine/src/brush_cmds/mixer_live.rs b/crates/engine/src/brush_cmds/mixer_live.rs index 21bed7d414..ad17b58b23 100644 --- a/crates/engine/src/brush_cmds/mixer_live.rs +++ b/crates/engine/src/brush_cmds/mixer_live.rs @@ -33,6 +33,7 @@ struct Core { pre: Surface, work: Surface, tiles: BTreeSet<(i32, i32)>, + preview_limits: bool, state: MixerState, settings: MixerSettings, layer: Option, @@ -67,7 +68,7 @@ impl Core { for ty in r.y0.div_euclid(TILE)..=(r.y1 - 1).div_euclid(TILE) { for tx in r.x0.div_euclid(TILE)..=(r.x1 - 1).div_euclid(TILE) { if !self.tiles.contains(&(tx, ty)) { - if self.tiles.len() >= MAX_TILES { + if self.preview_limits && self.tiles.len() >= MAX_TILES { return Err(bad(CMD, "Mixer Brush stroke working set exceeds 256 tiles")); } let tile = Rect::new(tx * TILE, ty * TILE, (tx + 1) * TILE, (ty + 1) * TILE); @@ -129,6 +130,10 @@ pub struct LiveMixer { impl LiveMixer { pub fn begin(s: &Session, p: &Value) -> Result { + Self::begin_with_limits(s, p, true) + } + + fn begin_with_limits(s: &Session, p: &Value, preview_limits: bool) -> Result { has_paintable(s).map_err(EngineError::Other)?; if p.get("points").and_then(Value::as_array).is_some_and(|v| v.len() > 65_536) { return Err(bad(CMD, "stroke exceeds 65536 recorded input samples")); @@ -181,6 +186,7 @@ impl LiveMixer { pre, work, tiles: BTreeSet::new(), + preview_limits, state: state.clone(), settings, layer, @@ -221,21 +227,36 @@ impl LiveMixer { if self.points.len().saturating_add(points.len()) > 65_536 { return Err(bad(CMD, "stroke exceeds 65536 recorded input samples")); } - let mut last = self.core.last; - for point in points { - if last.is_some_and(|p| (point.x - p.x).hypot(point.y - p.y) > 2048.0) { - return Err(bad(CMD, "input segment exceeds 2048 pixels; supply interpolated input")); - } - last = Some(*point); - } let cmyk = self.cmyk.clone(); photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || { let old_tail = self.tail; let mut damage = Rect::EMPTY; for point in points { - let (mut dabs, mut dual) = (Vec::new(), Vec::new()); - self.core.generator.push(&[*point], &mut dabs, &mut dual); - damage = damage.union(&self.core.apply(&dabs)?); + // Bound each temporary dab batch without rejecting sparse mouse events or + // long Shift-click lines. Live and recorded replay use identical interpolation. + let start = self.core.last.unwrap_or(*point); + let steps = ((point.x - start.x).hypot(point.y - start.y) / 2048.0).ceil().max(1.0) as usize; + for step in 1..=steps { + let f = step as f64 / steps as f64; + let ff = f as f32; + let q = if step == steps { + *point + } else { + StrokePoint { + x: start.x + (point.x - start.x) * f, + y: start.y + (point.y - start.y) * f, + pressure: start.pressure + (point.pressure - start.pressure) * ff, + tilt_x: start.tilt_x + (point.tilt_x - start.tilt_x) * ff, + tilt_y: start.tilt_y + (point.tilt_y - start.tilt_y) * ff, + rotation: start.rotation + (point.rotation - start.rotation) * ff, + wheel: start.wheel + (point.wheel - start.wheel) * ff, + time: start.time + (point.time - start.time) * f, + } + }; + let (mut dabs, mut dual) = (Vec::new(), Vec::new()); + self.core.generator.push(&[q], &mut dabs, &mut dual); + damage = damage.union(&self.core.apply(&dabs)?); + } self.core.last = Some(*point); } let mut finished = self.core.clone(); @@ -283,7 +304,7 @@ pub(super) fn run(s: &mut Session, p: &Value) -> Result { }); let live = match cached { Some(live) => live, - None => LiveMixer::begin(s, p)?, + None => LiveMixer::begin_with_limits(s, p, false)?, }; let damage = live.bounds(); s.edit("Mixer Brush", |doc, _| { diff --git a/crates/engine/src/brush_cmds/mixer_live_tests.rs b/crates/engine/src/brush_cmds/mixer_live_tests.rs index bac3a97446..4a823a6c5c 100644 --- a/crates/engine/src/brush_cmds/mixer_live_tests.rs +++ b/crates/engine/src/brush_cmds/mixer_live_tests.rs @@ -1,6 +1,7 @@ use super::mixer_live::LiveMixer; use super::*; use photocraft_color::PixelFormat; +use std::sync::Arc; fn fixture(fmt: PixelFormat) -> Session { let mut s = Session::new(); @@ -240,3 +241,61 @@ fn mixer_large_tip_retains_off_canvas_paint_and_commits_the_live_result() { s.execute("edit.undo", json!({})).unwrap(); assert_eq!(*s.active().unwrap().doc, *source); } + +#[test] +fn mixer_long_segments_are_interpolated_and_replay_matches_live() { + let mut s = fixture(PixelFormat::RGBA8); + let p = json!({"points":[[20,48],[5020,48]],"seed":71,"size":2,"sampleAllLayers":false,"wet":0,"mix":0,"load":100,"color":"#ff0000"}); + let live = LiveMixer::begin(&s, &p).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let expected = live.doc.layer(id).unwrap().surface().unwrap().clone(); + s.execute("paint.mixerBrush", p).unwrap(); + for x in [20, 1020, 2068, 4090, 5019] { + assert_eq!(target(&s).rgba(x, 48), expected.rgba(x, 48)); + assert!(target(&s).rgba(x, 48)[3] > 0.0); + } +} + +#[test] +fn mixer_prepared_offer_is_dropped_on_every_early_dispatch_failure() { + for failure in 0..4 { + let mut s = fixture(PixelFormat::RGBA8); + let live = LiveMixer::begin(&s, &json!({"points":[[20,20]],"size":4})).unwrap(); + let p = live.prepare_commit(&mut s).unwrap(); + let before = s.active().unwrap().doc.clone(); + let result = match failure { + 0 => { + s.authorize = Some(|_, _| Err(EngineError::Other("denied".into()))); + s.execute("paint.mixerBrush", p) + } + 1 => s.execute("paint.mixerBrush", json!("invalid params")), + 2 => s.execute("unknown.command", p), + _ => { + s.active_mut().unwrap().active_layer = None; + s.execute("paint.mixerBrush", p) + } + }; + assert!(result.is_err()); + assert!(s.prepared_mixer.is_none(), "failed dispatch retained the preview: {failure}"); + assert!(Arc::ptr_eq(&s.active().unwrap().doc, &before)); + } +} + +#[test] +fn mixer_command_outgrows_the_preview_tile_budget_without_losing_the_stroke() { + let mut s = fixture(PixelFormat::RGBA8); + let points: Vec<_> = (0..257).map(|i| [128.0 + (i % 17) as f64 * 512.0, 128.0 + (i / 17) as f64 * 512.0]).collect(); + let p = json!({"points":points,"size":2,"brush":{"spacing":10.0,"smoothing":{"amount":0.0}},"seed":44, + "sampleAllLayers":false,"wet":0,"mix":0,"load":100,"color":"#ff0000"}); + let before = s.active().unwrap().doc.clone(); + let err = LiveMixer::begin(&s, &p).err().expect("bounded preview"); + assert!(err.to_string().contains("256 tiles"), "{err}"); + assert!(Arc::ptr_eq(&s.active().unwrap().doc, &before)); + s.execute("paint.mixerBrush", p).unwrap(); + // Spacing deliberately leaves gaps; inspect the final tile's stroke region instead + // of assuming the exact release pixel is a dab center. + let end = Rect::new(600, 7776, 672, 7840); + assert!(target(&s).read_region(end).chunks_exact(4).any(|px| px[3] > 0.0)); + s.execute("edit.undo", json!({})).unwrap(); + assert!(target(&s).read_region(end).chunks_exact(4).all(|px| px[3] == 0.0)); +} diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 0ee0573170..c9e2c90bbd 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -714,6 +714,9 @@ impl Session { /// Run the command `id`: inline, or (with `background`) as a job when it is one. pub(crate) fn dispatch(&mut self, id: &str, params: Value, background: bool) -> Result { + // The offer belongs to this one dispatch. Early errors drop it locally, including + // authorization, validation, floating-selection and admission failures. + let prepared_mixer = self.prepared_mixer.take(); let spec = crate::commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; // Params are named fields: anything but an object (or null, meaning none) is a caller // mistake, not "use every default". @@ -761,8 +764,12 @@ impl Session { // Last-resort guard (AGENTS.md, Never crash): a command that panics anyway fails with an // error instead of taking the app down. `edit` only commits a document after its closure // returns, so the documents are unchanged. + if id == "paint.mixerBrush" { + self.prepared_mixer = prepared_mixer; + } let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, &run_params))) .unwrap_or_else(|_| Err(EngineError::Other(format!("`{id}` failed with an internal error (logged); the document is unchanged")))); + self.prepared_mixer = None; if self.actions.recording.is_none() { self.actions.recording = recording; } @@ -907,6 +914,7 @@ impl Session { .unwrap_or_else(|_| Err(EngineError::Other(format!("`{label}` failed with an internal error (logged); the document is unchanged")))), None => Err(EngineError::Other("internal error: job applied twice".into())), }; + self.prepared_mixer = None; if self.actions.recording.is_none() { self.actions.recording = recording; } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 801c596b9e..d462baea8a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -491,6 +491,7 @@ impl EngineStroke { /// A stroke shown while it is drawn: the engine renders the real result onto a copy of the /// document, and the canvas redraws only what each step changed. pub(crate) struct LiveStroke { + preview_failed: bool, stroke: EngineStroke, doc: photocraft_doc::DocId, revision: u64, @@ -705,7 +706,8 @@ fn begin_live_stroke(app: &PhotocraftApp) -> Option { let st = app.session.active()?; let d = app.drag.as_ref()?; let stroke = if d.tool == Tool::MixerBrush { - let p = stroke_params(app, d, &app.stylus.stroke_points(&d.points)); + let points = app.stylus.stroke_points(&d.points); + let p = stroke_params(app, d, points.get(..1)?); EngineStroke::Mixer(Box::new(photocraft_engine::brush_cmds::mixer_live::LiveMixer::begin(&app.session, &p).ok()?)) } else if d.tool == Tool::ColorReplacement { // The params `retouch_ui::finish_stroke` commits. @@ -729,7 +731,15 @@ fn begin_live_stroke(app: &PhotocraftApp) -> Option { }; let n = STROKES.fetch_add(1, std::sync::atomic::Ordering::Relaxed) & 0xff_ffff; let damage = vec![stroke.bounds()]; - Some(LiveStroke { stroke, doc: st.doc.id, revision: st.revision, key: (1 << 44) | (n << 20), damage, fed: d.points.len() }) + Some(LiveStroke { + stroke, + doc: st.doc.id, + revision: st.revision, + key: (1 << 44) | (n << 20), + damage, + fed: if d.tool == Tool::MixerBrush { 1 } else { d.points.len() }, + preview_failed: false, + }) } /// Cancel uncommitted mixer paint when its document, tool or input context goes away. @@ -756,6 +766,9 @@ fn feed_live_stroke(app: &mut PhotocraftApp) { return; } let (Some(l), Some(d)) = (app.live_stroke.as_mut(), app.drag.as_ref()) else { return }; + if l.preview_failed { + return; + } let pose = &app.stylus.stroke; // The same times `stroke_points` gives the commit (from the drag's first point). let t0 = app.stylus.point_ms(0); @@ -777,12 +790,14 @@ fn feed_live_stroke(app: &mut PhotocraftApp) { match l.stroke.push(&pts) { Ok(r) => l.damage.push(r), Err(e) => { - let mixer = d.tool == Tool::MixerBrush; - app.live_stroke = None; - if mixer { - app.drag = None; + if d.tool == Tool::MixerBrush { + // Keep the last valid preview and its document/revision guard. All input + // stays in the drag; release replays it without the preview tile budget. + l.preview_failed = true; app.ui.status = e.to_string(); app.ui.status_error = true; + } else { + app.live_stroke = None; } } } @@ -5696,8 +5711,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } app.live_stroke = if strokes_live(tool) { begin_live_stroke(app) } else { None }; if tool == Tool::MixerBrush && app.live_stroke.is_none() { - // A refused mixer stroke must never fall back to a stand-in trail and paint - // unexpectedly on release. Surface the engine's concrete error immediately. + // The first dab itself is invalid. Do not queue an unexpected release edit. if let Some(d) = app.drag.as_ref() { let p = stroke_params(app, d, &app.stylus.stroke_points(&d.points)); if let Err(e) = photocraft_engine::brush_cmds::mixer_live::LiveMixer::begin(&app.session, &p) { @@ -5706,6 +5720,8 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } } app.drag = None; + } else if tool == Tool::MixerBrush { + feed_live_stroke(app); } } ToolEvent::Move { x, y, pressure } => { @@ -6025,6 +6041,13 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { if let Some(l) = live { let (doc, key, steps) = (l.doc, l.key, l.damage.len() as u64); if let EngineStroke::Mixer(mixer) = l.stroke { + if l.preview_failed { + let mut p = stroke_params(app, &d, &app.stylus.stroke_points(&d.points)); + p["seed"] = json!(mixer.seed); + drop(mixer); + let _ = app.run("paint.mixerBrush", p); + return; + } match mixer.prepare_commit(&mut app.session) { Ok(p) => { if app.run("paint.mixerBrush", p).is_ok() { @@ -7895,7 +7918,7 @@ mod tests { #[test] fn mixer_release_includes_a_new_endpoint_and_deferred_frame_samples() { - for deferred in [false, true] { + for (deferred, failed) in [(false, false), (true, false), (false, true)] { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); app.run("file.new", json!({"width":120,"height":60,"background":"#00ff00"})).unwrap(); app.run("layer.new.layer", json!({})).unwrap(); @@ -7905,6 +7928,9 @@ mod tests { let m = egui::Modifiers::NONE; tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 30.0, pressure: 1.0 }, m); app.defer_live_stroke = deferred; + if failed { + app.live_stroke.as_mut().unwrap().preview_failed = true; + } tool_event(&mut app, ToolEvent::Move { x: 50.0, y: 30.0, pressure: 1.0 }, m); tool_event(&mut app, ToolEvent::Up { x: 95.0, y: 30.0 }, m); let doc = app.session.active().unwrap().doc.clone(); @@ -7920,7 +7946,7 @@ mod tests { #[test] fn mixer_escape_focus_loss_and_tool_switch_cancel_without_an_edit() { - for mode in 0..3 { + for mode in 0..6 { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); app.run("file.new", json!({"width":120,"height":60,"background":"#00ff00"})).unwrap(); app.run("layer.new.layer", json!({})).unwrap(); @@ -7929,6 +7955,10 @@ mod tests { let steps = app.session.active().unwrap().history.past_len(); tool_event(&mut app, ToolEvent::Down { x: 30.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); assert!(app.live_stroke.is_some()); + if mode >= 3 { + app.live_stroke.as_mut().unwrap().preview_failed = true; + } + let mode = mode % 3; let ctx = egui::Context::default(); let mut raw = egui::RawInput { focused: mode != 1, ..Default::default() }; if mode == 0 { diff --git a/docs/mixer-live.md b/docs/mixer-live.md index 577c4b3e2b..53dbe1223d 100644 --- a/docs/mixer-live.md +++ b/docs/mixer-live.md @@ -81,11 +81,17 @@ the six paint-load control labels are translated in all 20 supported catalogs. ## Resource envelope and limitations -Live mixer size is limited to 1024 pixels; dense footprints to 4,194,304 pixels; -one input segment to 2,048 pixels; one stroke to 250,000 dabs and 256 working tiles -(256 x 256 pixels each). Requests beyond these limits return errors. A spatial load -uses 64 KiB of float color data. Document snapshots share untouched tiles. These -limits are explicit initial engineering bounds, not hardware qualification. +Mixer size is limited to 1024 pixels, dense footprints to 4,194,304 pixels, +and a stroke to 65,536 input samples and 250,000 dabs. Long input segments are +interpolated into batches of at most 2,048 pixels instead of being rejected. +The live preview alone is limited to 256 working tiles (256 x 256 pixels each). +If that preview budget is exhausted, the UI retains all stroke input and the +last valid preview, then replays the full stroke atomically on release. The +normal command is not subject to the preview tile budget. Escape, focus loss, +and document/tool changes still cancel the retained gesture. Other invalid +requests return errors without committing partial paint. A spatial load uses +64 KiB of float color data. Document snapshots share untouched tiles. These +bounds do not constitute hardware qualification. No fluid solver or grouped-bristle deformation model is introduced. Existing brush tip/dynamics support is reused. Windows pen tilt/rotation remain limited by the From a77d513d74576ce8bfedb50f34d64300d5620bb9 Mon Sep 17 00:00:00 2001 From: durtyrobbin <76844303+durtyrobbin@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:58:20 -0400 Subject: [PATCH 056/229] Preserve Mixer load on empty samples and retain Alt-right brush resizing --- crates/engine/src/brush_cmds/mixer_live.rs | 9 +++++---- .../engine/src/brush_cmds/mixer_live_tests.rs | 14 +++++++++++++ crates/ui-egui/src/brush_resize.rs | 20 +++++++++++++++++++ crates/ui-egui/src/canvas.rs | 2 +- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/brush_cmds/mixer_live.rs b/crates/engine/src/brush_cmds/mixer_live.rs index a8e19363cb..23cc0978d0 100644 --- a/crates/engine/src/brush_cmds/mixer_live.rs +++ b/crates/engine/src/brush_cmds/mixer_live.rs @@ -396,10 +396,11 @@ pub(super) fn sample(s: &mut Session, p: &Value) -> Result { } average[3] += c[3]; } - if average[3] > 0.0 { - for k in 0..3 { - average[k] /= average[3]; - } + if average[3] <= 0.0 { + return Err(bad("brush.mixer.sample", "the sampled area contains no paint; the previous brush load is unchanged")); + } + for k in 0..3 { + average[k] /= average[3]; } average[3] = 1.0; s.tools.mixer.load(average); diff --git a/crates/engine/src/brush_cmds/mixer_live_tests.rs b/crates/engine/src/brush_cmds/mixer_live_tests.rs index ec7f1a95d8..fad24b54bc 100644 --- a/crates/engine/src/brush_cmds/mixer_live_tests.rs +++ b/crates/engine/src/brush_cmds/mixer_live_tests.rs @@ -299,3 +299,17 @@ fn mixer_command_outgrows_the_preview_tile_budget_without_losing_the_stroke() { s.execute("edit.undo", json!({})).unwrap(); assert!(target(&s).read_region(end).chunks_exact(4).all(|px| px[3] == 0.0)); } + +#[test] +fn mixer_empty_samples_preserve_the_previous_load_and_document() { + let mut s = fixture(PixelFormat::RGBA8); + s.execute("brush.mixer.sample", json!({"points": [[96,48]], "size": 40})).unwrap(); + let state = s.tools.mixer.clone(); + let doc = s.active().unwrap().doc.clone(); + for params in [json!({"points": [[20,20]], "size": 8, "sampleAllLayers": false}), json!({"points": [[-100,-100]], "size": 8, "sampleAllLayers": true})] { + let error = s.execute("brush.mixer.sample", params).unwrap_err().to_string(); + assert!(error.contains("contains no paint"), "{error}"); + assert_eq!(s.tools.mixer, state); + assert!(Arc::ptr_eq(&s.active().unwrap().doc, &doc)); + } +} diff --git a/crates/ui-egui/src/brush_resize.rs b/crates/ui-egui/src/brush_resize.rs index 7d41aaa1ba..fe02230f42 100644 --- a/crates/ui-egui/src/brush_resize.rs +++ b/crates/ui-egui/src/brush_resize.rs @@ -384,6 +384,26 @@ mod tests { } } + #[test] + fn mixer_alt_right_drag_resizes_without_sampling_or_painting() { + let mut a = app(Tool::MixerBrush); + let before = a.session.tools.mixer.clone(); + a.sync_views(); + let mut h = canvas(a); + let (undo0, size0) = (history(h.state()), h.state().session.tools.brush.size); + let end = alt_right_drag(&mut h, vec2(10.0, 8.0)); + assert!(h.state().brush_resize.is_some_and(|resize| resize.secondary)); + assert!(h.state().session.tools.brush.size > size0); + button(&mut h, end, PointerButton::Secondary, false, Modifiers::ALT); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + let a = h.state(); + assert!(a.brush_resize.is_none() && !a.brush_resize_armed); + assert_eq!(a.session.tools.mixer, before); + assert_eq!(history(a), undo0); + assert!(!a.session.journal.iter().any(|(id, _)| id == "brush.mixer.sample" || id == "paint.mixerBrush")); + } + #[test] fn plain_right_click_still_opens_the_picker() { let mut a = app(Tool::Brush); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 16f75cbd72..2c567b94c0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5365,7 +5365,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) return; } // View › Snap / Snap To and smart guides (snap_ui.rs). - if app.ui.tool == Tool::MixerBrush && mods.alt && !mods.ctrl && app.drag.is_none() { + if app.ui.tool == Tool::MixerBrush && mods.alt && !mods.ctrl && app.drag.is_none() && !armed && app.brush_resize.is_none() { if let ToolEvent::Down { x, y, pressure } = ev { let pen = app.stylus.sample().unwrap_or_default(); let p = json!({"points":[[x,y,pressure,pen.tilt_x,pen.tilt_y,pen.rotation]],"target":paint_target(app)}); From b4c5f4da0eefec7ced90dbea6730f562cc9a18d3 Mon Sep 17 00:00:00 2001 From: durtyrobbin <76844303+durtyrobbin@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:08:08 -0400 Subject: [PATCH 057/229] Print: keep dry runs out of live printer discovery --- crates/engine/src/print_cmds.rs | 25 ++++++++++++++++++++----- crates/engine/src/print_cmds/tests.rs | 11 +++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 9b264f0611..5627b65945 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -191,6 +191,7 @@ fn cups_spool(job: &PrintJob) -> std::result::Result { /// `craft-print` (`lpstat`, the Windows spooler) where that is the route; over IPP for a named /// server and where there is no `lp` (a sandbox). fn system_printers(_s: &mut Session, p: &Value) -> Result { + require_live_printer_query(p)?; let named = p.get("printServer").and_then(Value::as_str).filter(|v| !v.is_empty()); if named.is_none() && system_spooler().is_some() { let list = craft_print::printers(); @@ -854,7 +855,18 @@ fn print_windows(page: &PrintPage, p: &Value, title: &str, copies: u32) -> Resul ) } -fn printers(_: &mut Session, _: &Value) -> Result { +// Driver discovery/settings are external I/O, even when no page is spooled. A dry run must +// not block on a disconnected printer or a print server (including in the command panic hunt). +fn require_live_printer_query(p: &Value) -> Result<()> { + if p.get("dryRun").and_then(Value::as_bool).unwrap_or(false) { + Err(other("printer queries are unavailable in a dry run; omit dryRun to query the live printer backend")) + } else { + Ok(()) + } +} + +fn printers(_: &mut Session, p: &Value) -> Result { + require_live_printer_query(p)?; match craft_print::rgb::printers() { Ok(list) => Ok(json!({"supported": true, "printers": list.iter().map(|p| json!({"name": p.name, "default": p.default})).collect::>()})), Err(craft_print::rgb::PrintError::Unsupported) => Ok(json!({"supported": false, "printers": []})), @@ -863,6 +875,7 @@ fn printers(_: &mut Session, _: &Value) -> Result { } fn printer_page(_: &mut Session, p: &Value) -> Result { + require_live_printer_query(p)?; // Checked before the printer, so a bad value is the error whether or not a printer answers. let landscape = match p.get("orientation").filter(|v| !v.is_null()) { None => None, @@ -920,7 +933,9 @@ fn print_one_copy(s: &mut Session, p: &Value) -> Result { let mut q = last_print(s).unwrap_or_else(|| json!({})); // A printer remembered from an earlier session may be gone: then the default takes the job, // as the Print dialog falls back (#7410). A list that can't be read decides nothing. - if let Ok(listed) = system_printers(s, &q) { + if !p.get("dryRun").and_then(Value::as_bool).unwrap_or(false) + && let Ok(listed) = system_printers(s, &q) + { let names: Vec<&str> = listed["printers"].as_array().into_iter().flatten().filter_map(|p| p["name"].as_str()).collect(); drop_vanished_printer(&mut q, &names); } @@ -1107,7 +1122,7 @@ pub fn specs() -> Vec { label: "Printers", menu: &[], shortcut: None, - params: r##"{} → {supported, printers: [{name, default}]} (supported: Windows; elsewhere Print spools with lp)"##, + params: r##"{"dryRun":bool=false} → {supported, printers: [{name, default}]} (supported: Windows; elsewhere Print spools with lp; dryRun refuses live device queries)"##, enabled: |_| Ok(()), journal: false, run: printers, @@ -1117,7 +1132,7 @@ pub fn specs() -> Vec { label: "Printer Page", menu: &[], shortcut: None, - params: r##"{"printer":name? (default printer),"printerSettings":hex?,"orientation":"portrait|landscape"? (turn the settings, or the printer's own, that way)} → {printer, paper: [w,h] pt, printable: [x,y,w,h] pt from the top-left, dpi: [x,y], printerSettings: hex (only with orientation: the turned settings)} (Windows)"##, + params: r##"{"printer":name? (default printer),"printerSettings":hex?,"orientation":"portrait|landscape"? (turn the settings, or the printer's own, that way),"dryRun":bool=false (refuse live device queries)} → {printer, paper: [w,h] pt, printable: [x,y,w,h] pt from the top-left, dpi: [x,y], printerSettings: hex (only with orientation: the turned settings)} (Windows)"##, enabled: |_| Ok(()), journal: false, run: printer_page, @@ -1127,7 +1142,7 @@ pub fn specs() -> Vec { label: "Printers", menu: &[], shortcut: None, - params: r##"{} → {printers:[{name, default}], default:name|null} (the system spooler's printers; a name is Print's "printer")"##, + params: r##"{"printServer":str?,"dryRun":bool=false} → {printers:[{name, default}], default:name|null} (the system spooler's printers; a name is Print's "printer"; dryRun refuses live device queries)"##, enabled: crate::commands::always, journal: false, run: system_printers, diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 38567ffb6d..38bd2d1286 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -869,3 +869,14 @@ fn windows_pdf_component_is_detected_by_loading_its_dll() { fn the_windows_pdf_check_is_a_no_op_elsewhere() { assert!(require_windows_pdf().is_ok()); } + +#[test] +fn printer_queries_refuse_dry_runs_before_contacting_a_backend() { + let mut s = Session::new(); + for command in ["file.printers", "file.print.printers", "file.print.page"] { + for params in [json!({"dryRun": true}), json!({"dryRun": true, "printServer": "127.0.0.1:1", "printer": "Synthetic unavailable printer"})] { + let error = s.execute(command, params).unwrap_err().to_string(); + assert!(error.contains("printer queries are unavailable in a dry run"), "{command}: {error}"); + } + } +} From 9457861bd236b1a985c676caae7abb0d7d00a20f Mon Sep 17 00:00:00 2001 From: durtyrobbin <76844303+durtyrobbin@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:40:41 -0400 Subject: [PATCH 058/229] Mixer Brush: cancel scripted releases after tool changes --- crates/ui-egui/src/canvas.rs | 40 ++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2c567b94c0..3903fdf8f2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -6032,6 +6032,12 @@ fn snap_aliased_tip(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> (f64, f6 } pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { + // Control can select a different tool and release before the next frame cancels the drag. + // Cancel before even recording a Shift-stroke anchor, including the preview fallback paths. + if d.tool == Tool::MixerBrush && app.ui.tool != Tool::MixerBrush { + app.live_stroke = None; + return; + } let mut end = d.points.last().copied().unwrap_or([d.start[0], d.start[1], 1.0]); let (sx, sy) = snap_aliased_tip(app, d.tool, end[0], end[1]); end[0] = sx; @@ -7961,6 +7967,40 @@ mod tests { } } + #[test] + fn mixer_control_tool_switch_before_release_cancels_every_preview_path() { + for preview in 0..3 { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width":120,"height":60,"background":"#00ff00"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("tools.setBrush", json!({"size":12,"pressureSize":false})).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let steps = app.session.active().unwrap().history.past_len(); + let anchor = app.last_stroke_end; + let ctx = egui::Context::default(); + let (request, _reply) = crate::control::ControlRequest::new( + "ui.pointer", + json!({"tool":"mixerBrush","events":[{"kind":"down","x":20,"y":30},{"kind":"move","x":50,"y":30}]}), + ); + let _ = crate::control::handle(&mut app, &ctx, &request); + assert!(app.drag.is_some() && app.live_stroke.is_some()); + if preview == 1 { + app.live_stroke.as_mut().unwrap().preview_failed = true; + } else if preview == 2 { + app.live_stroke = None; + } + // No intervening UI frame: control selects the new tool before dispatching Up. + let (request, _reply) = crate::control::ControlRequest::new("ui.pointer", json!({"tool":"brush","events":[{"kind":"up","x":95,"y":30}]})); + let _ = crate::control::handle(&mut app, &ctx, &request); + assert_eq!(app.ui.tool, Tool::Brush); + assert!(app.drag.is_none() && app.live_stroke.is_none()); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + assert_eq!(app.last_stroke_end, anchor); + assert!(!app.session.journal.iter().any(|(id, _)| id == "paint.mixerBrush")); + } + } + #[test] fn mixer_escape_focus_loss_and_tool_switch_cancel_without_an_edit() { for mode in 0..6 { From 1ff31fc544c3168e4b07ac7056514ea811f4aa9d Mon Sep 17 00:00:00 2001 From: durtyrobbin <76844303+durtyrobbin@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:50:19 -0400 Subject: [PATCH 059/229] docs: clarify live mixer contracts and qualification fixtures --- crates/engine/examples/mixer_probe.rs | 2 ++ crates/engine/examples/painting_baseline.rs | 3 +++ crates/engine/examples/painting_live.rs | 3 +++ crates/engine/src/brush_cmds/mixer_live.rs | 11 +++++++++++ crates/engine/src/brush_cmds/mixer_live_tests.rs | 3 +++ crates/engine/src/jobs_tests.rs | 1 + crates/engine/src/print_cmds.rs | 5 +++-- crates/paint/src/mixer_reference_tests.rs | 1 + crates/ui-egui/src/canvas.rs | 1 + 9 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/engine/examples/mixer_probe.rs b/crates/engine/examples/mixer_probe.rs index d39b297661..29b1dabb85 100644 --- a/crates/engine/examples/mixer_probe.rs +++ b/crates/engine/examples/mixer_probe.rs @@ -9,6 +9,8 @@ use photocraft_raster::Surface; use serde_json::json; use std::time::Instant; +/// Measure live updates and prepared commits on a synthetic 24 MP document against a frozen-source reference. +/// These CPU timings do not measure hardware input or display latency. fn main() -> Result<(), Box> { let mut results = Vec::new(); for size in [16.0, 64.0, 256.0] { diff --git a/crates/engine/examples/painting_baseline.rs b/crates/engine/examples/painting_baseline.rs index a31c5375e1..7884eb8a15 100644 --- a/crates/engine/examples/painting_baseline.rs +++ b/crates/engine/examples/painting_baseline.rs @@ -6,10 +6,12 @@ use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; use serde_json::json; use std::time::Instant; +/// Express monotonic elapsed time in the milliseconds used by the benchmark report. fn ms(start: Instant) -> f64 { start.elapsed().as_secs_f64() * 1000.0 } +/// Report nearest-rank percentiles and the sample count; an empty measurement set yields JSON null. fn stats(mut times: Vec) -> serde_json::Value { if times.is_empty() { return serde_json::Value::Null; @@ -22,6 +24,7 @@ fn stats(mut times: Vec) -> serde_json::Value { "p99_ms":times.get((len as f64 * 0.99).ceil() as usize - 1),"max_ms":times.last(),"samples":len}) } +/// Run one validated size, speed and paint-mode case, excluding fixture construction from the timing report. fn main() -> Result<(), Box> { let args: Vec<_> = std::env::args().collect(); let dim: i32 = args.get(1).ok_or("missing dimension")?.parse()?; diff --git a/crates/engine/examples/painting_live.rs b/crates/engine/examples/painting_live.rs index 2e94d0ac4f..79f3dc4cc3 100644 --- a/crates/engine/examples/painting_live.rs +++ b/crates/engine/examples/painting_live.rs @@ -9,10 +9,12 @@ use photocraft_paint::{BrushSettings, Stroke, StrokePoint}; use serde_json::json; use std::time::Instant; +/// Express monotonic elapsed time in the milliseconds used by the benchmark report. fn ms(start: Instant) -> f64 { start.elapsed().as_secs_f64() * 1000.0 } +/// Report nearest-rank percentiles and the sample count; an empty measurement set yields JSON null. fn stats(mut times: Vec) -> serde_json::Value { if times.is_empty() { return serde_json::Value::Null; @@ -25,6 +27,7 @@ fn stats(mut times: Vec) -> serde_json::Value { "p99_ms":times.get((len as f64 * 0.99).ceil() as usize - 1),"max_ms":times.last(),"samples":len}) } +/// Measure incremental updates and the final prepared commit separately on a deterministic stroke fixture. fn main() -> Result<(), Box> { let args: Vec<_> = std::env::args().collect(); let dim: i32 = args.get(1).ok_or("missing dimension")?.parse()?; diff --git a/crates/engine/src/brush_cmds/mixer_live.rs b/crates/engine/src/brush_cmds/mixer_live.rs index 23cc0978d0..8c8e1d642d 100644 --- a/crates/engine/src/brush_cmds/mixer_live.rs +++ b/crates/engine/src/brush_cmds/mixer_live.rs @@ -14,10 +14,12 @@ const CMD: &str = "paint.mixerBrush"; const MAX_TILES: usize = 256; const TILE: i32 = 256; +/// Accept bounded finite channels, including HDR values, without admitting unsafe arithmetic inputs. fn valid_color(color: &[f32; 4]) -> bool { color.iter().all(|v| v.is_finite() && v.abs() <= 1_000_000.0) } +/// Validate finite paint-load values and the exact 64 by 64 shape of a spatial reservoir. fn valid_state(state: &MixerState) -> bool { state.amount.is_finite() && state.reservoir.as_ref().is_none_or(valid_color) @@ -48,6 +50,8 @@ struct Core { } impl Core { + /// Apply a batch against evolving working pixels and return its damage bounds. + /// The dab and footprint limits always apply; the tile limit applies only to live previews. fn apply(&mut self, dabs: &[Dab]) -> Result { if self.count.saturating_add(dabs.len()) > 250_000 { return Err(bad(CMD, "stroke exceeds 250000 dabs; start another stroke")); @@ -130,10 +134,12 @@ pub struct LiveMixer { } impl LiveMixer { + /// Capture the current document and paint load, then render the initial samples with live-preview limits. pub fn begin(s: &Session, p: &Value) -> Result { Self::begin_with_limits(s, p, true) } + /// Share initialization between live previews and command replay while keeping their tile budgets distinct. fn begin_with_limits(s: &Session, p: &Value, preview_limits: bool) -> Result { has_paintable(s).map_err(EngineError::Other)?; if p.get("points").and_then(Value::as_array).is_some_and(|v| v.len() > 65_536) { @@ -216,9 +222,11 @@ impl LiveMixer { Ok(live) } + /// Return all damaged pixels, including the provisional smoothing tail currently shown by the preview. pub fn bounds(&self) -> Rect { self.core.bounds.union(&self.tail) } + /// Whether the dab generator needs stationary time samples, as for a continuing airbrush stroke. pub fn wants_time(&self) -> bool { self.core.generator.wants_time() } @@ -273,6 +281,7 @@ impl LiveMixer { }) } + /// Serialize recorded points, the original paint load, resolved brush and seed for deterministic replay. fn commit_params(&self) -> Result { let mut params = self.core.params.clone(); params["points"] = serde_json::to_value(&self.points).map_err(|e| bad(CMD, e.to_string()))?; @@ -299,6 +308,8 @@ impl LiveMixer { } } +/// Consume a matching prepared result or replay without the preview tile cap, then commit one history step. +/// The completed stroke also applies the automatic clean/load settings to the stored paint load. pub(super) fn run(s: &mut Session, p: &Value) -> Result { let cached = s.prepared_mixer.take().filter(|live| { s.active().is_some_and(|st| Arc::ptr_eq(&st.doc, &live.original) && (live.core.layer.is_none() || st.active_layer == live.core.layer)) diff --git a/crates/engine/src/brush_cmds/mixer_live_tests.rs b/crates/engine/src/brush_cmds/mixer_live_tests.rs index fad24b54bc..b86d1ef10b 100644 --- a/crates/engine/src/brush_cmds/mixer_live_tests.rs +++ b/crates/engine/src/brush_cmds/mixer_live_tests.rs @@ -3,6 +3,7 @@ use super::*; use photocraft_color::PixelFormat; use std::sync::Arc; +/// Build format-specific red/green sampling layers and an empty painting target. fn fixture(fmt: PixelFormat) -> Session { let mut s = Session::new(); s.execute("file.new", json!({"width":192,"height":96,"background":"#808080"})).unwrap(); @@ -24,6 +25,7 @@ fn fixture(fmt: PixelFormat) -> Session { s } +/// Snapshot the active raster target for exact pixel comparisons without borrowing the session. fn target(s: &Session) -> Surface { let st = s.active().unwrap(); st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().clone() @@ -242,6 +244,7 @@ fn mixer_large_tip_retains_off_canvas_paint_and_commits_the_live_result() { assert_eq!(*s.active().unwrap().doc, *source); } +/// Large pointer jumps must replay identically instead of turning a valid stroke into an input-limit error. #[test] fn mixer_long_segments_are_interpolated_and_replay_matches_live() { let mut s = fixture(PixelFormat::RGBA8); diff --git a/crates/engine/src/jobs_tests.rs b/crates/engine/src/jobs_tests.rs index 93f2faf841..8876445a6d 100644 --- a/crates/engine/src/jobs_tests.rs +++ b/crates/engine/src/jobs_tests.rs @@ -606,6 +606,7 @@ fn an_inline_job_ctx_lets_another_thread_cancel_inline_work() { s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap(); } +/// Exercise six terminal job routes so prepared paint snapshots cannot outlive a completed or discarded job. #[test] fn every_job_terminal_path_drops_a_prepared_mixer_stroke() { use crate::brush_cmds::mixer_live::LiveMixer; diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 5627b65945..5ea99d0014 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -855,8 +855,8 @@ fn print_windows(page: &PrintPage, p: &Value, title: &str, copies: u32) -> Resul ) } -// Driver discovery/settings are external I/O, even when no page is spooled. A dry run must -// not block on a disconnected printer or a print server (including in the command panic hunt). +/// Driver discovery/settings are external I/O, even when no page is spooled. A dry run must +/// not block on a disconnected printer or a print server (including in the command panic hunt). fn require_live_printer_query(p: &Value) -> Result<()> { if p.get("dryRun").and_then(Value::as_bool).unwrap_or(false) { Err(other("printer queries are unavailable in a dry run; omit dryRun to query the live printer backend")) @@ -865,6 +865,7 @@ fn require_live_printer_query(p: &Value) -> Result<()> { } } +/// List native RGB-print destinations only for a live query; dry runs must not contact printer drivers. fn printers(_: &mut Session, p: &Value) -> Result { require_live_printer_query(p)?; match craft_print::rgb::printers() { diff --git a/crates/paint/src/mixer_reference_tests.rs b/crates/paint/src/mixer_reference_tests.rs index cdd86e2a48..d15eba28d7 100644 --- a/crates/paint/src/mixer_reference_tests.rs +++ b/crates/paint/src/mixer_reference_tests.rs @@ -1,5 +1,6 @@ // Frozen pre-optimization dab kernel. Keep independent of the optimized implementation. use super::*; +/// Keep the allocation-heavy scalar kernel independent so optimizations cannot silently change pickup or blending. fn reference( work: &mut crate::retouch::Region, fp: &crate::retouch::Footprint, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3903fdf8f2..69c11f7c74 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -7967,6 +7967,7 @@ mod tests { } } + /// Reproduce a tool change and release in one control call, before a UI frame can cancel the old gesture. #[test] fn mixer_control_tool_switch_before_release_cancels_every_preview_path() { for preview in 0..3 { From 020784da621f7b0f8256db4573dbabaef9b302f6 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 21 Oct 2026 02:32:12 -0400 Subject: [PATCH 060/229] PSD: type on a path opens on its path and saves back into the file's Txt2 (#7962) Photoshop keeps a path-text layer's path only in its Txt2 frame (EngineData calls it a text box the size of the path's bounds), so it opened as paragraph text. The frame (kind /2 /0 = 2) holds the path as a flat list of cubic segments at /1 /0, mapped to the TySh text space by /2 /2. apply_txt2_path reads it into TextShape::Path (a closed path starting at its last anchor, as Photoshop sets it); malformed or hostile frames keep the box/point shape with an import warning. build_txt2 writes a path layer's path into its object's frame (Photoshop's own frame left byte for byte when the path is unchanged; flip and a moved start written into the path), and a layer taken off its path loses its path frame. A new document still saves path text as point text. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/psd_import.rs | 7 + crates/io/tests/text_corpus.rs | 91 ++++++ crates/text/src/lib.rs | 1 + crates/text/src/psd.rs | 94 ++++-- crates/text/src/psd_path.rs | 517 +++++++++++++++++++++++++++++++++ docs/gaps.md | 7 +- docs/scorecard.md | 2 +- scorecard/type.toml | 2 +- 8 files changed, 697 insertions(+), 24 deletions(-) create mode 100644 crates/text/src/psd_path.rs diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 0eca6d704f..02142e1dd5 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -368,10 +368,17 @@ impl Ctx<'_> { let (text, transform) = blocks::parse_tysh(&data).unwrap_or_default(); TextLayer { text, transform, ..Default::default() } }); + let mut path_error = None; if let Some(txt2) = &self.txt2 { photocraft_text::psd::apply_txt2(&mut t, &data, txt2); + // Type on a path keeps its path only in its `Txt2` frame (#7962). + path_error = photocraft_text::psd::apply_txt2_path(&mut t, &data, txt2).err(); photocraft_text::psd::apply_txt2_frame(&mut t, &data, txt2); } + if let Some(e) = path_error { + let shape = if matches!(t.shape, photocraft_doc::text::TextShape::Box { .. }) { "paragraph" } else { "point" }; + self.warn(format!("layer \"{name}\": its type path could not be read ({e}); it opens as {shape} text")); + } // Photoshop's pixels are the cache only when the file has some: ag-psd, GIMP and // other writers leave type layers without image data (Photoshop re-renders them on // open), and an empty cache would show nothing until the layer is edited; the import diff --git a/crates/io/tests/text_corpus.rs b/crates/io/tests/text_corpus.rs index 8597ac49d8..c2ea2b667e 100644 --- a/crates/io/tests/text_corpus.rs +++ b/crates/io/tests/text_corpus.rs @@ -169,6 +169,97 @@ fn corpus_photoshop_txt2_survives_an_unedited_save() { println!("Txt2 kept byte-for-byte in {checked} Photoshop type files"); } +/// #7962: Photoshop's type on a path (`Txt2` frames, EngineData calls it a text box) opens on its +/// path, at the anchors the generator gave Photoshop; the circle re-renders where Photoshop drew +/// it; and a save after an edit (new text, a moved path) reopens on the layer's path. +#[cfg(feature = "corpus")] +#[test] +fn corpus_photoshop_path_text_opens_on_its_path() { + use photocraft_doc::text::TextShape; + use photocraft_geom::Point; + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop/text"); + let k = 0.5522847498 * 96.0; + let cases: [(&str, bool, Vec<[f64; 2]>); 2] = [ + // Photoshop sets the type from the closed path's last anchor (the bottom). + ("path-circle.psd", true, vec![[128.0, 224.0], [32.0, 128.0], [128.0, 32.0], [224.0, 128.0]]), + ("path-wave-open.psd", false, vec![[16.0, 100.0], [160.0, 80.0], [304.0, 60.0]]), + ]; + let mut engine = photocraft_text::TextEngine::with_system_fonts(); + for (file, closed, anchors) in cases { + let f = root.join(file); + let bytes = std::fs::read(&f).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --photoshop`", f.display())); + let imp = photocraft_io::import(file, &bytes).unwrap(); + assert!(!imp.warnings.iter().any(|w| w.contains("type path")), "{file}: {:?}", imp.warnings); + let doc = imp.document; + let mut all = Vec::new(); + walk(&doc.layers, &mut all); + let layer = all.iter().find(|l| matches!(l.content, LayerContent::Text(_))).unwrap(); + let LayerContent::Text(t) = &layer.content else { unreachable!() }; + let doc_anchors = |t: &photocraft_doc::TextLayer| -> Vec { + let TextShape::Path { path, start, flip } = &t.shape else { panic!("{file}: not path text: {:?}", t.shape) }; + assert_eq!((path.closed, *start, *flip), (closed, 0.0, false), "{file}"); + path.knots.iter().map(|k| t.transform.apply(k.anchor)).collect() + }; + let got = doc_anchors(t); + assert_eq!(got.len(), anchors.len(), "{file}"); + for (g, [x, y]) in got.iter().zip(&anchors) { + assert!((g.x - x).abs() < 1e-3 && (g.y - y).abs() < 1e-3, "{file}: anchor {g:?}, want ({x}, {y})"); + } + if closed { + // The circle's handles are the generator's (kappa × r), so the path is the circle. + let TextShape::Path { path, .. } = &t.shape else { unreachable!() }; + let h = t.transform.apply(path.knots[0].out_ctrl); + assert!((h.x - (128.0 - k)).abs() < 1e-3 && (h.y - 224.0).abs() < 1e-3, "{file}: handle {h:?}"); + } + + // Photoshop's pixels (the wave's EngineData text is blank: Photoshop drew nothing). + if let Some(ps) = t.cache.as_ref().filter(|c| !c.content_bounds().is_empty()) { + let ps_rect = ps.content_bounds(); + let (_, ours) = engine.render(t, doc.resolution_dpi, doc.pixel_format()); + let our_rect = ours.surface.content_bounds(); + let u = Rect::new(ps_rect.x0.min(our_rect.x0), ps_rect.y0.min(our_rect.y0), ps_rect.x1.max(our_rect.x1), ps_rect.y1.max(our_rect.y1)); + let (a, b) = (alpha(ps, u), alpha(&ours.surface, u)); + let inter: f32 = a.iter().zip(&b).map(|(x, y)| x.min(*y)).sum(); + let union: f32 = a.iter().zip(&b).map(|(x, y)| x.max(*y)).sum(); + let iou = if union > 0.0 { inter / union } else { 1.0 }; + let edges = [ps_rect.x0 - our_rect.x0, ps_rect.y0 - our_rect.y0, ps_rect.x1 - our_rect.x1, ps_rect.y1 - our_rect.y1]; + println!("{file}: ps={ps_rect:?} ours={our_rect:?} iou={iou:.2} edges={edges:?}"); + assert!(edges.iter().all(|d| d.abs() <= 8), "{file}: ink bounds {our_rect:?}, Photoshop's {ps_rect:?}"); + assert!(iou > 0.2, "{file}: the glyphs sit where Photoshop drew them (IoU {iou:.2})"); + } + + // Edited: new text and the path moved 10 px right; saved and opened again. + let mut edited = doc.clone(); + let id = layer.id; + for l in edited.layers.iter_mut() { + if l.id != id { + continue; + } + let LayerContent::Text(t) = &mut l.content else { continue }; + t.text = format!("{} again", t.text); + if let Some(r) = t.runs.last_mut() { + r.len += " again".len(); + } + t.sync_summary(); + if let TextShape::Path { path, .. } = &mut t.shape { + for k in &mut path.knots { + *k = k.transform(&photocraft_geom::Affine::translate(10.0, 0.0)); + } + } + t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::build_tysh(t, edited.resolution_dpi, None))); + } + let out = photocraft_io::export(&edited, file, &Default::default()).unwrap().bytes; + let back = photocraft_io::import(file, &out).unwrap().document; + let mut all = Vec::new(); + walk(&back.layers, &mut all); + let LayerContent::Text(bt) = &all.iter().find(|l| matches!(l.content, LayerContent::Text(_))).unwrap().content else { unreachable!() }; + assert!(bt.text.ends_with(" again"), "{file}: {:?}", bt.text); + for (g, [x, y]) in doc_anchors(bt).iter().zip(&anchors) { + assert!((g.x - (x + 10.0)).abs() < 1e-3 && (g.y - y).abs() < 1e-3, "{file}: saved anchor {g:?}, want ({}, {y})", x + 10.0); + } + } +} + /// A text layer created in PhotoCraft (no PSD data) exports as an editable type layer and /// imports back with the same model. #[test] diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index af4de19f52..48c710b654 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -24,6 +24,7 @@ pub mod glyphs; pub mod layout; pub mod optical; pub mod psd; +mod psd_path; pub mod psd_styles; pub mod raster; pub mod render; diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 99fb2ebf0f..e1b8d8df9f 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -214,12 +214,59 @@ pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { layer.sync_summary(); } -/// The frame (`Txt2 /0 /8 /0` entry) of text object `index`: its rendered frame's `/10` names it. -fn txt2_frame(txt2: &E, index: usize) -> Option<&E> { +/// The frame (`Txt2 /0 /8 /0` entry) of text object `index`: its rendered frame's `/10` names it +/// (with `named`, else the frame its view names; see [`object_frame`]). +fn txt2_frame(txt2: &E, index: usize, named: bool) -> Option<&E> { let object = txt2.path(&["1", "1"]).and_then(E::as_array)?.get(index)?; - let frame = - object.path(&["1", "2"]).and_then(E::as_array)?.first()?.get("6").and_then(E::as_array)?.iter().find_map(|f| f.get("10").and_then(E::as_i64))?; - txt2.path(&["0", "8", "0"]).and_then(E::as_array)?.get(usize::try_from(frame).ok()?)?.get("0") + txt2.path(&["0", "8", "0"]).and_then(E::as_array)?.get(object_frame(object, named)?)?.get("0") +} + +/// The number of the frame the text object `object` is drawn in: its rendered frame's `/10`, else +/// (with `named`) the frame its view names (`/1 /0 [ << /0 n >> ]`). Photoshop leaves the rendered +/// strikes out for type it did not lay out (`text/path-wave-open.psd`, whose EngineData text is +/// blank). +fn object_frame(object: &E, named: bool) -> Option { + let rendered = object + .path(&["1", "2"]) + .and_then(E::as_array) + .and_then(|s| s.first()) + .and_then(|s| s.get("6")) + .and_then(E::as_array) + .and_then(|parts| parts.iter().find_map(|f| f.get("10").and_then(E::as_i64))); + let view = || named.then(|| object.path(&["1", "0"]).and_then(E::as_array).and_then(|f| f.first()).and_then(|f| f.get("0")).and_then(E::as_i64)).flatten(); + usize::try_from(rendered.or_else(view)?).ok() +} + +/// The `TySh` of `tysh` and the `Txt2` frame of its text object (`named`: see [`object_frame`]), +/// when that object still holds `layer`'s text (an edit elsewhere leaves `Txt2` stale). +fn layer_frame<'a>(layer: &TextLayer, tysh: &[u8], txt2: &'a E, named: bool) -> Option<(TySh, &'a E)> { + let t = parse_tysh(tysh)?; + let index = match t.text.get("TextIndex") { + Some(D::Integer(i)) => usize::try_from(*i).ok()?, + _ => return None, + }; + let text = txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(index)).and_then(|o| o.path(&["0", "0"])).and_then(E::as_str)?; + if text.strip_suffix('\r').unwrap_or(text).replace('\r', "\n") != layer.text { + return None; + } + let frame = txt2_frame(txt2, index, named)?; + Some((t, frame)) +} + +/// Puts `layer` on its path when its `Txt2` frame is type on a path (#7962): Photoshop keeps the +/// path only there, and EngineData describes the layer as a text box the size of the path's +/// bounds. The path is read into the `TySh` text space, the type starting where Photoshop starts +/// it (see `psd_path`). `Ok(true)` when the layer is now path text, `Ok(false)` when its frame is +/// no path (or `Txt2` holds another text for it), `Err` with the reason when the path can't be +/// read: the layer then stays the box or point text EngineData describes. +pub fn apply_txt2_path(layer: &mut TextLayer, tysh: &[u8], txt2: &E) -> Result { + let Some((_, frame)) = layer_frame(layer, tysh, txt2, true) else { return Ok(false) }; + if crate::psd_path::frame_kind(frame) != crate::psd_path::PATH_FRAME { + return Ok(false); + } + let path = crate::psd_path::read(frame)?; + layer.shape = TextShape::Path { path, start: 0.0, flip: false }; + Ok(true) } /// Places `layer` where Photoshop draws it when the text object's frame in `Txt2` is moved @@ -231,18 +278,11 @@ fn txt2_frame(txt2: &E, index: usize) -> Option<&E> { /// alignment anchor. Only when the layer's `TextIndex` entry still holds the layer's text, as /// for [`apply_txt2`], and the frame agrees with the `TySh` `bounds`. pub fn apply_txt2_frame(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { - let Some(t) = parse_tysh(tysh) else { return }; - let Some(index) = (match t.text.get("TextIndex") { - Some(D::Integer(i)) => usize::try_from(*i).ok(), - _ => None, - }) else { - return; - }; - let text = txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(index)).and_then(|o| o.path(&["0", "0"])).and_then(E::as_str); - if text.is_none_or(|t| t.strip_suffix('\r').unwrap_or(t).replace('\r', "\n") != layer.text) { + // Type on a path: [`apply_txt2_path`] already read the path through the frame's matrix. + if matches!(layer.shape, TextShape::Path { .. }) { return; } - let Some(frame) = txt2_frame(txt2, index) else { return }; + let Some((t, frame)) = layer_frame(layer, tysh, txt2, false) else { return }; let m = arr_f(frame.path(&["2", "2"])); let matrix = if m.len() == 6 && m.iter().all(|v| v.is_finite()) { Affine { m: [m[0], m[1], m[2], m[3], m[4], m[5]] } } else { Affine::IDENTITY }; let at = match layer.shape { @@ -1111,6 +1151,9 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi let all_stubs = prev_objects.iter().all(is_stub); // A template is copied once per new layer, so a large one is skipped (the stub is written). let template = prev_objects.iter().rev().find(|o| names_one_frame(o) && fits(o, &mut { TEMPLATE_NODES }) && edited_object(o, "\r").is_some()); + // The frame each written object is drawn in, with its layer's shape (type on a path lives + // only in the frame, #7962). + let mut frame_shapes: Vec<(usize, &TextShape)> = Vec::new(); for (index, layer) in objects { if !(0..=MAX_TEXT_INDEX).contains(index) { continue; @@ -1162,6 +1205,9 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi _ => model.set("6", E::Dict(vec![("0".into(), E::Array(runs))])), } object.set("0", model); + if let Some(frame) = object_frame(&object, true) { + frame_shapes.push((frame, &layer.shape)); + } if slots.len() <= at { slots.resize(at + 1, E::dict()); } @@ -1173,6 +1219,15 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi // `/1` whatever it holds beside the text objects. let mut outer = prev.clone().unwrap_or_else(|| E::Dict(vec![("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), ("0".into(), E::dict())])); pad_frames(&mut outer, slots.len()); + // A path layer's frame holds its path; a layer taken off its path loses its path frame. An + // unchanged path leaves the file's frame as it was. + if let Some(E::Array(frames)) = outer.get_mut("0").and_then(|v| v.get_mut("8")).and_then(|v| v.get_mut("0")) { + for (at, shape) in frame_shapes { + if let Some(entry) = frames.get_mut(at) { + crate::psd_path::update_frame(entry, shape); + } + } + } if fonts.len() != fonts_before && let Some(list @ E::Dict(_)) = outer.get_mut("0").and_then(|v| v.get_mut("1")) { @@ -1260,10 +1315,7 @@ fn pad_frames(outer: &mut E, count: usize) { let Some(E::Array(frames)) = outer.get_mut("0").and_then(|v| v.get_mut("8")).and_then(|v| v.get_mut("0")) else { return }; let count = count.min(MAX_TEXT_INDEX as usize + 1); if frames.len() < count { - let kind = E::Dict(vec![("4".into(), E::Int(-1)), ("7".into(), E::Bool(false))]); - let data = E::Dict(vec![("6".into(), E::Array(vec![E::Real(-1.0), E::Real(-1.0)])), ("11".into(), kind)]); - let frame = E::Dict(vec![("0".into(), E::Dict(vec![("2".into(), data)]))]); - frames.resize(count, frame); + frames.resize(count, crate::psd_path::point_frame()); } } @@ -1512,7 +1564,9 @@ pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E dict.set("UseFractionalGlyphWidths", E::Bool(true)); } let (shape_type, box_bounds) = match layer.shape { - // PSD path type is not written yet: the layer saves as point text. + // Type on a path: the path goes into the layer's `Txt2` frame ([`build_txt2`], #7962), + // which only a file that already has a `Txt2` object for the layer gets; EngineData + // keeps it as point text, one line for readers (and files) without that frame. TextShape::Point | TextShape::Path { .. } => (0, None), TextShape::Box { x, y, width, height } => (1, Some([x, y, x + width, y + height])), }; diff --git a/crates/text/src/psd_path.rs b/crates/text/src/psd_path.rs new file mode 100644 index 0000000000..f01807d21e --- /dev/null +++ b/crates/text/src/psd_path.rs @@ -0,0 +1,517 @@ +//! Type on a path in a PSD (#7962): the path lives only in the type layer's frame in the +//! document's `Txt2` block (EngineData marks such a layer as box text, `ShapeType 1`, with the +//! path's bounds as its `BoxBounds`). +//! +//! Observed layout (Photoshop 2026, `text/path-circle.psd` and `text/path-wave-open.psd` in +//! photocraft-corpus): a frame `<< /0 << /1 << /0 [ … ] >> /2 << /0 2 /2 [ matrix ] … >> >> >>` +//! has the kind `/2 /0` (absent for point text, 1 for a text box, 2 for type on a path) and its +//! outline at `/1 /0` as a flat list of cubic Bézier segments, eight numbers each +//! (`p0 c1 c2 p1`, the next segment starting where the previous one ends, a closed path ending +//! where it started), in the frame's space; `/2 /2` maps the frame's space to the `TySh` text +//! space. On a closed path Photoshop sets the type from the path's last anchor (the circle's +//! glyph 0 sits on its fourth anchor and runs on through the first), on an open path from its +//! first. Neither oracle has a moved start or flipped type, so those are written into the path +//! itself (see [`frame_path`]). + +use photocraft_doc::text::TextShape; +use photocraft_doc::vector::{Knot, PathOp, Subpath}; +use photocraft_geom::{Affine, Point}; + +use crate::engine_data::Value as E; + +/// The `Txt2` frame kind (`/2 /0`) of type on a path. +pub(crate) const PATH_FRAME: i64 = 2; +/// The `Txt2` frame kind (`/2 /0`) of a text box. +const BOX_FRAME: i64 = 1; +/// Most cubic segments read from a frame (a hostile file cannot make this large). +const MAX_SEGMENTS: usize = 4096; +/// How close (px) two points must be to count as the same: Photoshop writes a segment's start as +/// an exact copy of the previous segment's end. +const SAME: f64 = 1e-3; + +/// The kind of the frame dictionary `frame` (the entry's `/0`): 0 point text, 1 box, 2 path. +pub(crate) fn frame_kind(frame: &E) -> i64 { + frame.path(&["2", "0"]).and_then(E::as_i64).unwrap_or(0) +} + +/// Every entry of `v` as a finite number, or `None`. +fn numbers(v: &E) -> Option> { + v.as_array()?.iter().map(E::as_f64).collect() +} + +fn same(a: Point, b: Point) -> bool { + (a.x - b.x).hypot(a.y - b.y) <= SAME +} + +/// Whether the handles `a` (in) and `b` (out) of `anchor` are collinear on opposite sides. +fn smooth(anchor: Point, a: Point, b: Point) -> bool { + let (ax, ay, bx, by) = (a.x - anchor.x, a.y - anchor.y, b.x - anchor.x, b.y - anchor.y); + let (la, lb) = (ax.hypot(ay), bx.hypot(by)); + la > SAME && lb > SAME && (ax * by - ay * bx).abs() <= 1e-3 * la * lb && ax * bx + ay * by < 0.0 +} + +fn knot(in_ctrl: Point, anchor: Point, out_ctrl: Point) -> Knot { + Knot { anchor, in_ctrl, out_ctrl, smooth: smooth(anchor, in_ctrl, out_ctrl) } +} + +/// The type path of a path frame `frame` (the entry's `/0`), in the `TySh` text space, with the +/// type starting at its first knot. `Err` says why it can't be read (the caller keeps the box or +/// point text EngineData describes). +pub(crate) fn read(frame: &E) -> Result { + let raw = frame.path(&["1", "0"]).ok_or("the frame has no path")?; + let v = numbers(raw).ok_or("a path coordinate is not a number")?; + if v.is_empty() || v.len() % 8 != 0 { + return Err(format!("its path has {} coordinates, not whole curve segments", v.len())); + } + if v.len() / 8 > MAX_SEGMENTS { + return Err(format!("its path has more than {MAX_SEGMENTS} segments")); + } + let matrix = match frame.path(&["2", "2"]) { + None => Affine::IDENTITY, + Some(m) => { + let Some([a, b, c, d, e, f]) = numbers(m).and_then(|m| <[f64; 6]>::try_from(m).ok()) else { + return Err("its frame matrix is not six numbers".into()); + }; + let m = Affine { m: [a, b, c, d, e, f] }; + let det = m.determinant(); + if !det.is_finite() || det.abs() <= 1e-12 { + return Err("its frame matrix is degenerate".into()); + } + m + } + }; + let pts: Vec = v.as_chunks::<2>().0.iter().map(|&[x, y]| matrix.apply(Point::new(x, y))).collect(); + if pts.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { + return Err("its path is out of range".into()); + } + let segs: Vec<[Point; 4]> = pts.as_chunks::<4>().0.to_vec(); + if segs.windows(2).any(|w| matches!(w, [a, b] if !same(a[3], b[0]))) { + return Err("its path has more than one piece".into()); + } + let (Some(first), Some(last)) = (segs.first(), segs.last()) else { return Err("the frame has no path".into()) }; + let (x0, x1) = pts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.x), hi.max(p.x))); + let (y0, y1) = pts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.y), hi.max(p.y))); + if x1 - x0 <= SAME && y1 - y0 <= SAME { + return Err("its path has no length".into()); + } + let closed = same(last[3], first[0]); + let mut knots: Vec = Vec::with_capacity(segs.len() + 1); + for (i, s) in segs.iter().enumerate() { + let prev = match i.checked_sub(1).and_then(|j| segs.get(j)) { + Some(p) => p[2], + None if closed => last[2], + None => s[0], + }; + knots.push(knot(prev, s[0], s[1])); + } + if closed { + // The type starts on the closing segment: from the last anchor. + knots.rotate_right(1); + } else { + knots.push(knot(last[2], last[3], last[3])); + } + Ok(Subpath { closed, knots, op: PathOp::Combine }) +} + +/// `path` with its knots in the opposite order (the segments run backwards). +fn reversed(path: &Subpath) -> Subpath { + let mut knots: Vec = path.knots.iter().rev().map(|k| Knot { anchor: k.anchor, in_ctrl: k.out_ctrl, out_ctrl: k.in_ctrl, smooth: k.smooth }).collect(); + if path.closed { + // Keep the first knot first: k0, k(n-1), …, k1. + knots.rotate_right(1); + } + Subpath { closed: path.closed, knots, op: path.op } +} + +fn lerp(a: Point, b: Point, t: f64) -> Point { + Point::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t) +} + +fn cubic(s: &[Point; 4], t: f64) -> Point { + let (a, b, c) = (lerp(s[0], s[1], t), lerp(s[1], s[2], t), lerp(s[2], s[3], t)); + lerp(lerp(a, b, t), lerp(b, c, t), t) +} + +/// Pieces each segment is measured in for [`starting_at`]. +const STEPS: usize = 64; + +/// `path` starting `s` px along it (the type origin moved there): an open path loses the part +/// before `s`, a closed one starts at a new smooth knot there. Unchanged for `s` at or before the +/// start, or (open) at or past the end. +fn starting_at(path: &Subpath, s: f64) -> Subpath { + let segs = path.segments(); + let lengths: Vec = segs + .iter() + .map(|seg| { + (1..=STEPS) + .map(|i| (cubic(seg, (i - 1) as f64 / STEPS as f64), cubic(seg, i as f64 / STEPS as f64))) + .map(|(a, b)| (b.x - a.x).hypot(b.y - a.y)) + .sum() + }) + .collect(); + let total: f64 = lengths.iter().sum(); + if !s.is_finite() || !total.is_finite() || total <= SAME { + return path.clone(); + } + let s = if path.closed { s.rem_euclid(total) } else { s }; + if s <= SAME || (!path.closed && s >= total - SAME) || (path.closed && s >= total - SAME) { + return path.clone(); + } + // The segment and parameter at arc length `s` (linear within a measured piece). + let mut left = s; + let mut at = None; + for (i, (seg, len)) in segs.iter().zip(&lengths).enumerate() { + if left > *len { + left -= len; + continue; + } + let mut walked = 0.0; + for k in 1..=STEPS { + let (t0, t1) = ((k - 1) as f64 / STEPS as f64, k as f64 / STEPS as f64); + let (a, b) = (cubic(seg, t0), cubic(seg, t1)); + let piece = (b.x - a.x).hypot(b.y - a.y); + if walked + piece >= left || k == STEPS { + let f = if piece > 0.0 { ((left - walked) / piece).clamp(0.0, 1.0) } else { 0.0 }; + at = Some((i, t0 + (t1 - t0) * f, *seg)); + break; + } + walked += piece; + } + break; + } + let Some((i, t, seg)) = at else { return path.clone() }; + // de Casteljau: left half p0 a ab m, right half m bc c p3. + let (a, b, c) = (lerp(seg[0], seg[1], t), lerp(seg[1], seg[2], t), lerp(seg[2], seg[3], t)); + let (ab, bc) = (lerp(a, b, t), lerp(b, c, t)); + let m = lerp(ab, bc, t); + let n = path.knots.len(); + if n == 0 { + return path.clone(); + } + let next = (i + 1) % n; + let mut knots = Vec::with_capacity(n + 1); + knots.push(Knot { anchor: m, in_ctrl: if path.closed { ab } else { m }, out_ctrl: bc, smooth: path.closed }); + let count = if path.closed { n } else { n - next }; + for r in 0..count { + let j = (next + r) % n; + let Some(mut k) = path.knots.get(j).copied() else { continue }; + if j == next { + k.in_ctrl = c; + } + if path.closed && j == i { + k.out_ctrl = a; + } + knots.push(k); + } + Subpath { closed: path.closed, knots, op: path.op } +} + +/// The path a frame is written with for `TextShape::Path { path, start, flip }`: flipped type is +/// the path run backwards and a moved start the path from there on (exactly how PhotoCraft lays +/// type on a path out), so Photoshop, which starts at the path's beginning, sets the same type. +pub(crate) fn frame_path(path: &Subpath, start: f32, flip: bool) -> Subpath { + let path = if flip { reversed(path) } else { path.clone() }; + starting_at(&path, f64::from(start)) +} + +fn real(v: f64) -> E { + E::Real(v) +} + +/// The segment list (`/1 /0`) Photoshop reads `path` from: on a closed path the type starts at +/// the last anchor, so the first knot goes last. +fn segment_list(path: &Subpath) -> Option> { + let mut knots = path.knots.clone(); + if path.closed && !knots.is_empty() { + knots.rotate_left(1); + } + let segs = Subpath { closed: path.closed, knots, op: path.op }.segments(); + let out: Vec = segs.iter().flatten().flat_map(|p| [real(p.x), real(p.y)]).collect(); + (!out.is_empty() && segs.iter().flatten().all(|p| p.x.is_finite() && p.y.is_finite())).then_some(out) +} + +/// Whether two type paths are the same within [`SAME`] (the smooth flags aside). +fn same_path(a: &Subpath, b: &Subpath) -> bool { + a.closed == b.closed + && a.knots.len() == b.knots.len() + && a.knots.iter().zip(&b.knots).all(|(p, q)| same(p.anchor, q.anchor) && same(p.in_ctrl, q.in_ctrl) && same(p.out_ctrl, q.out_ctrl)) +} + +/// The point-text frame Photoshop writes (`<< /0 << /2 << /6 [ -1.0 -1.0 ] /11 << /4 -1 /7 false +/// >> >> >> >>`; also what [`crate::psd`] pads the frame list with). +pub(crate) fn point_frame() -> E { + let kind = E::Dict(vec![("4".into(), E::Int(-1)), ("7".into(), E::Bool(false))]); + let data = E::Dict(vec![("6".into(), E::Array(vec![real(-1.0), real(-1.0)])), ("11".into(), kind)]); + E::Dict(vec![("0".into(), E::Dict(vec![("2".into(), data)]))]) +} + +/// A text box's frame as Photoshop writes it (`text/paragraph-box-left.psd`): the rectangle as +/// four straight segments from its top left, clockwise. +fn box_frame(x: f64, y: f64, w: f64, h: f64) -> E { + let c = [(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)]; + let pts: Vec = + c.windows(2).flat_map(|p| p.iter().flat_map(|&(a, b)| [(a, b), (a, b)]).collect::>()).flat_map(|(a, b)| [real(a), real(b)]).collect(); + let kind = E::Dict(vec![("4".into(), E::Int(-2)), ("18".into(), real(-2.0)), ("22".into(), real(0.025))]); + let data = E::Dict(vec![("0".into(), E::Int(BOX_FRAME)), ("6".into(), E::Array(vec![real(-2.0), real(-2.0)])), ("11".into(), kind)]); + E::Dict(vec![("0".into(), E::Dict(vec![("1".into(), E::Dict(vec![("0".into(), E::Array(pts))])), ("2".into(), data)]))]) +} + +/// Brings the frame list entry `entry` (`<< /0 frame >>`) of a type layer up to date with its +/// `shape`: type on a path gets a path frame for its path (left byte for byte when it already +/// holds that path, e.g. Photoshop's own after an unedited save), and a layer no longer on a path +/// loses its path frame (else it would reopen on the path). +pub(crate) fn update_frame(entry: &mut E, shape: &TextShape) { + let frame = entry.get("0"); + let kind = frame.map_or(0, frame_kind); + match shape { + TextShape::Path { path, start, flip } => { + let path = frame_path(path, *start, *flip); + if kind == PATH_FRAME && frame.and_then(|f| read(f).ok()).is_some_and(|p| same_path(&p, &path)) { + return; + } + let Some(list) = segment_list(&path) else { return }; + // Keep the other keys of a path frame; anything else becomes one. + let mut data = match frame.and_then(|f| f.get("2")) { + Some(d @ E::Dict(_)) if kind == PATH_FRAME => d.clone(), + _ => E::Dict(vec![("6".into(), E::Array(vec![real(-1.0), real(-1.0)]))]), + }; + data.set("0", E::Int(PATH_FRAME)); + // The path is written in text space. + data.set("2", E::Array([1.0, 0.0, 0.0, 1.0, 0.0, 0.0].map(real).to_vec())); + let mut frame = match frame { + Some(f @ E::Dict(_)) if kind == PATH_FRAME => f.clone(), + _ => E::dict(), + }; + frame.set("1", E::Dict(vec![("0".into(), E::Array(list))])); + frame.set("2", data); + *entry = E::Dict(vec![("0".into(), frame)]); + } + _ if kind != PATH_FRAME => {} + TextShape::Box { x, y, width, height } => *entry = box_frame(f64::from(*x), f64::from(*y), f64::from(*width), f64::from(*height)), + TextShape::Point => *entry = point_frame(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine_data::parse; + + fn frame(src: &str) -> E { + parse(src.as_bytes()).unwrap() + } + + /// The frame of `text/path-circle.psd` (Photoshop 2026). + const CIRCLE: &str = "<< /1 << /0 [ 32.0 128.0 32.0 74.98067 74.98067 32.0 128.0 32.0 128.0 32.0 181.01933 32.0 224.0 74.98067 224.0 128.0 224.0 128.0 224.0 181.01933 181.01933 224.0 128.0 224.0 128.0 224.0 74.98067 224.0 32.0 181.01933 32.0 128.0 ] >> /2 << /0 2 /2 [ 1.0 0.0 0.0 1.0 -32.0 -32.0 ] /6 [ -1.0 -1.0 ] >> >>"; + /// The frame of `text/path-wave-open.psd` (Photoshop 2026). + const WAVE: &str = "<< /1 << /0 [ 16.0 100.0 46.0 40.0 120.0 40.0 160.0 80.0 160.0 80.0 200.0 120.0 274.0 100.0 304.0 60.0 ] >> /2 << /0 2 /2 [ 1.0 0.0 0.0 1.0 -16.0 -52.12246 ] /6 [ -1.0 -1.0 ] >> >>"; + + #[test] + fn photoshop_path_frames_read_into_text_space() { + let wave = read(&frame(WAVE)).unwrap(); + assert!(!wave.closed); + assert_eq!(wave.knots.len(), 3); + let p = |x: f64, y: f64| Point::new(x - 16.0, y - 52.12246); + let k = &wave.knots; + assert!(same(k[0].anchor, p(16.0, 100.0)) && same(k[0].in_ctrl, p(16.0, 100.0)) && same(k[0].out_ctrl, p(46.0, 40.0))); + assert!(same(k[1].in_ctrl, p(120.0, 40.0)) && same(k[1].anchor, p(160.0, 80.0)) && same(k[1].out_ctrl, p(200.0, 120.0)) && k[1].smooth); + assert!(same(k[2].in_ctrl, p(274.0, 100.0)) && same(k[2].anchor, p(304.0, 60.0))); + + let circle = read(&frame(CIRCLE)).unwrap(); + assert!(circle.closed); + assert_eq!(circle.knots.len(), 4); + // The type starts at the bottom anchor (Photoshop's glyph 0) and runs to the left one. + assert!(same(circle.knots[0].anchor, Point::new(96.0, 192.0))); + assert!(same(circle.knots[1].anchor, Point::new(0.0, 96.0))); + assert!(circle.knots.iter().all(|k| k.smooth)); + } + + #[test] + fn written_frames_read_back() { + for src in [CIRCLE, WAVE] { + let path = read(&frame(src)).unwrap(); + let mut entry = E::Dict(vec![("0".into(), frame(src))]); + let before = entry.clone(); + update_frame(&mut entry, &TextShape::Path { path: path.clone(), start: 0.0, flip: false }); + assert_eq!(entry, before, "an unchanged path keeps Photoshop's frame"); + let mut moved = path.clone(); + for k in &mut moved.knots { + *k = k.transform(&Affine::translate(5.0, -3.0)); + } + update_frame(&mut entry, &TextShape::Path { path: moved.clone(), start: 0.0, flip: false }); + let back = read(entry.get("0").unwrap()).unwrap(); + assert!(same_path(&back, &moved), "{back:?}"); + assert_eq!(entry.path(&["0", "2", "6"]), before.path(&["0", "2", "6"]), "the frame's other keys stay"); + } + } + + /// Flipped type and a moved start are written into the path, which lays the type out the same. + #[test] + fn flip_and_start_are_baked_into_the_frame() { + use crate::warp::Warp; + for src in [CIRCLE, WAVE] { + let path = read(&frame(src)).unwrap(); + for (start, flip) in [(40.0, false), (0.0, true), (25.0, true)] { + let shape = TextShape::Path { path: path.clone(), start, flip }; + let baked = TextShape::Path { path: frame_path(&path, start, flip), start: 0.0, flip: false }; + let (a, b) = (Warp::on_path(&shape).unwrap(), Warp::on_path(&baked).unwrap()); + for x in [0.0, 10.0, 50.0, 120.0] { + for y in [-12.0, 0.0, 4.0] { + let (p, q) = (a.apply(x, y), b.apply(x, y)); + assert!((p.0 - q.0).hypot(p.1 - q.1) < 0.5, "{src:.10} start {start} flip {flip} at ({x}, {y}): {p:?} vs {q:?}"); + } + } + } + } + } + + #[test] + fn hostile_frames_are_errors() { + for src in [ + "<< /2 << /0 2 >> >>", + "<< /1 << /0 [ ] >> /2 << /0 2 >> >>", + "<< /1 << /0 [ 1.0 2.0 3.0 ] >> /2 << /0 2 >> >>", + "<< /1 << /0 [ 0 0 1 1 2 2 (x) 3 ] >> /2 << /0 2 >> >>", + "<< /1 << /0 [ 0 0 1 1 2 2 1e999 3 ] >> /2 << /0 2 >> >>", + "<< /1 << /0 [ 0 0 1 1 2 2 3 3 ] >> /2 << /0 2 /2 [ 0 0 0 0 0 0 ] >> >>", + "<< /1 << /0 [ 0 0 1 1 2 2 3 3 ] >> /2 << /0 2 /2 [ 1 0 0 ] >> >>", + "<< /1 << /0 [ 0 0 1 1 2 2 3 3 ] >> /2 << /0 2 /2 [ 1e300 0 0 1e300 0 0 ] >> >>", + "<< /1 << /0 [ 0 0 1 1 2 2 3 3 50 50 60 60 70 70 80 80 ] >> /2 << /0 2 >> >>", + "<< /1 << /0 [ 5 5 5 5 5 5 5 5 ] >> /2 << /0 2 >> >>", + "<< /1 << /0 (path) >> /2 << /0 2 >> >>", + ] { + assert!(read(&frame(src)).is_err(), "{src}"); + } + let many = format!("<< /1 << /0 [ {} ] >> /2 << /0 2 >> >>", "0 0 1 1 2 2 0 0 ".repeat(MAX_SEGMENTS + 1)); + assert!(read(&frame(&many)).is_err()); + } + + fn d(items: Vec<(&str, E)>) -> E { + E::Dict(items.into_iter().map(|(k, v)| (k.to_string(), v)).collect()) + } + + /// A Photoshop-shaped `Txt2` block with one point-text object for `text` (`\r` breaks, trailing + /// `\r`) drawn in frame 0. + fn photoshop_txt2(text: &str) -> Vec { + photoshop_txt2_in(text, point_frame()) + } + + /// [`photoshop_txt2`] with the frame list entry `frame`. + fn photoshop_txt2_in(text: &str, frame: E) -> Vec { + let len = text.encode_utf16().count() as i64; + let run = || { + d(vec![("0", d(vec![("0", d(vec![("0", E::String(String::new())), ("6", d(vec![("0", E::Int(0)), ("1", E::Real(12.0))]))]))])), ("1", E::Int(len))]) + }; + let model = d(vec![("0", E::String(text.into())), ("5", d(vec![("0", E::Array(vec![run()]))])), ("6", d(vec![("0", E::Array(vec![run()]))]))]); + let strike = d(vec![("99", E::Name("PC".into())), ("6", E::Array(vec![d(vec![("99", E::Name("F".into())), ("10", E::Int(0))])]))]); + let view = d(vec![ + ("0", E::Array(vec![d(vec![("0", E::Int(0))])])), + ("1", d(vec![("0", E::Array(vec![d(vec![("0", d(vec![("1", E::Int(1))])), ("1", E::Int(len))])]))])), + ("2", E::Array(vec![strike])), + ]); + let object = d(vec![("0", model), ("1", view)]); + crate::engine_data::write_bare(&[ + ("98".into(), d(vec![("0", E::Int(14))])), + ("0".into(), d(vec![("8", d(vec![("0", E::Array(vec![frame]))]))])), + ("1".into(), d(vec![("1", E::Array(vec![object]))])), + ]) + } + + fn layer(text: &str, shape: TextShape) -> photocraft_doc::TextLayer { + use photocraft_doc::text::{CharStyle, TextRun}; + let mut t = photocraft_doc::TextLayer { + text: text.into(), + runs: vec![TextRun { len: text.len(), style: CharStyle { size_pt: 12.0, ..Default::default() } }], + shape, + transform: Affine::translate(32.0, 20.0), + ..Default::default() + }; + t.sync_summary(); + t + } + + /// A layer read back, with what [`crate::psd::apply_txt2_path`] said. + type Opened = (photocraft_doc::TextLayer, Result); + + /// Saves `layers` (object `i` each) into a file whose `Txt2` is `prev`, then reads each back: + /// its `TySh` and what [`crate::psd::apply_txt2_path`] makes of the saved `Txt2`. + fn save_and_open(layers: &[&photocraft_doc::TextLayer], prev: &[u8]) -> (Vec, Vec) { + let objects: Vec<(i32, &photocraft_doc::TextLayer)> = layers.iter().enumerate().map(|(i, l)| (i as i32, *l)).collect(); + let txt2 = crate::psd::build_txt2(&objects, Some(prev)); + let parsed = crate::psd::parse_txt2(&txt2).unwrap(); + let back = objects + .iter() + .map(|(i, l)| { + let tysh = crate::psd::set_text_index(&crate::psd::build_tysh(l, 72.0, None), *i).unwrap(); + let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + crate::psd::apply_txt2(&mut back, &tysh, &parsed); + let applied = crate::psd::apply_txt2_path(&mut back, &tysh, &parsed); + crate::psd::apply_txt2_frame(&mut back, &tysh, &parsed); + (back, applied) + }) + .collect(); + (txt2, back) + } + + /// #7962: type on a path survives a PSD save and open, for the file's own text object and for + /// a layer the file had no object for; a moved start and flipped type come back laid out the + /// same, in the path; a second save changes nothing. + #[test] + fn path_text_round_trips_through_tysh_and_txt2() { + let circle = read(&frame(CIRCLE)).unwrap(); + let wave = read(&frame(WAVE)).unwrap(); + let a = layer("On a circle", TextShape::Path { path: circle.clone(), start: 0.0, flip: false }); + let b = layer("Along a wave", TextShape::Path { path: wave.clone(), start: 30.0, flip: true }); + let prev = photoshop_txt2("On a circle\r"); + let (txt2, back) = save_and_open(&[&a, &b], &prev); + for ((l, applied), (want, start, flip)) in back.iter().zip([(&circle, 0.0, false), (&wave, 30.0, true)]) { + assert_eq!(applied, &Ok(true), "{}", l.text); + let TextShape::Path { path, start: s, flip: f } = &l.shape else { panic!("{}: {:?}", l.text, l.shape) }; + assert_eq!((*s, *f), (0.0, false)); + assert!(same_path(path, &frame_path(want, start, flip)), "{}: {path:?}", l.text); + assert_eq!(l.transform, Affine::translate(32.0, 20.0), "the frame's matrix is not applied twice"); + } + let (again, _) = save_and_open(&[&back[0].0, &back[1].0], &txt2); + assert_eq!(again, txt2, "a re-save of the opened file changes nothing"); + // Off its path, the layer opens as point text again. + let detached = layer("On a circle", TextShape::Point); + let (_, back) = save_and_open(&[&detached], &txt2); + assert_eq!(back[0].1, Ok(false)); + assert_eq!(back[0].0.shape, TextShape::Point); + } + + /// A malformed path frame leaves the layer as EngineData describes it, with the reason. + #[test] + fn a_malformed_path_frame_keeps_the_engine_data_shape() { + let a = layer("Hostile", TextShape::Box { x: 0.0, y: 0.0, width: 50.0, height: 20.0 }); + let tysh = crate::psd::set_text_index(&crate::psd::build_tysh(&a, 72.0, None), 0).unwrap(); + for bad in ["<< /1 << /0 [ 1 2 3 ] >> /2 << /0 2 >> >>", "<< /1 << /0 [ 0 0 1 1 2 2 3 3 ] >> /2 << /0 2 /2 [ 0 0 0 0 0 0 ] >> >>"] { + let entry = E::Dict(vec![("0".into(), frame(bad))]); + let txt2 = crate::psd::parse_txt2(&photoshop_txt2_in("Hostile\r", entry)).unwrap(); + let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + let before = l.shape.clone(); + assert!(crate::psd::apply_txt2_path(&mut l, &tysh, &txt2).is_err(), "{bad}"); + assert_eq!(l.shape, before); + } + } + + /// A layer taken off its path doesn't keep its path frame (it would reopen on the path). + #[test] + fn a_layer_off_its_path_loses_the_path_frame() { + let mut entry = E::Dict(vec![("0".into(), frame(CIRCLE))]); + update_frame(&mut entry, &TextShape::Point); + assert_eq!(entry, point_frame()); + let mut entry = E::Dict(vec![("0".into(), frame(WAVE))]); + update_frame(&mut entry, &TextShape::Box { x: 1.0, y: 2.0, width: 30.0, height: 40.0 }); + assert_eq!(entry.get("0").map(frame_kind), Some(BOX_FRAME)); + assert_eq!( + entry.path(&["0", "1", "0"]).and_then(numbers).unwrap()[..8], + [1.0, 2.0, 1.0, 2.0, 31.0, 2.0, 31.0, 2.0], + "Photoshop's box outline: corners as straight segments from the top left" + ); + // A point or box frame is left alone. + let mut point = point_frame(); + update_frame(&mut point, &TextShape::Point); + assert_eq!(point, point_frame()); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index ab42d0f8ef..e74682cf01 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -82,7 +82,9 @@ what an agent can't finish alone. - **Estimate:** 80–140 h. ### G8. Type depth -- **Missing:** type on a path (TYPE-218-1), paragraph direction UI (TYPE-218-2), complex-script +- **Missing:** type on a path (TYPE-218-1: the Type tool clicking a path, the caret on the curve, + and PSD path text in a new document, which still saves as point text; Photoshop's path text + opens on its path and saves back into the file's `Txt2` since 2026-10-21, #7962), paragraph direction UI (TYPE-218-2), complex-script fallback fonts and golden tests (TYPE-218-3), OpenType Numerals and Stylistic Sets submenus (TYPE-218-5; the Character panel buttons landed 2026-10-11, #3752), live Warp Text preview (TYPE-218-7, #2689), "rasterize and continue" for distort/perspective on type @@ -274,6 +276,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | G8: Photoshop's type on a path opens on its path (read from the `Txt2` frame) and saves back into the file's `Txt2` object (#7962); TYPE-218-1 stays partial (new documents save path text as point text) | | 2026-10-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open | | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | diff --git a/docs/scorecard.md b/docs/scorecard.md index 42ae824b4d..40f2b1bb4c 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -214,7 +214,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | Id | Target | Status | Issue | Note | |---|---|---|---|---| -| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | partial | [#218](https://github.com/storytold/photocraft/issues/218) | TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip. Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text (it saves as point text) | +| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | partial | [#218](https://github.com/storytold/photocraft/issues/218) | TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip; PSD path text opens on its path from its Txt2 frame and saves back into the file's Txt2 object (#7962). Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text in a file without Photoshop's Txt2 object for the layer (a new document saves it as point text) | | TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | missing | [#218](https://github.com/storytold/photocraft/issues/218) | Engine has TextDirection (text.rs:155); no UI | | TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Fallback fonts are covered for Thai, Khmer, Lao, Myanmar (THAI_FAMILIES, KHMER_FAMILIES, LAO_FAMILIES, MYANMAR_FAMILIES in fonts.rs; #1909, #3259), the nine Indic scripts (INDIC_FAMILIES: Nirmala UI, Kohinoor and Sangam MN, Noto Sans) and Arabic and Hebrew. Still missing: per-script golden shaping tests against reference output (only smoke tests that an installed font shapes without .notdef, plus a bidi test) | | TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet | diff --git a/scorecard/type.toml b/scorecard/type.toml index ee8a876a41..632503cf33 100644 --- a/scorecard/type.toml +++ b/scorecard/type.toml @@ -9,7 +9,7 @@ id = "TYPE-218-1" target = "Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip" status = "partial" issue = 218 -note = "TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip. Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text (it saves as point text)" +note = "TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip; PSD path text opens on its path from its Txt2 frame and saves back into the file's Txt2 object (#7962). Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text in a file without Photoshop's Txt2 object for the layer (a new document saves it as point text)" [[item]] id = "TYPE-218-2" From b105c6b47f49bfc8627162a7ff77c926d2cfc973 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 21 Oct 2026 02:37:32 -0400 Subject: [PATCH 061/229] Crop: Content-Aware Fill keeps the pixels a non-deleting crop hid (#8060) A frame pulled back out over pixels an earlier crop with Delete Cropped Pixels off hid treated everything past the old canvas as uncovered, so Content-Aware Fill painted over the hidden image. The fill now snapshots the target layer before the crop and counts its opaque pixels past the canvas as covered (kept and sampled), for unrotated and rotated crops. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/crop_fill.rs | 43 +++++++++++++++-- crates/engine/src/image_cmds.rs | 14 +++++- crates/engine/tests/crop_content_aware.rs | 58 +++++++++++++++++++++++ 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/crop_fill.rs b/crates/engine/src/crop_fill.rs index 6ef0698cea..93b9dfa994 100644 --- a/crates/engine/src/crop_fill.rs +++ b/crates/engine/src/crop_fill.rs @@ -8,9 +8,15 @@ //! layers keep their transparency there, as after a plain crop. Each connected piece of the //! uncovered area (each corner, each edge strip) is filled on its own, with Photoshop's sampling //! window around that piece, and only pixels the image covers are sampled. +//! +//! The image is more than the old canvas: pixels an earlier crop with Delete Cropped Pixels off +//! hid past its edges are still the layer's, and the Crop tool shows them while the frame is +//! pulled out over them. They count as covered, so they are kept (and sampled) rather than filled +//! over (#8060). use photocraft_doc::{Document, LayerId}; use photocraft_geom::Rect; +use photocraft_raster::Surface; use rayon::prelude::*; use crate::{EngineError, Result}; @@ -22,7 +28,7 @@ const SEED: u64 = 1; const MAX_PIXELS: u64 = 400_000_000; /// Where the cropped canvas came from in the original document. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub(crate) struct Coverage { /// Cropped canvas → original document: `[a, b, c, d, e, f]` with /// `x = a·u + b·v + e`, `y = c·u + d·v + f`. @@ -32,12 +38,22 @@ pub(crate) struct Coverage { pub height: f64, /// Whether the map rotates (its resampled edge is antialiased against the background colour). pub rotated: bool, + /// The filled layer's pixels before the crop (original document px), set by [`Coverage::holding`]: + /// where they lie past the old canvas they are hidden image, not uncovered area. + pub held: Option, } impl Coverage { /// An unrotated crop to `r` (original document px). pub fn crop(r: Rect, width: u32, height: u32) -> Self { - Coverage { map: [1.0, 0.0, 0.0, 1.0, f64::from(r.x0), f64::from(r.y0)], width: f64::from(width), height: f64::from(height), rotated: false } + Coverage { map: [1.0, 0.0, 0.0, 1.0, f64::from(r.x0), f64::from(r.y0)], width: f64::from(width), height: f64::from(height), rotated: false, held: None } + } + + /// Takes the pixels of the layer the fill will go to from `doc` before it is cropped (the + /// surface is shared, not copied), so hidden pixels past the old canvas count as covered. + pub fn holding(mut self, doc: &Document, active: Option) -> Self { + self.held = target_layer(doc, active).ok().and_then(|id| doc.layer(id)).and_then(|l| l.surface()).cloned(); + self } /// Whether the cropped canvas's pixel (u, v) is the original image's: its centre maps inside @@ -48,7 +64,28 @@ impl Coverage { let [a, b, c, d, e, f] = self.map; let (cu, cv) = (f64::from(u) + 0.5, f64::from(v) + 0.5); let (x, y) = (a * cu + b * cv + e, c * cu + d * cv + f); - x >= margin && y >= margin && x <= self.width - margin && y <= self.height - margin + (x >= margin && y >= margin && x <= self.width - margin && y <= self.height - margin) || self.holds(x, y) + } + + /// Whether the layer held an image pixel at original document point (x, y) past the canvas + /// (#8060): mostly opaque there and, for a rotated crop, one pixel around it too (the turned + /// edge is blended with the background colour, as at the canvas edge). Without an alpha channel + /// hidden pixels can't be told from empty ones: nothing is held. + fn holds(&self, x: f64, y: f64) -> bool { + let Some(surf) = &self.held else { return false }; + let format = surf.format(); + if !format.alpha || !(x.is_finite() && y.is_finite()) { + return false; + } + let a = surf.channels().saturating_sub(1); + let at = |x: f64, y: f64| { + let (fx, fy) = (x.floor(), y.floor()); + let limit = f64::from(i32::MAX); + fx.abs() < limit && fy.abs() < limit && surf.sample_channel(fx as i32, fy as i32, a) > 0.5 + }; + const ROUND: [(f64, f64); 5] = [(0.0, 0.0), (-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0)]; + let points = if self.rotated { &ROUND[..] } else { &ROUND[..1] }; + points.iter().all(|&(dx, dy)| at(x + dx, y + dy)) } fn margin(&self) -> f64 { diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 6d5342b931..398c56ab2d 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -521,9 +521,11 @@ fn crop(s: &mut Session, p: &Value) -> Result { let extension = s.tools.background; s.edit("Crop", |doc, active| { let original = doc.size; + // Taken before the crop: the pixels a Delete Cropped Pixels off crop hid count as image (#8060). + let coverage = content_aware.then(|| crate::crop_fill::Coverage::crop(r, original.width, original.height).holding(doc, *active)); crop_doc(doc, "image.crop", r, delete, extension)?; - if content_aware { - crate::crop_fill::fill_uncovered(doc, *active, &crate::crop_fill::Coverage::crop(r, original.width, original.height))?; + if let Some(coverage) = &coverage { + crate::crop_fill::fill_uncovered(doc, *active, coverage)?; } doc.selection = None; target.apply(doc); @@ -666,6 +668,14 @@ fn rotated_crop(s: &mut Session, r: Rect, angle: f64, delete: bool, content_awar width: f64::from(size.width), height: f64::from(size.height), rotated: true, + held: None, + }; + // The pixels before the turn: a Delete Cropped Pixels off crop's hidden ones count as image (#8060). + let coverage = if content_aware { + let d = s.active().ok_or(EngineError::NoDocument)?; + coverage.holding(&d.doc, d.active_layer) + } else { + coverage }; crate::analysis_cmds::compound(s, "Crop", |s| { // Pixels the crop deletes needn't be turned first: keeping only what lands in the frame diff --git a/crates/engine/tests/crop_content_aware.rs b/crates/engine/tests/crop_content_aware.rs index f41d46a9b5..c9f6c34ff7 100644 --- a/crates/engine/tests/crop_content_aware.rs +++ b/crates/engine/tests/crop_content_aware.rs @@ -157,3 +157,61 @@ fn bad_params_and_unfillable_crops_fail_cleanly() { assert_eq!(s.active().unwrap().history.past_len(), past, "{p}"); } } + +/// `doc` with its left 16 columns red, then cropped to x = 16 keeping the cropped pixels: the red +/// lies hidden past the canvas's left edge. +fn hidden_red(background: &str) -> Session { + let mut s = doc("rgb", 8, background); + s.execute("tools.setColors", json!({"foreground": "#ff0000", "background": "#000000"})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 48})).unwrap(); + s.execute("edit.fill", json!({"contents": "foreground"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("image.crop", json!({"x": 16, "y": 0, "width": 48, "height": 48, "deleteCroppedPixels": false})).unwrap(); + assert_eq!(size(&s), (48, 48)); + s +} + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; + +/// A frame pulled back out over pixels a Delete Cropped Pixels off crop hid keeps them: only what +/// lies past them is filled (#8060). Before, the whole strip past the old canvas was filled over. +#[test] +fn extending_crop_keeps_the_hidden_pixels() { + let mut s = hidden_red("transparent"); + let green = layer(&s).rgba(24, 24); + crop(&mut s, json!({"x": -24, "y": 0, "width": 72, "height": 48, "deleteCroppedPixels": false}), true).unwrap(); + assert_eq!(size(&s), (72, 48)); + let surf = layer(&s); + for y in 0..48 { + for x in 8..24 { + assert_eq!(surf.rgba(x, y), RED, "the hidden red at ({x}, {y}) is kept"); + } + for x in 0..8 { + let px = surf.rgba(x, y); + assert!(px[3] > 0.99, "past the hidden pixels ({x}, {y}) is filled: {px:?}"); + } + assert!(close(surf.rgba(40, y), green, 1e-6), "the canvas is unchanged"); + } +} + +/// A turned frame that reaches over the Background's hidden pixels shows them, not a fill +/// from the canvas (#8060). +#[test] +fn rotated_crop_keeps_the_hidden_pixels() { + let mut s = hidden_red("white"); + // A diamond on the canvas's left edge: its left half lies over the hidden red, all of it inside + // the turned canvas (so nothing takes the background colour). + crop(&mut s, json!({"x": -10, "y": 14, "width": 20, "height": 20, "angle": 45, "deleteCroppedPixels": false}), true).unwrap(); + assert_eq!(size(&s), (20, 20)); + let (w, h) = size(&s); + let surf = layer(&s); + let mut red = 0; + for y in 0..h as i32 { + for x in 0..w as i32 { + let px = surf.rgba(x, y); + assert!(px[0] > 0.5 && px[3] > 0.99, "no background colour or transparency at ({x}, {y}): {px:?}"); + red += usize::from(close(px, RED, 0.06)); + } + } + assert!(red >= 100, "the hidden red shows in the turned frame: {red} red pixels"); +} From 909c0346869c23445aa1e8e5bbd7ba887ca9915b Mon Sep 17 00:00:00 2001 From: paulou-jpg <79102098+paulou-jpg@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:46:02 -0700 Subject: [PATCH 062/229] Blend mode keys: Shift+Alt+letter picks a mode, as in Photoshop Shift+Alt plus Photoshop's letter (N Normal, M Multiply, S Screen, O Overlay, R Clear, P Pass Through...) sets the blend mode. With the Brush, Pencil or Gradient it sets that tool's mode and never the layer's; with a non-painting tool it sets every selected layer in one undo step. Pass Through applies to groups only, Clear to Brush and Pencil only, and the Background is skipped. The typed letter wins, so German Z is still Pin Light; the physical key is the fallback for the characters Option types on a Mac. Shift+Plus/Minus now also step every selected layer, and Brush/Pencil cycling includes Clear after Dissolve, as in the options-bar dropdown. Fixes Shift+Alt+tool letter switching tools: the tool-letter loop's consume_key ignores extra modifiers, so blend_keys now runs before it. Not yet: Behind (Shift+Alt+Q does nothing), and the painting tools without an options-bar Mode (Clone Stamp, Healing, Paint Bucket...) ignore the keys. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/blend_keys.rs | 368 ++++++++++++++++++++++++++++--- crates/ui-egui/src/shortcuts.rs | 5 +- docs/scorecard.md | 2 +- docs/ui-parity.md | 4 +- scorecard/ui.toml | 2 +- 5 files changed, 340 insertions(+), 41 deletions(-) diff --git a/crates/ui-egui/src/blend_keys.rs b/crates/ui-egui/src/blend_keys.rs index d68bc79a11..b047330a66 100644 --- a/crates/ui-egui/src/blend_keys.rs +++ b/crates/ui-egui/src/blend_keys.rs @@ -1,8 +1,12 @@ -//! ⇧+ and ⇧− step through the blend modes, as in Photoshop (#3102): the Brush's or Pencil's mode -//! while one of them is active, the Gradient's with the Gradient tool, and otherwise (Move, the -//! selection tools…) the active layer's. The list wraps at either end. Each press is one call of -//! an engine command (`tools.setBrush`, `layer.setProps`), so it records and undoes like a pick -//! from the dropdown. +//! Blend mode keys, as in Photoshop. ⇧+ and ⇧− step through the modes (#3102), and ⇧⌥ plus a +//! letter picks one (⇧⌥M Multiply, ⇧⌥S Screen…). With a painting tool that has a mode of its own +//! they set that tool's mode and never the layer's: the Brush's or Pencil's (which also offer +//! Clear), the Gradient's; the other painting tools are left alone until their options bar has a +//! Mode. With any other tool (Move, the selection tools…) they set every selected layer's mode. +//! The step list wraps at either end. Each press is one call of an engine command per layer +//! (`tools.setBrush`, `layer.setProps`), coalesced into one undo step. + +use std::sync::atomic::{AtomicU64, Ordering}; use egui::{Event, Key}; use photocraft_color::BlendMode; @@ -13,7 +17,7 @@ use crate::state::Tool; /// The mode `step` places after (or before, when negative) `current` in `modes`, wrapping around. /// A mode that isn't listed steps from the top of the list. -pub fn stepped(modes: &[BlendMode], current: BlendMode, step: i32) -> Option { +pub fn stepped(modes: &[T], current: T, step: i32) -> Option { let n = i32::try_from(modes.len()).ok().filter(|n| *n > 0)?; let at = modes.iter().position(|m| *m == current).and_then(|i| i32::try_from(i).ok()); let next = match at { @@ -24,52 +28,180 @@ pub fn stepped(modes: &[BlendMode], current: BlendMode, step: i32) -> Option Option { + use BlendMode as B; + Some(Pick::Mode(match key { + Key::N => B::Normal, + Key::I => B::Dissolve, + Key::Q => return Some(Pick::Behind), + Key::R => return Some(Pick::Clear), + Key::P => B::PassThrough, + Key::K => B::Darken, + Key::M => B::Multiply, + Key::B => B::ColorBurn, + Key::A => B::LinearBurn, + Key::G => B::Lighten, + Key::S => B::Screen, + Key::D => B::ColorDodge, + Key::W => B::LinearDodge, + Key::O => B::Overlay, + Key::F => B::SoftLight, + Key::H => B::HardLight, + Key::V => B::VividLight, + Key::J => B::LinearLight, + Key::Z => B::PinLight, + Key::L => B::HardMix, + Key::E => B::Difference, + Key::X => B::Exclusion, + Key::U => B::Hue, + Key::T => B::Saturation, + Key::C => B::Color, + Key::Y => B::Luminosity, + _ => return None, + })) +} + +/// One key's request: a step through the list, or a mode picked by letter. +enum Press { + Step(i32), + Pick(Pick), +} + /// Consumes this frame's ⇧+ / ⇧− presses (⇧= on a US layout is `+`; on a German one -/// `layout_keys::apply` has already turned ⇧+ and ⇧- into `+` and `-`) and applies them. +/// `layout_keys::apply` has already turned ⇧+ and ⇧- into `+` and `-`) and ⇧⌥ letters, and +/// applies them. Runs before the tool letters, which would otherwise take ⇧⌥M as M. pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { - let steps: Vec = ctx.input_mut(|i| { + let presses: Vec = ctx.input_mut(|i| { let mut out = Vec::new(); i.events.retain(|e| match e { - Event::Key { key, pressed: true, modifiers, .. } if modifiers.shift && !modifiers.command && !modifiers.ctrl && !modifiers.alt => { - let step = match key { - Key::Plus | Key::Equals => 1, - Key::Minus => -1, - _ => return true, - }; - out.push(step); + Event::Key { key, physical_key, pressed: true, modifiers, .. } if modifiers.shift && !modifiers.command && !modifiers.ctrl => { + if modifiers.alt { + // The layout's letter (⇧⌥Z on a German keyboard is Pin Light), else the + // physical one: on a Mac ⌥ turns the letter into another character (⇧⌥M + // types Â) that may arrive as some other key. + match letter_pick(*key).or_else(|| physical_key.and_then(letter_pick)) { + Some(p) => out.push(Press::Pick(p)), + None => return true, + } + } else { + match key { + Key::Plus | Key::Equals => out.push(Press::Step(1)), + Key::Minus => out.push(Press::Step(-1)), + _ => return true, + } + } false } _ => true, }); out }); - for step in steps { - press(app, step); + for p in presses { + match p { + Press::Step(step) => press(app, step), + Press::Pick(pick) => pick_mode(app, pick), + } } } -/// One ⇧+ (`step` 1) or ⇧− (`step` -1) press. +/// Where the active tool's blend mode keys go. +enum Target { + /// The Brush's or Pencil's mode, Clear included. + Paint, + Gradient, + /// A painting tool whose mode has no options-bar control yet (or the Eraser, which has none): + /// the keys do nothing, and never reach the layer. + Nothing, + Layers, +} + +fn target(tool: Tool) -> Target { + match tool { + Tool::Brush | Tool::Pencil => Target::Paint, + Tool::Gradient => Target::Gradient, + t if t.is_brushlike() || t == Tool::MagicEraser || t == Tool::PaintBucket => Target::Nothing, + _ => Target::Layers, + } +} + +/// The Brush's or Pencil's mode list: the layer modes with Clear (`None`) after Dissolve, as in +/// its options-bar dropdown. +fn paint_modes() -> Vec> { + let mut modes: Vec> = BlendMode::LAYER_MODES.iter().copied().map(Some).collect(); + let at = modes.iter().position(|m| *m == Some(BlendMode::Dissolve)).map_or(modes.len(), |i| i + 1); + modes.insert(at, None); + modes +} + +/// The Brush's or Pencil's Clear flag. +fn clear_flag(app: &mut PhotocraftApp) -> &mut bool { + if app.ui.tool == Tool::Pencil { &mut app.ui.tool_options.pencil_clear } else { &mut app.ui.tool_options.brush_clear } +} + +/// Sets the Brush's or Pencil's mode; `None` is Clear. +fn set_paint_mode(app: &mut PhotocraftApp, mode: Option) { + *clear_flag(app) = mode.is_none(); + if let Some(m) = mode + && let Ok(m) = serde_json::to_value(m) + { + let _ = app.run("tools.setBrush", json!({ "brush": { "mode": m } })); + } +} + +fn set_gradient_mode(app: &mut PhotocraftApp, mode: BlendMode) { + let before = app.ui.tool_options.clone(); + app.ui.tool_options.gradient_blend_mode = mode; + // Live mode: a selected gradient fill layer follows, as with the dropdown. + crate::gradient_ui::options_changed(app, &before); +} + +/// Sets `mode` on every selected layer that can take it, in one undo step: the locked Background +/// has no blend mode, and only groups pass through. +fn set_layers_mode(app: &mut PhotocraftApp, mode: BlendMode) { + static PRESSES: AtomicU64 = AtomicU64::new(0); + let Some(st) = app.session.active() else { return }; + let ids: Vec<_> = st + .selected_layers() + .into_iter() + .filter_map(|id| st.doc.layer(id)) + .filter(|l| !crate::doc_props_ui::is_background(&st.doc, l) && (mode != BlendMode::PassThrough || l.is_group()) && l.blend != mode) + .map(|l| l.id) + .collect(); + let coalesce = format!("blend-keys-{}", PRESSES.fetch_add(1, Ordering::Relaxed)); + for id in ids { + let _ = app.run("layer.setProps", json!({ "layer": id.0, "blend": mode.label(), "coalesce": coalesce })); + } +} + +/// One ⇧+ (`step` 1) or ⇧− (`step` -1) press. Selected layers all take the mode after the active +/// layer's. pub fn press(app: &mut PhotocraftApp, step: i32) { - match app.ui.tool { - Tool::Brush | Tool::Pencil => { - let current = app.session.tools.brush.mode; - if let Some(mode) = stepped(&BlendMode::LAYER_MODES, current, step) - && let Ok(mode) = serde_json::to_value(mode) - { - let _ = app.run("tools.setBrush", json!({ "brush": { "mode": mode } })); + match target(app.ui.tool) { + Target::Paint => { + let current = if *clear_flag(app) { None } else { Some(app.session.tools.brush.mode) }; + if let Some(mode) = stepped(&paint_modes(), current, step) { + set_paint_mode(app, mode); } } - Tool::Gradient => { - let before = app.ui.tool_options.clone(); - if let Some(mode) = stepped(&BlendMode::LAYER_MODES, before.gradient_blend_mode, step) { - app.ui.tool_options.gradient_blend_mode = mode; - // Live mode: a selected gradient fill layer follows, as with the dropdown. - crate::gradient_ui::options_changed(app, &before); + Target::Gradient => { + if let Some(mode) = stepped(&BlendMode::LAYER_MODES, app.ui.tool_options.gradient_blend_mode, step) { + set_gradient_mode(app, mode); } } - // Their blend mode isn't the brush's (the Eraser has none): leave them alone. - t if t.is_brushlike() || t == Tool::MagicEraser || t == Tool::PaintBucket => {} - _ => { + Target::Nothing => {} + Target::Layers => { let Some(st) = app.session.active() else { return }; let Some(layer) = st.active_layer.and_then(|id| st.doc.layer(id)) else { return }; // The locked Background has no blend mode. @@ -78,13 +210,24 @@ pub fn press(app: &mut PhotocraftApp, step: i32) { } let modes: Vec = std::iter::once(BlendMode::PassThrough).filter(|_| layer.is_group()).chain(BlendMode::LAYER_MODES).collect(); if let Some(mode) = stepped(&modes, layer.blend, step) { - let id = layer.id.0; - let _ = app.run("layer.setProps", json!({ "layer": id, "blend": mode.label() })); + set_layers_mode(app, mode); } } } } +/// One ⇧⌥ letter press. A mode the target doesn't have (Clear or Behind for layers, Pass Through +/// for a painting tool) does nothing. +pub fn pick_mode(app: &mut PhotocraftApp, pick: Pick) { + match (target(app.ui.tool), pick) { + (Target::Paint, Pick::Mode(m)) if m != BlendMode::PassThrough => set_paint_mode(app, Some(m)), + (Target::Paint, Pick::Clear) => set_paint_mode(app, None), + (Target::Gradient, Pick::Mode(m)) if m != BlendMode::PassThrough => set_gradient_mode(app, m), + (Target::Layers, Pick::Mode(m)) => set_layers_mode(app, m), + _ => {} + } +} + #[cfg(test)] mod tests { use super::*; @@ -180,4 +323,157 @@ mod tests { let mut none = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); press(&mut none, 1); } + + /// Feeds one ⇧⌥ press of `key`, reported by the platform as `physical` too. + fn alt_shift(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, physical: Key) { + let m = egui::Modifiers::SHIFT | egui::Modifiers::ALT; + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(m), egui::Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers: m }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, ctx); + ctx.end_pass().textures_delta.clear(); + } + + fn blend_of(app: &PhotocraftApp, i: usize) -> BlendMode { + app.session.active().unwrap().doc.layers[i].blend + } + + /// Photoshop's letters: 26 keys, each a different mode, and the four modes without one. + #[test] + fn every_letter_picks_its_own_mode() { + let keys: Vec = "NIQRPKMBAGSDWOFHVJZLEXUTCY".chars().filter_map(|c| Key::from_name(&c.to_string())).collect(); + assert_eq!(keys.len(), 26); + let picks: Vec = keys.iter().filter_map(|k| letter_pick(*k)).collect(); + assert_eq!(picks.len(), keys.len()); + for (i, p) in picks.iter().enumerate() { + assert!(!picks[..i].contains(p), "{p:?} has two letters"); + } + assert_eq!(letter_pick(Key::M), Some(Pick::Mode(BlendMode::Multiply))); + assert_eq!(letter_pick(Key::W), Some(Pick::Mode(BlendMode::LinearDodge))); + assert_eq!(letter_pick(Key::Y), Some(Pick::Mode(BlendMode::Luminosity))); + for m in [BlendMode::DarkerColor, BlendMode::LighterColor, BlendMode::Subtract, BlendMode::Divide] { + assert!(!picks.contains(&Pick::Mode(m)), "{m:?} has no letter in Photoshop"); + } + for k in [Key::Num1, Key::Space] { + assert_eq!(letter_pick(k), None); + } + } + + /// With the Move tool ⇧⌥M makes the active layer Multiply in one undo step, and doesn't switch + /// to the Marquee tool's M. Clear and Behind are painting modes: a layer ignores them. + #[test] + fn alt_shift_letter_sets_the_layer_mode_and_keeps_the_tool() { + let ctx = egui::Context::default(); + let mut a = app(Tool::Move); + let undo = a.session.active().unwrap().history.past_len(); + alt_shift(&mut a, &ctx, Key::M, Key::M); + assert_eq!(layer_blend(&a), BlendMode::Multiply); + assert_eq!(a.ui.tool, Tool::Move, "not the Marquee tool"); + assert_eq!(a.session.active().unwrap().history.past_len(), undo + 1); + alt_shift(&mut a, &ctx, Key::R, Key::R); + alt_shift(&mut a, &ctx, Key::Q, Key::Q); + alt_shift(&mut a, &ctx, Key::P, Key::P); + assert_eq!(layer_blend(&a), BlendMode::Multiply, "no Clear, Behind or Pass Through on a layer"); + assert_eq!(a.ui.tool, Tool::Move); + // ⇧⌥ letters that are tool keys pick modes too. + alt_shift(&mut a, &ctx, Key::S, Key::S); + assert_eq!(layer_blend(&a), BlendMode::Screen); + assert_eq!(a.ui.tool, Tool::Move); + } + + /// A Mac's ⌥ turns the letter into another character; egui then reports the physical key. + /// A German layout's Z (physical Y) is still Pin Light: the typed letter wins. + #[test] + fn the_typed_letter_wins_and_the_physical_key_is_the_fallback() { + let ctx = egui::Context::default(); + let mut a = app(Tool::Move); + alt_shift(&mut a, &ctx, Key::Backtick, Key::O); + assert_eq!(layer_blend(&a), BlendMode::Overlay); + alt_shift(&mut a, &ctx, Key::Z, Key::Y); + assert_eq!(layer_blend(&a), BlendMode::PinLight); + } + + /// Every selected layer takes the mode, all in one undo step; the Background keeps Normal. + /// ⇧+ steps them all to the mode after the active layer's. + #[test] + fn mode_keys_set_every_selected_layer_in_one_undo_step() { + let ctx = egui::Context::default(); + let mut a = app(Tool::Move); + let ids: Vec = a.session.active().unwrap().doc.layers.iter().map(|l| l.id.0).collect(); + a.run("layer.new.layer", json!({})).unwrap(); + for id in &ids { + a.run("layer.select", json!({"layer": id, "mode": "add"})).unwrap(); + } + assert_eq!(a.session.active().unwrap().selected_layers().len(), 3); + let undo = a.session.active().unwrap().history.past_len(); + alt_shift(&mut a, &ctx, Key::S, Key::S); + assert_eq!([blend_of(&a, 0), blend_of(&a, 1), blend_of(&a, 2)], [BlendMode::Normal, BlendMode::Screen, BlendMode::Screen]); + assert_eq!(a.session.active().unwrap().history.past_len(), undo + 1, "one undo step for both layers"); + key(&mut a, &ctx, Key::Plus, egui::Modifiers::SHIFT); + assert_eq!([blend_of(&a, 1), blend_of(&a, 2)], [BlendMode::ColorDodge, BlendMode::ColorDodge]); + assert_eq!(a.session.active().unwrap().history.past_len(), undo + 2); + a.session.undo(); + assert_eq!([blend_of(&a, 1), blend_of(&a, 2)], [BlendMode::Screen, BlendMode::Screen]); + } + + /// ⇧⌥P is Pass Through, for groups only. + #[test] + fn pass_through_is_for_groups() { + let ctx = egui::Context::default(); + let mut a = app(Tool::Move); + a.run("layer.new.group", json!({})).unwrap(); + alt_shift(&mut a, &ctx, Key::N, Key::N); + assert_eq!(layer_blend(&a), BlendMode::Normal); + alt_shift(&mut a, &ctx, Key::P, Key::P); + assert_eq!(layer_blend(&a), BlendMode::PassThrough); + } + + /// With the Brush the letters set the brush's mode, Clear included, and never the layer's; + /// ⇧+ from Dissolve reaches Clear, as in the dropdown. The Pencil keeps its own Clear. + #[test] + fn with_a_painting_tool_the_letters_set_only_its_mode() { + let ctx = egui::Context::default(); + let mut a = app(Tool::Brush); + alt_shift(&mut a, &ctx, Key::M, Key::M); + assert_eq!(a.session.tools.brush.mode, BlendMode::Multiply); + assert_eq!(layer_blend(&a), BlendMode::Normal, "the layer is untouched"); + assert_eq!(a.ui.tool, Tool::Brush); + alt_shift(&mut a, &ctx, Key::R, Key::R); + assert!(a.ui.tool_options.brush_clear); + alt_shift(&mut a, &ctx, Key::P, Key::P); + assert!(a.ui.tool_options.brush_clear, "no Pass Through for a brush"); + alt_shift(&mut a, &ctx, Key::I, Key::I); + assert!(!a.ui.tool_options.brush_clear); + assert_eq!(a.session.tools.brush.mode, BlendMode::Dissolve); + key(&mut a, &ctx, Key::Plus, egui::Modifiers::SHIFT); + assert!(a.ui.tool_options.brush_clear, "Clear follows Dissolve"); + key(&mut a, &ctx, Key::Plus, egui::Modifiers::SHIFT); + assert!(!a.ui.tool_options.brush_clear); + assert_eq!(a.session.tools.brush.mode, BlendMode::Darken); + assert!(!a.ui.tool_options.pencil_clear); + a.ui.tool = Tool::Pencil; + alt_shift(&mut a, &ctx, Key::R, Key::R); + assert!(a.ui.tool_options.pencil_clear); + assert_eq!(layer_blend(&a), BlendMode::Normal); + } + + /// The Gradient takes the letters; a painting tool without a Mode control yet (Clone Stamp) + /// ignores them rather than changing the layer. + #[test] + fn gradient_takes_the_letters_and_clone_stamp_ignores_them() { + let ctx = egui::Context::default(); + let mut a = app(Tool::Gradient); + alt_shift(&mut a, &ctx, Key::O, Key::O); + assert_eq!(a.ui.tool_options.gradient_blend_mode, BlendMode::Overlay); + alt_shift(&mut a, &ctx, Key::R, Key::R); + assert_eq!(a.ui.tool_options.gradient_blend_mode, BlendMode::Overlay); + a.ui.tool = Tool::CloneStamp; + alt_shift(&mut a, &ctx, Key::M, Key::M); + assert_eq!(layer_blend(&a), BlendMode::Normal); + assert_eq!(a.ui.tool, Tool::CloneStamp); + let mut none = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + pick_mode(&mut none, Pick::Mode(BlendMode::Multiply)); + } } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 1ad8d6d92a..d9d846f8a6 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -673,6 +673,9 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { } return; } + // ⇧+ / ⇧− step the blend mode and ⇧⌥ letters pick one (`blend_keys`). Before the tool + // letters: `consume_key` ignores extra ⇧ and ⌥, so ⇧⌥M would otherwise pick a tool. + crate::blend_keys::handle(app, ctx); // Tool keys; pressing the key of the current group cycles within it. With Preferences › // Tools › Use Shift Key for Tool Switch, only ⇧+key cycles and the plain key keeps the // group's current tool. From another group, the key picks the group's last-used tool. @@ -706,8 +709,6 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { } // 1–0 set opacity, ⇧1–0 flow or fill (`opacity_keys`). crate::opacity_keys::handle(app, ctx); - // ⇧+ / ⇧− step the blend mode (`blend_keys`). - crate::blend_keys::handle(app, ctx); } #[cfg(test)] diff --git a/docs/scorecard.md b/docs/scorecard.md index 42ae824b4d..6e928d793b 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -199,7 +199,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | partial | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has fourteen themes: the twelve earlier ones plus Soft Dark and Soft Light (low-contrast Studio-layout palettes, text at 4.5:1, selectable in Preferences with the existing theme-card previews, ui.set {theme}, Window > Theme); outlined knobs in the light themes are still missing | | UI-217-12 | Save custom New Document presets; a real Recent category | done | [#217](https://github.com/storytold/photocraft/issues/217) | document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent lists Clipboard, the last 20 documents created in the dialog (document.recent.*, persisted with the presets, newest first, deduplicated) and the uncovered defaults (#3131); created_documents_fill_the_recent_category | | UI-217-13 | Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider | done | [#217](https://github.com/storytold/photocraft/issues/217) | widgets::value_field: arithmetic plus units (1in, 5mm, 72pt, 50%) converted at the document resolution (#3099); widgets::scrub_label makes options-bar, slider-row and Character labels scrubby (Shift x10, Alt fine); slider rows with a known default reset on double-click | -| UI-217-14 | Layers panel: wheel steps blend modes, drop on New/Trash duplicates/deletes, checker behind art | done | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs: the wheel steps the blend-mode dropdown (widgets::dropdown_wheel_hovered, #1747); rows dropped on New Layer/Group/Delete duplicate/group/delete them (footer_drop, #736), effect rows dropped on Delete delete that effect or the whole style (fx_trash_drop, #3102); pixel thumbnails draw widgets::checker behind the art; Shift+Plus/Minus step the layer's or the painting tool's blend mode (blend_keys.rs, #3102) | +| UI-217-14 | Layers panel: wheel steps blend modes, drop on New/Trash duplicates/deletes, checker behind art | done | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs: the wheel steps the blend-mode dropdown (widgets::dropdown_wheel_hovered, #1747); rows dropped on New Layer/Group/Delete duplicate/group/delete them (footer_drop, #736), effect rows dropped on Delete delete that effect or the whole style (fx_trash_drop, #3102); pixel thumbnails draw widgets::checker behind the art; Shift+Plus/Minus step the layer's or the painting tool's blend mode (blend_keys.rs, #3102), and Shift+Alt+letter picks one (Photoshop's letters; every selected layer, one undo step) | | UI-217-15 | Curves: smoothing / pencil mode, black/grey/white eyedroppers, tooltips | done | [#217](https://github.com/storytold/photocraft/issues/217) | Curves editor (adjust_editors.rs, dialog and Properties): Edit points / Draw (pencil) buttons and Smooth, all with tooltips; pencil curves are 256-level maps (Adjustment::Curves pencil, params pencil/smooth, PSD curv map form and smart-filter Mpng; #3088). Set Black/Neutral Gray/White Point eyedroppers in the Image > Adjustments > Curves dialog (adjust_dialog.rs, #873) and in a Curves adjustment layer's Properties panel (layer_picker.rs: a click on the image is one layer.setAdjustment with the eyedropper param, sampling below the layer; Esc or another tool disarms; #3110) | | UI-217-16 | Hue/Saturation: Saturation and Lightness sliders get hue-following colour tracks | done | [#217](https://github.com/storytold/photocraft/issues/217) | saturation_track/lightness_track (adjust_editors.rs): grey -> the hue and black -> the hue -> white, recolouring as the Hue slider moves; saturation_and_lightness_tracks_follow_the_hue | | UI-217-17 | Color panel: wheel plus RGB/HSB/CMYK/Lab/Gray/Web slider modes; resizes with its group | missing | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs:1837 single field + hex/RGB readout | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..7e53ba4c45 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (⇧⌥ blend mode letters; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -195,6 +195,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | | | Type formatting keys while editing text | done: ⇧⌘K, U, B, I and / toggle All Caps, Underline, Faux Bold, Faux Italic, Strikethrough (off when every selected character has it, else on); ⌥↑/↓ leading ∓2 pt, ⇧⌥↑/↓ baseline shift ±2 pt (10 with ⌘); ⌥←/→ tracking ±20 over a selection (100 with ⌘; a collapsed caret still kerns); ⌥⇧⌘H toggles Hyphenate for the touched paragraphs. One `type.setStyle` step per press (new `leadingBy`/`baselineShiftBy`/`trackingBy`; auto leading starts from its laid-out value); the global ⇧⌘K/U/B/I never fire while typing. Differs: a collapsed caret in non-empty text changes nothing, since the model can't hold a pending style for the next characters typed (empty text takes it as its typing style) (`type_tool.rs`, `input_tests.rs` `type_tool_format_keys_*`) | | | Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | | +| Blend mode keys: ⇧⌥ + letter picks a mode (N Normal, I Dissolve, R Clear, P Pass Through, K M B A darken, G S D W lighten, O F H V J Z L contrast, E X difference, U T C Y component), ⇧+ / ⇧− step through them | done (2026-10-14): Brush, Pencil and Gradient take them as their own mode (Clear on Brush and Pencil only), any non-painting tool sets every selected layer in one undo step (Pass Through on groups only; the Background is skipped); the typed letter wins, the physical key is the fallback for ⌥ characters on a Mac. ⇧⌥ letters no longer switch tools. Differs: Behind (⇧⌥Q) is not implemented; Clone Stamp, Pattern Stamp, History Brush, Healing, Paint Bucket and Magic Eraser ignore the keys until their options bar has a Mode | `blend_keys.rs` | ## Panels and workspace @@ -257,3 +258,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-14 | minor | Last selected tool restored across launches; temporary overrides excluded, explicit Eraser choices retained, and final selection flushed on normal exit | | 2026-10-13 | minor | Free Transform with a selection: the box is the selection's rectangle, and the marching ants follow it while it is transformed (#5297) | | 2026-10-14 | minor | Move › Show Transform Controls frames every selected layer and outlines the selected shapes | +| 2026-10-14 | minor | ⇧⌥ + letter blend mode keys for the painting tool or every selected layer; ⇧+ / ⇧− now step every selected layer and include Clear for Brush and Pencil | diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 7eff90c3ca..9caeb519fe 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -101,7 +101,7 @@ id = "UI-217-14" target = "Layers panel: wheel steps blend modes, drop on New/Trash duplicates/deletes, checker behind art" status = "done" issue = 217 -note = "panels.rs: the wheel steps the blend-mode dropdown (widgets::dropdown_wheel_hovered, #1747); rows dropped on New Layer/Group/Delete duplicate/group/delete them (footer_drop, #736), effect rows dropped on Delete delete that effect or the whole style (fx_trash_drop, #3102); pixel thumbnails draw widgets::checker behind the art; Shift+Plus/Minus step the layer's or the painting tool's blend mode (blend_keys.rs, #3102)" +note = "panels.rs: the wheel steps the blend-mode dropdown (widgets::dropdown_wheel_hovered, #1747); rows dropped on New Layer/Group/Delete duplicate/group/delete them (footer_drop, #736), effect rows dropped on Delete delete that effect or the whole style (fx_trash_drop, #3102); pixel thumbnails draw widgets::checker behind the art; Shift+Plus/Minus step the layer's or the painting tool's blend mode (blend_keys.rs, #3102), and Shift+Alt+letter picks one (Photoshop's letters; every selected layer, one undo step)" [[item]] id = "UI-217-15" From 5430f29e21e9240f937000da2200cac99466d7a4 Mon Sep 17 00:00:00 2001 From: paulou-jpg <79102098+paulou-jpg@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:24:24 -0700 Subject: [PATCH 063/229] Blend mode and number keys work while a transform or warp is open With a Free Transform box, Puppet Warp or Perspective Warp open, Shift+Alt letters, Shift+Plus/Minus and the number keys go where the active tool sends them (the Brush's mode and opacity with the Brush, the layer's with the Move tool) and never close the box. Puppet and Perspective Warp no longer swallow those keys. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/blend_keys.rs | 53 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/distort_ui.rs | 12 ++++++++ docs/ui-parity.md | 3 +- 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/blend_keys.rs b/crates/ui-egui/src/blend_keys.rs index b047330a66..81bd10026d 100644 --- a/crates/ui-egui/src/blend_keys.rs +++ b/crates/ui-egui/src/blend_keys.rs @@ -476,4 +476,57 @@ mod tests { let mut none = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); pick_mode(&mut none, Pick::Mode(BlendMode::Multiply)); } + + /// With a Free Transform box or a Puppet Warp open, ⇧⌥ letters, ⇧+ / ⇧− and the number keys + /// still go where the tool sends them: the Brush's mode and opacity with the Brush, the + /// layer's with the Move tool. They never pick a tool, so the box stays open, and Enter keeps + /// both the transform and the layer's new mode. + #[test] + fn keys_follow_the_tool_while_a_transform_or_warp_is_open() { + let ctx = egui::Context::default(); + let mut a = app(Tool::Brush); + a.run("select.rect", json!({"x": 4, "y": 4, "width": 12, "height": 12})).unwrap(); + a.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + a.run("select.deselect", json!({})).unwrap(); + a.sync_views(); + crate::transform_tool::begin(&mut a, &ctx).unwrap(); + crate::transform_tool::nudge(&mut a, 3.0, 0.0); + let shift_alt = egui::Modifiers::SHIFT | egui::Modifiers::ALT; + let opacity = |a: &PhotocraftApp| { + let st = a.session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().opacity + }; + // The Brush: its own mode and opacity. + key(&mut a, &ctx, Key::M, shift_alt); + assert_eq!(a.session.tools.brush.mode, BlendMode::Multiply); + key(&mut a, &ctx, Key::Num5, egui::Modifiers::NONE); + assert!((a.session.tools.brush.opacity - 0.5).abs() < 1e-6); + assert_eq!((layer_blend(&a), opacity(&a)), (BlendMode::Normal, 1.0), "the layer is left alone"); + assert!(a.ui.transform.is_some(), "the box stays open"); + assert_eq!(a.ui.tool, Tool::Brush, "no tool letter"); + // The Move tool: the layer's. + a.ui.tool = Tool::Move; + a.opacity_keys = None; + key(&mut a, &ctx, Key::E, shift_alt); + key(&mut a, &ctx, Key::Num7, egui::Modifiers::NONE); + assert_eq!(layer_blend(&a), BlendMode::Difference); + assert!((opacity(&a) - 0.7).abs() < 1e-6, "{}", opacity(&a)); + assert!(a.ui.transform.is_some()); + key(&mut a, &ctx, Key::Enter, egui::Modifiers::NONE); + assert!(a.ui.transform.is_none()); + let st = a.session.active().unwrap(); + let l = st.doc.layer(st.active_layer.unwrap()).unwrap(); + assert_eq!(l.blend, BlendMode::Difference, "the commit keeps it"); + assert_eq!(l.surface().unwrap().content_bounds().x0, 7, "and the move"); + // Puppet Warp keeps the other keys to itself, but these still reach the tool or layer. + crate::distort_ui::menu(&mut a, &ctx, "edit.puppetWarp", &json!({})).unwrap().unwrap(); + assert!(a.distort.puppet.is_some()); + key(&mut a, &ctx, Key::S, shift_alt); + assert_eq!(layer_blend(&a), BlendMode::Screen); + a.ui.tool = Tool::Brush; + key(&mut a, &ctx, Key::H, shift_alt); + assert_eq!(a.session.tools.brush.mode, BlendMode::HardLight); + assert_eq!(layer_blend(&a), BlendMode::Screen); + assert!(a.distort.puppet.is_some(), "the warp stays open"); + } } diff --git a/crates/ui-egui/src/distort_ui.rs b/crates/ui-egui/src/distort_ui.rs index 4987d8cc4d..61608c920a 100644 --- a/crates/ui-egui/src/distort_ui.rs +++ b/crates/ui-egui/src/distort_ui.rs @@ -160,6 +160,14 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> false } +/// Puppet Warp and Perspective Warp keep the keys to themselves, except the blend mode keys (⇧⌥ +/// letters, ⇧+ / ⇧−) and the number keys, which go on working as with Free Transform: on the +/// active tool's mode and opacity (the Brush's with the Brush), else on the layer's. +fn tool_keys(app: &mut PhotocraftApp, ctx: &egui::Context) { + crate::blend_keys::handle(app, ctx); + crate::opacity_keys::handle(app, ctx); +} + /// Keyboard: ↩ commits, Esc cancels (and Liquify's own shortcuts). True when the key handling /// of the rest of the app should be skipped. pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { @@ -202,6 +210,8 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { p.redo(); } else if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Z)) { p.undo(); + } else { + tool_keys(app, ctx); } return true; } @@ -210,6 +220,8 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { crate::perspective_ui::commit(app); } else if esc(ctx) { app.distort.perspective = None; + } else { + tool_keys(app, ctx); } return true; } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7e53ba4c45..c01aee5c4d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -195,7 +195,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | | | Type formatting keys while editing text | done: ⇧⌘K, U, B, I and / toggle All Caps, Underline, Faux Bold, Faux Italic, Strikethrough (off when every selected character has it, else on); ⌥↑/↓ leading ∓2 pt, ⇧⌥↑/↓ baseline shift ±2 pt (10 with ⌘); ⌥←/→ tracking ±20 over a selection (100 with ⌘; a collapsed caret still kerns); ⌥⇧⌘H toggles Hyphenate for the touched paragraphs. One `type.setStyle` step per press (new `leadingBy`/`baselineShiftBy`/`trackingBy`; auto leading starts from its laid-out value); the global ⇧⌘K/U/B/I never fire while typing. Differs: a collapsed caret in non-empty text changes nothing, since the model can't hold a pending style for the next characters typed (empty text takes it as its typing style) (`type_tool.rs`, `input_tests.rs` `type_tool_format_keys_*`) | | | Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | | -| Blend mode keys: ⇧⌥ + letter picks a mode (N Normal, I Dissolve, R Clear, P Pass Through, K M B A darken, G S D W lighten, O F H V J Z L contrast, E X difference, U T C Y component), ⇧+ / ⇧− step through them | done (2026-10-14): Brush, Pencil and Gradient take them as their own mode (Clear on Brush and Pencil only), any non-painting tool sets every selected layer in one undo step (Pass Through on groups only; the Background is skipped); the typed letter wins, the physical key is the fallback for ⌥ characters on a Mac. ⇧⌥ letters no longer switch tools. Differs: Behind (⇧⌥Q) is not implemented; Clone Stamp, Pattern Stamp, History Brush, Healing, Paint Bucket and Magic Eraser ignore the keys until their options bar has a Mode | `blend_keys.rs` | +| Blend mode keys: ⇧⌥ + letter picks a mode (N Normal, I Dissolve, R Clear, P Pass Through, K M B A darken, G S D W lighten, O F H V J Z L contrast, E X difference, U T C Y component), ⇧+ / ⇧− step through them | done (2026-10-14): Brush, Pencil and Gradient take them as their own mode (Clear on Brush and Pencil only), any non-painting tool sets every selected layer in one undo step (Pass Through on groups only; the Background is skipped); the typed letter wins, the physical key is the fallback for ⌥ characters on a Mac. ⇧⌥ letters no longer switch tools. While a Free Transform box, Puppet Warp or Perspective Warp is open they, and the number keys, still reach the tool (or the layers) without closing it. Differs: Behind (⇧⌥Q) is not implemented; Clone Stamp, Pattern Stamp, History Brush, Healing, Paint Bucket and Magic Eraser ignore the keys until their options bar has a Mode | `blend_keys.rs` | ## Panels and workspace @@ -259,3 +259,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-13 | minor | Free Transform with a selection: the box is the selection's rectangle, and the marching ants follow it while it is transformed (#5297) | | 2026-10-14 | minor | Move › Show Transform Controls frames every selected layer and outlines the selected shapes | | 2026-10-14 | minor | ⇧⌥ + letter blend mode keys for the painting tool or every selected layer; ⇧+ / ⇧− now step every selected layer and include Clear for Brush and Pencil | +| 2026-10-15 | minor | Blend mode and number keys work while a Free Transform, Puppet Warp or Perspective Warp is open | From b1a939f2654f41acd82e584e4d9fd898bb473af5 Mon Sep 17 00:00:00 2001 From: paulou-jpg <79102098+paulou-jpg@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:59:01 -0700 Subject: [PATCH 064/229] Mode dropdowns for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket Photoshop gives these painting tools a Mode in the options bar; here they always painted in Normal. - Clone Stamp, Pattern Stamp and History Brush offer the layer modes, and Healing its own list (Normal, Multiply, Screen, Darken, Lighten, Color, Luminosity). The mode is kept in each tool's own brush, like its size, and reaches the live stroke and the commit. - Paint Bucket: paint.bucket takes "mode" (any blend mode, or "clear" to erase the filled area). algo's bucket fill gains a FillMode for it. The options bar's Mode has Clear after Dissolve, as the Brush's does. - A tool's first brush now starts in Normal instead of inheriting the previous tool's mode, so a Multiply Brush no longer makes the first Clone Stamp stroke multiply. - The Shift+Alt letter and Shift+Plus/Minus mode keys reach these tools, within each tool's menu. Tools with no blend mode (the Erasers, Mixer Brush, Dodge, Burn, Sponge) now pass the keys to the selected layers. Not yet: Healing's Replace mode, and a Mode for Spot Healing, Color Replacement, Blur, Sharpen and Smudge (they ignore the keys meanwhile). Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/paint.rs | 63 +++-- crates/engine/src/paint_cmds.rs | 54 ++++- crates/ui-egui/src/blend_keys.rs | 219 ++++++++++++++---- crates/ui-egui/src/canvas.rs | 5 +- .../src/options_bar_mode_wheel_tests.rs | 37 ++- crates/ui-egui/src/paint_mouse.rs | 7 +- crates/ui-egui/src/panels.rs | 37 ++- crates/ui-egui/src/retouch_ui.rs | 57 ++++- crates/ui-egui/src/state.rs | 6 + crates/ui-egui/src/tool_reset.rs | 2 +- docs/brush-parity.md | 11 +- docs/ui-parity.md | 5 +- 12 files changed, 410 insertions(+), 93 deletions(-) diff --git a/crates/algo/src/paint.rs b/crates/algo/src/paint.rs index c32f28c648..a206c0c9a3 100644 --- a/crates/algo/src/paint.rs +++ b/crates/algo/src/paint.rs @@ -5,6 +5,14 @@ use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba_into}; use serde::{Deserialize, Serialize}; +/// How a fill lays down its colour: blended with a mode, or Clear, which erases what it covers +/// (the Paint Bucket's Mode › Clear). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FillMode { + Blend(BlendMode), + Clear, +} + /// Gradient tool shape. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -59,7 +67,7 @@ pub fn sample_stops(stops: &[(f32, [f32; 4])], t: f32) -> [f32; 4] { fn composite_area( s: &mut Surface, area: Rect, - blend_mode: BlendMode, + mode: FillMode, coverage: impl Fn(i32, i32) -> f32 + Sync, color: impl Fn(i32, i32) -> [f32; 4] + Sync, lock_transparency: bool, @@ -89,7 +97,10 @@ fn composite_area( let i = ((y - r.y0) as usize * w + (x - r.x0) as usize) * n; let px = &mut region[i..i + n]; let d = photocraft_raster::to_rgba(&fmt, px); - let mut o = blend::composite(blend_mode, d, color(x, y), k); + let mut o = match mode { + FillMode::Blend(m) => blend::composite(m, d, color(x, y), k), + FillMode::Clear => [d[0], d[1], d[2], d[3] * (1.0 - k.min(1.0))], + }; if lock_transparency { o[3] = d[3]; } @@ -97,7 +108,7 @@ fn composite_area( // A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is. if let Some(i) = inks && fmt.mode == photocraft_color::ColorMode::Cmyk - && blend_mode == BlendMode::Normal + && mode == FillMode::Blend(BlendMode::Normal) && k >= 1.0 { enc[..4].copy_from_slice(&i); @@ -138,7 +149,7 @@ pub fn paint_gradient( composite_area( s, area, - blend_mode, + FillMode::Blend(blend_mode), |x, y| opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), |x, y| { let t = tool_gradient_t(shape, from, to, x as f32 + 0.5, y as f32 + 0.5); @@ -190,11 +201,12 @@ pub fn bucket_fill_src( lock_transparency: bool, src: impl Fn(i32, i32) -> [f32; 4] + Sync, ) -> bool { - bucket_fill_inks(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, src, None) + bucket_fill_inks(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, src, None, FillMode::Blend(BlendMode::Normal)) } /// [`bucket_fill_src`] where `inks` (CMYK, 0..1) are what a fully covered pixel of a CMYK target -/// gets instead of `src`'s sRGB stand-in (a colour that was picked as CMYK). +/// gets instead of `src`'s sRGB stand-in (a colour that was picked as CMYK), laid down with `mode` +/// (the options bar's Mode). #[allow(clippy::too_many_arguments)] pub fn bucket_fill_inks( s: &mut Surface, @@ -208,6 +220,7 @@ pub fn bucket_fill_inks( lock_transparency: bool, src: impl Fn(i32, i32) -> [f32; 4] + Sync, inks: Option<[f32; 4]>, + mode: FillMode, ) -> bool { if !area.contains(seed.0, seed.1) { return false; @@ -215,7 +228,7 @@ pub fn bucket_fill_inks( let img = crate::selection::rgba8_image(s, area); let Some(region) = crate::selection::wand_region(&img, area, seed, tolerance, contiguous, anti_alias) else { return false }; drop(img); - bucket_fill_region_inks(s, ®ion, opacity, selection, lock_transparency, src, inks); + bucket_fill_region_inks(s, ®ion, opacity, selection, lock_transparency, src, inks, mode); true } @@ -229,10 +242,11 @@ pub fn bucket_fill_region_src( lock_transparency: bool, src: impl Fn(i32, i32) -> [f32; 4] + Sync, ) { - bucket_fill_region_inks(s, region, opacity, selection, lock_transparency, src, None); + bucket_fill_region_inks(s, region, opacity, selection, lock_transparency, src, None, FillMode::Blend(BlendMode::Normal)); } -/// [`bucket_fill_region_src`] with the CMYK `inks` of [`bucket_fill_inks`]. +/// [`bucket_fill_region_src`] with the CMYK `inks` and the `mode` of [`bucket_fill_inks`]. +#[allow(clippy::too_many_arguments)] pub fn bucket_fill_region_inks( s: &mut Surface, region: &crate::selection::Region, @@ -241,18 +255,11 @@ pub fn bucket_fill_region_inks( lock_transparency: bool, src: impl Fn(i32, i32) -> [f32; 4] + Sync, inks: Option<[f32; 4]>, + mode: FillMode, ) { // Composite only over the filled region's box. let b = region.bbox; - composite_area( - s, - b, - BlendMode::Normal, - |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), - src, - lock_transparency, - inks, - ); + composite_area(s, b, mode, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, lock_transparency, inks); } #[cfg(test)] @@ -320,4 +327,24 @@ mod tests { assert_eq!(s.rgba(-1, 4), [0.0; 4], "outside the selection"); assert_eq!(s.rgba(-4, 4), [0.0; 4], "outside the region"); } + + /// The Paint Bucket's Mode: Multiply darkens what's there, Clear erases it (half at 50 %). + #[test] + fn bucket_mode_blends_or_clears() { + let a = Rect::new(0, 0, 4, 1); + let grey = [0.5, 0.5, 0.5, 1.0]; + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut s = Surface::new(fmt); + s.fill_rect(a, &grey); + let red = |_, _| [1.0, 0.0, 0.0, 1.0]; + assert!(bucket_fill_inks(&mut s, a, (0, 0), 0.0, true, false, 1.0, None, false, red, None, FillMode::Blend(BlendMode::Multiply))); + let px = s.rgba(1, 0); + assert!((px[0] - 0.5).abs() < 0.01 && px[1] < 0.01 && (px[3] - 1.0).abs() < 1e-3, "{fmt:?} multiply: {px:?}"); + assert!(bucket_fill_inks(&mut s, a, (0, 0), 0.0, true, false, 0.5, None, false, red, None, FillMode::Clear)); + assert!((s.rgba(1, 0)[3] - 0.5).abs() < 0.01, "{fmt:?} clear at 50 %"); + // Locked transparency keeps the alpha, so Clear does nothing. + assert!(bucket_fill_inks(&mut s, a, (0, 0), 0.0, true, false, 1.0, None, true, red, None, FillMode::Clear)); + assert!((s.rgba(1, 0)[3] - 0.5).abs() < 0.01, "{fmt:?} locked"); + } + } } diff --git a/crates/engine/src/paint_cmds.rs b/crates/engine/src/paint_cmds.rs index 497d7f4fb1..7f23c6b8ba 100644 --- a/crates/engine/src/paint_cmds.rs +++ b/crates/engine/src/paint_cmds.rs @@ -1,6 +1,6 @@ //! Paint helpers: Paint Bucket and Gradient tool. -use photocraft_algo::paint::{GradientShape, bucket_fill_inks, bucket_fill_region_inks, bucket_fill_region_src, bucket_fill_src, paint_gradient}; +use photocraft_algo::paint::{FillMode, GradientShape, bucket_fill_inks, bucket_fill_region_inks, paint_gradient}; use serde_json::{Value, json}; use crate::commands::{CommandSpec, blend_from_str}; @@ -41,12 +41,27 @@ fn opacity(p: &Value, cmd: &str) -> Result { Ok(crate::eraser_cmds::number(p, cmd, "opacity", 100.0, 1.0..=100.0)? as f32 / 100.0) } +/// The Paint Bucket's `mode`: a blend mode (not Pass Through, a group's) or `clear`. +fn fill_mode(p: &Value) -> Result { + let bad = |msg: String| EngineError::BadParams { cmd: "paint.bucket".into(), msg }; + match p.get("mode") { + None | Some(Value::Null) => Ok(FillMode::Blend(photocraft_color::BlendMode::Normal)), + Some(Value::String(m)) if m.eq_ignore_ascii_case("clear") => Ok(FillMode::Clear), + Some(Value::String(m)) => blend_from_str(m) + .filter(|m| *m != photocraft_color::BlendMode::PassThrough) + .map(FillMode::Blend) + .ok_or_else(|| bad(format!("unknown blend mode `{m}`"))), + Some(v) => Err(bad(format!("`mode` must be a blend mode name or `clear`, not {v}"))), + } +} + fn bucket(s: &mut Session, p: &Value) -> Result { let fg = s.tools.foreground; let c = color(p.get("color"), fg); let (x, y) = (f(p, "x", 0.0).floor() as i32, f(p, "y", 0.0).floor() as i32); let (tol, contiguous, aa) = (f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true)); let opacity = opacity(p, "paint.bucket")?; + let mode = fill_mode(p)?; let all = b(p, "sampleAllLayers", false) && crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels; // Other visible layers can define the region, but the fill still writes only to the target. let region = if all { @@ -83,16 +98,16 @@ fn bucket(s: &mut Session, p: &Value) -> Result { let w = area.width() as usize; let src = |px: i32, py: i32| rendered[(py - area.y0) as usize * w + (px - area.x0) as usize]; if let Some(region) = ®ion { - bucket_fill_region_src(surf, region, opacity, sel.as_ref(), lock, src); + bucket_fill_region_inks(surf, region, opacity, sel.as_ref(), lock, src, None, mode); true } else { - bucket_fill_src(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, src) + bucket_fill_inks(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, src, None, mode) } } else if let Some(region) = ®ion { - bucket_fill_region_inks(surf, region, opacity, sel.as_ref(), lock, |_, _| c, inks); + bucket_fill_region_inks(surf, region, opacity, sel.as_ref(), lock, |_, _| c, inks, mode); true } else { - bucket_fill_inks(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, |_, _| c, inks) + bucket_fill_inks(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, |_, _| c, inks, mode) }; surf.prune(); Ok(ok) @@ -162,7 +177,7 @@ pub fn specs() -> Vec { label: "Paint Bucket", menu: &[], shortcut: None, - params: r##"{"x":px,"y":px,"tolerance":0..255=32,"contiguous":bool=true,"antiAlias":bool=true,"sampleAllLayers":bool=false,"contents":"foreground|pattern"="foreground","color":"#rrggbb"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":1..100=100,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, + params: r##"{"x":px,"y":px,"tolerance":0..255=32,"contiguous":bool=true,"antiAlias":bool=true,"sampleAllLayers":bool=false,"contents":"foreground|pattern"="foreground","color":"#rrggbb"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":1..100=100,"mode":"normal|multiply|…|clear (erases the filled area)"="normal","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, enabled: crate::commands::has_paintable, run: bucket, journal: true, @@ -210,6 +225,33 @@ mod tests { (s, bottom) } + /// The options bar's Mode reaches the fill: Multiply over grey, Clear erases, a bad name or + /// Pass Through is an error and changes nothing. + #[test] + fn bucket_mode_multiplies_clears_and_rejects_bad_names() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let base = json!({"x": 1, "y": 1, "antiAlias": false, "tolerance": 0}); + let with = |extra: Value| { + let mut p = base.clone(); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + p + }; + s.execute("paint.bucket", with(json!({"color": "#808080"}))).unwrap(); + s.execute("paint.bucket", with(json!({"color": "#ff0000", "mode": "multiply"}))).unwrap(); + let p = px(&s, 3, 3); + assert!((p[0] - 0.5).abs() < 0.01 && p[1] < 0.01 && p[3] > 0.99, "depth {depth}: {p:?}"); + for bad in [json!("passThrough"), json!("sparkle"), json!(3)] { + assert!(s.execute("paint.bucket", with(json!({"mode": bad}))).is_err(), "{bad}"); + } + assert_eq!(px(&s, 3, 3), p, "a refused mode paints nothing"); + s.execute("paint.bucket", with(json!({"mode": "Clear", "opacity": 50}))).unwrap(); + assert!((px(&s, 3, 3)[3] - 0.5).abs() < 0.01, "depth {depth}: half cleared"); + } + } + #[test] fn bucket_sample_all_layers_uses_visible_boundaries_at_all_depths() { for depth in [8, 16, 32] { diff --git a/crates/ui-egui/src/blend_keys.rs b/crates/ui-egui/src/blend_keys.rs index 81bd10026d..8f4af6c363 100644 --- a/crates/ui-egui/src/blend_keys.rs +++ b/crates/ui-egui/src/blend_keys.rs @@ -1,8 +1,8 @@ //! Blend mode keys, as in Photoshop. ⇧+ and ⇧− step through the modes (#3102), and ⇧⌥ plus a -//! letter picks one (⇧⌥M Multiply, ⇧⌥S Screen…). With a painting tool that has a mode of its own -//! they set that tool's mode and never the layer's: the Brush's or Pencil's (which also offer -//! Clear), the Gradient's; the other painting tools are left alone until their options bar has a -//! Mode. With any other tool (Move, the selection tools…) they set every selected layer's mode. +//! letter picks one (⇧⌥M Multiply, ⇧⌥S Screen…). With a painting tool that has a Mode of its own +//! (`tool_modes`: Brush, Pencil, Paint Bucket, Gradient, the stamps, History Brush, Healing) they +//! set that tool's mode within its menu and never the layer's. With a tool that has no blend mode +//! (Move, the selection tools, the Erasers…) they set every selected layer's mode. //! The step list wraps at either end. Each press is one call of an engine command per layer //! (`tools.setBrush`, `layer.setProps`), coalesced into one undo step. @@ -116,43 +116,85 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// Photoshop's Healing Brush modes, less Replace (not implemented yet). +pub const HEALING_MODES: [BlendMode; 7] = + [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Darken, BlendMode::Lighten, BlendMode::Color, BlendMode::Luminosity]; + +/// A painting tool's options-bar Mode menu in order, `None` being Clear (after Dissolve, on the +/// tools Photoshop offers it on). `None` for a tool without a blend mode control. +pub fn tool_modes(tool: Tool) -> Option>> { + let layer = || BlendMode::LAYER_MODES.iter().copied().map(Some).collect::>(); + Some(match tool { + Tool::Brush | Tool::Pencil | Tool::PaintBucket => { + let mut modes = layer(); + let at = modes.iter().position(|m| *m == Some(BlendMode::Dissolve)).map_or(modes.len(), |i| i + 1); + modes.insert(at, None); + modes + } + Tool::Gradient | Tool::CloneStamp | Tool::PatternStamp | Tool::HistoryBrush => layer(), + Tool::Healing => HEALING_MODES.iter().copied().map(Some).collect(), + _ => return None, + }) +} + /// Where the active tool's blend mode keys go. enum Target { - /// The Brush's or Pencil's mode, Clear included. - Paint, - Gradient, - /// A painting tool whose mode has no options-bar control yet (or the Eraser, which has none): - /// the keys do nothing, and never reach the layer. + /// The tool's own Mode, one of these. + Tool(Vec>), + /// A painting tool with a blend mode in Photoshop but no Mode control here yet: the keys do + /// nothing, and never reach the layer. Nothing, Layers, } fn target(tool: Tool) -> Target { + if let Some(modes) = tool_modes(tool) { + return Target::Tool(modes); + } match tool { - Tool::Brush | Tool::Pencil => Target::Paint, - Tool::Gradient => Target::Gradient, - t if t.is_brushlike() || t == Tool::MagicEraser || t == Tool::PaintBucket => Target::Nothing, + Tool::SpotHealing | Tool::ColorReplacement | Tool::Blur | Tool::Sharpen | Tool::Smudge => Target::Nothing, + // No blend mode of their own (the Erasers, Dodge, Sponge…): the layer's, as in Photoshop. _ => Target::Layers, } } -/// The Brush's or Pencil's mode list: the layer modes with Clear (`None`) after Dissolve, as in -/// its options-bar dropdown. -fn paint_modes() -> Vec> { - let mut modes: Vec> = BlendMode::LAYER_MODES.iter().copied().map(Some).collect(); - let at = modes.iter().position(|m| *m == Some(BlendMode::Dissolve)).map_or(modes.len(), |i| i + 1); - modes.insert(at, None); - modes +/// The active tool's mode, `None` being Clear. A brush-based tool's lives in its own brush. +pub fn tool_mode(app: &mut PhotocraftApp) -> Option { + let o = &app.ui.tool_options; + match app.ui.tool { + Tool::Brush if o.brush_clear => None, + Tool::Pencil if o.pencil_clear => None, + Tool::PaintBucket if o.bucket_clear => None, + Tool::PaintBucket => Some(o.bucket_blend_mode), + Tool::Gradient => Some(o.gradient_blend_mode), + _ => { + crate::paint_mouse::sync_tool_brush(app); + Some(app.session.tools.brush.mode) + } + } } -/// The Brush's or Pencil's Clear flag. -fn clear_flag(app: &mut PhotocraftApp) -> &mut bool { - if app.ui.tool == Tool::Pencil { &mut app.ui.tool_options.pencil_clear } else { &mut app.ui.tool_options.brush_clear } -} - -/// Sets the Brush's or Pencil's mode; `None` is Clear. -fn set_paint_mode(app: &mut PhotocraftApp, mode: Option) { - *clear_flag(app) = mode.is_none(); +/// Sets the active tool's mode; `None` is Clear. +fn set_tool_mode(app: &mut PhotocraftApp, mode: Option) { + match app.ui.tool { + Tool::Gradient => { + if let Some(m) = mode { + set_gradient_mode(app, m); + } + return; + } + Tool::PaintBucket => { + app.ui.tool_options.bucket_clear = mode.is_none(); + if let Some(m) = mode { + app.ui.tool_options.bucket_blend_mode = m; + } + return; + } + Tool::Brush => app.ui.tool_options.brush_clear = mode.is_none(), + Tool::Pencil => app.ui.tool_options.pencil_clear = mode.is_none(), + _ => {} + } + crate::paint_mouse::sync_tool_brush(app); if let Some(m) = mode && let Ok(m) = serde_json::to_value(m) { @@ -189,15 +231,10 @@ fn set_layers_mode(app: &mut PhotocraftApp, mode: BlendMode) { /// layer's. pub fn press(app: &mut PhotocraftApp, step: i32) { match target(app.ui.tool) { - Target::Paint => { - let current = if *clear_flag(app) { None } else { Some(app.session.tools.brush.mode) }; - if let Some(mode) = stepped(&paint_modes(), current, step) { - set_paint_mode(app, mode); - } - } - Target::Gradient => { - if let Some(mode) = stepped(&BlendMode::LAYER_MODES, app.ui.tool_options.gradient_blend_mode, step) { - set_gradient_mode(app, mode); + Target::Tool(modes) => { + let current = tool_mode(app); + if let Some(mode) = stepped(&modes, current, step) { + set_tool_mode(app, mode); } } Target::Nothing => {} @@ -216,14 +253,21 @@ pub fn press(app: &mut PhotocraftApp, step: i32) { } } -/// One ⇧⌥ letter press. A mode the target doesn't have (Clear or Behind for layers, Pass Through -/// for a painting tool) does nothing. +/// One ⇧⌥ letter press. A mode the target's menu doesn't have (Clear or Behind for layers, Pass +/// Through for a painting tool, Overlay for the Healing Brush) does nothing. pub fn pick_mode(app: &mut PhotocraftApp, pick: Pick) { - match (target(app.ui.tool), pick) { - (Target::Paint, Pick::Mode(m)) if m != BlendMode::PassThrough => set_paint_mode(app, Some(m)), - (Target::Paint, Pick::Clear) => set_paint_mode(app, None), - (Target::Gradient, Pick::Mode(m)) if m != BlendMode::PassThrough => set_gradient_mode(app, m), - (Target::Layers, Pick::Mode(m)) => set_layers_mode(app, m), + let want = match pick { + Pick::Mode(m) => Some(m), + Pick::Clear => None, + Pick::Behind => return, + }; + match target(app.ui.tool) { + Target::Tool(modes) if modes.contains(&want) => set_tool_mode(app, want), + Target::Layers => { + if let Some(m) = want { + set_layers_mode(app, m); + } + } _ => {} } } @@ -459,20 +503,20 @@ mod tests { assert_eq!(layer_blend(&a), BlendMode::Normal); } - /// The Gradient takes the letters; a painting tool without a Mode control yet (Clone Stamp) - /// ignores them rather than changing the layer. + /// The Gradient takes the letters; Smudge, which has a blend mode in Photoshop but no Mode + /// control here yet, ignores them rather than changing the layer. #[test] - fn gradient_takes_the_letters_and_clone_stamp_ignores_them() { + fn gradient_takes_the_letters_and_smudge_ignores_them() { let ctx = egui::Context::default(); let mut a = app(Tool::Gradient); alt_shift(&mut a, &ctx, Key::O, Key::O); assert_eq!(a.ui.tool_options.gradient_blend_mode, BlendMode::Overlay); alt_shift(&mut a, &ctx, Key::R, Key::R); - assert_eq!(a.ui.tool_options.gradient_blend_mode, BlendMode::Overlay); - a.ui.tool = Tool::CloneStamp; + assert_eq!(a.ui.tool_options.gradient_blend_mode, BlendMode::Overlay, "no Clear for the Gradient"); + a.ui.tool = Tool::Smudge; alt_shift(&mut a, &ctx, Key::M, Key::M); assert_eq!(layer_blend(&a), BlendMode::Normal); - assert_eq!(a.ui.tool, Tool::CloneStamp); + assert_eq!(a.ui.tool, Tool::Smudge); let mut none = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); pick_mode(&mut none, Pick::Mode(BlendMode::Multiply)); } @@ -529,4 +573,81 @@ mod tests { assert_eq!(layer_blend(&a), BlendMode::Screen); assert!(a.distort.puppet.is_some(), "the warp stays open"); } + + /// The stamps and the History Brush keep their mode in their own brush: the keys set it there, + /// never the layer's, and another tool's brush keeps its own. + #[test] + fn clone_stamp_pattern_stamp_and_history_brush_take_the_keys_into_their_own_brush() { + let ctx = egui::Context::default(); + let mut a = app(Tool::Brush); + alt_shift(&mut a, &ctx, Key::M, Key::M); + for (tool, key, mode) in + [(Tool::CloneStamp, Key::S, BlendMode::Screen), (Tool::PatternStamp, Key::O, BlendMode::Overlay), (Tool::HistoryBrush, Key::K, BlendMode::Darken)] + { + a.ui.tool = tool; + assert_eq!(tool_mode(&mut a), Some(BlendMode::Normal), "{tool:?} starts in Normal"); + alt_shift(&mut a, &ctx, key, key); + assert_eq!(tool_mode(&mut a), Some(mode), "{tool:?}"); + alt_shift(&mut a, &ctx, Key::R, Key::R); + assert_eq!(tool_mode(&mut a), Some(mode), "{tool:?} has no Clear"); + key_press(&mut a, &ctx, 1); + assert_ne!(tool_mode(&mut a), Some(mode), "{tool:?} steps"); + assert_eq!(layer_blend(&a), BlendMode::Normal, "{tool:?} leaves the layer alone"); + } + a.ui.tool = Tool::Brush; + assert_eq!(tool_mode(&mut a), Some(BlendMode::Multiply), "the Brush keeps its own mode"); + } + + fn key_press(a: &mut PhotocraftApp, ctx: &egui::Context, step: i32) { + key(a, ctx, if step > 0 { Key::Plus } else { Key::Minus }, egui::Modifiers::SHIFT); + } + + /// The Healing Brush's menu is short: letters outside it do nothing, and ⇧+ steps within it. + #[test] + fn healing_brush_takes_only_its_own_modes() { + let ctx = egui::Context::default(); + let mut a = app(Tool::Healing); + alt_shift(&mut a, &ctx, Key::O, Key::O); + assert_eq!(tool_mode(&mut a), Some(BlendMode::Normal), "Overlay isn't a Healing mode"); + alt_shift(&mut a, &ctx, Key::Y, Key::Y); + assert_eq!(tool_mode(&mut a), Some(BlendMode::Luminosity)); + key_press(&mut a, &ctx, 1); + assert_eq!(tool_mode(&mut a), Some(BlendMode::Normal), "wraps within the Healing list"); + key_press(&mut a, &ctx, 1); + assert_eq!(tool_mode(&mut a), Some(BlendMode::Multiply)); + assert_eq!(layer_blend(&a), BlendMode::Normal); + } + + /// The Paint Bucket has its own Mode, Clear included. + #[test] + fn paint_bucket_takes_the_keys_with_clear() { + let ctx = egui::Context::default(); + let mut a = app(Tool::PaintBucket); + alt_shift(&mut a, &ctx, Key::M, Key::M); + assert_eq!(a.ui.tool_options.bucket_blend_mode, BlendMode::Multiply); + alt_shift(&mut a, &ctx, Key::R, Key::R); + assert!(a.ui.tool_options.bucket_clear); + key_press(&mut a, &ctx, 1); + assert!(!a.ui.tool_options.bucket_clear); + assert_eq!(a.ui.tool_options.bucket_blend_mode, BlendMode::Darken, "Clear is followed by Darken"); + assert_eq!(layer_blend(&a), BlendMode::Normal); + } + + /// Tools without a blend mode of their own (the Erasers, Dodge, the Mixer Brush) pass the keys + /// to the selected layers, as Photoshop does. + #[test] + fn tools_without_a_blend_mode_set_the_layer() { + let ctx = egui::Context::default(); + for (tool, key, mode) in [ + (Tool::Eraser, Key::M, BlendMode::Multiply), + (Tool::MagicEraser, Key::S, BlendMode::Screen), + (Tool::Dodge, Key::O, BlendMode::Overlay), + (Tool::MixerBrush, Key::E, BlendMode::Difference), + ] { + let mut a = app(tool); + alt_shift(&mut a, &ctx, key, key); + assert_eq!(layer_blend(&a), mode, "{tool:?}"); + assert_eq!(a.ui.tool, tool); + } + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d2e535f3f6..8abfff411e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -709,7 +709,7 @@ fn begin_live_stroke(app: &PhotocraftApp) -> Option { EngineStroke::Dab(photocraft_engine::retouch_cmds::LiveDab::begin(&app.session, cmd, &p).ok()?) } else if let Some(cmd) = live_retouch_command(d.tool) { // The params `retouch_ui::finish_stroke` commits. - let mut p = if d.tool == Tool::HistoryBrush { json!({}) } else { crate::retouch_ui::clone_params(app)? }; + let mut p = if d.tool == Tool::HistoryBrush { crate::retouch_ui::history_brush_params(app) } else { crate::retouch_ui::clone_params(app)? }; p["points"] = json!(d.points); p["target"] = paint_target(app); EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?) @@ -5608,7 +5608,8 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) Tool::PaintBucket => { let o = app.ui.tool_options.clone(); let contents = if o.bucket_fill_pattern { "pattern" } else { "foreground" }; - let _ = app.run("paint.bucket", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "sampleAllLayers": o.sample_all.paint_bucket, "opacity": o.fill_opacity, "contents": contents, "target": paint_target(app)})); + let mode = if o.bucket_clear { "clear" } else { o.bucket_blend_mode.label() }; + let _ = app.run("paint.bucket", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "sampleAllLayers": o.sample_all.paint_bucket, "opacity": o.fill_opacity, "contents": contents, "mode": mode, "target": paint_target(app)})); return; } Tool::RedEye => { diff --git a/crates/ui-egui/src/options_bar_mode_wheel_tests.rs b/crates/ui-egui/src/options_bar_mode_wheel_tests.rs index 068561b4a0..9e6d172a44 100644 --- a/crates/ui-egui/src/options_bar_mode_wheel_tests.rs +++ b/crates/ui-egui/src/options_bar_mode_wheel_tests.rs @@ -1,5 +1,6 @@ -//! #2061: the scroll wheel over the options bar's Mode dropdown (Brush, Pencil, Gradient) steps -//! through the blend modes, as over the Layers panel's (#1747) and as in Photoshop. +//! #2061: the scroll wheel over the options bar's Mode dropdown (Brush, Pencil, Gradient, Paint +//! Bucket, the stamps, History Brush, Healing) steps through the blend modes, as over the Layers +//! panel's (#1747) and as in Photoshop. use egui::accesskit::Role; use egui::{Pos2, Rect, vec2}; @@ -81,3 +82,35 @@ fn the_wheel_over_the_gradient_mode_dropdown_steps_its_blend_mode() { wheel(&mut h, at, 1.0); assert_eq!(h.state().ui.tool_options.gradient_blend_mode, BlendMode::Dissolve); } + +/// The stamps, the History Brush and the Healing Brush have a Mode dropdown of their own brush's +/// mode; the Healing Brush's skips the modes it doesn't have. +#[test] +fn retouch_tools_have_a_mode_dropdown() { + for (tool, after_two) in [ + (Tool::CloneStamp, BlendMode::Darken), + (Tool::PatternStamp, BlendMode::Darken), + (Tool::HistoryBrush, BlendMode::Darken), + (Tool::Healing, BlendMode::Screen), + ] { + let mut h = harness(tool); + let at = mode_dropdown(&h).center(); + wheel(&mut h, at, -2.0); + assert_eq!(brush_mode(&h), after_two, "{tool:?}"); + assert_eq!(h.state().ui.tool, tool); + } +} + +/// The Paint Bucket's Mode dropdown has Clear after Dissolve, as the Brush's does. +#[test] +fn the_paint_bucket_mode_dropdown_has_clear() { + let mut h = harness(Tool::PaintBucket); + let at = mode_dropdown(&h).center(); + wheel(&mut h, at, -1.0); + assert_eq!(h.state().ui.tool_options.bucket_blend_mode, BlendMode::Dissolve); + wheel(&mut h, at, -1.0); + assert!(h.state().ui.tool_options.bucket_clear); + wheel(&mut h, at, -1.0); + assert!(!h.state().ui.tool_options.bucket_clear); + assert_eq!(h.state().ui.tool_options.bucket_blend_mode, BlendMode::Darken); +} diff --git a/crates/ui-egui/src/paint_mouse.rs b/crates/ui-egui/src/paint_mouse.rs index 6a19c9ff77..1188b70398 100644 --- a/crates/ui-egui/src/paint_mouse.rs +++ b/crates/ui-egui/src/paint_mouse.rs @@ -31,8 +31,10 @@ pub fn sync_tool_brush(app: &mut PhotocraftApp) { save_brush(app, app.ui.brush_tool, current.clone()); app.session.tools.brush = match app.ui.tool_brushes.iter().find(|(t, _)| *t == tool) { Some((_, b)) => session_fields(¤t, b), - // A tool seen for the first time takes what the brush has, at Photoshop's smoothing. - None => BrushSettings { smoothing: first_smoothing(), ..current }, + // A tool seen for the first time takes what the brush has, at Photoshop's smoothing, but + // in Normal mode: each tool's Mode starts at Normal, so a Multiply Brush doesn't make the + // first Clone Stamp stroke multiply (or give Healing a mode its menu doesn't have). + None => BrushSettings { smoothing: first_smoothing(), mode: photocraft_color::BlendMode::Normal, ..current }, }; // The picked preset was the outgoing tool's brush: forget it, so Update Brush can't overwrite // that preset with this tool's brush. @@ -611,6 +613,7 @@ mod tests { // Mode, opacity, flow and the dynamics sections are per tool too. app.ui.tool = Tool::CloneStamp; sync_tool_brush(&mut app); + assert_eq!(mode(&brush(&app)), photocraft_color::BlendMode::Normal, "a new tool starts in Normal, not the Brush's Multiply"); app.run("tools.setBrush", json!({"brush": {"mode": "Screen", "opacity": 0.3}})).unwrap(); app.ui.tool = Tool::Brush; sync_tool_brush(&mut app); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 18cef34c98..6fb870b93e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1009,12 +1009,21 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::brush_picker::settings_toggle(app, ui); widgets::vline(ui, 22.0); } - if tool == Tool::CloneStamp { + if matches!(tool, Tool::CloneStamp | Tool::PatternStamp | Tool::HistoryBrush | Tool::Healing) { let before = app.session.tools.brush.clone(); let mut brush = before.clone(); - let width = if t.pro { 62.0 } else { 66.0 }; - percent_field(ui, tl!("Opacity"), &mut brush.opacity, 1.0..=100.0, width); - percent_field(ui, tl!("Flow"), &mut brush.flow, 1.0..=100.0, width); + // Mode is the tool's own brush's, kept per tool like its size. + if let Some(modes) = crate::blend_keys::tool_modes(tool) { + opt_label(ui, tl!("Mode")); + if let Some(Some(m)) = tool_mode_dropdown(ui, "retouch-mode", &modes, Some(brush.mode)) { + brush.mode = m; + } + } + if tool == Tool::CloneStamp { + let width = if t.pro { 62.0 } else { 66.0 }; + percent_field(ui, tl!("Opacity"), &mut brush.opacity, 1.0..=100.0, width); + percent_field(ui, tl!("Flow"), &mut brush.flow, 1.0..=100.0, width); + } crate::brush_panel::commit_gesture(app, ui.ctx(), &before, &brush); widgets::vline(ui, 22.0); } @@ -1267,6 +1276,17 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if widgets::dropdown(ui, "bucket-src", &mut src, &[(0u8, tl!("Foreground")), (1, tl!("Pattern"))], 100.0) { app.ui.tool_options.bucket_fill_pattern = src == 1; } + opt_label(ui, tl!("Mode")); + if let Some(modes) = crate::blend_keys::tool_modes(tool) { + let o = &mut app.ui.tool_options; + let current = if o.bucket_clear { None } else { Some(o.bucket_blend_mode) }; + if let Some(m) = tool_mode_dropdown(ui, "bucket-mode", &modes, current) { + o.bucket_clear = m.is_none(); + if let Some(m) = m { + o.bucket_blend_mode = m; + } + } + } opt_label(ui, tl!("Opacity")); widgets::value_field(ui, &mut app.ui.tool_options.fill_opacity, 1.0..=100.0, "%", 62.0); opt_label(ui, tl!("Tolerance")); @@ -4099,6 +4119,15 @@ fn brush_tip(p: &egui::Painter, c: egui::Pos2, rad: f32, hardness: f32, color: C /// The Brush's or Pencil's Mode dropdown: the blend modes, with Photoshop's painting-only Clear /// after Dissolve (#3439). Clear erases with the brush (`clear`); it shows while chosen, or while /// `~` is held (`held`). The wheel over it steps the mode, as over the Layers panel's (#2061). +/// A painting tool's Mode dropdown over `modes` (`None` is Clear); the wheel over it steps the +/// mode. Returns the mode picked this frame. +fn tool_mode_dropdown(ui: &mut egui::Ui, id: &str, modes: &[Option], current: Option) -> Option> { + let opts: Vec<(Option, &str)> = modes.iter().map(|m| (*m, m.map_or("Clear", BlendMode::label))).collect(); + let mut mode = current; + let chosen = widgets::dropdown_wheel_hovered(ui, id, &mut mode, &opts, 96.0).0; + chosen.last().copied() +} + fn paint_mode_dropdown(ui: &mut egui::Ui, id: &str, b: &mut photocraft_engine::BrushSettings, clear: &mut bool, held: bool) { let mut opts: Vec<(Option, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (Some(*m), m.label())).collect(); let at = opts.iter().position(|o| o.0 == Some(BlendMode::Dissolve)).map_or(opts.len(), |i| i + 1); diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index 20efb9bc0b..0f3da83947 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -27,7 +27,8 @@ pub(crate) fn alt_flipped(tool: Tool, alt: bool) -> Tool { /// point is set yet. pub(crate) fn clone_params(app: &PhotocraftApp) -> Option { let o = &app.ui.tool_options; - let mut p = json!({"aligned": o.clone_aligned, "sampleLayer": o.clone_sample}); + // The options bar's Mode is the tool's own brush's. + let mut p = json!({"aligned": o.clone_aligned, "sampleLayer": o.clone_sample, "mode": app.session.tools.brush.mode.label()}); if app.ui.tool == Tool::CloneStamp { p["wrap"] = json!(app.ui.view.pattern_preview); p["opacity"] = json!(app.session.tools.brush.opacity * 100.0); @@ -45,6 +46,12 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option { Some(p) } +/// History Brush params for the live preview and the commit (without `points` and `target`): the +/// options bar's Mode, which is the tool's own brush's. +pub(crate) fn history_brush_params(app: &PhotocraftApp) -> Value { + json!({"mode": app.session.tools.brush.mode.label()}) +} + /// The command and options-bar params (without `points` and `target`) of a sequential-dab tool: /// Blur, Sharpen, Smudge, Dodge, Burn, Sponge. Shared by the live preview and the commit. pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static str, Value)> { @@ -100,6 +107,7 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m "impressionist": o.pattern_stamp_impressionist, "scale": o.pattern_stamp_scale, "angle": o.pattern_stamp_angle, + "mode": app.session.tools.brush.mode.label(), }); if o.pattern_stamp_aligned && let Some(ph) = app.ui.pattern_stamp_phase @@ -125,7 +133,7 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m }; (if tool == Tool::Healing { "paint.healingBrush" } else { "paint.cloneStamp" }, p) } - Tool::HistoryBrush => ("paint.historyBrush", json!({})), + Tool::HistoryBrush => ("paint.historyBrush", history_brush_params(app)), Tool::Blur | Tool::Sharpen | Tool::Smudge | Tool::Dodge | Tool::Burn | Tool::Sponge => match dab_params(app, tool) { Some(cp) => cp, None => return false, @@ -781,6 +789,51 @@ mod tests { assert_eq!(params["flow"], 50.0); } + /// The options bar's Mode (each tool's own brush's) reaches the stamps', the History Brush's + /// and the Healing Brush's strokes, live and committed. + #[test] + fn retouch_mode_reaches_the_command() { + for (tool, cmd, mode) in [ + (Tool::CloneStamp, "paint.cloneStamp", "Multiply"), + (Tool::Healing, "paint.healingBrush", "Luminosity"), + (Tool::HistoryBrush, "paint.historyBrush", "Screen"), + (Tool::PatternStamp, "paint.patternStamp", "Overlay"), + ] { + let mut app = app(); + app.ui.tool = tool; + crate::paint_mouse::sync_tool_brush(&mut app); + app.run("tools.setBrush", json!({"brush": {"mode": mode}})).unwrap(); + app.ui.clone_source = Some([10.0, 10.0]); + drag(&mut app, tool); + let (id, params) = app.session.journal.last().unwrap(); + assert_eq!((id.as_str(), params["mode"].as_str()), (cmd, Some(mode)), "{tool:?}"); + } + } + + /// The Paint Bucket's Mode reaches the fill: Multiply darkens, Clear erases. + #[test] + fn paint_bucket_mode_reaches_the_fill() { + let mut app = app(); + app.run("layer.new.layer", json!({})).unwrap(); + app.ui.tool = Tool::PaintBucket; + app.session.tools.foreground = [0.5, 0.5, 0.5, 1.0]; + let click = |app: &mut PhotocraftApp| { + let m = egui::Modifiers::NONE; + tool_event(app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x: 20.0, y: 20.0 }, m); + }; + click(&mut app); + app.session.tools.foreground = [1.0, 0.0, 0.0, 1.0]; + app.ui.tool_options.bucket_blend_mode = photocraft_color::BlendMode::Multiply; + click(&mut app); + let px = active(&app).surface().unwrap().rgba(20, 20); + assert!((px[0] - 0.5).abs() < 0.02 && px[1] < 0.02, "multiplied: {px:?}"); + app.ui.tool_options.bucket_clear = true; + click(&mut app); + assert_eq!(app.session.journal.last().unwrap().1["mode"], "clear"); + assert!(active(&app).surface().unwrap().rgba(20, 20)[3] < 0.01, "cleared"); + } + #[test] fn clone_without_a_source_names_the_platform_modifier() { // Option-click on the Mac, Alt-click elsewhere (#251). diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 974d3fde35..3d4c248f8a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -535,6 +535,10 @@ pub struct ToolOptions { pub fill_opacity: f32, /// Paint Bucket fill source: false = Foreground colour, true = Pattern (Patterns panel selection). pub bucket_fill_pattern: bool, + /// Paint Bucket › Mode (any layer mode; Clear is `bucket_clear`). + pub bucket_blend_mode: photocraft_color::BlendMode, + /// Paint Bucket › Mode: Clear, which erases the filled area (see `brush_clear`). + pub bucket_clear: bool, /// Type tool: family, style name, size (pt), anti-aliasing and paragraph alignment. pub type_font: String, pub type_style: String, @@ -828,6 +832,8 @@ impl Default for ToolOptions { gradient_blend_mode: photocraft_color::BlendMode::Normal, fill_opacity: 100.0, bucket_fill_pattern: false, + bucket_blend_mode: photocraft_color::BlendMode::Normal, + bucket_clear: false, type_font: "Inter".into(), type_style: "Regular".into(), type_size: 48.0, diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 3c154d3a45..b672b0825f 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -190,7 +190,7 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { Tool::ColorReplacement => reset!(cr_mode, cr_sampling, cr_limits, cr_tolerance, cr_anti_alias), Tool::MagicEraser => reset!(tolerance, anti_alias, contiguous, magic_eraser_opacity), Tool::Gradient => reset!(gradient_style, gradient_reverse, gradient_dither, gradient_classic, gradient_blend_mode, fill_opacity), - Tool::PaintBucket => reset!(bucket_fill_pattern, fill_opacity, tolerance, anti_alias, contiguous), + Tool::PaintBucket => reset!(bucket_fill_pattern, bucket_blend_mode, bucket_clear, fill_opacity, tolerance, anti_alias, contiguous), Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => reset!(type_font, type_style, type_size, type_aa, type_align), Tool::Zoom => reset!(zoom_scrubby), Tool::SpotHealing => reset!(spot_type), diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 5c75a3e55f..18a62f69f1 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Mode for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -92,10 +92,10 @@ tablets against Photoshop side by side. |---|---|---| | Brush, Pencil (aliased, Auto Erase), Eraser (Brush/Pencil/Block) | done | Pencil 1 px quality #2139 | | Mixer Brush (Wet, Load, Mix, Flow, Sample All Layers, Current Brush Load with Load Brush and Clean Brush, automatic Load and Clean) | partial | No live result while dragging, no Alt-click loading (#2247) | -| Clone Stamp (opacity, flow, aligned, sample, overlay) | partial | Clone Source W/H link and Alt target cursor (TOOL-213-8) | -| Pattern Stamp (aligned, impressionist) | done | | -| History Brush | done | Art History Brush missing | -| Spot Healing, Healing | partial | grain not matched on flat colour (#2104), "horrible in general" (#1092), stops responding after switching (#1751) | +| Clone Stamp (mode, opacity, flow, aligned, sample, overlay) | partial | Clone Source W/H link and Alt target cursor (TOOL-213-8) | +| Pattern Stamp (mode, aligned, impressionist) | done | | +| History Brush (mode) | done | Art History Brush missing | +| Spot Healing, Healing (Healing mode: Normal, Multiply, Screen, Darken, Lighten, Color, Luminosity) | partial | Healing's Replace mode and Spot Healing's Mode missing; grain not matched on flat colour (#2104), "horrible in general" (#1092), stops responding after switching (#1751) | | Blur, Sharpen, Smudge (premultiplied, Sample All Layers, spacing) | done | BUG-207-2/3, #2171 | | Dodge, Burn, Sponge | done | | | Background Eraser, Magic Eraser | done | | @@ -123,4 +123,5 @@ tablets against Photoshop side by side. | 2026-10-12 | minor | A shaped tip's cursor outline turns and flips with a rotated or flipped view, and follows pixel aspect correction | | 2026-10-13 | minor | Shape Dynamics and Transfer minimum inputs follow their Control and retain their values while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-13 | minor | Hue Jitter uses the same themed amount slider as other jitter controls (#3825, partial). No brush-feel estimate changed. | +| 2026-10-14 | minor | Options-bar Mode for Clone Stamp, Pattern Stamp, History Brush and Healing (kept in each tool's brush) and Paint Bucket (with Clear); each tool's first brush starts in Normal. No brush-feel estimate changed. | | 2026-10-13 | minor | Dragging a brush preset shows its cached tip thumbnail and name in the Brushes panel and preset picker (#4225, partial). No brush-feel estimate changed. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c01aee5c4d..084bf3f87f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (⇧⌥ blend mode letters; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (⇧⌥ blend mode letters; painting tool Mode menus; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -195,7 +195,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | | | Type formatting keys while editing text | done: ⇧⌘K, U, B, I and / toggle All Caps, Underline, Faux Bold, Faux Italic, Strikethrough (off when every selected character has it, else on); ⌥↑/↓ leading ∓2 pt, ⇧⌥↑/↓ baseline shift ±2 pt (10 with ⌘); ⌥←/→ tracking ±20 over a selection (100 with ⌘; a collapsed caret still kerns); ⌥⇧⌘H toggles Hyphenate for the touched paragraphs. One `type.setStyle` step per press (new `leadingBy`/`baselineShiftBy`/`trackingBy`; auto leading starts from its laid-out value); the global ⇧⌘K/U/B/I never fire while typing. Differs: a collapsed caret in non-empty text changes nothing, since the model can't hold a pending style for the next characters typed (empty text takes it as its typing style) (`type_tool.rs`, `input_tests.rs` `type_tool_format_keys_*`) | | | Guides: click selects, Delete deletes, right-click menu, ⌥-click turns a guide, double-click opens Guides, Grid & Slices | done (2026-10-12) | | -| Blend mode keys: ⇧⌥ + letter picks a mode (N Normal, I Dissolve, R Clear, P Pass Through, K M B A darken, G S D W lighten, O F H V J Z L contrast, E X difference, U T C Y component), ⇧+ / ⇧− step through them | done (2026-10-14): Brush, Pencil and Gradient take them as their own mode (Clear on Brush and Pencil only), any non-painting tool sets every selected layer in one undo step (Pass Through on groups only; the Background is skipped); the typed letter wins, the physical key is the fallback for ⌥ characters on a Mac. ⇧⌥ letters no longer switch tools. While a Free Transform box, Puppet Warp or Perspective Warp is open they, and the number keys, still reach the tool (or the layers) without closing it. Differs: Behind (⇧⌥Q) is not implemented; Clone Stamp, Pattern Stamp, History Brush, Healing, Paint Bucket and Magic Eraser ignore the keys until their options bar has a Mode | `blend_keys.rs` | +| Blend mode keys: ⇧⌥ + letter picks a mode (N Normal, I Dissolve, R Clear, P Pass Through, K M B A darken, G S D W lighten, O F H V J Z L contrast, E X difference, U T C Y component), ⇧+ / ⇧− step through them | done (2026-10-14): Brush, Pencil and Gradient take them as their own mode (Clear on Brush and Pencil only), any non-painting tool sets every selected layer in one undo step (Pass Through on groups only; the Background is skipped); the typed letter wins, the physical key is the fallback for ⌥ characters on a Mac. ⇧⌥ letters no longer switch tools. While a Free Transform box, Puppet Warp or Perspective Warp is open they, and the number keys, still reach the tool (or the layers) without closing it. Clone Stamp, Pattern Stamp, History Brush, Healing (its own short list) and Paint Bucket (with Clear) take them as their own mode; tools without a blend mode (the Erasers, Mixer Brush, Dodge, Burn, Sponge) set the layers. Differs: Behind (⇧⌥Q) is not implemented; Spot Healing, Color Replacement, Blur, Sharpen and Smudge ignore the keys until their options bar has a Mode | `blend_keys.rs` | ## Panels and workspace @@ -260,3 +260,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-14 | minor | Move › Show Transform Controls frames every selected layer and outlines the selected shapes | | 2026-10-14 | minor | ⇧⌥ + letter blend mode keys for the painting tool or every selected layer; ⇧+ / ⇧− now step every selected layer and include Clear for Brush and Pencil | | 2026-10-15 | minor | Blend mode and number keys work while a Free Transform, Puppet Warp or Perspective Warp is open | +| 2026-10-14 | minor | Mode dropdowns for Clone Stamp, Pattern Stamp, History Brush, Healing and Paint Bucket; the blend mode keys reach them, and tools without a blend mode pass the keys to the layers | From eabd5c13986ca68efba3f386451e16df86b51e39 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 13:23:33 +0200 Subject: [PATCH 065/229] Crop tool: right-click on a pending crop opens its menu: presets, overlays, Cancel and Crop (#6561) --- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/canvas_tool_menu.rs | 339 ++++++++++++++++++++- crates/ui-egui/src/control.rs | 46 ++- crates/ui-egui/src/crop_overlay.rs | 13 +- crates/ui-egui/src/crop_presets.rs | 31 +- crates/ui-egui/src/crop_size.rs | 9 + crates/ui-egui/src/panels.rs | 6 +- crates/ui-egui/src/shortcuts.rs | 5 +- crates/ui-egui/src/transform_type_tests.rs | 1 + docs/context-menu-parity.md | 5 +- docs/gaps.md | 6 +- 11 files changed, 434 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d2e535f3f6..4715613a87 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3704,9 +3704,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: && !crate::layer_pick_ui::is_gesture(tool, mods) && let Some(p) = response.interact_pointer_pos() { + // A pending crop: its menu, also outside the image, where the frame can reach (#6561). // Outside the image: the pasteboard colour menu (#2462), else the tool's own. The view // menu belongs to the selected Zoom or Hand, not to Space or a held zoom key. - if crate::canvas_tool_menu::pasteboard_at(app, tool, xf.to_doc(p)) { + if crate::canvas_tool_menu::open_crop(app, &ctx, tool, [p.x, p.y]) { + } else if crate::canvas_tool_menu::pasteboard_at(app, tool, xf.to_doc(p)) { crate::canvas_tool_menu::open_pasteboard(app, &ctx, tool, [p.x, p.y]); } else if crate::canvas_tool_menu::is_view_tool(tool) { if tool == app.ui.tool { diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index 43418d5ff9..df77c70976 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -1,6 +1,6 @@ //! Canvas context menus for the selection tools, the Pen, the Zoom and Hand tools, an active Free //! Transform box, text being edited with a Type tool, committed type under a Type tool, the -//! pasteboard (outside the image) and a guide. +//! pasteboard (outside the image), a guide and a pending crop. use egui::Context; use photocraft_doc::LayerContent; @@ -42,6 +42,10 @@ pub struct CanvasToolMenu { /// the menu. #[serde(default)] pub spelling: Option, + /// Opened on a pending crop: the crop menu, listing the saved crop presets by the names they + /// had when it opened. + #[serde(default)] + pub crop: Option>, } /// A misspelled word in the type layer being edited (`edit.checkSpelling`'s `list` entry). @@ -63,7 +67,7 @@ const SPELLING_NONE: &str = "spelling.none"; const SPELLING_ADD: &str = "spelling.add"; /// One menu row: label and command id. `None` is a separator. -pub type Row = Option<(&'static str, &'static str)>; +pub type Row = Option<(S, S)>; /// Right-click while transforming, in Photoshop's order: what the box's handles do, then Rotate /// and Flip, which turn the open box without committing it (`transform_tool::intercept`). @@ -196,6 +200,146 @@ pub const GUIDE_MENU: &[Row] = &[ Some(("Clear Selected Artboard Guides", "view.clearSelectedArtboardGuides")), ]; +/// The crop menu's row ids. A ratio row is `crop.ratio.`, the options bar's preset dropdown +/// key (`crop_size::dropdown_options`); a saved preset is `crop.preset.`, the n-th name the menu +/// opened with; an overlay row is `crop.overlay.` or `crop.overlayShow.`. +const CROP_RATIO: &str = "crop.ratio."; +const CROP_PRESET: &str = "crop.preset."; +const CROP_OVERLAY: &str = "crop.overlay."; +const CROP_SHOW: &str = "crop.overlayShow."; +pub const CROP_CLEAR: &str = "crop.clear"; +pub const CROP_NEW: &str = "crop.newPreset"; +pub const CROP_DELETE: &str = "crop.deletePreset"; +pub const CROP_CANCEL: &str = "crop.cancel"; +pub const CROP_COMMIT: &str = "crop.commit"; + +/// The crop menu (right-click on a pending crop, #6561): the options bar's preset dropdown, in its +/// order, and its Clear; the overlay and when it shows; then Cancel and Crop, the bar's ⊘ and ✓. +/// Built from the bar's own lists, so the two cannot disagree. Empty for any other menu. +pub fn crop_rows(menu: &CanvasToolMenu) -> Vec> { + use crate::crop_overlay::{CropOverlay, OverlayShow}; + let Some(users) = menu.crop.as_ref() else { return Vec::new() }; + let ratio = |(k, l): &(String, &str)| Some((l.to_string(), format!("{CROP_RATIO}{k}"))); + let (before, after): (Vec<_>, Vec<_>) = crate::crop_size::dropdown_options().into_iter().partition(|(k, _)| k != crate::crop_size::FRONT); + let mut rows: Vec> = before.iter().map(ratio).collect(); + rows.extend(users.iter().enumerate().map(|(i, name)| Some((name.clone(), format!("{CROP_PRESET}{i}"))))); + rows.extend(after.iter().map(ratio)); + rows.push(Some(("Clear".into(), CROP_CLEAR.into()))); + rows.push(None); + rows.push(Some(("New Crop Preset…".into(), CROP_NEW.into()))); + rows.push(Some(("Delete Crop Preset…".into(), CROP_DELETE.into()))); + rows.push(None); + rows.extend(CropOverlay::ALL.iter().map(|k| Some((k.label().to_string(), format!("{CROP_OVERLAY}{}", k.id()))))); + rows.push(None); + rows.extend(OverlayShow::ALL.iter().map(|k| Some((k.label().to_string(), format!("{CROP_SHOW}{}", k.id()))))); + rows.push(None); + rows.push(Some(("Cancel".into(), CROP_CANCEL.into()))); + rows.push(Some(("Crop".into(), CROP_COMMIT.into()))); + rows +} + +/// Is a crop row's label UI text (translated), rather than a saved preset's name? +pub fn crop_label_is_ui(id: &str) -> bool { + !id.starts_with(CROP_PRESET) +} + +/// Is `command` one of the crop menu's rows? +fn is_crop(menu: &CanvasToolMenu, command: &str) -> bool { + crop_rows(menu).iter().flatten().any(|(_, id)| id == command) +} + +/// The saved crop preset a `crop.preset.` row picks. +fn crop_preset<'a>(menu: &'a CanvasToolMenu, command: &str) -> Option<&'a String> { + let n = command.strip_prefix(CROP_PRESET)?.parse::().ok()?; + menu.crop.as_ref()?.get(n) +} + +/// The crop menu's checkmarks, as the options bar shows them: the dropdown's entry (a saved +/// preset the fields hold, else the mode or ratio, so a size preset checks W x H x Resolution), +/// the overlay and when it shows. +fn crop_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { + let o = &app.ui.tool_options; + let user = crate::crop_presets::current_user(app); + if let Some(k) = command.strip_prefix(CROP_RATIO) { + return user.is_none() && k == o.crop_ratio; + } + if command.starts_with(CROP_PRESET) { + return user.is_some() && crop_preset(menu, command) == user.as_ref(); + } + if let Some(k) = command.strip_prefix(CROP_OVERLAY) { + return k == o.crop_overlay.id(); + } + command.strip_prefix(CROP_SHOW).is_some_and(|k| k == o.crop_overlay_show.id()) +} + +fn crop_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { + match command { + CROP_NEW => crate::crop_presets::savable(&app.ui.tool_options).is_some(), + CROP_DELETE => menu.crop.as_ref().is_some_and(|u| !u.is_empty()), + c => is_crop(menu, c), + } +} + +/// Right-click with `tool` on a pending crop: the crop menu at screen point `pos`. Only the Crop +/// tool itself, not one held over it (Space's Hand). +pub fn open_crop(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2]) -> bool { + if tool != Tool::Crop || !crate::crop_ui::pending(app) || !pos.iter().all(|v| v.is_finite()) { + return false; + } + app.ui.brush_picker = None; + app.ui.layer_menu = None; + let users = crate::crop_presets::user_presets(app).into_iter().map(|(name, _)| name).collect(); + app.ui.canvas_tool_menu = Some(CanvasToolMenu { + pos, + tool, + has_selection: false, + has_path: false, + path_name: None, + transform: false, + pasteboard: false, + anchor: None, + text: false, + type_layer: false, + guide: false, + spelling: None, + crop: Some(users), + }); + crate::canvas_popup::mark_open(ctx, menu_id()); + true +} + +/// A crop row: each runs what its options-bar twin runs. +fn choose_crop(app: &mut PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> Result<(), String> { + use crate::crop_overlay::{CropOverlay, OverlayShow}; + use crate::crop_presets::{Choice, apply_choice}; + match command { + CROP_CANCEL => crate::crop_ui::cancel(app), + CROP_COMMIT => crate::canvas::commit_crop(app), + CROP_NEW => apply_choice(app, Choice::New), + CROP_DELETE => apply_choice(app, Choice::Delete), + _ => { + let before = crate::crop_ui::preset_ratio(app); + if let Some(k) = command.strip_prefix(CROP_RATIO) { + apply_choice(app, Choice::Builtin(k.to_string())); + } else if let Some(name) = crop_preset(menu, command) { + apply_choice(app, Choice::User(name.clone())); + } else if command == CROP_CLEAR { + crate::crop_size::clear_ratio(&mut app.ui.tool_options); + } else if let Some(k) = command.strip_prefix(CROP_OVERLAY).and_then(CropOverlay::from_id) { + crate::crop_overlay::set_overlay(&mut app.ui.tool_options, k); + } else if let Some(k) = command.strip_prefix(CROP_SHOW).and_then(OverlayShow::from_id) { + app.ui.tool_options.crop_overlay_show = k; + } + // As the options bar does: a saved preset or Clear changes the ratio without refitting + // the frame, so refit it when the ratio (or W x H) the frame follows changed. + if crate::crop_ui::preset_ratio(app) != before { + crate::crop_ui::apply_ratio(app); + } + } + } + Ok(()) +} + /// The pasteboard colours in the menu's order, which Space+F steps through. const CANVAS_COLORS: [CanvasColor; 6] = [CanvasColor::Default, CanvasColor::Black, CanvasColor::DarkGray, CanvasColor::MediumGray, CanvasColor::LightGray, CanvasColor::Custom]; @@ -337,7 +481,10 @@ pub fn selection_rows(has_selection: bool) -> &'static [Row] { /// The rows of an open menu. pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { - if menu.guide { + if menu.crop.is_some() { + // Built per menu: [`crop_rows`]. + &[] + } else if menu.guide { GUIDE_MENU } else if menu.pasteboard { PASTEBOARD_MENU @@ -358,13 +505,16 @@ pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { /// Is `command` one of the open menu's rows, and enabled? pub fn available(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { - (rows(menu).iter().flatten().any(|(_, id)| *id == command) || is_spelling(menu, command)) && entry_enabled(app, menu, command) + (rows(menu).iter().flatten().any(|(_, id)| *id == command) || is_spelling(menu, command) || is_crop(menu, command)) && entry_enabled(app, menu, command) } /// Is `command` the transform box's current mode (checked in the transform menu)? /// Scale stays Free Transform's mode (only Free Transform is checked). Rotate is its own mode. /// Warp is not a box mode (it is its own session), and `for_command` would read it as Free. pub(crate) fn mode_checked(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { + if menu.crop.is_some() { + return crop_checked(app, menu, command); + } if menu.guide { return command == "view.lockGuides" && app.ui.extras.lock_guides; } @@ -486,6 +636,7 @@ pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) -> type_layer: false, guide: false, spelling: None, + crop: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -500,6 +651,9 @@ fn pasteboard_choice(command: &str) -> Option { /// The engine command (and params) a pasteboard row runs, for the automation policy: every row /// changes a preference. pub fn engine_call(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> (String, Value) { + if menu.crop.is_some() && command == CROP_COMMIT { + return ("image.crop".into(), json!({})); + } if menu.guide { let selected = crate::guide_select::selected(app); return match command { @@ -560,6 +714,7 @@ pub fn open_pasteboard(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: type_layer: false, guide: false, spelling: None, + crop: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -590,6 +745,7 @@ pub fn open_guide(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, mods: egui type_layer: false, guide: true, spelling: None, + crop: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -617,6 +773,9 @@ fn choose_pasteboard(app: &mut PhotocraftApp, menu: &CanvasToolMenu, command: &s } pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { + if menu.crop.is_some() { + return crop_enabled(app, menu, command); + } if menu.guide { return match command { GUIDE_EDIT | GUIDE_CLEAR => crate::guide_select::selected(app).is_some(), @@ -717,6 +876,7 @@ pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2] type_layer, guide: false, spelling, + crop: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -743,6 +903,7 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; type_layer: false, guide: false, spelling: None, + crop: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -779,7 +940,9 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { if !available(app, &menu, command) { return; } - let result = if menu.guide { + let result = if menu.crop.is_some() { + choose_crop(app, &menu, command) + } else if menu.guide { choose_guide(app, ctx, command) } else if menu.pasteboard { choose_pasteboard(app, &menu, command) @@ -925,6 +1088,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { || (menu.transform && app.ui.transform.is_none()) || (menu.text && app.ui.text_edit.is_none()) || (menu.type_layer && app.ui.text_edit.is_some()) + || (menu.crop.is_some() && !crate::crop_ui::pending(app)) { app.ui.canvas_tool_menu = None; return; @@ -959,6 +1123,17 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { if !spelling.is_empty() { ui.separator(); } + for row in crop_rows(&menu) { + let Some((label, command)) = row else { + ui.separator(); + continue; + }; + let text = if crop_label_is_ui(&command) { tl!(&label).to_string() } else { label }; + let item = egui::Button::selectable(mode_checked(app, &menu, &command), text).min_size(egui::vec2(200.0, 20.0)); + if ui.add_enabled(entry_enabled(app, &menu, &command), item).clicked() { + selected = Some(command); + } + } for row in rows(&menu) { let Some((label, command)) = *row else { ui.separator(); @@ -1830,4 +2005,158 @@ mod tests { } assert!(entry_enabled(&app, menu, "path.toSelection")); } + + /// A 32 × 32 document with the Crop tool picked (Classic Mode, so the view stays put) and its + /// untouched default frame. + fn crop_app() -> PhotocraftApp { + let mut app = app(); + app.ui.tool = Tool::Crop; + app.ui.tool_options.crop_shield.classic_mode = true; + app.ui.extras.snap = false; + crate::crop_ui::ensure_frame(&mut app); + app + } + + /// Draws a crop frame from (4, 4) to (28, 28): a pending crop. Returns the frame. + fn draw_crop(app: &mut PhotocraftApp) -> [f64; 4] { + use crate::canvas::{ToolEvent, tool_event}; + tool_event(app, ToolEvent::Down { x: 4.0, y: 4.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Move { x: 16.0, y: 16.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Move { x: 28.0, y: 28.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Up { x: 28.0, y: 28.0 }, Modifiers::NONE); + assert!(crate::crop_ui::pending(app), "the drag draws a pending frame"); + app.ui.crop_rect.unwrap() + } + + fn aspect(r: [f64; 4]) -> f64 { + (r[2] - r[0]) / (r[3] - r[1]) + } + + /// #6561: right-click on a pending crop opens its menu without touching the frame, and a ratio + /// row refits the frame as the options bar's dropdown does. + #[test] + fn right_click_on_a_pending_crop_opens_the_crop_menu_and_a_ratio_refits_the_frame() { + let mut app = crop_app(); + let frame = draw_crop(&mut app); + let mut h = harness(app); + let center = h.state().last_canvas_rect.center(); + right_click(&mut h, center, Modifiers::NONE); + let menu = h.state().ui.canvas_tool_menu.clone().expect("right-click on a pending crop opens a menu"); + assert_eq!(h.state().ui.crop_rect, Some(frame), "the right-click leaves the frame alone"); + assert!(available(h.state(), &menu, "crop.ratio.16:9"), "the menu lists the options bar's ratios"); + let ctx = h.ctx.clone(); + choose(h.state_mut(), &ctx, "crop.ratio.16:9"); + assert_eq!(h.state().ui.tool_options.crop_ratio, "16:9"); + let r = h.state().ui.crop_rect.unwrap(); + assert!((aspect(r) - 16.0 / 9.0).abs() < 1e-9, "the frame is 16 : 9 now: {r:?}"); + assert!(r[0] >= frame[0] && r[1] >= frame[1] && r[2] <= frame[2] && r[3] <= frame[3], "fitted inside the old frame: {r:?}"); + assert!(h.state().ui.canvas_tool_menu.is_none()); + } + + #[test] + fn untouched_crop_frame_has_no_menu_and_a_pending_one_wins_over_the_pasteboard() { + let mut h = harness(crop_app()); + let rect = h.state().last_canvas_rect; + let outside = rect.min + vec2(5.0, 5.0); + right_click(&mut h, rect.center(), Modifiers::NONE); + assert!(h.state().ui.canvas_tool_menu.is_none(), "the tool's untouched frame is no crop in progress"); + right_click(&mut h, outside, Modifiers::NONE); + assert!(h.state().ui.canvas_tool_menu.as_ref().is_some_and(|m| m.pasteboard), "outside: the pasteboard menu, as before"); + h.state_mut().ui.canvas_tool_menu = None; + draw_crop(h.state_mut()); + h.run_steps(2); + right_click(&mut h, outside, Modifiers::NONE); + let menu = h.state().ui.canvas_tool_menu.clone().unwrap(); + assert!(menu.crop.is_some() && !menu.pasteboard, "with a crop pending, its menu also outside the image"); + } + + #[test] + fn esc_closes_only_the_crop_menu() { + let mut app = crop_app(); + let frame = draw_crop(&mut app); + app.sync_views(); + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + // The app's order: shortcuts (the crop's Esc among them), then the canvas. + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default()); + h.run_steps(4); + let center = h.state().last_canvas_rect.center(); + right_click(&mut h, center, Modifiers::NONE); + assert!(h.state().ui.canvas_tool_menu.as_ref().is_some_and(|m| m.crop.is_some())); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.canvas_tool_menu.is_none(), "Esc closes the menu"); + assert_eq!(h.state().ui.crop_rect, Some(frame), "and only the menu: the crop is not cancelled"); + assert!(crate::crop_ui::pending(h.state())); + } + + #[test] + fn crop_menu_rows_act_like_the_options_bar() { + use crate::crop_overlay::{CropOverlay, OverlayShow}; + let ctx = Context::default(); + let mut app = crop_app(); + draw_crop(&mut app); + // Space's Hand held over the Crop tool keeps its own right-click. + assert!(!open_crop(&mut app, &ctx, Tool::Hand, [10.0, 10.0])); + let reopen = |app: &mut PhotocraftApp| { + assert!(open_crop(app, &Context::default(), Tool::Crop, [10.0, 10.0])); + app.ui.canvas_tool_menu.clone().unwrap() + }; + // Nothing to save with a free ratio, nothing to delete without saved presets. + let menu = reopen(&mut app); + assert!(!entry_enabled(&app, &menu, CROP_NEW) && !entry_enabled(&app, &menu, CROP_DELETE)); + assert!(mode_checked(&app, &menu, "crop.ratio."), "free Ratio is checked, as in the dropdown"); + // A size preset fills W x H x Resolution, which the menu checks, as the dropdown does. + choose(&mut app, &ctx, "crop.ratio.preset:4x5in"); + let menu = reopen(&mut app); + assert!(mode_checked(&app, &menu, "crop.ratio.whr") && !mode_checked(&app, &menu, "crop.ratio.preset:4x5in")); + assert!((aspect(app.ui.crop_rect.unwrap()) - 0.8).abs() < 1e-9); + assert!(entry_enabled(&app, &menu, CROP_NEW)); + // Clear empties W x H x Resolution, as the bar's Clear. + choose(&mut app, &ctx, CROP_CLEAR); + assert_eq!(app.ui.tool_options.crop_width, ""); + // Overlays: another overlay starts at its first orientation; when it shows. + app.ui.tool_options.crop_overlay = CropOverlay::GoldenSpiral; + app.ui.tool_options.crop_overlay_orientation = 2; + reopen(&mut app); + choose(&mut app, &ctx, "crop.overlay.triangle"); + assert_eq!((app.ui.tool_options.crop_overlay, app.ui.tool_options.crop_overlay_orientation), (CropOverlay::Triangle, 0)); + reopen(&mut app); + choose(&mut app, &ctx, "crop.overlayShow.never"); + assert_eq!(app.ui.tool_options.crop_overlay_show, OverlayShow::Never); + let menu = reopen(&mut app); + assert!(mode_checked(&app, &menu, "crop.overlay.triangle") && mode_checked(&app, &menu, "crop.overlayShow.never")); + // A saved preset is listed by name; picking it refits the frame to its W x H, though the + // ratio key is "whr" before and after. + app.ui.canvas_tool_menu = None; + app.ui.tool_options.crop_ratio = crate::crop_size::WHR.into(); + let saved = json!({"crop_ratio": "whr", "crop_width": "300 px", "crop_height": "100 px", "crop_resolution": "72", "crop_resolution_unit": "px/in"}); + app.run("tool.presets.new", json!({"name": "Banner", "tool": "crop", "options": {"toolOptions": saved}})).unwrap(); + let menu = reopen(&mut app); + let banner = crop_rows(&menu).into_iter().flatten().find(|(l, _)| l == "Banner").expect("the saved preset is a row").1; + assert!(entry_enabled(&app, &menu, CROP_DELETE)); + choose(&mut app, &ctx, &banner); + assert_eq!(app.ui.tool_options.crop_width, "300 px"); + assert!((aspect(app.ui.crop_rect.unwrap()) - 3.0).abs() < 1e-9, "the frame follows the preset: {:?}", app.ui.crop_rect); + let menu = reopen(&mut app); + assert!(mode_checked(&app, &menu, &banner)); + // Crop commits as ↵ does: the preset's size. + choose(&mut app, &ctx, CROP_COMMIT); + let size = app.session.active().unwrap().doc.size; + assert_eq!((size.width, size.height), (300, 100)); + // Cancel drops a pending crop. + draw_crop(&mut app); + reopen(&mut app); + choose(&mut app, &ctx, CROP_CANCEL); + assert!(!crate::crop_ui::pending(&app)); + } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 46f17357d1..14fd82c522 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -372,12 +372,16 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest /// The open canvas context menu's rows for `ui.state`: the spelling suggestions first (type edit /// menu over a misspelled word), then the menu's own rows. fn context_entries(app: &PhotocraftApp, menu: &crate::canvas_tool_menu::CanvasToolMenu) -> Vec { - use crate::canvas_tool_menu::{entry_enabled, rows, spelling_rows}; + use crate::canvas_tool_menu::{crop_rows, entry_enabled, rows, spelling_rows}; let spelling = spelling_rows(menu); let mut entries: Vec = spelling.iter().map(|(label, id)| json!({"label": label, "id": id, "enabled": entry_enabled(app, menu, id)})).collect(); if !spelling.is_empty() { entries.push(json!({"separator": true})); } + entries.extend(crop_rows(menu).iter().map(|row| match row { + Some((label, id)) => json!({"label": label, "id": id, "enabled": entry_enabled(app, menu, id)}), + None => json!({"separator": true}), + })); entries.extend(rows(menu).iter().map(|row| match row { Some((label, id)) => json!({"label": label, "id": id, "enabled": entry_enabled(app, menu, id)}), None => json!({"separator": true}), @@ -1088,6 +1092,12 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } continue; } + if app.ui.tool == crate::state::Tool::Crop && crate::crop_ui::pending(app) { + if down { + crate::canvas_tool_menu::open_crop(app, ctx, app.ui.tool, screen_point(app, x, y)); + } + continue; + } if crate::canvas_tool_menu::pasteboard_at(app, app.ui.tool, [x, y]) { if down { crate::canvas_tool_menu::open_pasteboard(app, ctx, app.ui.tool, screen_point(app, x, y)); @@ -1339,6 +1349,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { "tool": menu.tool, "pasteboard": menu.pasteboard, "guide": menu.guide, + "crop": menu.crop.is_some(), "entries": context_entries(app, menu) }) }), @@ -1871,6 +1882,39 @@ mod tests { assert!(app.ui.canvas_tool_menu.is_none()); } + /// #6561: an agent's right-click on a pending crop opens the crop menu, as on the canvas. + #[test] + fn agent_right_click_on_a_pending_crop_opens_and_runs_the_crop_menu() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.sync_views(); + app.ui.tool = crate::state::Tool::Crop; + app.ui.tool_options.crop_shield.classic_mode = true; + app.ui.extras.snap = false; + crate::crop_ui::ensure_frame(&mut app); + tool_event(&mut app, ToolEvent::Down { x: 4.0, y: 4.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 16.0, y: 12.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 28.0, y: 20.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 28.0, y: 20.0 }, egui::Modifiers::NONE); + let frame = app.ui.crop_rect.unwrap(); + let result = call( + &mut app, + &ctx, + "ui.pointer", + json!({"tool": "Crop", "button": "secondary", "events": [{"kind":"down","x":16,"y":12},{"kind":"up","x":16,"y":12}]}), + ); + assert_eq!(result["ok"], true); + let inspected = call(&mut app, &ctx, "ui.inspect", json!({})); + assert_eq!(inspected.pointer("/result/canvasToolMenu/crop"), Some(&json!(true))); + let entries = inspected.pointer("/result/canvasToolMenu/entries").and_then(Value::as_array).unwrap(); + assert!(entries.iter().any(|row| row.get("id") == Some(&json!("crop.commit")) && row.get("enabled") == Some(&json!(true)))); + assert_eq!(app.ui.crop_rect, Some(frame), "the right-click leaves the frame alone"); + assert_eq!(call(&mut app, &ctx, "ui.context.choose", json!({"id":"crop.commit"}))["ok"], true); + let size = app.session.active().unwrap().doc.size; + assert_eq!([f64::from(size.width), f64::from(size.height)], [frame[2] - frame[0], frame[3] - frame[1]], "cropped to the frame"); + } + #[test] fn agent_can_inspect_and_choose_pen_make_selection_from_full_context_menu() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/crop_overlay.rs b/crates/ui-egui/src/crop_overlay.rs index b0be59f2b1..c93f5e12e1 100644 --- a/crates/ui-egui/src/crop_overlay.rs +++ b/crates/ui-egui/src/crop_overlay.rs @@ -249,6 +249,14 @@ pub fn cycle(o: &mut ToolOptions) { o.crop_overlay_orientation = 0; } +/// Picks overlay `k`; another overlay starts at its first orientation. +pub fn set_overlay(o: &mut ToolOptions, k: CropOverlay) { + if o.crop_overlay != k { + o.crop_overlay_orientation = 0; + } + o.crop_overlay = k; +} + /// ⇧O: the overlay's next orientation (nothing for a symmetric one). pub fn cycle_orientation(o: &mut ToolOptions) { let n = o.crop_overlay.orientations().max(1); @@ -288,10 +296,7 @@ pub fn options_button(o: &mut ToolOptions, ui: &mut egui::Ui) { }; for k in CropOverlay::ALL { if item(ui, o.crop_overlay == k, tl!(k.label()), None) { - if o.crop_overlay != k { - o.crop_overlay_orientation = 0; - } - o.crop_overlay = k; + set_overlay(o, k); ui.close(); } } diff --git a/crates/ui-egui/src/crop_presets.rs b/crates/ui-egui/src/crop_presets.rs index e30b0d1cfc..b0986f9e1e 100644 --- a/crates/ui-egui/src/crop_presets.rs +++ b/crates/ui-egui/src/crop_presets.rs @@ -34,9 +34,9 @@ pub const MAX_NAME: usize = 255; /// The `ToolOptions` fields a crop preset saves. const KEYS: [&str; 5] = ["crop_ratio", "crop_width", "crop_height", "crop_resolution", "crop_resolution_unit"]; -/// One entry of the preset dropdown. +/// One entry of the preset dropdown (and of the Crop tool's canvas menu). #[derive(Clone, Debug, PartialEq)] -enum Choice { +pub(crate) enum Choice { /// A built-in key (`crop_ratio` value, size preset or Front Image), see [`crop_size::chosen`]. Builtin(String), /// A saved crop preset, by name. @@ -139,7 +139,7 @@ pub fn dropdown(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let users = user_presets(app); let o = &app.ui.tool_options; let label = button_label(o, &builtin, &users); - let current_user = users.iter().rev().find(|(_, f)| matches(o, f)).map(|(n, _)| n.clone()); + let current_user = current_user(app); let can_new = savable(o).is_some(); let mut chosen: Option = None; egui::ComboBox::from_id_salt("crop-ratio").selected_text(label).width(140.0).height(420.0).icon(crate::widgets::chevron_icon).show_ui(ui, |ui| { @@ -167,27 +167,40 @@ pub fn dropdown(app: &mut PhotocraftApp, ui: &mut egui::Ui) { chosen = Some(Choice::Delete); } }); - match chosen { - Some(Choice::Builtin(k)) => { + if let Some(c) = chosen { + apply_choice(app, c); + } +} + +/// What picking a dropdown entry does; the Crop tool's canvas menu picks through it too. +pub(crate) fn apply_choice(app: &mut PhotocraftApp, choice: Choice) { + match choice { + Choice::Builtin(k) => { app.ui.tool_options.crop_ratio = k; // A size preset or Front Image fills W x H x Resolution (#2443). crop_size::chosen(app); sync_custom_ratio_fields(app); } - Some(Choice::User(name)) => { + Choice::User(name) => { select(app, &name); sync_custom_ratio_fields(app); } - Some(Choice::New) => { + Choice::New => { open_new(app); } - Some(Choice::Delete) => { + Choice::Delete => { open_delete(app); } - None => {} } } +/// The saved crop preset whose values the fields hold (the latest saved one first), as the +/// dropdown shows it. +pub(crate) fn current_user(app: &PhotocraftApp) -> Option { + let o = &app.ui.tool_options; + user_presets(app).into_iter().rev().find(|(_, f)| matches(o, f)).map(|(n, _)| n) +} + /// A newly chosen ratio fills the editable custom W/H fields (free Ratio clears them, #3188). fn sync_custom_ratio_fields(app: &mut PhotocraftApp) { if app.ui.tool_options.crop_ratio != crop_size::WHR { diff --git a/crates/ui-egui/src/crop_size.rs b/crates/ui-egui/src/crop_size.rs index b30cdd8c4a..194760e6de 100644 --- a/crates/ui-egui/src/crop_size.rs +++ b/crates/ui-egui/src/crop_size.rs @@ -223,6 +223,15 @@ pub fn clear(o: &mut ToolOptions) { o.crop_resolution.clear(); } +/// The options bar's Clear: W, H and the resolution in W x H x Resolution mode, else the ratio. +pub fn clear_ratio(o: &mut ToolOptions) { + if o.crop_ratio == WHR { + clear(o); + } else { + o.crop_ratio.clear(); + } +} + /// Swaps W and H (the ⇄ button, X). False outside W x H x Resolution mode. pub fn swap(o: &mut ToolOptions) -> bool { if o.crop_ratio != WHR { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 18cef34c98..d15914828c 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1350,11 +1350,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } widgets::vline(ui, 22.0); if widgets::secondary_button(ui, tl!("Clear"), 0.0).clicked() { - if o.crop_ratio == crate::crop_size::WHR { - crate::crop_size::clear(o); - } else { - o.crop_ratio.clear(); - } + crate::crop_size::clear_ratio(o); } crate::crop_straighten::options_button(&mut app.crop.straighten, ui); crate::crop_overlay::options_button(o, ui); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 1ad8d6d92a..94787c8193 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -391,8 +391,9 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } // So does the right-click menu of text being edited: Esc closes only it, and keys typed while - // it is open don't reach the text beneath. - if app.ui.canvas_tool_menu.as_ref().is_some_and(|m| m.text) { + // it is open don't reach the text beneath. The crop menu's likewise: Esc and ↵ don't cancel or + // commit the crop under it (#6561). + if app.ui.canvas_tool_menu.as_ref().is_some_and(|m| m.text || m.crop.is_some()) { return; } let symmetry_editing = app.ui.symmetry_transform.is_some(); diff --git a/crates/ui-egui/src/transform_type_tests.rs b/crates/ui-egui/src/transform_type_tests.rs index 457c03c1cb..eeb91f44b9 100644 --- a/crates/ui-egui/src/transform_type_tests.rs +++ b/crates/ui-egui/src/transform_type_tests.rs @@ -55,6 +55,7 @@ fn type_layers_offer_free_transform_but_not_distort_or_perspective() { type_layer: false, guide: false, spelling: None, + crop: None, }; assert!(crate::canvas_tool_menu::available(&app, &menu, "edit.freeTransform")); assert!(!crate::canvas_tool_menu::available(&app, &menu, "edit.transform.distort")); diff --git a/docs/context-menu-parity.md b/docs/context-menu-parity.md index 53b6d99bd9..d6ec330909 100644 --- a/docs/context-menu-parity.md +++ b/docs/context-menu-parity.md @@ -1,6 +1,6 @@ # Context-menu parity contract -> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (Type tool menu over committed type, #7324) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-21 · **Change:** minor (Crop tool menu on a pending crop, #6561; Type tool menu over committed type, #7324) · **Target:** Adobe Photoshop 2026 Status: implementation contract, updated 2026-10-09. Scope: canvas, document tabs, and panels. “Right-click” means a secondary pointer click; macOS Control-click should also open the same menu where the platform sends a context-menu gesture. Photoshop changes a context menu with the active tool, selection, and panel item. Adobe documents that principle but does not publish a complete current menu catalog. **Documented** means an Adobe source explicitly names the gesture or action. **Inferred** means a PhotoCraft design target or an item known from a main menu whose presence in a Photoshop context menu still needs a current-product capture. Do not present inferred rows as exact Photoshop menus. [Adobe: panels and menus](https://helpx.adobe.com/photoshop/using/panels-menus.html), [Adobe: shortcuts and context menus](https://helpx.adobe.com/sa_en/photoshop/using/customizing-keyboard-shortcuts.html). @@ -22,7 +22,7 @@ The “PhotoCraft at branch point” column records behavior before this PR. Pic |---|---|---|---| | Move | Choose a layer at the point; an auto-select choice also exists in the options bar | Layers | P0; [Adobe layer movement](https://helpx.adobe.com/photoshop/using/moving-stacking-locking-layers.html); exact pop-up layout requires capture | | Rectangular/Elliptical Marquee, Lasso, Polygonal Lasso, Magnetic Lasso, Magic Wand, Quick Selection, Object Selection | Active selection: the 2026 selection menu (see *Selection canvas reference* below); no selection: making a selection | Quick Selection: Picker (a brush tool, as in Photoshop); the other six: the selection menu below (#614, #738) | P1; no-selection variant still needs a dated capture | -| Crop | During crop: ratio/preset, overlay, clear/reset, commit/cancel **(inferred context placement)** | No tool menu | P1; pending crop target | +| Crop | During crop: ratio/preset, overlay, clear/reset, commit/cancel **(inferred context placement)** | On a pending crop (canvas_tool_menu.rs `crop_rows`, #6561): the options bar's preset dropdown in its order (Ratio, W x H x Resolution, the ratios, the size presets, saved crop presets, Front Image), Clear, New/Delete Crop Preset…, the six overlays and Auto/Always/Never Show Overlay (checked as in the bar), Cancel and Crop; also outside the image while a crop is pending. The untouched default frame has no menu. Esc closes only the menu. Reset remains open (no reset of frame, rotation and ratio yet, nor the options bar's ↺) | P1; pending crop target; item list still needs a dated Photoshop capture | | Eyedropper | Sample-size/source choices **(inferred)** | No tool menu | P2; [Adobe illustration uses Eyedropper](https://helpx.adobe.com/photoshop/using/panels-menus.html), but does not publish item text | | Ruler, Note, Count | Unit / measurement, note edit/delete, count-group operations **(inferred)** | No tool menu | P2 | | Brush, Pencil, Eraser, Background Eraser, Spot Healing, Healing, Clone Stamp, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge | Brush preset, size and hardness where applicable | Picker | P0; [Adobe Brush Settings](https://helpx.adobe.com/photoshop/desktop/apply-painting-techniques/brushes-presets/create-brush-set-painting-options.html). Alt+secondary-drag on Windows and Control+Option-drag on macOS adjust size/hardness ([Adobe cursor shortcuts](https://helpx.adobe.com/in/photoshop/desktop/get-started/settings-and-preferences/change-tool-pointers.html)) | @@ -100,6 +100,7 @@ Each slice needs pure model tests (including every item dispatching a known comm | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Crop tool menu on a pending crop (#6561) | | 2026-10-18 | minor | Type tool menu over committed type (#7324) | | 2026-10-13 | minor | Layer-mask thumbnail menu (#4575) | | 2026-10-10 | trivial | Status line, revision history | diff --git a/docs/gaps.md b/docs/gaps.md index ab42d0f8ef..894370c42f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (G9: Crop tool canvas menu, #6561; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -204,7 +204,8 @@ what an agent can't finish alone. - **Missing:** 27 of 156 preferences do nothing (scorecard prefs audit); panel groups can't float or tear off into OS windows (UI-217-5, #1828, #1213, #2305); Color panel lacks wheel and slider modes (UI-217-17, #2569); more themes (UI-217-11, #2661); a single pixel layer - copies without its mask and styles (UI-217-23; fill and adjustment layers copy whole, #2344); tool-specific context menus beyond brushes, selections and Pen + copies without its mask and styles (UI-217-23; fill and adjustment layers copy whole, #2344); tool-specific context menus beyond brushes, selections, Pen and + Crop ([context-menu-parity.md](context-menu-parity.md)); Libraries, Comments, Contextual Task Bar panels. - **Estimate:** 150–250 h. @@ -274,6 +275,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | G9: right-click on a pending crop opens the Crop tool menu (presets, Clear, overlays, Cancel, Crop; #6561); Reset still open | | 2026-10-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open | | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | From 6d92b12691bd3257ac9421bf7a38bda1d8de2356 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:51:55 -0400 Subject: [PATCH 066/229] Update crates/text/src/psd_path.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- crates/text/src/psd_path.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/text/src/psd_path.rs b/crates/text/src/psd_path.rs index f01807d21e..793b24dc7b 100644 --- a/crates/text/src/psd_path.rs +++ b/crates/text/src/psd_path.rs @@ -286,7 +286,8 @@ pub(crate) fn update_frame(entry: &mut E, shape: &TextShape) { frame.set("2", data); *entry = E::Dict(vec![("0".into(), frame)]); } - _ if kind != PATH_FRAME => {} + // Leave unreadable path frames unchanged. They were not imported as detached paths. + _ if kind != PATH_FRAME || frame.is_none_or(|f| read(f).is_err()) => {} TextShape::Box { x, y, width, height } => *entry = box_frame(f64::from(*x), f64::from(*y), f64::from(*width), f64::from(*height)), TextShape::Point => *entry = point_frame(), } From 71df947c27f5c13e94c9009db927fb189716a937 Mon Sep 17 00:00:00 2001 From: SBaksa Date: Thu, 22 Oct 2026 00:39:42 +1100 Subject: [PATCH 067/229] Raw: a DNG whose raw data isn't supported opens its JPEG XL preview, not the small JPEG thumbnail (#8125) A DNG 1.7 converter that writes its raw data as JPEG XL writes the larger previews that way too, keeping only a small thumbnail as a JPEG stream. When the raw data couldn't be decoded, the fallback took the largest JPEG preview, so such a file opened as its thumbnail (256x171 for a full-size photo). photocraft-raw gains embedded_jxl_preview: the largest JPEG XL RGB preview IFD of a DNG, decoded with the raw strip and tile reader, with its PreviewColorSpace (PreviewColorSpace gains ProPhoto). The import fallback opens it when it is larger than the JPEG preview, in its stated colour space and turned upright by the raw's orientation, and the warning names the preview's format and depth. The browser build has no JPEG XL decoder, so it keeps the JPEG preview. --- Cargo.lock | 2 ++ crates/io/Cargo.toml | 3 ++ crates/io/src/raw.rs | 57 ++++++++++++++++++++++-------- crates/io/tests/raw.rs | 73 +++++++++++++++++++++++++++++++++++++++ crates/raw/src/lib.rs | 2 +- crates/raw/src/preview.rs | 47 +++++++++++++++++++++++++ crates/raw/src/sensor.rs | 2 +- 7 files changed, 169 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b0ff196c55..b30044383b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4924,6 +4924,8 @@ dependencies = [ "roxmltree 0.20.0", "serde_json", "thiserror 2.0.21", + "zune-core 0.5.3", + "zune-jpegxl", ] [[package]] diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index eb8c0b00d7..8e096aceb5 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -55,3 +55,6 @@ flate2 = { workspace = true } photocraft-psd = { workspace = true, features = ["testgen"] } photocraft-raw = { workspace = true, features = ["testgen"] } proptest = { workspace = true } +# Lossless JPEG XL encoder for the JPEG XL DNG preview test (tests only), as in photocraft-raw. +zune-jpegxl = { version = "=0.5.2", default-features = false, features = ["std"] } +zune-core = { workspace = true } diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index aba70c21e7..5e5c264a20 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -255,25 +255,52 @@ pub fn import_raw_with(name: &str, bytes: &[u8], opts: &DevelopOptions) -> Resul let summary = format!("{DEVELOPED_NOTE} ({} demosaic, as-shot white balance)", opts.demosaic.id()); developed_document(name, format, dev, &summary) } - Err(RawError::Unsupported(reason)) => match photocraft_raw::embedded_preview(bytes) { - Some(p) => { - let img = upright_preview(bytes, p.jpeg)?; - let mut r = image_to_document(name, &img)?; - r.warnings.insert( - 0, - format!( - "{format}: {reason} is not supported yet; opened the camera's embedded {}x{} JPEG preview instead (8-bit, not the raw sensor data)", - p.width, p.height - ), - ); - Ok(r) - } - None => Err(IoError::Raw(RawError::Unsupported(reason))), - }, + Err(RawError::Unsupported(reason)) => { + let jpeg = photocraft_raw::embedded_preview(bytes); + // A DNG 1.7 converter keeps only its small thumbnail as JPEG and writes the larger + // previews as JPEG XL, like the raw data (#8125): open the largest of either kind. + let jpeg_area = jpeg.map_or(0, |p| u64::from(p.width) * u64::from(p.height)); + let jxl = photocraft_raw::embedded_jxl_preview(bytes, &opts.limits).filter(|p| u64::from(p.width) * u64::from(p.height) > jpeg_area); + let (img, kind, (w, h)) = match (jxl, jpeg) { + (Some(p), _) => { + let size = (p.width, p.height); + (jxl_preview_image(bytes, p)?, "JPEG XL", size) + } + (None, Some(p)) => (upright_preview(bytes, p.jpeg)?, "JPEG", (p.width, p.height)), + (None, None) => return Err(IoError::Raw(RawError::Unsupported(reason))), + }; + let mut r = image_to_document(name, &img)?; + let depth = if img.sample_type() == SampleType::U8 { "8-bit" } else { "16-bit" }; + r.warnings.insert( + 0, + format!( + "{format}: {reason} is not supported yet; opened the camera's embedded {w}x{h} {kind} preview instead ({depth}, not the raw sensor data)" + ), + ); + Ok(r) + } Err(e) => Err(IoError::Raw(e)), } } +/// A decoded JPEG XL preview as an image: in its stated colour space (sRGB when it states +/// none), turned upright by the raw's orientation (DNG previews are stored as the raw is). +fn jxl_preview_image(raw: &[u8], p: photocraft_raw::DecodedPreview) -> Result { + let img = if p.bits == 8 { + Image::from_u8(p.width, p.height, ChannelLayout::Rgb, p.rgb.iter().map(|&v| v.min(255) as u8).collect())? + } else { + let max = f64::from((1u32 << p.bits.clamp(1, 16)) - 1); + let rgb: Vec = p.rgb.iter().map(|&v| (f64::from(v).min(max) * 65535.0 / max).round() as u16).collect(); + Image::from_u16(p.width, p.height, ChannelLayout::Rgb, &rgb)? + }; + let icc = match p.color_space { + Some(PreviewColorSpace::AdobeRgb) => Some(Builtin::AdobeRgbCompat.profile().to_bytes().to_vec()), + Some(PreviewColorSpace::ProPhoto) => Some(Builtin::ProPhotoCompat.profile().to_bytes().to_vec()), + Some(PreviewColorSpace::Srgb) | None => None, + }; + Ok(img.with_icc(icc).oriented(photocraft_raw::orientation(raw).unwrap_or(1))?) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/io/tests/raw.rs b/crates/io/tests/raw.rs index d9e31a87c1..9a8a0798ca 100644 --- a/crates/io/tests/raw.rs +++ b/crates/io/tests/raw.rs @@ -82,6 +82,79 @@ fn unsupported_raw_falls_back_to_the_embedded_preview() { assert!(r.warnings.first().is_some_and(|w| w.contains("Nikon compressed NEF") && w.contains("embedded")), "{:?}", r.warnings); } +/// A DNG 1.7 laid out as converters write one with JPEG XL raw data: an 8×6 JPEG thumbnail in +/// IFD0, raw data this reader refuses (here floating-point LinearRaw), and a 40×30 preview +/// compressed with JPEG XL, stored as the raw is (IFD0 Orientation 6). +fn dng_with_jxl_preview() -> Vec { + use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions}; + let thumb = Image::from_u8(8, 6, ChannelLayout::Rgb, vec![90; 8 * 6 * 3]).unwrap(); + let thumb = photocraft_codecs::encode(&thumb, Format::Jpeg, &EncodeOptions::default()).unwrap(); + let (w, h) = (40usize, 30usize); + let rgb: Vec = (0..w * h).flat_map(|i| [(i % w * 6) as u8, (i / w * 8) as u8, 120]).collect(); + let mut preview = Vec::new(); + zune_jpegxl::JxlSimpleEncoder::new(&rgb, EncoderOptions::new(w, h, ColorSpace::RGB, BitDepth::Eight)).encode(&mut preview).unwrap(); + let mut t = TiffBuilder::default(); + let raw_strip = t.blob(vec![0; 4 * 4 * 3 * 2]); + let raw = t.ifd(vec![ + (254, Val::Long(vec![0])), + (256, Val::Long(vec![4])), + (257, Val::Long(vec![4])), + (258, Val::Short(vec![16, 16, 16])), + (259, Val::Short(vec![1])), + (262, Val::Short(vec![34892])), + (273, Val::Blobs(vec![raw_strip])), + (277, Val::Short(vec![3])), + (278, Val::Long(vec![4])), + (279, Val::Long(vec![4 * 4 * 3 * 2])), + (339, Val::Short(vec![3, 3, 3])), + ]); + let preview_len = preview.len() as u32; + let preview_strip = t.blob(preview); + let preview = t.ifd(vec![ + (254, Val::Long(vec![1])), + (256, Val::Long(vec![w as u32])), + (257, Val::Long(vec![h as u32])), + (258, Val::Short(vec![8, 8, 8])), + (259, Val::Short(vec![52546])), + (262, Val::Short(vec![2])), + (273, Val::Blobs(vec![preview_strip])), + (277, Val::Short(vec![3])), + (278, Val::Long(vec![h as u32])), + (279, Val::Long(vec![preview_len])), + (50970, Val::Long(vec![2])), + ]); + let thumb_len = thumb.len() as u32; + let thumb_strip = t.blob(thumb); + let ifd0 = t.ifd(vec![ + (254, Val::Long(vec![1])), + (256, Val::Long(vec![8])), + (257, Val::Long(vec![6])), + (258, Val::Short(vec![8, 8, 8])), + (259, Val::Short(vec![7])), + (262, Val::Short(vec![6])), + (271, Val::Ascii("Synthetic".into())), + (273, Val::Blobs(vec![thumb_strip])), + (274, Val::Short(vec![6])), + (277, Val::Short(vec![3])), + (278, Val::Long(vec![6])), + (279, Val::Long(vec![thumb_len])), + (330, Val::Ifds(vec![raw, preview])), + (50706, Val::Byte(vec![1, 7, 0, 0])), + ]); + t.chain = vec![ifd0]; + t.build() +} + +/// #8125: a DNG whose raw data isn't supported opens its full JPEG XL preview, upright, not +/// the small JPEG thumbnail. +#[test] +fn unsupported_dng_falls_back_to_its_jpeg_xl_preview() { + let r = import("photo.dng", &dng_with_jxl_preview()).unwrap(); + assert_eq!((r.document.size.width, r.document.size.height), (30, 40), "the 40×30 preview, turned upright: {:?}", r.warnings); + let w = r.warnings.first().map_or("", String::as_str); + assert!(w.contains("floating-point") && w.contains("40x30 JPEG XL preview"), "{:?}", r.warnings); +} + #[test] fn unsupported_raw_without_preview_is_a_clear_error() { let mut cr3 = vec![0, 0, 0, 24]; diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 0d1a99e796..f2d26ed68e 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -77,7 +77,7 @@ pub use crate::develop::{DevelopOptions, Developed, RawInfo, WhiteBalance, devel pub use crate::error::RawError; pub use crate::look::{CameraLook, LookRejected, PROXY_EDGE, Reference, ToneCurve, fit_look}; pub use crate::opcodes::GainMap; -pub use crate::preview::{Preview, PreviewColorSpace, embedded_preview, embedded_preview_color_space, embedded_previews}; +pub use crate::preview::{DecodedPreview, Preview, PreviewColorSpace, embedded_jxl_preview, embedded_preview, embedded_preview_color_space, embedded_previews}; pub use crate::sensor::{BlackLevels, Cfa, Rect, Sensor}; pub use crate::tone::{Tone, retone}; diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index d5dd013310..489eea3b9e 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -1,5 +1,7 @@ //! Finding the camera-rendered JPEG preview embedded in a raw file. +use crate::Limits; +use crate::sensor::{JPEG_XL, JpegLayout, read_plane}; use crate::tiff::{Tiff, tag}; /// An embedded baseline-JPEG preview. @@ -100,11 +102,56 @@ pub fn embedded_previews(bytes: &[u8]) -> Vec> { found } +/// A DNG 1.7 preview compressed with JPEG XL (Compression 52546), decoded. Converters that +/// write their raw data as JPEG XL store the larger previews that way too, leaving only a small +/// thumbnail as a JPEG stream for [`embedded_previews`] (#8125). +#[derive(Debug, Clone, PartialEq)] +pub struct DecodedPreview { + pub width: u32, + pub height: u32, + /// BitsPerSample: `rgb` holds `0..=2^bits - 1`. + pub bits: u32, + /// Interleaved RGB, `width * height * 3` samples. + pub rgb: Vec, + /// The DNG PreviewColorSpace (tag 50970); `None` when it's unknown or grey. + pub color_space: Option, +} + +/// DNG PreviewColorSpace (DNG Specification 1.7). +const PREVIEW_COLOR_SPACE: u16 = 50970; + +/// The largest JPEG XL-compressed RGB preview of a DNG, decoded within `limits`. `None` when the +/// file has none, or it doesn't decode (always in the browser build, which has no JPEG XL decoder). +pub fn embedded_jxl_preview(bytes: &[u8], limits: &Limits) -> Option { + let t = Tiff::new(bytes)?; + let ifd0 = t.ifd_at(t.first_ifd, 0)?; + if !ifd0.has(tag::DNG_VERSION) { + return None; + } + let area = |i: &crate::tiff::Ifd| u64::from(t.tag_uint(i, tag::IMAGE_WIDTH).unwrap_or(0)) * u64::from(t.tag_uint(i, tag::IMAGE_LENGTH).unwrap_or(0)); + let ifd = t + .all_ifds() + .into_iter() + .filter(|i| t.tag_uint(i, tag::NEW_SUBFILE_TYPE) == Some(1)) + .filter(|i| t.tag_uint(i, tag::COMPRESSION) == Some(JPEG_XL) && t.tag_uint(i, tag::SAMPLES_PER_PIXEL) == Some(3)) + .max_by_key(area)?; + let plane = read_plane(&t, &ifd, limits, JpegLayout::Flat).ok()?; + let color_space = match t.tag_uint(&ifd, PREVIEW_COLOR_SPACE).or_else(|| t.tag_uint(&ifd0, PREVIEW_COLOR_SPACE)) { + Some(2) => Some(PreviewColorSpace::Srgb), + Some(3) => Some(PreviewColorSpace::AdobeRgb), + Some(4) => Some(PreviewColorSpace::ProPhoto), + _ => None, + }; + Some(DecodedPreview { width: u32::try_from(plane.width).ok()?, height: u32::try_from(plane.height).ok()?, bits: plane.bits, rgb: plane.data, color_space }) +} + /// The colour space a raw file states for its embedded previews, which carry no ICC profile of their own. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PreviewColorSpace { Srgb, AdobeRgb, + /// ProPhoto RGB, which a DNG can state for its previews (PreviewColorSpace 4). + ProPhoto, } /// The colour space the raw's EXIF gives for the camera's JPEG rendering, for a preview that declares none itself: diff --git a/crates/raw/src/sensor.rs b/crates/raw/src/sensor.rs index f2cffdb808..8defea86ca 100644 --- a/crates/raw/src/sensor.rs +++ b/crates/raw/src/sensor.rs @@ -212,7 +212,7 @@ pub(crate) enum JpegLayout { } /// DNG 1.7 JPEG XL compression. -const JPEG_XL: u32 = 52546; +pub(crate) const JPEG_XL: u32 = 52546; /// Inflates one zlib tile into at most `need` bytes. The stream must run to its end: a truncated /// stream, or one that holds more than `need` bytes, is an error rather than a silent cut-off. From 49df3fb921af93615bb46f5d6c192e076a22dc0b Mon Sep 17 00:00:00 2001 From: fjamesprice Date: Sat, 17 Oct 2026 01:20:07 -0400 Subject: [PATCH 068/229] perf(camera-raw): develop the preview's per-pixel stages on the GPU (#6916) Step 1 of #6916: one wgpu fragment pass for develop's per-pixel stages (white balance and exposure, Contrast without Texture or Clarity, Whites and Blacks, Vibrance and Saturation, tone curves, Color Mixer, Color Grading, vignette, grain in full-resolution coordinates). - algo: camera_raw::per_pixel resolves those stages' constants and tables; the CPU develop builds its own from the same functions, so its output is unchanged bit for bit. - gpu: CameraRawDevelop (camera_raw.rs, camera_raw.wgsl), curves and Whites/Blacks rows in one R32Float table texture, readback for the histogram and scopes. Built on demand, not with the compositor (#6419). - ui-egui: the proxy worker develops on the GPU when the settings have no spatial stage; Rendering Mode CPU, no GPU canvas, a software adapter, a fault or a failed develop use the CPU develop. The pipeline builds on a thread at the first open. describe() reports developMs and developBackend. GPU vs CPU at 8, 16 and 32 bits: at most 2.7e-5 (RTX 5060 Ti, Vulkan and DX12), 6.7e-6 (WARP); tests hold 1/4096. 858x572 proxy, release: CPU 20.7-28.1 ms, GPU 2.7 ms. Part of #6916. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 3 + crates/algo/src/camera_raw.rs | 124 +++--- crates/algo/src/camera_raw/per_pixel.rs | 239 +++++++++++ crates/algo/src/camera_raw/whites_blacks.rs | 21 +- crates/gpu/Cargo.toml | 4 + crates/gpu/examples/bench_camera_raw.rs | 109 +++++ crates/gpu/src/camera_raw.rs | 418 ++++++++++++++++++++ crates/gpu/src/camera_raw.wgsl | 416 +++++++++++++++++++ crates/gpu/src/lib.rs | 5 + crates/gpu/tests/camera_raw.rs | 343 ++++++++++++++++ crates/ui-egui/src/camera_raw_gpu.rs | 277 +++++++++++++ crates/ui-egui/src/camera_raw_ui.rs | 58 ++- crates/ui-egui/src/gpu_canvas.rs | 41 +- crates/ui-egui/src/lib.rs | 1 + docs/hardware-parity.md | 5 +- 15 files changed, 1986 insertions(+), 78 deletions(-) create mode 100644 crates/algo/src/camera_raw/per_pixel.rs create mode 100644 crates/gpu/examples/bench_camera_raw.rs create mode 100644 crates/gpu/src/camera_raw.rs create mode 100644 crates/gpu/src/camera_raw.wgsl create mode 100644 crates/gpu/tests/camera_raw.rs create mode 100644 crates/ui-egui/src/camera_raw_gpu.rs diff --git a/Cargo.lock b/Cargo.lock index 90330fd4a5..e039ea036e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4876,14 +4876,17 @@ dependencies = [ name = "photocraft-gpu" version = "0.11.1" dependencies = [ + "bytemuck", "half", "log", + "photocraft-algo", "photocraft-cms", "photocraft-color", "photocraft-compose", "photocraft-doc", "photocraft-geom", "photocraft-raster", + "photocraft-testkit", "photocraft-vector", "pollster 1.0.1", "rayon", diff --git a/crates/algo/src/camera_raw.rs b/crates/algo/src/camera_raw.rs index 3cadd1d20c..053a0e1180 100644 --- a/crates/algo/src/camera_raw.rs +++ b/crates/algo/src/camera_raw.rs @@ -56,8 +56,11 @@ 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 per_pixel; mod whites_blacks; +pub use per_pixel::{CURVE_LUT, Grade, Grain, PerPixel, Vignette, VignetteStyle, WB_STEPS, WhitesBlacks, per_pixel, white_black_points}; + /// 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)] @@ -580,15 +583,10 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo let ps = p.pixel_scale.clamp(0.01, 1.0); let long = w.max(h) as f32; // 1. White balance + exposure in linear light. - if !p.wb_neutral() { - let mut g = white_balance_gains(p.temperature, p.tint); - let norm = luma(g); - g = g.map(|v| v / norm * 2f32.powf(p.exposure)); + if let Some(g) = per_pixel::gains(p) { par_rows(px, w, 1, |_, row| { for q in row.iter_mut() { - for c in 0..3 { - q[c] = linear_to_srgb((srgb_to_linear(q[c]) * g[c]).max(0.0)); - } + *q = balance(*q, g); } }); } @@ -606,7 +604,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo 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; - let tau = std::f32::consts::TAU; par_rows(px, w, 1, |y, row| { for (x, q) in row.iter_mut().enumerate() { let i = y * w + x; @@ -619,22 +616,14 @@ 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 k_con != 0.0 { - let xx = lv.clamp(0.0, 1.0); - lv += -k_con * (tau * xx).sin() / tau; - } - let lv = lv.max(0.0) + (l0 - lc[i]); - let ratio = if l0 > 1e-5 { lv / l0 } else { 1.0 }; - for c in 0..3 { - q[c] = if l0 > 1e-5 { q[c] * ratio } else { q[c] + (lv - l0) }; - } + *q = tone(*q, l0, lc[i], lv, k_con); } }); } // 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 (bp, wp) = luminance_points(px, w, h, enc, |q| q); 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); } @@ -646,14 +635,7 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo let (master, cr, cg, cb) = (pts(&p.point_curve), pts(&p.point_curve_red), pts(&p.point_curve_green), pts(&p.point_curve_blue)); let hsl = !p.hsl_neutral(); let grade = !p.grade_neutral(); - let wheel_tint = |wh: &Wheel| -> [f32; 3] { - let c = hsv_to_rgb(wh.hue, 1.0, 1.0); - let l = luma(c); - [c[0] - l, c[1] - l, c[2] - l].map(|v| v * wh.sat / 100.0 * 0.3) - }; - let tints = [wheel_tint(&p.grade_shadows), wheel_tint(&p.grade_midtones), wheel_tint(&p.grade_highlights), wheel_tint(&p.grade_global)]; - let blend = 0.15 + p.grade_blending.clamp(0.0, 100.0) / 100.0 * 0.35; - let bal = p.grade_balance.clamp(-100.0, 100.0) / 100.0 * 0.25; + let Grade { tints, lums, blend, balance: bal } = Grade::new(p); par_rows(px, w, 1, |_, row| { for q in row.iter_mut() { let mut c = [q[0], q[1], q[2]]; @@ -706,7 +688,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo let hi = smoothstep(0.5 + bal - blend, 1.0, l); let mid = (1.0 - sh - hi).max(0.0); let masks = [sh, mid, hi, 1.0]; - let lums = [p.grade_shadows.lum, p.grade_midtones.lum, p.grade_highlights.lum, p.grade_global.lum]; for k in 0..4 { for ch in 0..3 { c[ch] += masks[k] * tints[k][ch]; @@ -732,11 +713,7 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo vignette(px, w, h, p); } if p.grain_amount > 0.0 { - let cell = 1.0 + p.grain_size / 25.0; - let rough = p.grain_roughness / 100.0; - let amt = p.grain_amount / 100.0 * 0.12; - let seed = p.seed as u64; - let pscale = 1.0 / ps; + let Grain { cell, roughness: rough, amount: amt, seed, scale: pscale } = Grain::new(p); par_rows(px, w, 1, |y, row| { for (x, q) in row.iter_mut().enumerate() { // Sample in full-resolution coordinates so previews match the result. @@ -759,24 +736,60 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo } } +/// The end of stage 3 on one pixel: Contrast (strength `k_con`) on the luminance `lv` that +/// Clarity and Texture left, then the pixel rescaled to it. `l0` is its luminance, `lc` that +/// clipped to 1. +fn tone(mut q: [f32; 4], l0: f32, lc: f32, mut lv: f32, k_con: f32) -> [f32; 4] { + if k_con != 0.0 { + let tau = std::f32::consts::TAU; + let xx = lv.clamp(0.0, 1.0); + lv += -k_con * (tau * xx).sin() / tau; + } + let lv = lv.max(0.0) + (l0 - lc); + let ratio = if l0 > 1e-5 { lv / l0 } else { 1.0 }; + for c in 0..3 { + q[c] = if l0 > 1e-5 { q[c] * ratio } else { q[c] + (lv - l0) }; + } + q +} + +/// Stage 3 on one pixel when Contrast is its only control. +fn contrast(q: [f32; 4], k_con: f32) -> [f32; 4] { + let l0 = luma([q[0], q[1], q[2]]).max(0.0); + let lc = l0.min(1.0); + tone(q, l0, lc, lc, k_con) +} + +/// Stage 1 on one pixel: white balance and exposure `gains` in linear light. +fn balance(mut q: [f32; 4], g: [f32; 3]) -> [f32; 4] { + for c in 0..3 { + q[c] = linear_to_srgb((srgb_to_linear(q[c]) * g[c]).max(0.0)); + } + q +} + /// 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) { +/// preview proxy and the full image agree). `map` is applied to each pixel first. +fn luminance_points(px: &[[f32; 4]], w: usize, h: usize, enc: Encoding, map: impl Fn([f32; 4]) -> [f32; 4] + Sync) -> (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)); + // Each band of `f` rows sums into its own row of cells, in the same order as one pass would. + let bands = crate::photo_util::par_map(sh, |sy| { + let mut sum = vec![[0.0f64; 4]; sw]; + for y in sy * f..((sy + 1) * f).min(h) { + for x in 0..w { + let q = map(px[y * w + x]); + let s = &mut sum[x / f]; + for c in 0..3 { + s[c] += f64::from(enc.decode(q[c]).clamp(0.0, 1.0)); + } + s[3] += 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(); + sum + }); + let cells: Vec<[f32; 3]> = bands.iter().flatten().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); } @@ -923,24 +936,7 @@ fn detail(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, ps: f32) { } fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) { - let amt = p.vignette_amount / 100.0; - let mid = p.vignette_midpoint.clamp(0.0, 100.0) / 100.0; - let feather = p.vignette_feather.clamp(0.0, 100.0) / 100.0; - let round = p.vignette_roundness.clamp(-100.0, 100.0) / 100.0; - let hl = p.vignette_highlights.clamp(0.0, 100.0) / 100.0; - let style = p.vignette_style.as_str(); - let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0); - // Roundness: 0 follows the frame's aspect, +1 a circle, −1 a rounded rectangle. - let (ax, ay) = if round >= 0.0 { - let r = cx.min(cy) + (cx.max(cy) - cx.min(cy)) * (1.0 - round); - (if cx >= cy { r } else { cx }, if cy > cx { r } else { cy }) - } else { - (cx, cy) - }; - let pw = 2.0 + (-round).max(0.0) * 6.0; - let inner = 1.0 - mid * 0.8; - let edge0 = inner - feather * 0.5 * inner; - let edge1 = inner + feather * 0.5 * (1.4 - inner) + 0.02; + let Vignette { amount: amt, highlights: hl, style, centre: [cx, cy], axes: [ax, ay], power: pw, edges: [edge0, edge1] } = Vignette::new(w, h, p); par_rows(px, w, 1, |y, row| { for (x, q) in row.iter_mut().enumerate() { let (dx, dy) = (((x as f32 + 0.5 - cx) / ax).abs(), ((y as f32 + 0.5 - cy) / ay).abs()); @@ -951,7 +947,7 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) { } let l = luma([q[0], q[1], q[2]]).clamp(0.0, 1.0); match style { - "paintOverlay" => { + VignetteStyle::PaintOverlay => { let target = if amt < 0.0 { 0.0 } else { 1.0 }; let k = m * amt.abs(); for c in 0..3 { @@ -965,7 +961,7 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) { let protect = smoothstep(0.6, 1.0, l) * hl; k += (1.0 - k) * protect; } - if style == "colorPriority" { + if style == VignetteStyle::ColorPriority { let nl = l * k; for c in 0..3 { q[c] += nl - l; diff --git a/crates/algo/src/camera_raw/per_pixel.rs b/crates/algo/src/camera_raw/per_pixel.rs new file mode 100644 index 0000000000..88bfc9a006 --- /dev/null +++ b/crates/algo/src/camera_raw/per_pixel.rs @@ -0,0 +1,239 @@ +//! Develop's per-pixel stages resolved for one image: every constant and table they use, so +//! another implementation of them (the GPU preview, `photocraft_gpu::CameraRawDevelop`) runs the +//! same maths. [`develop`](super::develop) builds its own constants with the same functions. + +use super::{CameraRaw, Encoding, Wheel, curve_lut, hsv_to_rgb, is_linear, luma, parametric_lut, white_balance_gains, whites_blacks}; + +/// Entries of the tone curve tables (parametric and point curves). +pub const CURVE_LUT: usize = 1024; +/// Samples of a Whites or Blacks curve row: grey levels 0, 1/32, …, 1. +pub const WB_STEPS: usize = whites_blacks::STEPS; + +/// White balance and exposure: channel gains in linear light, when the stage is on. +pub(crate) fn gains(p: &CameraRaw) -> Option<[f32; 3]> { + if p.wb_neutral() { + return None; + } + let g = white_balance_gains(p.temperature, p.tint); + let norm = luma(g); + Some(g.map(|v| v / norm * 2f32.powf(p.exposure))) +} + +/// Contrast's strength, when Contrast is set. +fn contrast(p: &CameraRaw) -> Option { + (p.contrast != 0.0).then_some(p.contrast / 100.0 * 0.6) +} + +/// Whites and Blacks as two curve rows for the image's amounts. +#[derive(Clone, Debug, PartialEq)] +pub struct WhitesBlacks { + /// The Whites curve over grey levels 0, 1/32, …, 1, when Whites is set. + pub whites: Option<[f32; WB_STEPS]>, + /// The Blacks curve, applied after Whites. + pub blacks: Option<[f32; WB_STEPS]>, + /// The clipping directions (Whites up, Blacks down) curve each channel; the others scale the + /// pixel by the change of its luminance. + pub per_channel: bool, +} + +/// Color Grading's wheels resolved. +#[derive(Clone, Debug, PartialEq)] +pub struct Grade { + /// Colour offsets of the shadows, midtones, highlights and global wheels. + pub tints: [[f32; 3]; 4], + /// Luminance of the same wheels (slider units). + pub lums: [f32; 4], + pub blend: f32, + pub balance: f32, +} + +impl Grade { + pub(crate) fn new(p: &CameraRaw) -> Self { + let tint = |wh: &Wheel| -> [f32; 3] { + let c = hsv_to_rgb(wh.hue, 1.0, 1.0); + let l = luma(c); + [c[0] - l, c[1] - l, c[2] - l].map(|v| v * wh.sat / 100.0 * 0.3) + }; + Grade { + tints: [tint(&p.grade_shadows), tint(&p.grade_midtones), tint(&p.grade_highlights), tint(&p.grade_global)], + lums: [p.grade_shadows.lum, p.grade_midtones.lum, p.grade_highlights.lum, p.grade_global.lum], + blend: 0.15 + p.grade_blending.clamp(0.0, 100.0) / 100.0 * 0.35, + balance: p.grade_balance.clamp(-100.0, 100.0) / 100.0 * 0.25, + } + } +} + +/// The vignette's `vignette_style`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VignetteStyle { + HighlightPriority, + ColorPriority, + PaintOverlay, +} + +/// The post-crop vignette's shape on a `w × h` frame. +#[derive(Clone, Debug, PartialEq)] +pub struct Vignette { + /// −1 … 1. + pub amount: f32, + /// 0 … 1. + pub highlights: f32, + pub style: VignetteStyle, + /// Frame centre (pixels). + pub centre: [f32; 2], + /// Half axes of the shape (pixels). + pub axes: [f32; 2], + /// Exponent of the superellipse. + pub power: f32, + /// The mask's smoothstep edges in normalised radius. + pub edges: [f32; 2], +} + +impl Vignette { + pub(crate) fn new(w: usize, h: usize, p: &CameraRaw) -> Self { + let mid = p.vignette_midpoint.clamp(0.0, 100.0) / 100.0; + let feather = p.vignette_feather.clamp(0.0, 100.0) / 100.0; + let round = p.vignette_roundness.clamp(-100.0, 100.0) / 100.0; + let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0); + // Roundness: 0 follows the frame's aspect, +1 a circle, −1 a rounded rectangle. + let (ax, ay) = if round >= 0.0 { + let r = cx.min(cy) + (cx.max(cy) - cx.min(cy)) * (1.0 - round); + (if cx >= cy { r } else { cx }, if cy > cx { r } else { cy }) + } else { + (cx, cy) + }; + let inner = 1.0 - mid * 0.8; + Vignette { + amount: p.vignette_amount / 100.0, + highlights: p.vignette_highlights.clamp(0.0, 100.0) / 100.0, + style: match p.vignette_style.as_str() { + "paintOverlay" => VignetteStyle::PaintOverlay, + "colorPriority" => VignetteStyle::ColorPriority, + _ => VignetteStyle::HighlightPriority, + }, + centre: [cx, cy], + axes: [ax, ay], + power: 2.0 + (-round).max(0.0) * 6.0, + edges: [inner - feather * 0.5 * inner, inner + feather * 0.5 * (1.4 - inner) + 0.02], + } + } +} + +/// Grain: value noise sampled in full-resolution coordinates. +#[derive(Clone, Debug, PartialEq)] +pub struct Grain { + /// Noise cell size (full-resolution pixels). + pub cell: f32, + /// Weight of the finer octave. + pub roughness: f32, + pub amount: f32, + pub seed: u64, + /// Full-resolution pixels per pixel (`1 / pixel_scale`). + pub scale: f32, +} + +impl Grain { + pub(crate) fn new(p: &CameraRaw) -> Self { + Grain { + cell: 1.0 + p.grain_size / 25.0, + roughness: p.grain_roughness / 100.0, + amount: p.grain_amount / 100.0 * 0.12, + seed: u64::from(p.seed), + scale: 1.0 / p.pixel_scale.clamp(0.01, 1.0), + } + } +} + +/// Develop's per-pixel stages for one image and settings, in order. `None` stages are off. +#[derive(Clone, Debug, PartialEq)] +pub struct PerPixel { + /// 1. White balance and exposure gains in linear light (sRGB-decoded values). + pub gains: Option<[f32; 3]>, + /// 3. Contrast's strength (Contrast without Texture or Clarity is per pixel). + pub contrast: Option, + /// 3b. Whites and Blacks, in the document's encoding. + pub whites_blacks: Option, + /// The document's transfer curve: 0 for sRGB's, else a power-law gamma. + pub encoding: f32, + /// 4. Vibrance and Saturation (slider / 100). + pub presence: Option<[f32; 2]>, + /// 5. The parametric tone curve, [`CURVE_LUT`] entries over 0 … 1. + pub parametric: Option>, + /// 5. Point curves: master, red, green, blue. + pub point: [Option>; 4], + /// 6. Color Mixer hue, saturation and luminance per band. + pub hsl: Option<[[f32; 8]; 3]>, + /// 7. Color Grading. + pub grade: Option, + /// 9. Vignette. + pub vignette: Option, + /// 9. Grain. + pub grain: Option, + /// Clip RGB to 0 … 1 at the end (all but 32-bit documents). + pub clamp: bool, +} + +impl CameraRaw { + /// Whether developing needs a stage that reads neighbouring pixels: Dehaze, Shadows and + /// Highlights, Texture, Clarity, sharpening or noise reduction. + pub fn spatial(&self) -> bool { + self.dehaze != 0.0 + || [self.highlights, self.shadows, self.texture, self.clarity].iter().any(|v| *v != 0.0) + || self.sharpen_amount > 0.0 + || self.noise_luminance > 0.0 + || self.noise_color > 0.0 + } +} + +/// The image's black and white points that Whites and Blacks adapt to, for `px` (`w × h`, +/// display-encoded) developed per pixel with `p`: what [`develop`](super::develop) measures +/// after white balance and Contrast. `None` when Whites and Blacks are 0 or `p` needs a +/// spatial stage. +pub fn white_black_points(px: &[[f32; 4]], w: usize, h: usize, p: &CameraRaw) -> Option<(f32, f32)> { + if (p.whites == 0.0 && p.blacks == 0.0) || p.spatial() || px.len() != w.saturating_mul(h) { + return None; + } + let enc = Encoding::of(p.encoding_gamma); + let (g, k_con) = (gains(p), contrast(p)); + Some(super::luminance_points(px, w, h, enc, |q| { + let q = g.map_or(q, |g| super::balance(q, g)); + k_con.map_or(q, |k| super::contrast(q, k)) + })) +} + +/// Develop's stages for settings without spatial stages, on a `w × h` image; `points` are the +/// image's [`white_black_points`]. `None` when `p` needs a spatial stage, or Whites or Blacks is +/// set without `points`. +pub fn per_pixel(p: &CameraRaw, w: usize, h: usize, float: bool, points: Option<(f32, f32)>) -> Option { + if p.spatial() { + return None; + } + let identity = p.is_identity(); + let whites_blacks = if p.whites != 0.0 || p.blacks != 0.0 { + let (bp, wp) = points?; + let (xw, xb) = whites_blacks::amounts(p.whites.clamp(-100.0, 100.0), p.blacks.clamp(-100.0, 100.0), bp, wp); + (xw != 0.0 || xb != 0.0).then(|| WhitesBlacks { + whites: (xw != 0.0).then(|| whites_blacks::whites_row(xw)), + blacks: (xb != 0.0).then(|| whites_blacks::blacks_row(xb)), + per_channel: xw > 0.0 || xb < 0.0, + }) + } else { + None + }; + let parametric_on = !(p.curve_highlights == 0.0 && p.curve_lights == 0.0 && p.curve_darks == 0.0 && p.curve_shadows == 0.0); + let pts = |c: &Vec<[f32; 2]>| (!is_linear(c)).then(|| curve_lut(c, CURVE_LUT)); + Some(PerPixel { + gains: gains(p), + contrast: contrast(p), + whites_blacks, + encoding: Encoding::of(p.encoding_gamma).0, + presence: (p.vibrance != 0.0 || p.saturation != 0.0).then_some([p.vibrance / 100.0, p.saturation / 100.0]), + parametric: parametric_on.then(|| parametric_lut(p, CURVE_LUT)), + point: [pts(&p.point_curve), pts(&p.point_curve_red), pts(&p.point_curve_green), pts(&p.point_curve_blue)], + hsl: (!p.hsl_neutral()).then_some([p.hsl_hue, p.hsl_sat, p.hsl_lum]), + grade: (!p.grade_neutral()).then(|| Grade::new(p)), + vignette: (p.vignette_amount != 0.0).then(|| Vignette::new(w, h, p)), + grain: (p.grain_amount > 0.0).then(|| Grain::new(p)), + clamp: !float && !identity, + }) +} diff --git a/crates/algo/src/camera_raw/whites_blacks.rs b/crates/algo/src/camera_raw/whites_blacks.rs index 93bb9cff31..cfce5378b4 100644 --- a/crates/algo/src/camera_raw/whites_blacks.rs +++ b/crates/algo/src/camera_raw/whites_blacks.rs @@ -24,7 +24,7 @@ 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; +pub(crate) 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; @@ -315,6 +315,25 @@ fn curve(rows: &[[f32; STEPS]], from: f32, x: f32, v: f32) -> f32 { at(&rows[i]) * (1.0 - t) + at(&rows[i + 1]) * t } +/// The curve at a fractional internal amount `x` as one row: `curve(rows, from, x, v)` is this +/// row interpolated at `v`, because both interpolations are linear. +fn row_at(rows: &[[f32; STEPS]], from: f32, x: f32) -> [f32; STEPS] { + 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; + std::array::from_fn(|j| rows[i][j] * (1.0 - t) + rows[i + 1][j] * t) +} + +/// The Whites curve at internal amount `x` as a row over grey levels 0, 1/32, …, 1. +pub(crate) fn whites_row(x: f32) -> [f32; STEPS] { + row_at(&WHITES, WHITES_FROM, x) +} + +/// The Blacks curve at internal amount `x` as a row over grey levels 0, 1/32, …, 1. +pub(crate) fn blacks_row(x: f32) -> [f32; STEPS] { + row_at(&BLACKS, BLACKS_FROM, x) +} + /// 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) diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index 81b51f4c35..ecc9fdc392 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -14,6 +14,9 @@ photocraft-color = { workspace = true } photocraft-raster = { workspace = true } photocraft-doc = { workspace = true } photocraft-compose = { workspace = true } +# Camera Raw's per-pixel stages (`camera_raw.rs`) take their constants from the CPU develop. +photocraft-algo = { workspace = true } +bytemuck = { workspace = true } # Same major version as eframe's wgpu so the app's device can be shared. wgpu = { version = "30", default-features = false, features = ["std", "wgsl"] } log = { workspace = true } @@ -29,3 +32,4 @@ pollster = "1" photocraft-vector = { workspace = true } # A document-tagged CMYK profile in the tile conversion test. photocraft-cms = { workspace = true } +photocraft-testkit = { workspace = true } diff --git a/crates/gpu/examples/bench_camera_raw.rs b/crates/gpu/examples/bench_camera_raw.rs new file mode 100644 index 0000000000..8f31be5e8f --- /dev/null +++ b/crates/gpu/examples/bench_camera_raw.rs @@ -0,0 +1,109 @@ +//! Camera Raw's preview develop on the 858×572 proxy of a 6000×4000 image: the CPU develop +//! against the GPU pass (upload, render, readback), for per-pixel settings. +//! `cargo run --release -p photocraft-gpu --example bench_camera_raw [-- --quick]` +use std::time::Instant; + +use photocraft_algo::camera_raw::{CameraRaw, Wheel, develop, per_pixel, white_black_points}; +use photocraft_gpu::CameraRawDevelop; + +fn median(mut v: Vec) -> f64 { + v.sort_by(f64::total_cmp); + v[v.len() / 2] +} + +fn time(runs: usize, mut f: impl FnMut()) -> f64 { + f(); + median( + (0..runs) + .map(|_| { + let t = Instant::now(); + f(); + t.elapsed().as_secs_f64() * 1e3 + }) + .collect(), + ) +} + +fn main() -> Result<(), Box> { + let runs = if std::env::args().any(|a| a == "--quick") { 5 } else { 40 }; + let (w, h) = (858usize, 572usize); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); + let options = wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }; + let adapter = pollster::block_on(instance.request_adapter(&options))?; + let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?; + let info = adapter.get_info(); + println!("adapter: {} ({:?}), proxy {w}x{h}, median of {runs}", info.name, info.backend); + let built = Instant::now(); + let mut pass = CameraRawDevelop::new(&device)?; + println!("pipeline build: {:.1} ms", built.elapsed().as_secs_f64() * 1e3); + let src: Vec<[f32; 4]> = (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as f32 / w as f32, (i / w) as f32 / h as f32); + [0.1 + 0.8 * x, 0.2 + 0.6 * y, 0.6 - 0.5 * x * y, 1.0] + }) + .collect(); + let scale = w as f32 / 6000.0; + let mut hsl = [0.0; 8]; + hsl[1] = 30.0; + hsl[5] = -40.0; + let sets = [ + ( + "Basic (WB, Exposure, Contrast, Whites, Blacks)", + CameraRaw { temperature: 20.0, exposure: 0.4, contrast: 25.0, whites: 30.0, blacks: -20.0, pixel_scale: scale, ..Default::default() }, + ), + ( + "every per-pixel stage", + CameraRaw { + temperature: 20.0, + tint: 5.0, + exposure: 0.4, + contrast: 25.0, + whites: 30.0, + blacks: -20.0, + vibrance: 30.0, + saturation: 10.0, + curve_lights: 20.0, + point_curve: vec![[0.0, 8.0], [128.0, 140.0], [255.0, 250.0]], + hsl_hue: hsl, + hsl_sat: hsl, + hsl_lum: hsl, + grade_shadows: Wheel { hue: 210.0, sat: 30.0, lum: 0.0 }, + grade_highlights: Wheel { hue: 40.0, sat: 20.0, lum: 5.0 }, + vignette_amount: -30.0, + grain_amount: 25.0, + pixel_scale: scale, + ..Default::default() + }, + ), + ]; + for (name, p) in &sets { + let cpu = time(runs, || { + let mut px = src.clone(); + develop(&mut px, w, h, p, false); + }); + let points_ms = time(runs, || { + std::hint::black_box(white_black_points(&src, w, h, p)); + }); + let points = white_black_points(&src, w, h, p); + let stages = per_pixel(p, w, h, false, points).ok_or("not per pixel")?; + let size = [w as u32, h as u32]; + let upload = time(runs, || { + pass.develop(&device, &queue, None, &src, None, size, &stages).ok(); + }); + let cached = time(runs, || { + pass.develop(&device, &queue, None, &src, Some(1), size, &stages).ok(); + }); + println!("{name}:"); + println!(" CPU develop {cpu:7.2} ms"); + println!(" GPU, source uploaded every time {upload:7.2} ms"); + println!(" GPU, source cached (slider drag) {cached:7.2} ms"); + println!(" Whites/Blacks points on the CPU {points_ms:7.2} ms (cached per WB/Exposure/Contrast in the dialog)"); + } + let shadows = CameraRaw { shadows: 50.0, pixel_scale: scale, ..Default::default() }; + let cpu = time(runs.min(10), || { + let mut px = src.clone(); + develop(&mut px, w, h, &shadows, false); + }); + println!("for reference, Shadows +50 (CPU only): {cpu:.2} ms"); + Ok(()) +} diff --git a/crates/gpu/src/camera_raw.rs b/crates/gpu/src/camera_raw.rs new file mode 100644 index 0000000000..3f1833a54b --- /dev/null +++ b/crates/gpu/src/camera_raw.rs @@ -0,0 +1,418 @@ +//! Camera Raw's per-pixel develop on the GPU, for the dialog's interactive preview (#6916). +//! +//! One fragment pass (`camera_raw.wgsl`) runs the stages that read only their own pixel: white +//! balance and exposure, Contrast without Texture or Clarity, Whites and Blacks, Vibrance and +//! Saturation, the tone curves, the Color Mixer, Color Grading, the vignette and grain. Their +//! constants and tables come from [`photocraft_algo::camera_raw::per_pixel`], which the CPU +//! develop builds its own from, so both evaluate the same maths; the curves and the Whites and +//! Blacks rows are rows of one `R32Float` texture. Settings with a spatial stage (Dehaze, Shadows +//! and Highlights, Texture, Clarity, Detail) have no [`PerPixel`] and stay on the CPU. +//! +//! The pipeline is built on demand ([`CameraRawDevelop::new`]), not with the compositor (#6419). +//! It renders into `Rgba32Float`; [`CameraRawDevelop::supported`] says whether the adapter can. + +use photocraft_algo::camera_raw::{CURVE_LUT, PerPixel, VignetteStyle, WB_STEPS}; + +use crate::Unsupported; + +/// The pass's WGSL source. +pub const CAMERA_RAW_SHADER: &str = include_str!("camera_raw.wgsl"); + +const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; +/// `Params` in the shader: 19 rows of 16 bytes. +const PARAMS_SIZE: u64 = 19 * 16; +/// Rows of the table texture: parametric, master, red, green, blue curves; Whites, Blacks. +const LUT_ROWS: u32 = 7; +const LUT_WIDTH: u32 = CURVE_LUT as u32; +const BYTES_PER_PIXEL: u32 = 16; + +struct Image { + key: Option, + size: [u32; 2], + texture: wgpu::Texture, + view: wgpu::TextureView, +} + +impl Image { + fn new(device: &wgpu::Device, label: &str, size: [u32; 2], usage: wgpu::TextureUsages) -> Self { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { width: size[0], height: size[1], depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FORMAT, + usage, + view_formats: &[], + }); + let view = texture.create_view(&Default::default()); + Image { key: None, size, texture, view } + } +} + +/// The Camera Raw preview pass and its textures (reused while the size stays). +pub struct CameraRawDevelop { + pipeline: wgpu::RenderPipeline, + layout: wgpu::BindGroupLayout, + params: wgpu::Buffer, + luts: wgpu::Texture, + luts_view: wgpu::TextureView, + source: Option, + target: Option, + #[cfg(not(target_arch = "wasm32"))] + readback: Option, +} + +impl CameraRawDevelop { + /// Whether `adapter` can render the pass's `Rgba32Float` output. + pub fn supported(adapter: &wgpu::Adapter) -> bool { + adapter.get_texture_format_features(FORMAT).allowed_usages.contains(wgpu::TextureUsages::RENDER_ATTACHMENT) + } + + /// Builds the pipeline. A shader or pipeline the driver can't build is an `Err`, so the + /// caller keeps the CPU develop (where errors only arrive asynchronously, on WebGPU, creation + /// is assumed to have worked). + pub fn new(device: &wgpu::Device) -> Result { + match crate::first_error(device, || Self::build(device)) { + (dev, None) => Ok(dev), + (_, Some(e)) => Err(Unsupported(format!("couldn't build the Camera Raw pipeline: {e}"))), + } + } + + fn build(device: &wgpu::Device) -> Self { + let module = device + .create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("pc_camera_raw"), source: wgpu::ShaderSource::Wgsl(CAMERA_RAW_SHADER.into()) }); + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("pc_camera_raw"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: wgpu::BufferSize::new(PARAMS_SIZE), + }, + count: None, + }, + crate::tex_entry(1), + crate::tex_entry(2), + ], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("pc_camera_raw"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("pc_camera_raw"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() }, + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs"), + targets: &[Some(wgpu::ColorTargetState { format: FORMAT, blend: None, write_mask: wgpu::ColorWrites::ALL })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }); + let params = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pc_camera_raw_params"), + size: PARAMS_SIZE, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let luts = device.create_texture(&wgpu::TextureDescriptor { + label: Some("pc_camera_raw_luts"), + size: wgpu::Extent3d { width: LUT_WIDTH, height: LUT_ROWS, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R32Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let luts_view = luts.create_view(&Default::default()); + CameraRawDevelop { + pipeline, + layout, + params, + luts, + luts_view, + source: None, + target: None, + #[cfg(not(target_arch = "wasm32"))] + readback: None, + } + } + + /// Records the develop of `source` (`w × h` straight RGBA, display-encoded) with `stages` + /// into `encoder` and returns the `Rgba32Float` texture it renders. The source is uploaded + /// unless the last one had the same `key` and size (`None` always uploads). + #[allow(clippy::too_many_arguments)] + pub fn encode( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + source: &[[f32; 4]], + key: Option, + size: [u32; 2], + stages: &PerPixel, + ) -> Result<&wgpu::Texture, Unsupported> { + check_size(device, source, size)?; + let [w, h] = size; + let usage = wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST; + let src = match self.source.take() { + Some(s) if s.size == size => s, + _ => Image::new(device, "pc_camera_raw_source", size, usage), + }; + let src = self.source.insert(src); + if key.is_none() || src.key != key { + queue.write_texture( + wgpu::TexelCopyTextureInfo { texture: &src.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + bytemuck::cast_slice(source), + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(w * BYTES_PER_PIXEL), rows_per_image: Some(h) }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + src.key = key; + } + queue.write_buffer(&self.params, 0, ¶ms(size, stages)); + queue.write_texture( + wgpu::TexelCopyTextureInfo { texture: &self.luts, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + bytemuck::cast_slice(&tables(stages)), + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(LUT_WIDTH * 4), rows_per_image: Some(LUT_ROWS) }, + wgpu::Extent3d { width: LUT_WIDTH, height: LUT_ROWS, depth_or_array_layers: 1 }, + ); + let target = match self.target.take() { + Some(t) if t.size == size => t, + _ => Image::new(device, "pc_camera_raw_target", size, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC), + }; + let target = self.target.insert(target); + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pc_camera_raw"), + layout: &self.layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: self.params.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&src.view) }, + wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self.luts_view) }, + ], + }); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("pc_camera_raw"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &target.view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &group, &[]); + pass.draw(0..3, 0..1); + } + Ok(&target.texture) + } + + /// Develops `source` and reads the result back (blocking; native only, the web has no + /// blocking wait). GPU errors and a lost device are `Err`; `health`, when the device is + /// watched, records a loss. + #[cfg(not(target_arch = "wasm32"))] + #[allow(clippy::too_many_arguments)] + pub fn develop( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + health: Option<&crate::DeviceHealth>, + source: &[[f32; 4]], + key: Option, + size: [u32; 2], + stages: &PerPixel, + ) -> Result, Unsupported> { + if let Some(f) = health.and_then(crate::DeviceHealth::fault) { + return Err(Unsupported(f.to_string())); + } + let (result, error) = crate::first_error(device, || self.develop_inner(device, queue, health, source, key, size, stages)); + match error { + Some(e) => { + // A half-done upload must not be taken for the cached source next time. + self.source = None; + Err(Unsupported(format!("GPU Camera Raw develop failed: {e}"))) + } + None => result, + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[allow(clippy::too_many_arguments)] + fn develop_inner( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + health: Option<&crate::DeviceHealth>, + source: &[[f32; 4]], + key: Option, + size: [u32; 2], + stages: &PerPixel, + ) -> Result, Unsupported> { + check_size(device, source, size)?; + let [w, h] = size; + let row = (w * BYTES_PER_PIXEL).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let bytes = u64::from(row) * u64::from(h); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_camera_raw") }); + self.encode(device, queue, &mut encoder, source, key, size, stages)?; + let Some(target) = self.target.as_ref().map(|t| &t.texture) else { return Err(Unsupported("no Camera Raw target".into())) }; + let buffer = match self.readback.take() { + Some(b) if b.size() == bytes => b, + _ => device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pc_camera_raw_readback"), + size: bytes, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }), + }; + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { texture: target, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyBufferInfo { buffer: &buffer, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: Some(h) } }, + wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, + ); + let index = queue.submit([encoder.finish()]); + buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + match health { + Some(hl) => { + if !hl.wait(device, Some(index)) { + return Err(Unsupported(hl.fault().map_or_else(|| "GPU device lost".into(), |f| f.to_string()))); + } + } + None => { + device.poll(wgpu::PollType::Wait { submission_index: Some(index), timeout: None }).map_err(|e| Unsupported(format!("GPU wait failed: {e}")))?; + } + } + let mut out = Vec::with_capacity(source.len()); + { + let data = buffer.slice(..).get_mapped_range().map_err(|e| Unsupported(format!("GPU readback failed: {e:?}")))?; + let width = w as usize * BYTES_PER_PIXEL as usize; + for y in 0..h as usize { + let start = y * row as usize; + let line = data.get(start..start + width).ok_or_else(|| Unsupported("short GPU readback".into()))?; + out.extend(bytemuck::pod_collect_to_vec::(line)); + } + } + buffer.unmap(); + self.readback = Some(buffer); + Ok(out) + } +} + +fn check_size(device: &wgpu::Device, source: &[[f32; 4]], [w, h]: [u32; 2]) -> Result<(), Unsupported> { + let max = device.limits().max_texture_dimension_2d; + if w == 0 || h == 0 || w > max || h > max { + return Err(Unsupported(format!("a {w}×{h} Camera Raw preview doesn't fit the GPU's {max} px textures"))); + } + if source.len() as u64 != u64::from(w) * u64::from(h) { + return Err(Unsupported(format!("{} Camera Raw preview pixels for {w}×{h}", source.len()))); + } + Ok(()) +} + +/// Flags of `Params.flags`, in the shader's bit order. +fn flags(s: &PerPixel) -> u32 { + let wb = s.whites_blacks.as_ref(); + let on = [ + s.gains.is_some(), + s.contrast.is_some(), + wb.is_some_and(|w| w.whites.is_some()), + wb.is_some_and(|w| w.blacks.is_some()), + wb.is_some_and(|w| w.per_channel), + s.presence.is_some(), + s.parametric.is_some(), + s.point[0].is_some(), + s.point[1].is_some(), + s.point[2].is_some(), + s.point[3].is_some(), + s.hsl.is_some(), + s.grade.is_some(), + s.vignette.is_some(), + s.grain.is_some(), + s.clamp, + ]; + on.iter().enumerate().fold(0, |f, (bit, on)| if *on { f | 1 << bit } else { f }) +} + +/// The shader's `Params` for a `size` image. +fn params(size: [u32; 2], s: &PerPixel) -> Vec { + let g = s.gains.unwrap_or([1.0; 3]); + let presence = s.presence.unwrap_or_default(); + let hsl = s.hsl.unwrap_or_default(); + let grade = s.grade.as_ref(); + let vig = s.vignette.as_ref(); + let grain = s.grain.as_ref(); + let mut out: Vec = Vec::with_capacity(PARAMS_SIZE as usize); + out.extend([size[0], size[1], flags(s)].iter().flat_map(|v| v.to_le_bytes()).chain(s.encoding.to_le_bytes())); + let mut f = |v: [f32; 4]| out.extend(v.iter().flat_map(|x| x.to_le_bytes())); + f([g[0], g[1], g[2], s.contrast.unwrap_or(0.0)]); + f([presence[0], presence[1], 0.0, 0.0]); + for row in hsl { + f([row[0], row[1], row[2], row[3]]); + f([row[4], row[5], row[6], row[7]]); + } + for k in 0..4 { + let (t, l) = grade.map_or(([0.0; 3], 0.0), |gr| (gr.tints[k], gr.lums[k])); + f([t[0], t[1], t[2], l]); + } + f(grade.map_or([0.0; 4], |gr| [gr.blend, gr.balance, 0.0, 0.0])); + let style = |v: VignetteStyle| match v { + VignetteStyle::HighlightPriority => 0.0, + VignetteStyle::ColorPriority => 1.0, + VignetteStyle::PaintOverlay => 2.0, + }; + f(vig.map_or([0.0, 0.0, 0.0, 2.0], |v| [v.amount, v.highlights, style(v.style), v.power])); + f(vig.map_or([0.0, 0.0, 1.0, 1.0], |v| [v.centre[0], v.centre[1], v.axes[0], v.axes[1]])); + f(vig.map_or([0.0; 4], |v| [v.edges[0], v.edges[1], 0.0, 0.0])); + f(grain.map_or([1.0, 0.0, 0.0, 1.0], |gr| [gr.cell, gr.roughness, gr.amount, gr.scale])); + let seed = grain.map_or(0, |gr| gr.seed); + let fine = seed.wrapping_add(7); + out.extend([seed as u32, (seed >> 32) as u32, fine as u32, (fine >> 32) as u32].iter().flat_map(|v| v.to_le_bytes())); + out +} + +/// The table texture's rows (see [`LUT_ROWS`]); rows of stages that are off stay zero. +fn tables(s: &PerPixel) -> Vec { + let mut t = vec![0.0f32; (LUT_WIDTH * LUT_ROWS) as usize]; + let mut put = |row: usize, v: &[f32]| { + let start = row * LUT_WIDTH as usize; + let n = v.len().min(LUT_WIDTH as usize); + if let (Some(dst), Some(src)) = (t.get_mut(start..start + n), v.get(..n)) { + dst.copy_from_slice(src); + } + }; + if let Some(l) = &s.parametric { + put(0, l); + } + for (k, l) in s.point.iter().enumerate() { + if let Some(l) = l { + put(1 + k, l); + } + } + if let Some(wb) = &s.whites_blacks { + if let Some(r) = &wb.whites { + put(5, r); + } + if let Some(r) = &wb.blacks { + put(6, r); + } + } + t +} + +// The Whites and Blacks rows fit a table row. +const _: () = assert!(WB_STEPS <= CURVE_LUT); diff --git a/crates/gpu/src/camera_raw.wgsl b/crates/gpu/src/camera_raw.wgsl new file mode 100644 index 0000000000..abad7cbbc1 --- /dev/null +++ b/crates/gpu/src/camera_raw.wgsl @@ -0,0 +1,416 @@ +// Camera Raw's per-pixel develop stages (`photocraft_algo::camera_raw::develop`, constants from +// `camera_raw::per_pixel`), one fragment per pixel. Each function mirrors the CPU code it is +// named after, in the same order of operations; `camera_raw.rs` holds the bindings. + +struct Params { + size: vec2, + flags: u32, + // The document's transfer curve: 0 for sRGB's, else a power-law gamma. + encoding: f32, + // White balance gains (rgb), Contrast's strength (w). + gains: vec4, + // Vibrance, Saturation. + presence: vec4, + // Color Mixer bands 0-3 and 4-7: hue, saturation, luminance. + hsl: array, 6>, + // Color Grading wheels: tint (rgb), luminance (w). + tints: array, 4>, + // Blending, Balance. + grade: vec4, + // [amount, highlights, style, power], [centre, axes], [edges]. + vignette: array, 3>, + // Cell, roughness, amount, full-resolution pixels per pixel. + grain: vec4, + // The two octaves' seeds as (low, high) words. + seeds: vec4, +} + +@group(0) @binding(0) var P: Params; +@group(0) @binding(1) var src: texture_2d; +// R32Float rows of `LUT_WIDTH`: parametric, master, red, green, blue curves; Whites, Blacks rows. +@group(0) @binding(2) var luts: texture_2d; + +const GAINS: u32 = 0u; +const CONTRAST: u32 = 1u; +const WHITES: u32 = 2u; +const BLACKS: u32 = 3u; +const PER_CHANNEL: u32 = 4u; +const PRESENCE: u32 = 5u; +const PARAMETRIC: u32 = 6u; +const MASTER: u32 = 7u; +const RED: u32 = 8u; +const HSL: u32 = 11u; +const GRADE: u32 = 12u; +const VIGNETTE: u32 = 13u; +const GRAIN: u32 = 14u; +const CLAMP: u32 = 15u; + +const CURVE_LUT: i32 = 1024; +const WB_STEPS: i32 = 33; +const TAU: f32 = 6.2831855; + +struct VOut { + @builtin(position) pos: vec4, +} + +@vertex +fn vs(@builtin(vertex_index) vi: u32) -> VOut { + let uv = vec2(f32((vi << 1u) & 2u), f32(vi & 2u)); + return VOut(vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0)); +} + +fn on(flag: u32) -> bool { + return (P.flags & (1u << flag)) != 0u; +} + +fn luma(c: vec3) -> f32 { + return 0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z; +} + +fn smoothstep_(e0: f32, e1: f32, x: f32) -> f32 { + let t = clamp((x - e0) / (e1 - e0), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} + +fn rem_euclid(x: f32, m: f32) -> f32 { + let r = x % m; + return select(r, r + m, r < 0.0); +} + +fn srgb_to_linear(v: f32) -> f32 { + if v <= 0.04045 { + return v / 12.92; + } + return pow((v + 0.055) / 1.055, 2.4); +} + +fn linear_to_srgb(v: f32) -> f32 { + if v <= 0.0031308 { + return v * 12.92; + } + return 1.055 * pow(v, 1.0 / 2.4) - 0.055; +} + +fn decode(v: f32) -> f32 { + if P.encoding > 0.0 { + return pow(max(v, 0.0), P.encoding); + } + return srgb_to_linear(v); +} + +fn encode(v: f32) -> f32 { + if P.encoding > 0.0 { + return pow(max(v, 0.0), 1.0 / P.encoding); + } + return linear_to_srgb(v); +} + +fn lut_at(row: i32, i: i32) -> f32 { + return textureLoad(luts, vec2(i, row), 0).r; +} + +// `lut_eval`: linear inside 0..1, extended linearly outside (float documents). +fn lut_eval(row: i32, v: f32) -> f32 { + if v < 0.0 { + return v + lut_at(row, 0); + } + if v > 1.0 { + return lut_at(row, CURVE_LUT - 1) + (v - 1.0); + } + let x = v * f32(CURVE_LUT - 1); + let i = min(i32(x), CURVE_LUT - 2); + let t = x - f32(i); + let a = lut_at(row, i); + return a + (lut_at(row, i + 1) - a) * t; +} + +// `whites_blacks::curve` on one row. +fn wb_curve(row: i32, v: f32) -> f32 { + if v >= 1.0 { + return 1.0; + } + let p = max(v, 0.0) * f32(WB_STEPS - 1); + let j = min(i32(p), WB_STEPS - 2); + let s = p - f32(j); + let a = lut_at(row, j); + return a + (lut_at(row, j + 1) - a) * s; +} + +fn grey(v: f32) -> f32 { + var g = v; + if on(WHITES) { + g = wb_curve(5, g); + } + if on(BLACKS) { + g = wb_curve(6, g); + } + return g; +} + +// `whites_blacks::apply`. +fn whites_blacks(q: vec3) -> vec3 { + let lin = vec3(decode(q.x), decode(q.y), decode(q.z)); + var out = q; + if on(PER_CHANNEL) { + for (var c = 0; c < 3; c++) { + let over = max(lin[c] - 1.0, 0.0); + out[c] = encode(srgb_to_linear(grey(linear_to_srgb(min(lin[c], 1.0)))) + over); + } + return out; + } + let y = max(luma(lin), 0.0); + if y <= 1e-7 { + return q; + } + let y2 = srgb_to_linear(grey(linear_to_srgb(min(y, 1.0)))) + max(y - 1.0, 0.0); + let k = y2 / y; + for (var c = 0; c < 3; c++) { + out[c] = encode(lin[c] * k); + } + return out; +} + +fn rgb_to_hsv(c: vec3) -> vec3 { + let mx = max(max(c.x, c.y), c.z); + let mn = min(min(c.x, c.y), c.z); + let d = mx - mn; + var h = 0.0; + if d <= 1e-9 { + h = 0.0; + } else if mx == c.x { + h = 60.0 * rem_euclid((c.y - c.z) / d, 6.0); + } else if mx == c.y { + h = 60.0 * ((c.z - c.x) / d + 2.0); + } else { + h = 60.0 * ((c.x - c.y) / d + 4.0); + } + return vec3(h, select(0.0, d / mx, mx > 1e-9), mx); +} + +fn hsv_to_rgb(hue: f32, s: f32, v: f32) -> vec3 { + let h = rem_euclid(hue, 360.0) / 60.0; + let c = v * s; + let x = c * (1.0 - abs((h % 2.0) - 1.0)); + var rgb: vec3; + switch u32(h) { + case 0u: { rgb = vec3(c, x, 0.0); } + case 1u: { rgb = vec3(x, c, 0.0); } + case 2u: { rgb = vec3(0.0, c, x); } + case 3u: { rgb = vec3(0.0, x, c); } + case 4u: { rgb = vec3(x, 0.0, c); } + default: { rgb = vec3(c, 0.0, x); } + } + let m = v - c; + return rgb + vec3(m); +} + +const HSL_BANDS = array(0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 270.0, 300.0); + +fn band(k: i32, row: i32) -> f32 { + return P.hsl[row * 2 + k / 4][k % 4]; +} + +// `band_weights` folded into the three sums. +fn hsl_shift(h: f32) -> vec3 { + var bands = HSL_BANDS; + var d = vec3(0.0); + for (var k = 0; k < 8; k++) { + let c = bands[k]; + let prev = bands[(k + 7) % 8]; + let next = bands[(k + 1) % 8]; + let diff = ((h - c + 540.0) % 360.0) - 180.0; + let span = select(rem_euclid(c - prev, 360.0), rem_euclid(next - c, 360.0), diff >= 0.0); + let w = max(1.0 - abs(diff) / max(span, 1e-3), 0.0); + d += w * vec3(band(k, 0), band(k, 1), band(k, 2)); + } + return d; +} + +// `photo_util::hash01`: 64-bit words as (low, high). +fn mul_wide(a: u32, b: u32) -> vec2 { + let a0 = a & 0xffffu; + let a1 = a >> 16u; + let b0 = b & 0xffffu; + let b1 = b >> 16u; + let p00 = a0 * b0; + let p01 = a0 * b1; + let p10 = a1 * b0; + let p11 = a1 * b1; + let mid = (p00 >> 16u) + (p01 & 0xffffu) + (p10 & 0xffffu); + return vec2((p00 & 0xffffu) | (mid << 16u), p11 + (p01 >> 16u) + (p10 >> 16u) + (mid >> 16u)); +} + +fn mul64(a: vec2, b: vec2) -> vec2 { + let w = mul_wide(a.x, b.x); + return vec2(w.x, w.y + a.x * b.y + a.y * b.x); +} + +fn shr64(z: vec2, n: u32) -> vec2 { + return vec2((z.x >> n) | (z.y << (32u - n)), z.y >> n); +} + +fn i64_(v: i32) -> vec2 { + return vec2(bitcast(v), select(0u, 0xffffffffu, v < 0)); +} + +fn hash01(x: i32, y: i32, seed: vec2) -> f32 { + var z = mul64(i64_(x), vec2(0x7f4a7c15u, 0x9e3779b9u)) ^ mul64(i64_(y), vec2(0x27d4eb4fu, 0xc2b2ae3du)) ^ mul64(seed, vec2(0x9e3779f9u, 0x165667b1u)); + z = mul64(z ^ shr64(z, 30u), vec2(0x1ce4e5b9u, 0xbf58476du)); + z = mul64(z ^ shr64(z, 27u), vec2(0x133111ebu, 0x94d049bbu)); + z = z ^ shr64(z, 31u); + return f32(z.y >> 8u) / 16777216.0; +} + +fn value_noise(x: f32, y: f32, seed: vec2) -> f32 { + let xi = floor(x); + let yi = floor(y); + let tx = x - xi; + let ty = y - yi; + let sx = tx * tx * (3.0 - 2.0 * tx); + let sy = ty * ty * (3.0 - 2.0 * ty); + let ix = i32(xi); + let iy = i32(yi); + let h00 = hash01(ix, iy, seed); + let h10 = hash01(ix + 1, iy, seed); + let h01 = hash01(ix, iy + 1, seed); + let h11 = hash01(ix + 1, iy + 1, seed); + let top = h00 + (h10 - h00) * sx; + let bot = h01 + (h11 - h01) * sx; + return top + (bot - top) * sy; +} + +@fragment +fn fs(@builtin(position) pos: vec4) -> @location(0) vec4 { + let p = vec2(floor(pos.xy)); + let q = textureLoad(src, p, 0); + var c = q.rgb; + // 1. White balance and exposure. + if on(GAINS) { + for (var i = 0; i < 3; i++) { + c[i] = linear_to_srgb(max(srgb_to_linear(c[i]) * P.gains[i], 0.0)); + } + } + // 3. Contrast (without Texture and Clarity). + if on(CONTRAST) { + let l0 = max(luma(c), 0.0); + let lc = min(l0, 1.0); + var lv = lc; + let xx = clamp(lv, 0.0, 1.0); + lv += -P.gains.w * sin(TAU * xx) / TAU; + lv = max(lv, 0.0) + (l0 - lc); + if l0 > 1e-5 { + c = c * (lv / l0); + } else { + c = c + vec3(lv - l0); + } + } + // 3b. Whites and Blacks. + if on(WHITES) || on(BLACKS) { + c = whites_blacks(c); + } + // 4. Vibrance and Saturation. + if on(PRESENCE) { + let l = luma(c); + let mx = max(max(c.x, c.y), c.z); + let mn = min(min(c.x, c.y), c.z); + let sat = select(0.0, (mx - mn) / mx, mx > 1e-6); + let hue = rgb_to_hsv(c).x; + let skin = clamp(1.0 - (abs(hue - 25.0) / 25.0), 0.0, 1.0) * 0.5; + let k = 1.0 + P.presence.y + P.presence.x * (1.0 - sat) * (1.0 - skin); + c = vec3(l) + (c - vec3(l)) * max(k, 0.0); + } + // 5. Tone curves. + if on(PARAMETRIC) { + c = vec3(lut_eval(0, c.x), lut_eval(0, c.y), lut_eval(0, c.z)); + } + if on(MASTER) { + c = vec3(lut_eval(1, c.x), lut_eval(1, c.y), lut_eval(1, c.z)); + } + for (var i = 0; i < 3; i++) { + if on(RED + u32(i)) { + c[i] = lut_eval(2 + i, c[i]); + } + } + // 6. Color Mixer. + if on(HSL) { + let hsv = rgb_to_hsv(max(c, vec3(0.0))); + let s = hsv.y; + if s > 1e-4 { + let d = hsl_shift(hsv.x); + let nh = hsv.x + d.x / 100.0 * 30.0; + let ns = clamp(s * (1.0 + d.y / 100.0), 0.0, 1.0); + var out = hsv_to_rgb(nh, ns, hsv.z); + let l0 = luma(c); + let l1 = luma(out); + if l1 > 1e-6 { + out = out * l0 / l1; + } + let k = exp2(d.z / 100.0 * 1.0 * s); + c = out * k; + } + } + // 7. Color Grading. + if on(GRADE) { + let l = clamp(luma(c), 0.0, 1.0); + let blend = P.grade.x; + let bal = P.grade.y; + let sh = 1.0 - smoothstep_(0.0, 0.5 + bal + blend, l); + let hi = smoothstep_(0.5 + bal - blend, 1.0, l); + let mid = max(1.0 - sh - hi, 0.0); + let masks = vec4(sh, mid, hi, 1.0); + for (var k = 0; k < 4; k++) { + c += masks[k] * P.tints[k].xyz; + if P.tints[k].w != 0.0 { + c = c * exp2(masks[k] * P.tints[k].w / 100.0 * 0.6); + } + } + } + // 9. Vignette. + if on(VIGNETTE) { + let amt = P.vignette[0].x; + let hl = P.vignette[0].y; + let style = u32(P.vignette[0].z); + let pw = P.vignette[0].w; + let centre = P.vignette[1].xy; + let axes = P.vignette[1].zw; + let dx = abs((f32(p.x) + 0.5 - centre.x) / axes.x); + let dy = abs((f32(p.y) + 0.5 - centre.y) / axes.y); + let r = pow(pow(dx, pw) + pow(dy, pw), 1.0 / pw); + let m = smoothstep_(P.vignette[2].x, P.vignette[2].y, r); + if m > 0.0 { + let l = clamp(luma(c), 0.0, 1.0); + if style == 2u { + let goal = select(1.0, 0.0, amt < 0.0); + let k = m * abs(amt); + c += (vec3(goal) - c) * k; + } else { + var k = exp2(amt * 2.0 * m); + if amt < 0.0 && hl > 0.0 { + let protect = smoothstep_(0.6, 1.0, l) * hl; + k += (1.0 - k) * protect; + } + if style == 1u { + let nl = l * k; + c += vec3(nl - l); + } else { + c = c * k; + } + } + } + } + // 9. Grain, in full-resolution coordinates. + if on(GRAIN) { + let cell = P.grain.x; + let rough = P.grain.y; + let fx = f32(p.x) * P.grain.w / cell; + let fy = f32(p.y) * P.grain.w / cell; + let n = value_noise(fx, fy, P.seeds.xy) * (1.0 - rough) + (value_noise(fx * 2.3, fy * 2.3, P.seeds.zw) - 0.0) * rough; + let l = clamp(luma(c), 0.0, 1.0); + let g = (n - 0.5) * 2.0 * P.grain.z * (0.4 + 2.4 * l * (1.0 - l)); + c += vec3(g); + } + if on(CLAMP) { + c = clamp(c, vec3(0.0), vec3(1.0)); + } + return vec4(c, q.a); +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index fbc43f9d8d..230ba2a065 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -34,10 +34,14 @@ //! (Multichannel documents, patterns larger than the texture limit, uncached gradients with //! coincident colour or opacity stops) returns [`Unsupported`]; //! callers fall back to the CPU compositor. +//! +//! [`CameraRawDevelop`] (`camera_raw.rs`) is a separate pass, not part of the compositor: Camera +//! Raw's per-pixel develop stages for the dialog's preview, built the first time the dialog opens. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod bounds; +mod camera_raw; mod fx; pub mod health; pub mod plan; @@ -54,6 +58,7 @@ use photocraft_doc::{DocId, Document, LayerContent, LayerId, Pattern}; use photocraft_geom::{Rect, TILE_SIZE, TileCoord}; use photocraft_raster::{Surface, Tile}; +pub use camera_raw::{CAMERA_RAW_SHADER, CameraRawDevelop}; pub use health::{DeviceHealth, Fault}; pub use plan::{Kernel, Plan, Role, Unsupported, plan}; pub use timing::{GpuTimer, GpuTiming}; diff --git a/crates/gpu/tests/camera_raw.rs b/crates/gpu/tests/camera_raw.rs new file mode 100644 index 0000000000..f6bce1c55a --- /dev/null +++ b/crates/gpu/tests/camera_raw.rs @@ -0,0 +1,343 @@ +//! Camera Raw's GPU preview pass against the CPU develop (the oracle), stage by stage, at 8, 16 +//! and 32 bits. Skips when there is no adapter or it can't render `Rgba32Float`. + +use photocraft_algo::camera_raw::{CameraRaw, Wheel, develop, per_pixel, white_black_points}; +use photocraft_gpu::{CameraRawDevelop, DeviceHealth, Fault}; + +/// Largest difference from the CPU develop, per channel. The pass evaluates the same +/// expressions in f32, so what remains is the GPU's own `pow`, `exp2` and `sin` (a few ulps) and +/// how they move a value across a table entry; measured at most about 1e-5 on NVIDIA (Vulkan, +/// DX12) and WARP. 1/4096 keeps a margin and stays well under one 8-bit step (the compositor's +/// tolerance is 1/255). +const TOL: f32 = 1.0 / 4096.0; + +struct Gpu { + device: wgpu::Device, + queue: wgpu::Queue, + dev: CameraRawDevelop, + _lock: std::sync::MutexGuard<'static, ()>, +} + +fn gpu() -> Option { + let lock = photocraft_testkit::gpu_lock(); + // `WGPU_BACKEND` picks the backend; `PHOTOCRAFT_TEST_FALLBACK_ADAPTER=1` the software one (WARP). + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); + let fallback = std::env::var_os("PHOTOCRAFT_TEST_FALLBACK_ADAPTER").is_some(); + let options = wgpu::RequestAdapterOptions { force_fallback_adapter: fallback, ..Default::default() }; + let adapter = match pollster::block_on(instance.request_adapter(&options)) { + Ok(a) => a, + Err(e) => { + eprintln!("skipping Camera Raw GPU tests: no adapter ({e})"); + return None; + } + }; + if !CameraRawDevelop::supported(&adapter) { + eprintln!("skipping Camera Raw GPU tests: adapter can't render Rgba32Float"); + return None; + } + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?; + eprintln!("Camera Raw GPU tests on {:?} ({})", adapter.get_info().backend, adapter.get_info().name); + let dev = CameraRawDevelop::new(&device).ok()?; + Some(Gpu { device, queue, dev, _lock: lock }) +} + +#[derive(Clone, Copy, Debug)] +enum Depth { + Eight, + Sixteen, + Float, +} + +const DEPTHS: [Depth; 3] = [Depth::Eight, Depth::Sixteen, Depth::Float]; + +fn hash(i: usize, c: u64) -> f32 { + let mut z = (i as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ c.wrapping_mul(0xC2B2_AE3D_27D4_EB4F); + z = (z ^ (z >> 29)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + (z >> 40) as f32 / (1u64 << 24) as f32 +} + +/// Gradients, noise and saturated patches (every Color Mixer band), quantized to `depth`. The +/// 32-bit image also has values above 1 and a few below 0. +fn image(w: usize, h: usize, depth: Depth) -> Vec<[f32; 4]> { + (0..w * h) + .map(|i| { + let (x, y) = ((i % w) as f32 / w as f32, (i / w) as f32 / h as f32); + let mut c = if (i / w).is_multiple_of(4) { + // A hue sweep at full and half saturation. + let hue = x * 6.0; + let f = |n: f32| (((hue + n) % 6.0 - 3.0).abs() - 1.0).clamp(0.0, 1.0); + let s = if (i / w).is_multiple_of(8) { 1.0 } else { 0.5 }; + [f(0.0), f(4.0), f(2.0)].map(|v| (1.0 - s) * 0.6 + s * v) + } else { + [0.1 + 0.8 * x, 0.2 + 0.6 * y, 0.5 - 0.4 * x * y].map(|v| v + (hash(i, 1) - 0.5) * 0.15) + }; + if let Depth::Float = depth { + c = c.map(|v| v * (1.0 + 1.5 * y) - 0.02 * hash(i, 2)); + } + let q = |v: f32| match depth { + Depth::Eight => (v.clamp(0.0, 1.0) * 255.0).round() / 255.0, + Depth::Sixteen => (v.clamp(0.0, 1.0) * 32768.0).round() / 32768.0, + Depth::Float => v, + }; + [q(c[0]), q(c[1]), q(c[2]), 1.0] + }) + .collect() +} + +/// Develops `p` both ways and returns the largest channel difference. +fn compare(g: &mut Gpu, p: &CameraRaw, depth: Depth) -> f32 { + let (w, h) = (171, 113); + let float = matches!(depth, Depth::Float); + let src = image(w, h, depth); + let mut cpu = src.clone(); + develop(&mut cpu, w, h, p, float); + let points = white_black_points(&src, w, h, p); + let stages = per_pixel(p, w, h, float, points).unwrap_or_else(|| panic!("{p:?} has a spatial stage")); + let out = g.dev.develop(&g.device, &g.queue, None, &src, None, [w as u32, h as u32], &stages).unwrap(); + assert_eq!(out.len(), cpu.len()); + let mut worst = (0.0f32, 0usize); + for (i, (a, b)) in out.iter().zip(&cpu).enumerate() { + assert!(a.iter().all(|v| v.is_finite()), "{p:?} {depth:?}: non-finite GPU pixel {i}: {a:?}"); + assert_eq!(a[3], b[3], "alpha is kept"); + for c in 0..3 { + let d = (a[c] - b[c]).abs(); + if d > worst.0 { + worst = (d, i); + } + } + } + let (d, i) = worst; + assert!(d <= TOL, "{p:?} {depth:?}: GPU {:?} vs CPU {:?} at pixel {i} (source {:?}), off by {d}", out[i], cpu[i], src[i]); + if !float { + assert!(out.iter().all(|q| q[..3].iter().all(|v| (0.0..=1.0).contains(v))), "{p:?} {depth:?}: clipped like the CPU"); + } + d +} + +fn check(name: &str, sets: &[CameraRaw]) { + let Some(mut g) = gpu() else { return }; + for p in sets { + for depth in DEPTHS { + let d = compare(&mut g, p, depth); + eprintln!("{name} {depth:?}: max |GPU - CPU| = {d:.2e}"); + } + } +} + +#[test] +fn white_balance_and_exposure() { + check( + "white balance", + &[ + CameraRaw { temperature: 40.0, tint: -25.0, ..Default::default() }, + CameraRaw { temperature: -70.0, tint: 60.0, exposure: -1.2, ..Default::default() }, + CameraRaw { exposure: 1.5, ..Default::default() }, + ], + ); +} + +#[test] +fn contrast_without_texture_or_clarity() { + check("contrast", &[CameraRaw { contrast: 60.0, ..Default::default() }, CameraRaw { contrast: -80.0, exposure: 0.4, ..Default::default() }]); +} + +#[test] +fn whites_and_blacks() { + check( + "whites/blacks", + &[ + CameraRaw { whites: 70.0, blacks: -45.0, ..Default::default() }, + CameraRaw { whites: -60.0, blacks: 50.0, temperature: 20.0, ..Default::default() }, + CameraRaw { whites: 30.0, ..Default::default() }, + CameraRaw { blacks: -100.0, exposure: -0.5, ..Default::default() }, + // ProPhoto's power-law encoding. + CameraRaw { whites: 50.0, blacks: -30.0, encoding_gamma: 1.8, ..Default::default() }, + ], + ); +} + +#[test] +fn tone_curves() { + check( + "tone curves", + &[ + CameraRaw { + curve_highlights: -60.0, + curve_lights: 30.0, + curve_darks: 40.0, + curve_shadows: -20.0, + curve_splits: [20.0, 45.0, 80.0], + ..Default::default() + }, + CameraRaw { point_curve: vec![[0.0, 20.0], [64.0, 50.0], [192.0, 220.0], [255.0, 245.0]], ..Default::default() }, + CameraRaw { + point_curve_red: vec![[0.0, 0.0], [128.0, 160.0], [255.0, 255.0]], + point_curve_green: vec![[0.0, 10.0], [255.0, 230.0]], + point_curve_blue: vec![[0.0, 0.0], [100.0, 70.0], [255.0, 255.0]], + ..Default::default() + }, + ], + ); +} + +#[test] +fn vibrance_and_saturation() { + check( + "presence", + &[ + CameraRaw { vibrance: 70.0, ..Default::default() }, + CameraRaw { saturation: -60.0, ..Default::default() }, + CameraRaw { vibrance: -40.0, saturation: 50.0, ..Default::default() }, + ], + ); +} + +#[test] +fn color_mixer() { + let ramp = |k: f32| std::array::from_fn(|i| ((i as f32 * 37.0 + k) % 200.0) - 100.0); + check( + "color mixer", + &[ + CameraRaw { hsl_hue: ramp(0.0), ..Default::default() }, + CameraRaw { hsl_sat: ramp(50.0), ..Default::default() }, + CameraRaw { hsl_lum: ramp(120.0), ..Default::default() }, + CameraRaw { hsl_hue: ramp(10.0), hsl_sat: ramp(80.0), hsl_lum: ramp(150.0), ..Default::default() }, + ], + ); +} + +#[test] +fn color_grading() { + check( + "color grading", + &[ + CameraRaw { grade_shadows: Wheel { hue: 220.0, sat: 70.0, lum: 20.0 }, ..Default::default() }, + CameraRaw { + grade_midtones: Wheel { hue: 120.0, sat: 30.0, lum: -15.0 }, + grade_highlights: Wheel { hue: 40.0, sat: 50.0, lum: 10.0 }, + grade_global: Wheel { hue: 300.0, sat: 15.0, lum: 5.0 }, + grade_blending: 80.0, + grade_balance: -40.0, + ..Default::default() + }, + ], + ); +} + +#[test] +fn vignette() { + check( + "vignette", + &[ + CameraRaw { vignette_amount: -70.0, ..Default::default() }, + CameraRaw { vignette_amount: -50.0, vignette_roundness: -60.0, vignette_feather: 20.0, vignette_highlights: 70.0, ..Default::default() }, + CameraRaw { + vignette_amount: 60.0, + vignette_roundness: 80.0, + vignette_midpoint: 20.0, + vignette_style: "colorPriority".into(), + ..Default::default() + }, + CameraRaw { vignette_amount: -40.0, vignette_style: "paintOverlay".into(), ..Default::default() }, + CameraRaw { vignette_amount: 40.0, vignette_style: "paintOverlay".into(), ..Default::default() }, + ], + ); +} + +#[test] +fn grain_in_full_resolution_coordinates() { + check( + "grain", + &[ + CameraRaw { grain_amount: 60.0, seed: 3, ..Default::default() }, + // A preview proxy: the noise lattice is sampled at full-resolution positions. + CameraRaw { grain_amount: 80.0, grain_size: 60.0, grain_roughness: 90.0, seed: u32::MAX, pixel_scale: 0.143, ..Default::default() }, + CameraRaw { grain_amount: 25.0, grain_size: 0.0, grain_roughness: 0.0, seed: 77, pixel_scale: 0.5, ..Default::default() }, + ], + ); +} + +#[test] +fn every_per_pixel_stage_at_once() { + let mut hsl = [0.0; 8]; + hsl[1] = 35.0; + hsl[5] = -50.0; + check( + "all stages", + &[CameraRaw { + temperature: 25.0, + tint: 10.0, + exposure: 0.3, + contrast: 25.0, + whites: 20.0, + blacks: -15.0, + vibrance: 30.0, + saturation: 10.0, + curve_darks: 20.0, + point_curve: vec![[0.0, 8.0], [255.0, 250.0]], + hsl_hue: hsl, + hsl_sat: hsl, + hsl_lum: hsl, + grade_shadows: Wheel { hue: 210.0, sat: 25.0, lum: 0.0 }, + vignette_amount: -30.0, + grain_amount: 20.0, + pixel_scale: 0.2, + ..Default::default() + }], + ); +} + +#[test] +fn identity_leaves_pixels_alone_and_spatial_settings_have_no_pass() { + let Some(mut g) = gpu() else { return }; + let p = CameraRaw::default(); + let src = image(64, 32, Depth::Float); + let stages = per_pixel(&p, 64, 32, false, None).unwrap(); + let out = g.dev.develop(&g.device, &g.queue, None, &src, Some(1), [64, 32], &stages).unwrap(); + assert_eq!(out, src, "the CPU develop returns early, unclipped"); + for spatial in [ + CameraRaw { dehaze: 10.0, ..Default::default() }, + CameraRaw { shadows: 10.0, ..Default::default() }, + CameraRaw { highlights: -10.0, ..Default::default() }, + CameraRaw { texture: 10.0, ..Default::default() }, + CameraRaw { clarity: 10.0, ..Default::default() }, + CameraRaw { sharpen_amount: 10.0, ..Default::default() }, + CameraRaw { noise_luminance: 10.0, ..Default::default() }, + CameraRaw { noise_color: 10.0, ..Default::default() }, + ] { + assert!(spatial.spatial() && per_pixel(&spatial, 64, 32, false, Some((0.0, 1.0))).is_none(), "{spatial:?}"); + } + // Whites and Blacks need the image's points. + assert!(per_pixel(&CameraRaw { whites: 10.0, ..Default::default() }, 64, 32, false, None).is_none()); +} + +#[test] +fn a_cached_source_is_reused_and_a_new_key_uploads() { + let Some(mut g) = gpu() else { return }; + let p = CameraRaw { exposure: 0.5, ..Default::default() }; + let stages = per_pixel(&p, 64, 32, false, None).unwrap(); + let a = image(64, 32, Depth::Eight); + let b: Vec<[f32; 4]> = a.iter().map(|q| [q[2], q[0], q[1], 1.0]).collect(); + let first = g.dev.develop(&g.device, &g.queue, None, &a, Some(7), [64, 32], &stages).unwrap(); + // Same key: the uploaded source stays (callers key by the proxy's identity). + assert_eq!(g.dev.develop(&g.device, &g.queue, None, &b, Some(7), [64, 32], &stages).unwrap(), first); + let mut cpu = b.clone(); + develop(&mut cpu, 64, 32, &p, false); + let other = g.dev.develop(&g.device, &g.queue, None, &b, Some(8), [64, 32], &stages).unwrap(); + assert!(other.iter().zip(&cpu).all(|(x, y)| (0..3).all(|c| (x[c] - y[c]).abs() <= TOL))); +} + +#[test] +fn bad_sizes_and_faulted_devices_are_errors() { + let Some(mut g) = gpu() else { return }; + let stages = per_pixel(&CameraRaw { exposure: 0.5, ..Default::default() }, 4, 4, false, None).unwrap(); + let px = vec![[0.5f32; 4]; 16]; + for size in [[0, 4], [4, 0], [5, 4], [3, 3], [u32::MAX, 1]] { + assert!(g.dev.develop(&g.device, &g.queue, None, &px, None, size, &stages).is_err(), "{size:?}"); + } + let health = DeviceHealth::new(); + health.mark(Fault::Lost("test".into())); + assert!(g.dev.develop(&g.device, &g.queue, Some(&health), &px, None, [4, 4], &stages).is_err()); + // And the pass still works afterwards. + assert!(g.dev.develop(&g.device, &g.queue, Some(&DeviceHealth::new()), &px, None, [4, 4], &stages).is_ok()); +} diff --git a/crates/ui-egui/src/camera_raw_gpu.rs b/crates/ui-egui/src/camera_raw_gpu.rs new file mode 100644 index 0000000000..d017063048 --- /dev/null +++ b/crates/ui-egui/src/camera_raw_gpu.rs @@ -0,0 +1,277 @@ +//! Camera Raw's preview develop on the GPU (#6916). When every stage the settings use reads +//! only its own pixel, the proxy develops in one wgpu pass (`photocraft_gpu::CameraRawDevelop`) +//! on the canvas's device and is read back for the histogram, scopes and display conversion. +//! Spatial stages, Rendering Mode CPU, `PHOTOCRAFT_CPU_CAMERA_RAW`, no GPU canvas, a software +//! adapter, a device fault or a failed develop all keep the CPU develop on the worker. +//! +//! The pipeline builds on a thread the first time a dialog opens (DX12's shader compiler takes +//! about 0.35 s, #6419); frames develop on the CPU until it is ready. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; + +use eframe::wgpu; +use photocraft_algo::camera_raw::CameraRaw; +use photocraft_gpu::{CameraRawDevelop, DeviceHealth}; + +use crate::PhotocraftApp; + +/// The built pass, shared by every Camera Raw dialog on the device (never built on the web). +#[derive(Clone)] +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(crate) struct Pass(Arc>); + +/// A device's pass: unset while it builds, `None` when the device can't run it. +pub(crate) type Slot = Arc>>; + +impl Pass { + /// Whether `adapter` may run the pass: not a software adapter (the CPU develop is faster + /// there), one that renders `Rgba32Float`, and not on the web (no blocking readback yet). + fn usable(adapter: &wgpu::Adapter) -> bool { + if cfg!(target_arch = "wasm32") { + return false; + } + if adapter.get_info().device_type == wgpu::DeviceType::Cpu { + log::info!("Camera Raw develops on the CPU: software graphics adapter"); + return false; + } + if !CameraRawDevelop::supported(adapter) { + log::info!("Camera Raw develops on the CPU: the adapter can't render Rgba32Float"); + return false; + } + true + } + + /// Builds the pass into `slot`, on a thread where there are threads. + pub(crate) fn start(adapter: &wgpu::Adapter, device: &wgpu::Device, slot: &Slot) { + if !Self::usable(adapter) { + let _ = (slot.set(None), device); + return; + } + #[cfg(not(target_arch = "wasm32"))] + { + let (on, out) = (device.clone(), slot.clone()); + let spawned = std::thread::Builder::new().name("camera-raw-pipeline".into()).spawn(move || { + let pass = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Self::build_on(&on))).unwrap_or_else(|_| { + log::warn!("building the Camera Raw pipeline panicked; Camera Raw develops on the CPU"); + None + }); + let _ = out.set(pass); + }); + if spawned.is_err() { + let _ = slot.set(Self::build_on(device)); + } + } + } + + /// The pass on `device`, whatever its adapter. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + pub(crate) fn build_on(device: &wgpu::Device) -> Option { + match CameraRawDevelop::new(device) { + Ok(pass) => Some(Pass(Arc::new(Mutex::new(pass)))), + Err(e) => { + log::warn!("{e}; Camera Raw develops on the CPU"); + None + } + } + } +} + +/// The dialog's GPU develop, if the app may use one. +pub(crate) fn for_app(app: &PhotocraftApp) -> Option { + let cpu = app.session.prefs().performance.effective_rendering_mode() == photocraft_engine::prefs::RenderingMode::Cpu; + if cpu || std::env::var_os("PHOTOCRAFT_CPU_CAMERA_RAW").is_some() { + return None; + } + app.gpu.as_ref()?.camera_raw() +} + +/// A key for a dialog's proxy pixels, unique in the process: the pass keeps the last source it +/// uploaded, and a new dialog's proxy may reuse a closed one's memory. +pub(crate) fn source_key() -> u64 { + static NEXT: AtomicU64 = AtomicU64::new(1); + NEXT.fetch_add(1, Ordering::Relaxed) +} + +/// The source key and the settings the black and white points depend on. +type PointsKey = (u64, [u32; 5]); +/// The last [`PointsKey`] and its points. +type Points = Option<(PointsKey, Option<(f32, f32)>)>; + +/// One dialog's handle on the pass (cheap to clone into each proxy job). +#[derive(Clone)] +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(crate) struct CameraRawGpu { + device: wgpu::Device, + queue: wgpu::Queue, + health: DeviceHealth, + pass: Slot, + /// Set after a develop failed: the dialog stays on the CPU. + failed: Arc, + /// Whites and Blacks adapt to the image; its points change only with these settings. + points: Arc>, +} + +impl CameraRawGpu { + pub(crate) fn new(device: wgpu::Device, queue: wgpu::Queue, health: DeviceHealth, pass: Slot) -> Self { + CameraRawGpu { device, queue, health, pass, failed: Arc::default(), points: Arc::default() } + } + + /// Develops `px` (`w × h`) with `p`, or `None` when the CPU must: a spatial stage, the pass + /// still building, a fault, or a GPU error (logged; the dialog then stays on the CPU). `key` names the pixels when they + /// are the dialog's unchanged proxy ([`source_key`]), so they upload once. + pub(crate) fn develop(&self, px: &[[f32; 4]], key: Option, w: usize, h: usize, p: &CameraRaw, float: bool) -> Option> { + if p.spatial() || self.failed.load(Ordering::Relaxed) || !self.health.is_ok() { + return None; + } + #[cfg(target_arch = "wasm32")] + { + let _ = (px, key, w, h, float); + None + } + #[cfg(not(target_arch = "wasm32"))] + { + let Some(Some(pass)) = self.pass.get() else { return None }; + let points = self.points(px, key, w, h, p); + let stages = photocraft_algo::camera_raw::per_pixel(p, w, h, float, points)?; + let size = [u32::try_from(w).ok()?, u32::try_from(h).ok()?]; + let mut pass = pass.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + match pass.develop(&self.device, &self.queue, Some(&self.health), px, key, size, &stages) { + Ok(out) => Some(out), + Err(e) => { + log::warn!("{}; Camera Raw develops on the CPU", e.0); + self.failed.store(true, Ordering::Relaxed); + None + } + } + } + } + + #[cfg(not(target_arch = "wasm32"))] + fn points(&self, px: &[[f32; 4]], key: Option, w: usize, h: usize, p: &CameraRaw) -> Option<(f32, f32)> { + if p.whites == 0.0 && p.blacks == 0.0 { + return None; + } + let Some(key) = key else { return photocraft_algo::camera_raw::white_black_points(px, w, h, p) }; + let key = (key, [p.temperature, p.tint, p.exposure, p.contrast, p.encoding_gamma].map(f32::to_bits)); + let mut cache = self.points.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some((k, points)) = *cache + && k == key + { + return points; + } + let points = photocraft_algo::camera_raw::white_black_points(px, w, h, p); + *cache = Some((key, points)); + points + } +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_algo::camera_raw::develop; + use serde_json::json; + + /// The pass evaluates develop's expressions in f32 (see `photocraft-gpu`'s Camera Raw tests). + const TOL: f32 = 1.0 / 4096.0; + + /// The app on egui_kittest's device (a software adapter where there is one), or `None`. + fn gpu_app(depth: u32) -> Option<(PhotocraftApp, egui::Context)> { + let Ok(rs) = std::panic::catch_unwind(|| egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default())) else { + eprintln!("skipping: no GPU adapter"); + return None; + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.set_wgpu(rs); + app.gpu.as_ref()?; + app.run("file.new", json!({"width": 300, "height": 200, "depth": depth})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("filter.render.clouds", json!({"seed": 5})).unwrap(); + app.run("select.rect", json!({"x": 40, "y": 30, "width": 120, "height": 90})).unwrap(); + app.run("edit.fill", json!({"color": "#cc4422"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + Some((app, egui::Context::default())) + } + + /// The canvas's pass, built even on a software adapter (which the app leaves on the CPU). + fn force_pass(app: &PhotocraftApp) { + app.gpu.as_ref().expect("GPU canvas").force_camera_raw_pass(); + } + + fn developed(app: &mut PhotocraftApp, ctx: &egui::Context, p: CameraRaw) -> (&'static str, Vec<[f32; 4]>, Vec<[f32; 4]>) { + let d = app.camera_raw.as_mut().expect("dialog"); + d.params = p; + d.render(ctx); + let mut cpu = d.proxy.to_vec(); + let mut q = d.params.clone(); + q.pixel_scale = (d.pw as f32 / d.full_w.max(1) as f32).min(1.0); + develop(&mut cpu, d.pw, d.ph, &q, d.float); + (d.develop_backend, d.processed.clone(), cpu) + } + + #[test] + fn the_pass_is_built_when_camera_raw_first_opens_not_with_the_canvas() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some((mut app, ctx)) = gpu_app(8) else { return }; + let canvas = app.gpu.clone().expect("GPU canvas"); + assert!(!canvas.camera_raw_started(), "no Camera Raw pipeline at launch (#6419)"); + crate::camera_raw_ui::open(&mut app, &ctx).unwrap(); + assert!(canvas.camera_raw_started(), "built (or refused) from the first open"); + assert_eq!(app.camera_raw.as_ref().map(|d| d.develop_backend), Some("cpu"), "the first frame doesn't wait for the pipeline"); + canvas.wait_camera_raw_pass(); + let software = canvas.adapter_info().device_type == wgpu::DeviceType::Cpu; + let d = app.camera_raw.as_ref().expect("dialog"); + assert_eq!(d.gpu.is_none(), software, "a software adapter keeps the CPU develop"); + } + + #[test] + fn per_pixel_settings_develop_on_the_gpu_like_the_cpu_and_the_rest_stays_on_the_cpu() { + let _gpu = photocraft_testkit::gpu_lock(); + for depth in [8, 16, 32] { + let Some((mut app, ctx)) = gpu_app(depth) else { return }; + force_pass(&app); + crate::camera_raw_ui::open(&mut app, &ctx).unwrap(); + if app.camera_raw.as_ref().is_some_and(|d| d.gpu.is_none()) { + eprintln!("skipping: the device can't run the Camera Raw pass"); + return; + } + let per_pixel = CameraRaw { + temperature: 20.0, + exposure: 0.4, + contrast: 30.0, + whites: 25.0, + blacks: -20.0, + vibrance: 40.0, + curve_lights: 25.0, + grade_shadows: photocraft_algo::camera_raw::Wheel { hue: 220.0, sat: 40.0, lum: 0.0 }, + vignette_amount: -40.0, + grain_amount: 30.0, + ..Default::default() + }; + let (backend, gpu, cpu) = developed(&mut app, &ctx, per_pixel.clone()); + assert_eq!(backend, "gpu", "{depth}-bit"); + let worst = gpu.iter().zip(&cpu).flat_map(|(a, b)| (0..4).map(move |c| (a[c] - b[c]).abs())).fold(0.0f32, f32::max); + assert!(worst <= TOL, "{depth}-bit: off by {worst}"); + let described = app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview)).unwrap(); + assert_eq!(described["developBackend"], "gpu"); + // A spatial stage develops the whole frame on the CPU. + let (backend, shown, cpu) = developed(&mut app, &ctx, CameraRaw { shadows: 40.0, ..per_pixel.clone() }); + assert_eq!((backend, shown == cpu), ("cpu", true), "{depth}-bit"); + // A device fault: the CPU, from the next frame on. + app.gpu.as_ref().unwrap().health().mark(photocraft_gpu::Fault::Lost("test".into())); + let (backend, shown, cpu) = developed(&mut app, &ctx, per_pixel); + assert_eq!((backend, shown == cpu), ("cpu", true), "{depth}-bit after a fault"); + } + } + + #[test] + fn rendering_mode_cpu_keeps_the_cpu_develop() { + let _gpu = photocraft_testkit::gpu_lock(); + let Some((mut app, ctx)) = gpu_app(8) else { return }; + force_pass(&app); + app.run("prefs.set", json!({"path": "performance.renderingMode", "value": "cpu"})).unwrap(); + crate::camera_raw_ui::open(&mut app, &ctx).unwrap(); + let (backend, shown, cpu) = developed(&mut app, &ctx, CameraRaw { exposure: 0.5, ..Default::default() }); + assert_eq!((backend, shown == cpu), ("cpu", true)); + } +} diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index 8766b8efc6..61cbd64117 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -4,8 +4,10 @@ //! The preview runs [`photocraft_algo::camera_raw::develop`] on a CPU proxy of the layer //! (≤ 900 px, `pixel_scale` keeps pixel radii true to the full image), recomputed when a //! control changes: on a worker for the dialog's own controls, so a drag never waits for it, and -//! on the UI thread for the control channel, so a reply describes its own settings. OK runs `filter.cameraRaw` with the non-default settings, so the result is -//! one history step (or a smart filter on a smart object) and exactly replayable. +//! on the UI thread for the control channel, so a reply describes its own settings. Settings +//! whose stages are all per pixel develop on the GPU instead where it can (`camera_raw_gpu`). +//! OK runs `filter.cameraRaw` with the non-default settings, so the result is one history step +//! (or a smart filter on a smart object) and exactly replayable. //! //! Control channel: `ui.menu.invoke {"id":"filter.cameraRaw","params":{"ui":{"set":{…}, //! "commit":true | "cancel":true}}}`; the reply describes the dialog. `{"ui":{"saveSettings":true}}` @@ -43,7 +45,7 @@ pub struct CameraRawDialog { pub(crate) selection: Option>>, pub(crate) pw: usize, pub(crate) ph: usize, - full_w: usize, + pub(crate) full_w: usize, full_h: usize, preview_ppp: f32, pub(crate) detail: super::camera_raw_detail_ui::DetailPreview, @@ -74,6 +76,13 @@ pub struct CameraRawDialog { pending: Option>>, /// The last worker failure the status bar shows, with the preview revision it failed on. preview_error: Option<(String, u64)>, + /// The GPU develop, when the app may use one. + pub(crate) gpu: Option, + /// Names [`Self::proxy`] for the GPU pass's upload cache. + source_key: u64, + /// The last frame's develop alone, and where it ran ("gpu" or "cpu"). + develop_ms: f64, + pub(crate) develop_backend: &'static str, /// Settings before each finished edit, so Ctrl+Z works inside the dialog (#4100). undo: Vec, redo: Vec, @@ -85,7 +94,7 @@ impl CameraRawDialog { pub fn describe(&self, view: &crate::state::CameraRawScopeState, navigation: &crate::state::CameraRawPreviewState) -> Value { let h = self.histogram(); let mut result = json!({"layer": self.layer.0, "params": serde_json::to_value(&self.params).unwrap_or(Value::Null), "proxy": [self.pw, self.ph], - "renderMs": self.render_ms, "histogramMs": self.histogram_ms, "previewRevision": self.preview_revision, "before": self.show_before, + "renderMs": self.render_ms, "developMs": self.develop_ms, "developBackend": self.develop_backend, "histogramMs": self.histogram_ms, "previewRevision": self.preview_revision, "before": self.show_before, "histogram": {"source": if self.show_before { "before" } else { "after" }, "approximate": true, "size": [self.pw, self.ph], "sampleDomain": "preview-rgb", "range": [0, 1], "red": h.channels[0].as_slice(), "green": h.channels[1].as_slice(), "blue": h.channels[2].as_slice(), "samples": h.samples, "transparent": h.transparent, "invalid": h.invalid, "underflow": h.underflow, "overflow": h.overflow, "shadows": h.shadows, "highlights": h.highlights}}); @@ -166,6 +175,8 @@ impl CameraRawDialog { size: (self.pw, self.ph), full_w: self.full_w, display: self.display.clone(), + gpu: self.gpu.clone(), + source_key: self.source_key, } } @@ -187,6 +198,8 @@ impl CameraRawDialog { self.processed = frame.px; self.scope.cache = None; self.render_ms = frame.ms; + self.develop_ms = frame.develop_ms; + self.develop_backend = frame.backend; self.preview_revision = self.preview_revision.saturating_add(1); } @@ -266,6 +279,8 @@ struct ProxyJob { size: (usize, usize), full_w: usize, display: std::sync::Arc, + gpu: Option, + source_key: u64, } /// A developed proxy, ready to show. @@ -276,21 +291,38 @@ struct ProxyFrame { image: egui::ColorImage, display: std::sync::Arc, ms: f64, + develop_ms: f64, + backend: &'static str, } impl ProxyJob { fn run(self) -> ProxyFrame { let t0 = crate::gpu_canvas::now_ms(); let (pw, ph) = self.size; - let mut px = self.proxy.to_vec(); let mut p = self.params; p.pixel_scale = (pw as f32 / self.full_w.max(1) as f32).min(1.0); - if let Some(curve) = &self.raw_curve { - let tone = raw_stage(&p); - photocraft_io::raw::retone(&mut px, pw, ph, curve, tone); + // The open-time dialog re-tones the proxy for each job; otherwise the GPU reads the + // proxy as it is and uploads it once. + let retoned = self.raw_curve.as_ref().map(|curve| { + let mut px = self.proxy.to_vec(); + photocraft_io::raw::retone(&mut px, pw, ph, curve, raw_stage(&p)); + px + }); + if retoned.is_some() { (p.shadows, p.highlights, p.clarity, p.texture, p.dehaze) = (0.0, 0.0, 0.0, 0.0, 0.0); } - photocraft_algo::camera_raw::develop(&mut px, pw, ph, &p, self.float); + let develop_start = crate::gpu_canvas::now_ms(); + let key = retoned.is_none().then_some(self.source_key); + let input = retoned.as_deref().unwrap_or(&self.proxy); + let (mut px, backend) = match self.gpu.as_ref().and_then(|g| g.develop(input, key, pw, ph, &p, self.float)) { + Some(out) => (out, "gpu"), + None => { + let mut px = retoned.unwrap_or_else(|| self.proxy.to_vec()); + photocraft_algo::camera_raw::develop(&mut px, pw, ph, &p, self.float); + (px, "cpu") + } + }; + let develop_ms = crate::gpu_canvas::now_ms() - develop_start; if let Some(mask) = &self.selection { for ((out, original), coverage) in px.iter_mut().zip(self.proxy.iter()).zip(mask.iter()) { for (out, original) in out.iter_mut().zip(original) { @@ -302,7 +334,7 @@ impl ProxyJob { let histogram = RgbHistogram::from_rgba(&px); let histogram_ms = crate::gpu_canvas::now_ms() - histogram_start; let image = CameraRawDialog::image(&px, pw, ph, &self.display); - ProxyFrame { px, histogram, histogram_ms, image, display: self.display, ms: crate::gpu_canvas::now_ms() - t0 } + ProxyFrame { px, histogram, histogram_ms, image, display: self.display, ms: crate::gpu_canvas::now_ms() - t0, develop_ms, backend } } } @@ -722,6 +754,8 @@ fn open_pixels( app.ui.camera_raw_scope.selected_region = false; } let float = surf.format().sample == photocraft_color::SampleType::F32; + // Builds the GPU pass the first time any Camera Raw dialog opens. + let gpu = crate::camera_raw_gpu::for_app(app); let mut d = CameraRawDialog { layer, layer_name: name, @@ -770,6 +804,10 @@ fn open_pixels( raw_curve: None, pending: None, preview_error: None, + gpu, + source_key: crate::camera_raw_gpu::source_key(), + develop_ms: 0.0, + develop_backend: "cpu", undo: Vec::new(), redo: Vec::new(), edit_origin: None, diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 76eb7a652d..7029fb1dac 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -120,6 +120,9 @@ pub struct GpuCanvas { /// [`canvas_budget`], shared with the paint callback's resources: [`GpuCanvas::format_for`] /// reads it while the renderer is locked. canvas_budget: std::sync::Arc, + /// Camera Raw's preview pass, built the first time the dialog asks (#6419). + camera_raw: crate::camera_raw_gpu::Slot, + camera_raw_started: std::sync::Arc, } /// Whether `regenerate_mips` builds each level in a scratch texture and copies it back: on @@ -177,7 +180,7 @@ impl GpuCanvas { rs.target_format, vram.map_or_else(|| "unknown".to_owned(), |b| format!("{} MB", b >> 20)) ); - Self { rs: rs.clone(), tile, high, health, vram, canvas_budget: budget } + Self { rs: rs.clone(), tile, high, health, vram, canvas_budget: budget, camera_raw: Default::default(), camera_raw_started: Default::default() } } /// The device's health flag (shared with the wgpu compositor and the paint callback). @@ -185,6 +188,42 @@ impl GpuCanvas { &self.health } + /// Camera Raw's GPU preview develop on this device; the first call starts building its + /// pipeline. `None` on a software adapter, without `Rgba32Float` render targets, after a + /// fault, or when the driver couldn't build the pipeline; the dialog then develops on the CPU. + pub(crate) fn camera_raw(&self) -> Option { + if self.fault().is_some() { + return None; + } + if !self.camera_raw_started.swap(true, Ordering::AcqRel) { + crate::camera_raw_gpu::Pass::start(&self.rs.adapter, &self.rs.device, &self.camera_raw); + } + if matches!(self.camera_raw.get(), Some(None)) { + return None; + } + Some(crate::camera_raw_gpu::CameraRawGpu::new(self.rs.device.clone(), self.rs.queue.clone(), self.health.clone(), self.camera_raw.clone())) + } + + /// Whether [`Self::camera_raw`] started building the pass. + #[cfg(test)] + pub(crate) fn camera_raw_started(&self) -> bool { + self.camera_raw_started.load(Ordering::Acquire) + } + + /// Waits until the pass is built or refused. + #[cfg(test)] + pub(crate) fn wait_camera_raw_pass(&self) { + let _ = self.camera_raw.wait(); + } + + /// Builds Camera Raw's pass even where [`Self::camera_raw`] wouldn't (egui_kittest's software + /// adapter), so tests drive the GPU develop. + #[cfg(test)] + pub(crate) fn force_camera_raw_pass(&self) { + self.camera_raw_started.store(true, Ordering::Release); + let _ = self.camera_raw.set(crate::camera_raw_gpu::Pass::build_on(&self.rs.device)); + } + /// Why the GPU can't be used any more (`None` while it can). pub fn fault(&self) -> Option { self.health.fault() diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8784800ddd..660099bff1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -43,6 +43,7 @@ pub mod caf_ui; pub(crate) mod cam_preview; mod cam_transform; mod camera_raw_detail_ui; +mod camera_raw_gpu; mod camera_raw_preview_ui; mod camera_raw_scope_ui; pub mod camera_raw_ui; diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 391c92f0ce..e73fcd7290 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: GPU, pen tablets, displays, gestures per platform against Photoshop 2026) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Camera Raw's preview develops its per-pixel stages on the GPU) · **Target:** Adobe Photoshop 2026 Each hardware feature Photoshop 2026 (27.11.0) uses, per platform, against PhotoCraft. Photoshop side: its bundle (Metal and OpenCL paths, "Precise Color Management for HDR Display" and EDR @@ -19,7 +19,7 @@ HDR display to verify. |---|---|---|---| | GPU canvas, zoom/pan, rotate view | Metal (macOS), DirectX/OpenCL (Windows) | wgpu 30: Metal, Vulkan, DX12, GL; backend selectable | done | | GPU compositing of layers, blend modes, effects | partial (Photoshop composites mostly on CPU) | wgpu compositor, ≤ 1/255 vs CPU oracle; Multichannel and > texture-limit regions fall back to CPU | done (ahead in places) | -| GPU filters (Blur Gallery, Liquify, Smart Sharpen, Camera Raw) | yes | CPU (rayon) for filters; GPU for compose and effects | partial | +| GPU filters (Blur Gallery, Liquify, Smart Sharpen, Camera Raw) | yes | CPU (rayon) for filters; GPU for compose and effects. Camera Raw's preview runs its per-pixel stages (white balance to grain) in one wgpu pass, 2.7 ms vs 21-28 ms on the CPU for the 858×572 proxy; spatial stages and OK still develop on the CPU (#6916) | partial | | Documents larger than the GPU texture limit | yes (tiled) | CPU fallback; tiling in progress (#49) | partial | | CPU-only fallback when no GPU | yes, reduced | `cpu` canvas preference; fails to start without an adapter on some Linux systems (#2519, #2412) | partial | | Choosing the GPU on a multi-GPU machine | Preferences › Performance › Use Graphics Processor | Linux only: Preferences › Performance › Advanced › GPU adapter and `PHOTOCRAFT_GPU_ADAPTER` (#1885); ignored on Windows and macOS | partial | @@ -63,4 +63,5 @@ HDR display to verify. | Date | Change | Summary | |---|---|---| | 2026-10-10 | major | First version | +| 2026-10-17 | minor | GPU filters: Camera Raw's preview, per-pixel stages (#6916 step 1) | | 2026-10-19 | minor | Graphics Adapter choice on Linux (#1885) | From 4830c326d8789365c2b76533a11bc885780ac936 Mon Sep 17 00:00:00 2001 From: fjamesprice Date: Sun, 18 Oct 2026 22:15:01 -0400 Subject: [PATCH 069/229] fix(camera-raw): the GPU benchmark stops on a failed develop bench_camera_raw dropped each GPU develop's Result with .ok(), so a device fault or a refused size would have been timed and printed as a GPU figure. try_time returns the first error instead, and main propagates it. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/examples/bench_camera_raw.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/gpu/examples/bench_camera_raw.rs b/crates/gpu/examples/bench_camera_raw.rs index 8f31be5e8f..be206fe382 100644 --- a/crates/gpu/examples/bench_camera_raw.rs +++ b/crates/gpu/examples/bench_camera_raw.rs @@ -24,6 +24,17 @@ fn time(runs: usize, mut f: impl FnMut()) -> f64 { ) } +/// [`time`] for a call that can fail: its first error ends the benchmark instead of being timed. +fn try_time(runs: usize, mut f: impl FnMut() -> Result<(), E>) -> Result { + let mut failed = None; + let ms = time(runs, || { + if failed.is_none() { + failed = f().err(); + } + }); + failed.map_or(Ok(ms), Err) +} + fn main() -> Result<(), Box> { let runs = if std::env::args().any(|a| a == "--quick") { 5 } else { 40 }; let (w, h) = (858usize, 572usize); @@ -87,12 +98,8 @@ fn main() -> Result<(), Box> { let points = white_black_points(&src, w, h, p); let stages = per_pixel(p, w, h, false, points).ok_or("not per pixel")?; let size = [w as u32, h as u32]; - let upload = time(runs, || { - pass.develop(&device, &queue, None, &src, None, size, &stages).ok(); - }); - let cached = time(runs, || { - pass.develop(&device, &queue, None, &src, Some(1), size, &stages).ok(); - }); + let upload = try_time(runs, || pass.develop(&device, &queue, None, &src, None, size, &stages).map(drop))?; + let cached = try_time(runs, || pass.develop(&device, &queue, None, &src, Some(1), size, &stages).map(drop))?; println!("{name}:"); println!(" CPU develop {cpu:7.2} ms"); println!(" GPU, source uploaded every time {upload:7.2} ms"); From bf7dd539f5cb323cdae972000cedf8fae966f964 Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 11:25:23 -0400 Subject: [PATCH 070/229] codecs: read and write TIFF extra samples (spot and alpha channels) A TIFF's extra samples other than the image alpha (spot colours and further alpha channels, ExtraSamples 0) were dropped on decode, and the encoder could write only one alpha sample. They now travel through `Metadata::extra_samples`, one plane per sample with its ExtraSamples value as `kind`. - Decode: chunky and planar, strips and tiles, every depth, Gray, RGB, CMYK and Lab. At most 16 (`MAX_EXTRA_SAMPLES`) are kept; the planes count against `Limits::max_alloc` and are allocated fallibly. Layout and pixel data are unchanged: the first alpha sample (1 or 2) is still the image alpha. - Encode: extra samples follow the colour and alpha samples, with their kinds in the ExtraSamples tag; Lab as CIELab (float Lab at 16 bits). More than 16, or a plane of the wrong size, is an error. - `Image::convert` converts the planes with the sample type, and `Image::oriented` turns them with the image. - main's `extra_samples_that_are_not_alpha_are_dropped` becomes `extra_samples_that_are_not_alpha_are_kept_apart`: same inputs, same layout and data, and it now checks the extra planes. From #3121 (split out of #7415). Co-authored-by: Zer0C00L01 <12090083+Zer0C00L01@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/codecs/README.md | 11 +- crates/codecs/src/codecs/tiff.rs | 528 ++++++++++++++++++++++++-- crates/codecs/src/image.rs | 32 +- crates/codecs/src/lib.rs | 3 +- crates/codecs/src/orientation.rs | 6 + crates/codecs/tests/orientation.rs | 19 + crates/codecs/tests/tiff_lab.rs | 33 ++ crates/codecs/tests/tiff_pages.rs | 16 +- crates/codecs/tests/tiff_photoshop.rs | 181 +++++++++ 9 files changed, 790 insertions(+), 39 deletions(-) diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 539365c95b..e3799d357b 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -187,8 +187,12 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them planar configuration 1 or 2. * Supports 1, 2 and 4-bit gray, 8/16/32/64-bit integers (32 and 64-bit are reduced to U16), F16, F32 and F64 (converted to F32), WhiteIsZero (#691: the tones are no longer inverted), - palette images (opened as RGB), associated alpha (made straight) and extra samples (dropped - unless the first is alpha). + palette images (opened as RGB) and associated alpha (made straight). + * Extra samples: the first alpha sample (ExtraSamples 1 or 2, wherever it sits) is the image's + alpha. Every other extra sample, such as a spot colour or a further alpha channel + (ExtraSamples 0), comes back in `Metadata::extra_samples`: one plane per sample in the + image's sample type, with its ExtraSamples value as `kind`. At most `MAX_EXTRA_SAMPLES` (16) + are kept, and they count against `Limits::max_alloc` with the image. * Every directory is reachable: `tiff_info` lists the main IFD chain and its SubIFDs (tag 330) with each page's size, kind (page, reduced-resolution copy, mask) and storage, walked with a seen-set, a depth limit and a 10 000-directory cap; `decode_tiff_page` opens any of them. @@ -202,7 +206,8 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them * YCbCr, CIE Lab, signed integers and compressions that the `tiff` crate lacks (CCITT, JPEG-in-TIFF) return `Unsupported`. * **TIFF encode** writes classic TIFF, or BigTIFF when `EncodeOptions::tiff_bigtiff` is set or - the file could pass 4 GiB. + the file could pass 4 GiB. `Metadata::extra_samples` (up to 16) are written after the colour + and alpha samples, with their `kind` in the ExtraSamples tag; more than 16 is an error. * **EXR** * Multi-part files (a Maya/Arnold render writes one part per AOV) open the part that looks most like a colour image — RGBA beats RGB beats YA beats a single channel, so a diff --git a/crates/codecs/src/codecs/tiff.rs b/crates/codecs/src/codecs/tiff.rs index 9528fec9c8..1cfa38bc10 100644 --- a/crates/codecs/src/codecs/tiff.rs +++ b/crates/codecs/src/codecs/tiff.rs @@ -249,7 +249,9 @@ pub(crate) fn decode_page(bytes: &[u8], page: Option, limits: &Limits) -> .ok_or_else(|| CodecError::InvalidImage(format!("TIFF page {index} does not exist (the file has {} directories)", info.pages.len())))?; let dir = file.ifd(p.ifd_offset).ok_or_else(|| err("image directory cut off"))?; let mut img = decode_ifd(&file, &dir, limits)?; + let extra_samples = std::mem::take(&mut img.meta.extra_samples); img.meta = metadata(&file, &dir, &mut img.icc); + img.meta.extra_samples = extra_samples; let pages = info.page_count(); if pages > 1 { img.warnings.push(DecodeWarning::MorePages { total: info.complete.then_some(u32::try_from(pages).unwrap_or(u32::MAX)) }); @@ -423,6 +425,8 @@ struct Layout { palette: Option>, /// Upper bound for one chunk's scratch buffer. scratch_cap: usize, + /// File sample indices that are neither colour nor the chosen alpha (spot / extra alpha). + extra_at: Vec, } impl Layout { @@ -436,6 +440,9 @@ impl Layout { fn out_row(&self) -> usize { self.w * self.out_channels() * self.out.bytes() } + fn extra_row(&self) -> usize { + self.w.saturating_mul(self.extra_at.len()).saturating_mul(self.out.bytes()) + } /// A chunky strip already in the output's layout: decoded in place. fn direct(&self) -> bool { self.copyable() && self.across == 1 @@ -543,13 +550,16 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result 3, 5 => 4, _ => 1, }; - let alpha_at = extra.iter().position(|&e| e == 1 || e == 2).filter(|&a| (color + a as u64) < spp); - let all_samples = palette.is_none() && matches!(photometric, 2 | 5 | 8 | 9) && alpha_at.is_some_and(|a| a > 0); + let alpha_at = extra.iter().position(|&e| e == 1 || e == 2).filter(|&a| (color as u64).saturating_add(a as u64) < spp); + // Samples the colour model does not keep. RGB, CMYK and Lab only hand back every sample when + // asked (photometric patched to black-is-zero below); gray already comes back as multiband. + let unused = !extra.is_empty() && (spp as usize) > color.saturating_add(usize::from(alpha_at.is_some())); + let all_samples = palette.is_none() && matches!(photometric, 2 | 5 | 8 | 9) && (alpha_at.is_some_and(|a| a > 0) || (!planar && unused)); let mut patches: Vec<(u64, Vec)> = Vec::new(); let encode_u = |v: u64, len: usize| -> Vec { let b = if f.le { v.to_le_bytes() } else { v.to_be_bytes() }; @@ -592,7 +602,31 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result (ChannelLayout::Rgb, 1), tiff::ColorType::Gray(_) => (ChannelLayout::Gray, 1), tiff::ColorType::Multiband { num_samples, .. } if all_samples => { - (if photometric == 5 { ChannelLayout::CmykA } else { ChannelLayout::Rgba }, usize::from(num_samples)) + // Lab reads as RGB here and is relabelled below. + let layout = match photometric { + 2 | 8 | 9 => { + if alpha_at.is_some() { + ChannelLayout::Rgba + } else { + ChannelLayout::Rgb + } + } + 5 => { + if alpha_at.is_some() { + ChannelLayout::CmykA + } else { + ChannelLayout::Cmyk + } + } + _ => { + if gray_alpha { + ChannelLayout::GrayA + } else { + ChannelLayout::Gray + } + } + }; + (layout, usize::from(num_samples)) } tiff::ColorType::Multiband { num_samples, .. } if photometric <= 1 => { (if gray_alpha { ChannelLayout::GrayA } else { ChannelLayout::Gray }, usize::from(num_samples)) @@ -661,6 +695,7 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result, dir: &Ifd, limits: &Limits) -> Result, dir: &Ifd, limits: &Limits) -> Result 0 { return Err(err("the image data is missing (cut off or past the end of the file)")); } @@ -693,6 +739,10 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result 0 { img.warnings.push(DecodeWarning::Truncated { format: F }); } @@ -728,6 +778,52 @@ fn convert_ab(data: &mut [u8], channels: usize, sample: SampleType, signed: bool } } +/// File sample indices that are not a colour channel and not the one alpha sample, capped at +/// [`crate::MAX_EXTRA_SAMPLES`]. +fn extra_sample_indices(spp: usize, color: usize, alpha_at: Option, has_alpha: bool) -> Vec { + let mut used = vec![false; spp]; + for i in 0..color.min(spp) { + if let Some(slot) = used.get_mut(i) { + *slot = true; + } + } + if has_alpha { + let a = alpha_at.map(|k| color.saturating_add(k)).unwrap_or(color); + if let Some(slot) = used.get_mut(a) { + *slot = true; + } + } + used.into_iter().enumerate().filter(|(_, on)| !*on).map(|(i, _)| i).take(crate::MAX_EXTRA_SAMPLES).collect() +} + +/// Interleaved extra samples (`extra_at.len()` per pixel) → one plane per sample. Each plane is +/// allocated fallibly (the caller has checked their total against the limits). +fn split_extra_samples( + buf: &[u8], + w: usize, + h: usize, + at: &[usize], + sample: SampleType, + mut kind_of: impl FnMut(usize) -> u16, +) -> Result, CodecError> { + let b = sample.bytes(); + let n = w.saturating_mul(h); + let spp = at.len(); + let mut out = Vec::with_capacity(spp); + for (ei, &si) in at.iter().enumerate() { + let mut data = zeroed(n.saturating_mul(b))?; + for i in 0..n { + let src = (i.saturating_mul(spp).saturating_add(ei)).saturating_mul(b); + let dst = i.saturating_mul(b); + if let (Some(s), Some(d)) = (buf.get(src..src.saturating_add(b)), data.get_mut(dst..dst.saturating_add(b))) { + d.copy_from_slice(s); + } + } + out.push(crate::ExtraSample { kind: kind_of(si), data }); + } + Ok(out) +} + /// A zero-filled buffer, allocated fallibly. On native targets the zeroing (and the first /// touch of every page, which dominates on a fresh multi-hundred-megabyte buffer) is spread over /// all cores. @@ -760,20 +856,43 @@ impl std::ops::Add for Tally { } /// Decodes every band into `data`, in parallel on native targets (one decoder and scratch -/// buffer per worker). +/// buffer per worker). `extra` receives samples that are not part of the colour image, one +/// band of rows at a time, when it is non-empty. #[cfg(not(target_arch = "wasm32"))] -fn decode_bands<'a, O>(lay: &Layout, data: &mut [u8], band_bytes: usize, _first: &mut Decoder>, open: &O) -> Result +fn decode_bands<'a, O>( + lay: &Layout, + data: &mut [u8], + band_bytes: usize, + extra: &mut [u8], + extra_band: usize, + _first: &mut Decoder>, + open: &O, +) -> Result where O: Fn() -> Result>, CodecError> + Sync, { use rayon::prelude::*; + if extra.is_empty() { + return data + .par_chunks_mut(band_bytes.max(1)) + .enumerate() + .map_init( + || open().map(|d| (d, Vec::new())).map_err(|e| e.to_string()), + |worker, (b, band)| { + let (dec, scratch) = worker.as_mut().map_err(|e| err(e.clone()))?; + decode_band(lay, dec, scratch, b, band, None) + }, + ) + .try_reduce(Tally::default, |a, b| Ok(a + b)); + } data.par_chunks_mut(band_bytes.max(1)) + .zip(extra.par_chunks_mut(extra_band.max(1))) .enumerate() .map_init( || open().map(|d| (d, Vec::new())).map_err(|e| e.to_string()), - |worker, (b, band)| { + |worker, (b, (band, extra_band))| { let (dec, scratch) = worker.as_mut().map_err(|e| err(e.clone()))?; - decode_band(lay, dec, scratch, b, band) + decode_band(lay, dec, scratch, b, band, Some(extra_band)) }, ) .try_reduce(Tally::default, |a, b| Ok(a + b)) @@ -781,20 +900,41 @@ where /// Decodes every band into `data`, one after the other. #[cfg(target_arch = "wasm32")] -fn decode_bands<'a, O>(lay: &Layout, data: &mut [u8], band_bytes: usize, first: &mut Decoder>, _open: &O) -> Result +fn decode_bands<'a, O>( + lay: &Layout, + data: &mut [u8], + band_bytes: usize, + extra: &mut [u8], + extra_band: usize, + first: &mut Decoder>, + _open: &O, +) -> Result where O: Fn() -> Result>, CodecError> + Sync, { let mut scratch = Vec::new(); let mut tally = Tally::default(); - for (b, band) in data.chunks_mut(band_bytes.max(1)).enumerate() { - tally = tally + decode_band(lay, first, &mut scratch, b, band)?; + if extra.is_empty() { + for (b, band) in data.chunks_mut(band_bytes.max(1)).enumerate() { + tally = tally + decode_band(lay, first, &mut scratch, b, band, None)?; + } + return Ok(tally); + } + for (b, (band, extra_band)) in data.chunks_mut(band_bytes.max(1)).zip(extra.chunks_mut(extra_band.max(1))).enumerate() { + tally = tally + decode_band(lay, first, &mut scratch, b, band, Some(extra_band))?; } Ok(tally) } /// Decodes band `b` (one strip, or one row of tiles, in every plane) into its output rows. -fn decode_band(lay: &Layout, dec: &mut Decoder>, scratch: &mut Vec, b: usize, band: &mut [u8]) -> Result { +fn decode_band( + lay: &Layout, + dec: &mut Decoder>, + scratch: &mut Vec, + b: usize, + band: &mut [u8], + mut extra: Option<&mut [u8]>, +) -> Result { let out_row = lay.out_row(); let rows = band.len() / out_row.max(1); let px = lay.out_channels() * lay.out.bytes(); @@ -836,16 +976,95 @@ fn decode_band(lay: &Layout, dec: &mut Decoder>, scratch: &mut Vec>, + scratch: &mut Vec, + b: usize, + rows: usize, + extra: Option<&mut [u8]>, + tally: &mut Tally, +) -> Result<(), CodecError> { + let Some(extra) = extra else { return Ok(()) }; + let ech = lay.extra_at.len(); + let erow = lay.extra_row(); + if ech == 0 || erow == 0 { + return Ok(()); + } + for (ei, &file_plane) in lay.extra_at.iter().enumerate() { + for col in 0..lay.across { + let chunk = file_plane + .checked_mul(lay.chunks_per_plane) + .and_then(|c| c.checked_add(b.checked_mul(lay.across)?)) + .and_then(|c| c.checked_add(col)) + .and_then(|c| u32::try_from(c).ok()) + .ok_or_else(|| err("chunk index out of range"))?; + let pref = dec.image_chunk_buffer_layout(chunk).map_err(map_err)?; + let stride = pref.row_stride.map(|s| s.get()).ok_or_else(|| err("empty chunk row"))?; + if pref.len > lay.scratch_cap { + return Err(CodecError::LimitExceeded(format!("a {} byte strip or tile", pref.len))); + } + if scratch.len() < pref.len { + scratch.resize(pref.len, 0); + } + let buf = scratch.get_mut(..pref.len).ok_or_else(|| err("scratch buffer"))?; + match dec.read_chunk_bytes(chunk, buf) { + Ok(()) => tally.decoded += 1, + Err(e) if is_eof(&e) => { + tally.missing += 1; + continue; + } + Err(e) => return Err(map_err(e)), + } + let x0 = col * lay.cw; + let n = lay.cw.min(lay.w.saturating_sub(x0)); + for (ri, src_row) in buf.chunks_exact(stride).take(rows).enumerate() { + let start = ri.saturating_mul(erow); + let Some(er) = extra.get_mut(start..start.saturating_add(erow)) else { continue }; + let px = ech * lay.out.bytes(); + let Some(dst) = er.get_mut(x0.saturating_mul(px)..(x0.saturating_add(n)).saturating_mul(px)) else { continue }; + transfer(lay.src, src_row, 1, 0, dst, lay.out, ech, ei, n); + } + } + } + Ok(()) +} + /// Converts `n` pixels of one decoded chunk row into output pixels (`plane` for planar data). fn convert_row(lay: &Layout, src: &[u8], dst: &mut [u8], n: usize, plane: usize) { let och = lay.out_channels(); @@ -1072,6 +1291,214 @@ custom_colortype!(Lab16, u16, PI::CIELab, &[16; 3], &[SampleFormat::Uint; 3], in custom_colortype!(LabA8, u8, PI::CIELab, &[8; 4], &[SampleFormat::Uint; 4], int); custom_colortype!(LabA16, u16, PI::CIELab, &[16; 4], &[SampleFormat::Uint; 4], int); +// Colour types with 1..=MAX_EXTRA_SAMPLES trailing extra samples (spot channels, extra alpha). +// One generic type per (sample, photometric, bit depth, total sample count) instead of a +// hand-written type for every layout. `N` is the total sample count, not the extra count. +const fn extra_photo(v: u16) -> PhotometricInterpretation { + match v { + 1 => PhotometricInterpretation::BlackIsZero, + 2 => PhotometricInterpretation::RGB, + 5 => PhotometricInterpretation::CMYK, + 8 => PhotometricInterpretation::CIELab, + _ => PhotometricInterpretation::RGB, + } +} + +trait ExtraKind: Copy + TiffValue + 'static { + const FORMAT: SampleFormat; + fn predict(row: &[Self], out: &mut Vec, samples: usize); +} + +impl ExtraKind for u8 { + const FORMAT: SampleFormat = SampleFormat::Uint; + fn predict(row: &[Self], out: &mut Vec, samples: usize) { + let n = samples.min(row.len()); + out.extend_from_slice(&row[..n]); + out.extend(row.iter().zip(&row[n..]).map(|(prev, current)| current.wrapping_sub(*prev))); + } +} + +impl ExtraKind for u16 { + const FORMAT: SampleFormat = SampleFormat::Uint; + fn predict(row: &[Self], out: &mut Vec, samples: usize) { + let n = samples.min(row.len()); + out.extend_from_slice(&row[..n]); + out.extend(row.iter().zip(&row[n..]).map(|(prev, current)| current.wrapping_sub(*prev))); + } +} + +impl ExtraKind for f32 { + const FORMAT: SampleFormat = SampleFormat::IEEEFP; + fn predict(row: &[Self], out: &mut Vec, _samples: usize) { + out.extend_from_slice(row); + } +} + +struct Extras(std::marker::PhantomData); + +impl TiffColorType for Extras +where + T: ExtraKind, +{ + type Inner = T; + const TIFF_VALUE: PhotometricInterpretation = extra_photo(PHOTO); + const BITS_PER_SAMPLE: &'static [u16] = &[BITS; N]; + const SAMPLE_FORMAT: &'static [SampleFormat] = &[T::FORMAT; N]; + fn horizontal_predict(row: &[Self::Inner], result: &mut Vec) { + T::predict(row, result, N); + } +} + +macro_rules! write_extras { + ($n:expr, $enc:expr, $w:expr, $h:expr, $data:expr, $tags:expr, $sample:ty, $photo:literal, $bits:literal, $base:literal) => { + match $n { + 1 => write_one::, K>($enc, $w, $h, $data, $tags), + 2 => write_one::, K>($enc, $w, $h, $data, $tags), + 3 => write_one::, K>($enc, $w, $h, $data, $tags), + 4 => write_one::, K>($enc, $w, $h, $data, $tags), + 5 => write_one::, K>($enc, $w, $h, $data, $tags), + 6 => write_one::, K>($enc, $w, $h, $data, $tags), + 7 => write_one::, K>($enc, $w, $h, $data, $tags), + 8 => write_one::, K>($enc, $w, $h, $data, $tags), + 9 => write_one::, K>($enc, $w, $h, $data, $tags), + 10 => write_one::, K>($enc, $w, $h, $data, $tags), + 11 => write_one::, K>($enc, $w, $h, $data, $tags), + 12 => write_one::, K>($enc, $w, $h, $data, $tags), + 13 => write_one::, K>($enc, $w, $h, $data, $tags), + 14 => write_one::, K>($enc, $w, $h, $data, $tags), + 15 => write_one::, K>($enc, $w, $h, $data, $tags), + 16 => write_one::, K>($enc, $w, $h, $data, $tags), + other => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unsupported extra samples ({other})")).into()), + } + }; +} + +fn bytes_as_u16(b: &[u8]) -> Vec { + b.as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes(*c)).collect() +} + +fn bytes_as_f32(b: &[u8]) -> Vec { + b.as_chunks::<4>().0.iter().map(|c| f32::from_ne_bytes(*c)).collect() +} + +/// Interleaves colour (and alpha) samples with [`Metadata::extra_samples`]. +fn interleave_extras(img: &Image) -> Result, String> { + let (w, h) = img.dimensions(); + let n = (w as usize).checked_mul(h as usize).ok_or_else(|| "image size overflows".to_string())?; + let sb = img.sample_type().bytes(); + let ch = img.layout().channels(); + let extras = &img.meta.extra_samples; + let total = ch.checked_add(extras.len()).ok_or_else(|| "too many samples".to_string())?; + let px = total.checked_mul(sb).ok_or_else(|| "sample size overflows".to_string())?; + let mut out = vec![0u8; n.checked_mul(px).ok_or_else(|| "image size overflows".to_string())?]; + let base = img.data(); + let base_px = ch.saturating_mul(sb); + for i in 0..n { + let src = i.saturating_mul(base_px); + let dst = i.saturating_mul(px); + let Some(s) = base.get(src..src.saturating_add(base_px)) else { + return Err("image buffer is shorter than its dimensions".into()); + }; + let Some(d) = out.get_mut(dst..dst.saturating_add(base_px)) else { + return Err("extra-sample buffer is short".into()); + }; + d.copy_from_slice(s); + for (k, e) in extras.iter().enumerate() { + let es = i.saturating_mul(sb); + let ed = dst.saturating_add(base_px).saturating_add(k.saturating_mul(sb)); + let Some(s) = e.data.get(es..es.saturating_add(sb)) else { + return Err(format!("extra sample {k} is shorter than the image")); + }; + let Some(d) = out.get_mut(ed..ed.saturating_add(sb)) else { + return Err("extra-sample buffer is short".into()); + }; + d.copy_from_slice(s); + } + } + Ok(out) +} + +fn write_with_extras(enc: &mut TiffEncoder<&mut Cursor>, K>, img: &Image, tags: &TagSet<'_>) -> tiff::TiffResult<()> { + use ChannelLayout as L; + use SampleType as S; + let (w, h) = img.dimensions(); + let n = img.meta.extra_samples.len(); + if img.layout().is_lab() { + let file = lab_file(img); + let bytes = interleave_extras(&file).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + return match (file.layout(), file.sample_type()) { + (L::Lab, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 8, 8, 3), + (L::Lab, _) => { + let v = bytes_as_u16(&bytes); + write_extras!(n, enc, w, h, &v, tags, u16, 8, 16, 3) + } + (_, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 8, 8, 4), + _ => { + let v = bytes_as_u16(&bytes); + write_extras!(n, enc, w, h, &v, tags, u16, 8, 16, 4) + } + }; + } + let bytes = interleave_extras(img).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + match (img.layout(), img.sample_type()) { + (L::Gray, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 1, 8, 1), + (L::Gray, S::U16) => { + let v = bytes_as_u16(&bytes); + write_extras!(n, enc, w, h, &v, tags, u16, 1, 16, 1) + } + (L::Gray, S::F32) => { + let v = bytes_as_f32(&bytes); + write_extras!(n, enc, w, h, &v, tags, f32, 1, 32, 1) + } + (L::GrayA, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 1, 8, 2), + (L::GrayA, S::U16) => { + let v = bytes_as_u16(&bytes); + write_extras!(n, enc, w, h, &v, tags, u16, 1, 16, 2) + } + (L::GrayA, S::F32) => { + let v = bytes_as_f32(&bytes); + write_extras!(n, enc, w, h, &v, tags, f32, 1, 32, 2) + } + (L::Rgb, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 2, 8, 3), + (L::Rgb, S::U16) => { + let v = bytes_as_u16(&bytes); + write_extras!(n, enc, w, h, &v, tags, u16, 2, 16, 3) + } + (L::Rgb, S::F32) => { + let v = bytes_as_f32(&bytes); + write_extras!(n, enc, w, h, &v, tags, f32, 2, 32, 3) + } + (L::Rgba, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 2, 8, 4), + (L::Rgba, S::U16) => { + let v = bytes_as_u16(&bytes); + write_extras!(n, enc, w, h, &v, tags, u16, 2, 16, 4) + } + (L::Rgba, S::F32) => { + let v = bytes_as_f32(&bytes); + write_extras!(n, enc, w, h, &v, tags, f32, 2, 32, 4) + } + (L::Cmyk, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 5, 8, 4), + (L::Cmyk, S::U16) => { + let v = bytes_as_u16(&bytes); + write_extras!(n, enc, w, h, &v, tags, u16, 5, 16, 4) + } + (L::Cmyk, S::F32) => { + let v = bytes_as_f32(&bytes); + write_extras!(n, enc, w, h, &v, tags, f32, 5, 32, 4) + } + (L::CmykA, S::U8) => write_extras!(n, enc, w, h, &bytes, tags, u8, 5, 8, 5), + (L::CmykA, S::U16) => { + let v = bytes_as_u16(&bytes); + write_extras!(n, enc, w, h, &v, tags, u16, 5, 16, 5) + } + (L::CmykA, S::F32) => { + let v = bytes_as_f32(&bytes); + write_extras!(n, enc, w, h, &v, tags, f32, 5, 32, 5) + } + _ => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unsupported extra samples ({n})")).into()), + } +} + struct TagSet<'a> { icc: Option<&'a [u8]>, /// The EXIF block's directories and IFD0 tags, written as the TIFF's own. @@ -1079,7 +1506,8 @@ struct TagSet<'a> { xmp: Option<&'a [u8]>, dpi: Option<(f32, f32)>, text: Vec<(Tag, &'a str)>, - alpha: bool, + /// ExtraSamples tag, including the unassociated-alpha marker when the image has alpha. + extra_samples: &'a [u16], photoshop_resources: Option<&'a [u8]>, photoshop_layers: Option<&'a [u8]>, } @@ -1109,9 +1537,8 @@ where im.y_resolution(Rational { n: (y * 1000.0).round() as u32, d: 1000 }); } let d = im.encoder(); - if tags.alpha { - // 2 = unassociated alpha - d.write_tag(Tag::ExtraSamples, &[2u16][..])?; + if !tags.extra_samples.is_empty() { + d.write_tag(Tag::ExtraSamples, tags.extra_samples)?; } if let Some(icc) = tags.icc { d.write_tag(Tag::IccProfile, Undefined(icc))?; @@ -1142,10 +1569,24 @@ where im.write_data(data) } +/// A Lab image as CIELab stores it: a* and b* signed, 8 or 16 bits (CIELab has no float form: +/// 32-bit Lab is written with 16 bits). Extra samples follow the new sample type. +fn lab_file(img: &Image) -> Image { + let sample = if img.sample_type() == SampleType::U8 { SampleType::U8 } else { SampleType::U16 }; + // `convert` brings the extra samples to the new sample type with the image. + let mut file = img.convert(img.layout(), sample); + let channels = file.layout().channels(); + convert_ab(file.data_mut(), channels, sample, true, true); + file +} + /// Writes the image with the colour type matching its layout and sample type. fn write_image(enc: &mut TiffEncoder<&mut Cursor>, K>, img: &Image, tags: &TagSet<'_>) -> tiff::TiffResult<()> { use ChannelLayout as L; use SampleType as S; + if !img.meta.extra_samples.is_empty() { + return write_with_extras::(enc, img, tags); + } let (w, h) = img.dimensions(); let u16s = || img.to_u16_samples().unwrap_or_default(); let f32s = || img.to_f32_samples().unwrap_or_default(); @@ -1170,13 +1611,9 @@ fn write_image(enc: &mut TiffEncoder<&mut Cursor>, K>, img: (L::CmykA, S::U16) => write_one::(enc, w, h, &u16s(), tags), (L::CmykA, S::F32) => write_one::(enc, w, h, &f32s(), tags), (L::Lab | L::LabA, _) => { - // CIELab has no float form: 32-bit Lab is written with 16 bits. - let sample = if img.sample_type() == S::U8 { S::U8 } else { S::U16 }; - let mut file = img.convert(img.layout(), sample); - let channels = file.layout().channels(); - convert_ab(file.data_mut(), channels, sample, true, true); + let file = lab_file(img); let u16s = || file.to_u16_samples().unwrap_or_default(); - match (file.layout(), sample) { + match (file.layout(), file.sample_type()) { (L::Lab, S::U8) => write_one::(enc, w, h, file.data(), tags), (L::Lab, _) => write_one::(enc, w, h, &u16s(), tags), (_, S::U8) => write_one::(enc, w, h, file.data(), tags), @@ -1198,11 +1635,16 @@ fn estimated_size(img: &Image, tags: &TagSet<'_>) -> u64 { + tags.exif.iter().flat_map(|e| e.ifd0.iter().chain(&e.exif).chain(&e.gps)).map(|f| f.value.len() as u64 + 12).sum::(); // Strip tables: one offset and one count per strip of about 1 MB. let tables = (img.data().len() as u64 / (1 << 20) + 1) * 16; - (img.data().len() as u64).saturating_add(payloads).saturating_add(text).saturating_add(tables).saturating_add(1 << 20) + let extras: u64 = img.meta.extra_samples.iter().map(|e| e.data.len() as u64).sum(); + (img.data().len() as u64).saturating_add(extras).saturating_add(payloads).saturating_add(text).saturating_add(tables).saturating_add(1 << 20) } pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { - let img = src.converted(plan.layout, plan.sample); + // `converted` brings the extra samples to the plan's sample type with the image. + encode_ready(&src.converted(plan.layout, plan.sample), opts) +} + +fn encode_ready(img: &Image, opts: &EncodeOptions) -> Result, CodecError> { let compression = match opts.tiff_compression { TiffCompression::None => tiff::encoder::Compression::Uncompressed, TiffCompression::Lzw => tiff::encoder::Compression::Lzw, @@ -1226,13 +1668,23 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result crate::MAX_EXTRA_SAMPLES { + return Err(CodecError::encode(F, format!("TIFF stores at most {} extra samples", crate::MAX_EXTRA_SAMPLES))); + } + let mut kinds = Vec::with_capacity(img.meta.extra_samples.len().saturating_add(1)); + if img.layout().has_alpha() { + kinds.push(2); + } + for e in &img.meta.extra_samples { + kinds.push(e.kind); + } let tags = TagSet { icc: if opts.embed_icc { img.icc.as_deref() } else { None }, exif: if opts.embed_metadata { img.meta.exif.as_deref().and_then(|e| tiff_exif::read(crate::orientation::tiff_body(e))) } else { None }, xmp: xmp.as_deref().map(str::as_bytes), dpi: if opts.embed_metadata { img.meta.dpi.filter(|d| d.0 > 0.0 && d.1 > 0.0) } else { None }, text, - alpha: img.layout().has_alpha(), + extra_samples: &kinds, // Document content, not metadata: written whenever present. photoshop_resources: img.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()), photoshop_layers: img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()), @@ -1245,17 +1697,33 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result(&mut enc, &img, &tags)?; + write_image::(&mut enc, img, &tags)?; } else { let mut enc = TiffEncoder::new(&mut cursor)?.with_compression(compression).with_predictor(predictor); - write_image::(&mut enc, &img, &tags)?; + write_image::(&mut enc, img, &tags)?; } Ok(cursor.into_inner()) }; - let big = opts.tiff_bigtiff || estimated_size(&img, &tags) > CLASSIC_LIMIT; + let big = opts.tiff_bigtiff || estimated_size(img, &tags) > CLASSIC_LIMIT; match write(big) { Ok(v) => Ok(v), Err(tiff::TiffError::IntSizeError) if !big => write(true).map_err(|e| CodecError::encode(F, e)), Err(e) => Err(CodecError::encode(F, e)), } } + +#[cfg(test)] +mod bits_len { + use super::Extras; + use tiff::encoder::colortype::ColorType as TiffColorType; + + #[test] + fn const_generic_sample_tables_have_one_entry_per_sample() { + // A repeat expression in an associated const (`&[BITS; N]`) has, on some compilers, + // promoted to a slice shorter than `N`. The encoder sizes every strip from that length. + assert_eq!( as TiffColorType>::BITS_PER_SAMPLE.len(), 4, "{:?}", as TiffColorType>::BITS_PER_SAMPLE); + assert_eq!( as TiffColorType>::BITS_PER_SAMPLE.len(), 5); + assert_eq!( as TiffColorType>::BITS_PER_SAMPLE.len(), 19); + assert_eq!( as TiffColorType>::SAMPLE_FORMAT.len(), 6); + } +} diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index 5a03e7d283..a15ab72779 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -145,6 +145,24 @@ pub struct Metadata { /// Photoshop layer data (TIFF tag 37724 `ImageSourceData`): a layered TIFF's layers. Kept /// opaque here; `photocraft-io` reads and writes it through `photocraft-psd`. pub photoshop_layers: Option>, + /// Samples per pixel beyond the colour channels and the one alpha sample. TIFF extra + /// samples (spot colours, extra alpha channels) the pixel layout does not hold. Each entry + /// is one sample per pixel, native endian, in the image's [`SampleType`], row-major. + pub extra_samples: Vec, +} + +/// How many extra TIFF samples an encoded file may carry. Decoders keep at most this many. +/// Sixteen covers a UV-DTF stack (white, varnish, gloss, primer, and further spots) without +/// approaching the PSD 56-channel limit. +pub const MAX_EXTRA_SAMPLES: usize = 16; + +/// One TIFF extra sample (a spot channel or an alpha channel that is not the image alpha). +#[derive(Debug, Clone, PartialEq)] +pub struct ExtraSample { + /// TIFF ExtraSamples value: 0 unspecified, 1 associated alpha, 2 unassociated alpha. + pub kind: u16, + /// One sample per pixel, native endian, same sample type as the image. + pub data: Vec, } impl Metadata { @@ -155,6 +173,7 @@ impl Metadata { && self.text.is_empty() && self.photoshop_resources.is_none() && self.photoshop_layers.is_none() + && self.extra_samples.is_empty() } } @@ -639,6 +658,17 @@ impl Image { let values = convert_layout(&src, self.layout, layout); quantize(&values, sample) }; + let mut meta = self.meta.clone(); + if sample != self.sample { + // Extra samples are stored in the image's sample type, so they convert with it. A + // plane of the wrong size is left as it is: importers drop it with a warning. + let plane_len = (self.width as usize).saturating_mul(self.height as usize).saturating_mul(self.sample.bytes()); + for e in meta.extra_samples.iter_mut().filter(|e| e.data.len() == plane_len) { + if let Ok(plane) = Image::from_raw(self.width, self.height, ChannelLayout::Gray, self.sample, std::mem::take(&mut e.data)) { + e.data = plane.convert(ChannelLayout::Gray, sample).data; + } + } + } Image { width: self.width, height: self.height, @@ -646,7 +676,7 @@ impl Image { sample, data, icc: if layout.same_model(self.layout) { self.icc.clone() } else { None }, - meta: self.meta.clone(), + meta, warnings: self.warnings.clone(), } } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 8c06694b9a..5c0e37b0db 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -36,7 +36,8 @@ pub use crate::exif_color::{EXIF_COLOR_SPACE_SRGB, exif_color_space}; pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_with}; pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, from_extension}; pub use crate::image::{ - ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType, + ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, ExtraSample, Image, + MAX_EXTRA_SAMPLES, Metadata, SampleType, }; pub use crate::options::{ DdsCompression, DdsOptions, DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, PngStereo, TiffCompression, diff --git a/crates/codecs/src/orientation.rs b/crates/codecs/src/orientation.rs index 3d10d5ddea..7f31a6b271 100644 --- a/crates/codecs/src/orientation.rs +++ b/crates/codecs/src/orientation.rs @@ -331,6 +331,12 @@ impl Image { } let data = orient_bytes(self.data(), bpp, o, (w, h), ow)?; let mut meta = self.meta.clone(); + // Extra samples (a TIFF's alpha and spot planes, one sample a pixel) turn with the image. + // A plane of the wrong size is left as it is: importers drop it with a warning. + let plane_len = w.saturating_mul(h).saturating_mul(sample.bytes()); + for e in meta.extra_samples.iter_mut().filter(|e| e.data.len() == plane_len) { + e.data = orient_bytes(&e.data, sample.bytes(), o, (w, h), ow)?; + } if let Some(e) = &mut meta.exif && let Cow::Owned(fixed) = upright_exif(e) { diff --git a/crates/codecs/tests/orientation.rs b/crates/codecs/tests/orientation.rs index c1f176b87c..0532b44066 100644 --- a/crates/codecs/tests/orientation.rs +++ b/crates/codecs/tests/orientation.rs @@ -211,6 +211,25 @@ fn png_opens_upright_for_every_orientation() { } } +/// Extra samples (a TIFF's alpha and spot planes) turn with the image, so a channel stays +/// aligned with the pixels for every orientation. +#[test] +fn extra_samples_turn_with_the_image() { + let (w, h) = (4, 7); + let up = exact_scene(w, h); + let up_extra: Vec = up.iter().map(|v| 255 - v).collect(); + for o in 1..=8u16 { + let (s, sw, sh) = stored(&up, w, h, o); + let (s_extra, _, _) = stored(&up_extra, w, h, o); + let mut img = gray(sw, sh, s); + img.meta.extra_samples = vec![ExtraSample { kind: 0, data: s_extra }]; + let turned = img.oriented(o).unwrap(); + assert_eq!(turned.data(), &up[..], "o={o}"); + assert_eq!(turned.meta.extra_samples.len(), 1, "o={o}"); + assert_eq!(turned.meta.extra_samples[0].data, up_extra, "o={o}"); + } +} + #[test] fn tiff_opens_upright_for_every_orientation() { let (w, h) = (4, 7); diff --git a/crates/codecs/tests/tiff_lab.rs b/crates/codecs/tests/tiff_lab.rs index 7076633ef5..9e41ef80b2 100644 --- a/crates/codecs/tests/tiff_lab.rs +++ b/crates/codecs/tests/tiff_lab.rs @@ -74,3 +74,36 @@ fn lab_converts_to_rgb_for_other_formats() { assert_eq!(&p[4..7], &[255, 255, 255], "white"); assert!(p[0] > 180 && p[1] < 120 && p[2] > 140, "L 50, a 50, b −30 is a magenta-ish purple: {:?}", &p[..3]); } + +#[test] +fn lab_keeps_extra_samples() { + // A spot plate next to Lab: written as CIELab plus one unspecified extra sample, and read + // back as Lab with the spot (not as LabA). + for (layout, sample) in [(ChannelLayout::Lab, SampleType::U8), (ChannelLayout::LabA, SampleType::U16)] { + let px: Vec = + [lab(50.0, 50.0, -30.0), lab(100.0, 0.0, 0.0)].iter().flat_map(|p| p.iter().copied().chain(layout.has_alpha().then_some(0.5))).collect(); + let mut img = Image::from_normalized(2, 1, layout, sample, &px).unwrap(); + let spot: Vec = (0..2 * sample.bytes()).map(|i| (i * 37 + 5) as u8).collect(); + img.meta.extra_samples = vec![ExtraSample { kind: 0, data: spot.clone() }]; + let file = uncompressed(&img); + if layout == ChannelLayout::Lab { + assert!(find(&file, &[128, 50, (-30i8) as u8, spot[0], 255, 0, 0, spot[1]]).is_some(), "CIELab samples with the spot"); + } + let back = decode(&file).unwrap(); + assert_eq!((back.layout(), back.sample_type()), (layout, sample)); + let (a, b) = (img.to_normalized(), back.to_normalized()); + assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= 1.5 / 65535.0), "{layout:?}: {a:?} {b:?}"); + assert_eq!(back.meta.extra_samples, vec![ExtraSample { kind: 0, data: spot }], "{layout:?}"); + } +} + +#[test] +fn float_lab_writes_its_extra_samples_with_16_bits() { + let px: Vec = [lab(50.0, 50.0, -30.0), lab(100.0, 0.0, 0.0)].concat(); + let mut img = Image::from_normalized(2, 1, ChannelLayout::Lab, SampleType::F32, &px).unwrap(); + img.meta.extra_samples = vec![ExtraSample { kind: 0, data: [0.25f32, 1.0].iter().flat_map(|v| v.to_ne_bytes()).collect() }]; + let back = decode(&uncompressed(&img)).unwrap(); + assert_eq!((back.layout(), back.sample_type()), (ChannelLayout::Lab, SampleType::U16)); + let spot: Vec = [16384u16, 65535].iter().flat_map(|v| v.to_ne_bytes()).collect(); + assert_eq!(back.meta.extra_samples, vec![ExtraSample { kind: 0, data: spot }]); +} diff --git a/crates/codecs/tests/tiff_pages.rs b/crates/codecs/tests/tiff_pages.rs index 60885e527d..dce4317a8c 100644 --- a/crates/codecs/tests/tiff_pages.rs +++ b/crates/codecs/tests/tiff_pages.rs @@ -128,13 +128,16 @@ fn planar_strips_at_every_depth() { } } +/// Extra samples that are not the alpha (ExtraSamples 0: a spot colour or an extra channel) are +/// not part of the image: they come back in `meta.extra_samples`, one plane each. #[test] -fn extra_samples_that_are_not_alpha_are_dropped() { +fn extra_samples_that_are_not_alpha_are_kept_apart() { let (w, h) = (5usize, 3usize); let t = truth(w * h * 5, 4); let rgb_of = |spp: usize, keep: usize| -> Vec { t.u8s[..w * h * spp].chunks(spp).flat_map(|p| p[..keep].to_vec()).collect() }; + let extra = |spp: usize, at: usize| vec![ExtraSample { kind: 0, data: t.u8s[..w * h * spp].chunks(spp).map(|p| p[at]).collect() }]; for planar in [false, true] { - // RGB + one unspecified extra sample: RGB. + // RGB + one unspecified extra sample: RGB, and the sample as an extra plane. let px = &t.u8s[..w * h * 4]; let chunks = if planar { planar_strips(px, w, h, 4, 1, 2) } else { px.chunks(w * 4 * 2).map(<[u8]>::to_vec).collect() }; let mut d = Dir { entries: base(w as u32, h as u32, 8, 4, 2), chunks, tiled: false }.tag(278, LONG, &[2]).tag(338, SHORT, &[0]); @@ -144,7 +147,8 @@ fn extra_samples_that_are_not_alpha_are_dropped() { let img = decode_ok(&build(true, false, &[d], &[0]).bytes); assert_eq!(img.layout(), ChannelLayout::Rgb); assert_eq!(img.data(), &rgb_of(4, 3)[..], "planar {planar}"); - // RGB + alpha + an unspecified extra sample: RGBA. + assert_eq!(img.meta.extra_samples, extra(4, 3), "planar {planar}"); + // RGB + alpha + an unspecified extra sample: RGBA and one extra plane. let px = &t.u8s[..w * h * 5]; let chunks = if planar { planar_strips(px, w, h, 5, 1, 2) } else { px.chunks(w * 5 * 2).map(<[u8]>::to_vec).collect() }; let mut d = Dir { entries: base(w as u32, h as u32, 8, 5, 2), chunks, tiled: false }.tag(278, LONG, &[2]).tag(338, SHORT, &[2, 0]); @@ -154,18 +158,22 @@ fn extra_samples_that_are_not_alpha_are_dropped() { let img = decode_ok(&build(false, true, &[d], &[0]).bytes); assert_eq!(img.layout(), ChannelLayout::Rgba); assert_eq!(img.data(), &rgb_of(5, 4)[..], "planar {planar}"); + assert_eq!(img.meta.extra_samples, extra(5, 4), "planar {planar}"); } - // Gray + alpha + extra: gray and alpha; gray + an unspecified extra: gray. + // Gray + alpha + extra: gray and alpha; gray + an unspecified extra: gray. Each keeps the + // extra sample as a plane. let px = &t.u8s[..w * h * 3]; let d = strips(w as u32, h as u32, 8, 3, 1, 3, px).tag(338, SHORT, &[2, 0]); let img = decode_ok(&build(true, false, &[d], &[0]).bytes); assert_eq!(img.layout(), ChannelLayout::GrayA); assert_eq!(img.data(), &rgb_of(3, 2)[..]); + assert_eq!(img.meta.extra_samples, extra(3, 2)); let px = &t.u8s[..w * h * 2]; let d = strips(w as u32, h as u32, 8, 2, 1, 3, px).tag(338, SHORT, &[0]); let img = decode_ok(&build(true, false, &[d], &[0]).bytes); assert_eq!(img.layout(), ChannelLayout::Gray); assert_eq!(img.data(), &rgb_of(2, 1)[..]); + assert_eq!(img.meta.extra_samples, extra(2, 1)); } #[test] diff --git a/crates/codecs/tests/tiff_photoshop.rs b/crates/codecs/tests/tiff_photoshop.rs index 400b72eb0c..82f4b74e7d 100644 --- a/crates/codecs/tests/tiff_photoshop.rs +++ b/crates/codecs/tests/tiff_photoshop.rs @@ -41,6 +41,187 @@ fn tags_round_trip_through_the_encoder() { } } +#[test] +fn extra_samples_round_trip() { + for (layout, sample) in [ + (ChannelLayout::Rgb, SampleType::U8), + (ChannelLayout::Rgba, SampleType::U8), + (ChannelLayout::Gray, SampleType::U16), + (ChannelLayout::Cmyk, SampleType::F32), + ] { + let mut img = synth(4, 3, layout, sample, 2, 0.4); + let n = 4 * 3; + let b = sample.bytes(); + let data: Vec = (0..n * b).map(|i| (i * 17) as u8).collect(); + img.meta.extra_samples = vec![ExtraSample { kind: 0, data: data.clone() }]; + let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + let back = decode(&bytes).unwrap(); + assert_eq!(back.layout(), layout, "{layout:?} {sample:?}"); + assert_eq!(back.data(), img.data(), "{layout:?} {sample:?}"); + assert_eq!(back.meta.extra_samples.len(), 1, "{layout:?} {sample:?}"); + assert_eq!(back.meta.extra_samples[0].kind, 0, "{layout:?} {sample:?}"); + assert_eq!(back.meta.extra_samples[0].data, data, "{layout:?} {sample:?}"); + } +} + +/// Tall enough that the default strip size (about 1 MB) cannot hold every row, so a writer +/// that only fills the first strip, or predicts across the wrong sample count, cannot hide. +/// The spot plane is checked with the `tiff` crate, not PhotoCraft's decoder. +#[test] +fn multi_strip_spot_round_trip_and_dump() { + let w = 800u32; + let h = 400u32; + let n = (w as usize) * (h as usize); + for (layout, channels) in [(ChannelLayout::Rgb, 3usize), (ChannelLayout::Rgba, 4usize)] { + let mut px = vec![0u8; n * channels]; + let mut spot = vec![0u8; n]; + for y in 0..h as usize { + for x in 0..w as usize { + let i = y * w as usize + x; + px[i * channels] = (x % 251) as u8; + px[i * channels + 1] = (y % 251) as u8; + px[i * channels + 2] = 40; + if channels == 4 { + px[i * channels + 3] = if x > 20 && x < 780 && y > 10 && y + 10 < h as usize { 255 } else { 0 }; + } + spot[i] = ((x * 3 + y * 17) % 251) as u8; + } + } + let mut img = Image::from_u8(w, h, layout, px).unwrap(); + img.meta.extra_samples = vec![ExtraSample { kind: 0, data: spot.clone() }]; + for compression in [TiffCompression::None, TiffCompression::Deflate, TiffCompression::Lzw, TiffCompression::PackBits] { + let bytes = encode(&img, Format::Tiff, &EncodeOptions { tiff_compression: compression, ..Default::default() }).unwrap(); + let label = format!("{layout:?} {compression:?}"); + let back = decode(&bytes).unwrap(); + assert_eq!(back.meta.extra_samples.len(), 1, "{label}"); + assert_eq!(back.meta.extra_samples[0].data, spot, "own decoder {label}"); + assert_eq!(back.data(), img.data(), "own decoder rgb {label}"); + let mut dec = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes.as_slice())).unwrap(); + let spp: u16 = dec.get_tag_unsigned(tiff::tags::Tag::SamplesPerPixel).unwrap(); + let bps = dec.get_tag_u16_vec(tiff::tags::Tag::BitsPerSample).unwrap(); + let extra = dec.get_tag_u16_vec(tiff::tags::Tag::ExtraSamples).unwrap(); + let rows = dec.find_tag_unsigned::(tiff::tags::Tag::RowsPerStrip).unwrap(); + assert_eq!(bps.len(), spp as usize, "{label}"); + assert_eq!(spp as usize, channels + 1, "{label}"); + assert_eq!(*extra.last().unwrap(), 0, "{label} spot ExtraSamples {extra:?}"); + assert!(rows.is_some_and(|r| r < h) || compression == TiffCompression::PackBits, "{label} rows {rows:?}"); + let patched = min_is_black(&bytes); + let mut indep = tiff::decoder::Decoder::new(std::io::Cursor::new(patched.as_slice())).unwrap(); + let tiff::decoder::DecodingResult::U8(samples) = indep.read_image().unwrap() else { panic!("{label} not u8") }; + let spp = spp as usize; + assert_eq!(samples.len(), n * spp, "{label}"); + let mut bad = 0usize; + let mut first = None; + for (i, want) in spot.iter().enumerate() { + let got = samples.get(i * spp + spp - 1).copied(); + if got != Some(*want) { + bad += 1; + first.get_or_insert((i, got)); + } + } + assert_eq!(bad, 0, "{label} independent reader, first {first:?}"); + } + } +} + +/// Retag photometric interpretation 262 as MinIsBlack so `tiff`'s decoder returns every sample. +fn min_is_black(src: &[u8]) -> Vec { + let mut bytes = src.to_vec(); + let le = bytes.first().copied() == Some(b'I'); + let ru16 = |b: &[u8], i: usize| -> Option { + let s = b.get(i..i + 2)?; + Some(if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) }) + }; + let ru32 = |b: &[u8], i: usize| -> Option { + let s = b.get(i..i + 4)?; + Some(if le { u32::from_le_bytes(s.try_into().ok()?) } else { u32::from_be_bytes(s.try_into().ok()?) }) + }; + let Some(ifd) = ru32(&bytes, 4) else { return bytes }; + let Some(n) = ru16(&bytes, ifd as usize) else { return bytes }; + for e in 0..n as usize { + let at = ifd as usize + 2 + e * 12; + if ru16(&bytes, at) == Some(262) { + let value = if le { 1u16.to_le_bytes() } else { 1u16.to_be_bytes() }; + if let Some(slot) = bytes.get_mut(at + 8..at + 10) { + slot.copy_from_slice(&value); + } + break; + } + } + bytes +} + +#[test] +fn more_than_sixteen_extra_samples_is_refused() { + let mut img = synth(2, 2, ChannelLayout::Rgb, SampleType::U8, 1, 0.0); + img.meta.extra_samples = (0..MAX_EXTRA_SAMPLES + 1).map(|_| ExtraSample { kind: 0, data: vec![0; 4] }).collect(); + assert!(encode(&img, Format::Tiff, &EncodeOptions::default()).is_err()); +} + +#[test] +fn sixteen_extra_samples_round_trip() { + for (layout, sample, base) in [ + (ChannelLayout::Rgb, SampleType::U8, 3usize), + (ChannelLayout::Rgba, SampleType::U8, 4), + (ChannelLayout::Gray, SampleType::U16, 1), + (ChannelLayout::Cmyk, SampleType::F32, 4), + ] { + let mut img = synth(2, 2, layout, sample, 1, 0.0); + let n = 2 * 2 * sample.bytes(); + img.meta.extra_samples = (0..MAX_EXTRA_SAMPLES).map(|k| ExtraSample { kind: 0, data: vec![k as u8; n] }).collect(); + let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + let back = decode(&bytes).unwrap(); + assert_eq!(back.layout(), layout, "{layout:?} {sample:?} base {base}"); + assert_eq!(back.data(), img.data(), "{layout:?}"); + assert_eq!(back.meta.extra_samples.len(), MAX_EXTRA_SAMPLES, "{layout:?}"); + assert_eq!(back.meta.extra_samples[7].kind, 0, "{layout:?}"); + assert_eq!(back.meta.extra_samples[15].data, vec![15; n], "{layout:?}"); + } +} + +/// Extra samples count against `max_alloc` with the image (their interleaved buffer and the +/// planes split from it): a budget that holds the RGB image but not sixteen extra planes refuses +/// the file instead of allocating past it. +#[test] +fn extra_samples_count_against_the_allocation_limit() { + let (w, h) = (64u32, 64u32); + let n = (w * h) as usize; + let mut img = synth(w, h, ChannelLayout::Rgb, SampleType::U8, 1, 0.0); + img.meta.extra_samples = (0..MAX_EXTRA_SAMPLES).map(|k| ExtraSample { kind: 0, data: vec![k as u8; n] }).collect(); + let bytes = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap(); + let with = |max_alloc: u64| decode_with(&bytes, &DecodeOptions { limits: Limits { max_alloc, ..Limits::default() }, ..DecodeOptions::default() }); + // RGB is 3 bytes a pixel; the extra samples add 2 x 16 more. + let image_only = 3 * n as u64; + let with_extras = (3 + 2 * MAX_EXTRA_SAMPLES as u64) * n as u64; + assert!(matches!(with(image_only), Err(CodecError::LimitExceeded(_))), "the image fits, the extra samples don't"); + assert!(matches!(with(with_extras - 1), Err(CodecError::LimitExceeded(_)))); + let back = with(with_extras).expect("everything fits"); + assert_eq!(back.meta.extra_samples.len(), MAX_EXTRA_SAMPLES); +} + +/// Converting an image's sample type converts its extra samples with it, so they keep matching +/// the image: a 16-bit float TIFF's spot planes survive the 32-bit float a layered import uses. +#[test] +fn extra_samples_follow_a_sample_type_conversion() { + let (w, h) = (3u32, 2u32); + let n = (w * h) as usize; + let values = [0.0f32, 0.25, 0.5, 0.75, 1.0, 0.125]; + let half = |v: f32| f16::from_f32(v).to_bits().to_ne_bytes(); + let rgb: Vec = (0..n * 3).flat_map(|_| half(0.5)).collect(); + let mut img = Image::from_raw(w, h, ChannelLayout::Rgb, SampleType::F16, rgb).unwrap(); + img.meta.extra_samples = vec![ExtraSample { kind: 0, data: values.iter().flat_map(|v| half(*v)).collect() }]; + + let wide = img.convert(ChannelLayout::Rgb, SampleType::F32); + let plane: Vec = wide.meta.extra_samples[0].data.as_chunks::<4>().0.iter().map(|c| f32::from_ne_bytes(*c)).collect(); + assert_eq!(plane, values); + + // To 8 bits, the plane quantizes exactly as a gray image of the same values does. + let narrow = wide.convert(ChannelLayout::Rgb, SampleType::U8); + let gray = Image::from_raw(w, h, ChannelLayout::Gray, SampleType::F32, wide.meta.extra_samples[0].data.clone()).unwrap(); + assert_eq!(narrow.meta.extra_samples[0].data, gray.convert(ChannelLayout::Gray, SampleType::U8).data()); + assert_eq!(narrow.meta.extra_samples[0].data.len(), n); +} + #[test] fn absent_and_empty_tags_are_not_written() { let mut img = synth(4, 4, ChannelLayout::Rgb, SampleType::U8, 1, 0.0); From ef754074b504b6194234f2947fd30e3c985e1628 Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 11:25:52 -0400 Subject: [PATCH 071/229] contributors: credit jasallen Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index b3cab8e04f..2526e9c96c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -191,3 +191,8 @@ sync_github_name = true [people.ha1des] sync_github_name = true + +[people.jasallen] +real_name = "Jason DeBoever" +display_name = "jasallen" +sync_github_name = true From 7b7b6c930d4b5c193af5638e177845c0662578ec Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 10:28:54 -0400 Subject: [PATCH 072/229] PSD: recognise Photoshop's MTrn merged-transparency marker Photoshop 27.11 saves a layered TIFF with Save Transparency as an RGBA composite whose layer data has a positive layer count and an `MTrn` block (`nrTM` in an II file). The specification's key is `Mtrn`, so the composite's transparency opened as an extra alpha channel and a PSD save wrote it twice. TaggedBlock::is_merged_transparency_marker lists Mtrn, MTrn, Mt16 and Mt32; PsdFile::merged_has_alpha and the PSD exporter's layer-block placement use it. Split from #7415; the work started in #3121. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Zer0C00L01 <12090083+Zer0C00L01@users.noreply.github.com> --- crates/io/src/psd_export.rs | 15 ++++--- crates/io/tests/psd16_merged_transparency.rs | 2 +- crates/io/tests/tiff_layers.rs | 41 ++++++++++++++++++++ crates/psd/src/file.rs | 37 ++++++++++++++---- crates/psd/src/tagged.rs | 8 ++++ docs/file-format-parity.md | 3 +- 6 files changed, 92 insertions(+), 14 deletions(-) diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 9ad93bdf66..f8605331c8 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1826,10 +1826,10 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c resources.push(version_info_resource(real_merged)); let has_layers = !ex.records.is_empty(); - // Photoshop writes a merged-transparency marker (`Mtrn`, `Mt16`, `Mt32`) before the layer - // block, and won't open a file whose `Lr32` comes first (#5068): the layer block goes right - // after the last kept marker, else first. - let index = global_blocks.iter().rposition(|b| matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32")).map_or(0, |i| i + 1); + // Photoshop writes a merged-transparency marker (`Mtrn`, `Mt16`, `Mt32`; `MTrn` in a TIFF) + // before the layer block, and won't open a file whose `Lr32` comes first (#5068): the layer + // block goes right after the last kept marker, else first. + let index = global_blocks.iter().rposition(TaggedBlock::is_merged_transparency_marker).map_or(0, |i| i + 1); let placement = match (has_layers, sample) { (true, SampleType::U16) => LayerInfoPlacement::GlobalBlock { index, signature: *b"8BIM", key: *b"Lr16", padding: None }, (true, SampleType::F32) => LayerInfoPlacement::GlobalBlock { index, signature: *b"8BIM", key: *b"Lr32", padding: None }, @@ -1963,7 +1963,12 @@ mod tests { /// `transparentbg.psd` re-saved both ways). The layer block follows a kept marker. #[test] fn layer_block_follows_a_kept_merged_transparency_marker() { - for (depth, key, marker) in [(SampleType::F32, *b"Lr32", *b"Mt32"), (SampleType::U16, *b"Lr16", *b"Mt16"), (SampleType::F32, *b"Lr32", *b"Mtrn")] { + for (depth, key, marker) in [ + (SampleType::F32, *b"Lr32", *b"Mt32"), + (SampleType::U16, *b"Lr16", *b"Mt16"), + (SampleType::F32, *b"Lr32", *b"Mtrn"), + (SampleType::F32, *b"Lr32", *b"MTrn"), + ] { let mut doc = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, depth); let mut s = Surface::new(doc.pixel_format()); s.fill_rect(photocraft_geom::Rect::new(1, 1, 5, 4), &[0.5, 0.2, 0.9, 1.0]); diff --git a/crates/io/tests/psd16_merged_transparency.rs b/crates/io/tests/psd16_merged_transparency.rs index 02d6c0e6b3..3cbdd4b460 100644 --- a/crates/io/tests/psd16_merged_transparency.rs +++ b/crates/io/tests/psd16_merged_transparency.rs @@ -43,7 +43,7 @@ fn resave_keeps_a_name_marked_merged_transparency() { // transparency named in the alpha channel names. let mut ps = ours.clone(); ps.layer_info.as_mut().expect("layers").merged_alpha = false; - ps.global_blocks.retain(|b| !matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32")); + ps.global_blocks.retain(|b| !b.is_merged_transparency_marker()); assert!(!ps.merged_has_alpha(), "{case}: nothing marks it yet"); let name: Vec = [0u8, 0, 0, 13].into_iter().chain("Transparency\0".encode_utf16().flat_map(u16::to_be_bytes)).collect(); ps.resources.retain(|r| !matches!(r.id, 1006 | 1045)); diff --git a/crates/io/tests/tiff_layers.rs b/crates/io/tests/tiff_layers.rs index 56e51bf9f9..4cf78aca68 100644 --- a/crates/io/tests/tiff_layers.rs +++ b/crates/io/tests/tiff_layers.rs @@ -202,6 +202,47 @@ fn transparency_is_straight_in_the_composite() { assert_docs_eq(&d, &back); } +/// Photoshop (27.11) saves a layered TIFF with Save Transparency as a composite with a +/// transparency sample, a positive layer count and an `MTrn` block first in the layer data +/// (the specification's key is `Mtrn`; `nrTM` in an `II` file). That sample is the merged +/// transparency: it opens as no alpha channel, and a PSD save doesn't write it twice. +#[test] +fn photoshop_mtrn_marks_the_merged_transparency_of_a_layered_tiff() { + use photocraft_psd::{PsdFile, TaggedBlock}; + for depth in [SampleType::U8, SampleType::U16] { + let mut d = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, depth); + let fmt = d.pixel_format(); + d.layers.push(raster("shape", fmt, photocraft_geom::Rect::new(1, 1, 5, 4), 3, true)); + let r = tiff(&d, true); + let mut img = photocraft_codecs::decode(&r.bytes).expect("decode"); + assert!(img.layout().has_alpha(), "{depth:?}: the composite has a transparency sample"); + let (isd, _) = ImageSourceData::from_bytes(&tags(&r.bytes).1.expect("layer data")).expect("parse"); + for order in [ByteOrder::Little, ByteOrder::Big] { + let case = format!("{depth:?} {order:?}"); + // Photoshop's form of the same layer data. + let mut ps = isd.clone(); + ps.layer_info.as_mut().expect("layers").merged_alpha = false; + ps.global_blocks.insert(0, TaggedBlock::new(*b"MTrn", Vec::new())); + let layers = ps.to_bytes(order).expect("layer data").0; + if order == ByteOrder::Little { + assert!(layers.windows(12).any(|w| w == b"MIB8nrTM\0\0\0\0"), "{case}: stored as Photoshop stores it"); + } + img.meta.photoshop_layers = Some(layers); + let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &photocraft_codecs::EncodeOptions::default()).expect("encode"); + + let opened = import("x.tif", &bytes).expect("import").document; + let names: Vec<&str> = opened.channels.iter().map(|c| c.name.as_str()).collect(); + assert!(names.is_empty(), "{case}: no alpha channel from the merged transparency: {names:?}"); + // The marker is kept for the next save. + let mut want = d.clone(); + want.metadata.psd_global_blocks = vec![(*b"8BIM", *b"MTrn", std::sync::Arc::new(Vec::new()))]; + assert_docs_eq(&want, &opened); + let psd = PsdFile::from_bytes(&export(&opened, "x.psd", &ExportOptions::default()).expect("PSD save").bytes).expect("parse"); + assert_eq!(psd.header.channels, 4, "{case}: colour + merged transparency"); + } + } +} + #[test] fn big_documents_use_the_psb_signature() { // Forcing PSB stands in for a > 30000 px document. diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index fab262732b..740d44f8bf 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -352,12 +352,12 @@ impl PsdFile { } /// `true` if the merged image carries a transparency channel: its first extra channel, when - /// the layer count was stored negative or a "Saving Merged Transparency" global block (`Mtrn`, - /// `Mt16`, `Mt32`) is present. Photoshop marks 32-bit documents with `Mt32` and a positive - /// layer count (psd-tools transparentbg.psd). Photoshop also names the merged transparency - /// "Transparency" and lists it first among the alpha channel names, and some of its 16-bit - /// PSBs carry no other mark (#4654): when the layers sit in a global `Lr16`/`Lr32` block, that - /// name with one name per extra channel marks it too. + /// the layer count was stored negative or a "Saving Merged Transparency" global block (see + /// [`TaggedBlock::is_merged_transparency_marker`]) is present. Photoshop marks 32-bit documents + /// with `Mt32` and a positive layer count (psd-tools transparentbg.psd). Photoshop also names + /// the merged transparency "Transparency" and lists it first among the alpha channel names, + /// and some of its 16-bit PSBs carry no other mark (#4654): when the layers sit in a global + /// `Lr16`/`Lr32` block, that name with one name per extra channel marks it too. pub fn merged_has_alpha(&self) -> bool { let Some(color) = self.header.color_mode.color_channels() else { return false; @@ -365,7 +365,7 @@ impl PsdFile { if self.header.channels <= color { return false; } - let marked = self.global_blocks.iter().any(|b| matches!(&b.key, b"Mtrn" | b"Mt16" | b"Mt32")); + let marked = self.global_blocks.iter().any(TaggedBlock::is_merged_transparency_marker); match &self.layer_info { Some(li) => { let in_block = matches!(self.layer_info_placement, LayerInfoPlacement::GlobalBlock { .. }); @@ -583,4 +583,27 @@ mod write_tests { } } } + + /// A positive layer count plus a merged-transparency marker still makes the first extra + /// channel the transparency, for the specification's keys and for the `MTrn` Photoshop + /// writes in a layered TIFF. + #[test] + fn merged_transparency_markers_mark_the_first_extra_channel() { + let mut file = testgen::small(Version::Psd, Compression::Raw); + if let Some(li) = &mut file.layer_info { + li.merged_alpha = false; + } + assert!(!file.merged_has_alpha()); + for key in [*b"Mtrn", *b"MTrn", *b"Mt16", *b"Mt32"] { + let mut marked = file.clone(); + marked.global_blocks.push(TaggedBlock::new(key, Vec::new())); + assert!(marked.merged_has_alpha(), "{}", String::from_utf8_lossy(&key)); + } + // Other keys, and the marker's letters in another case, do not. + for key in [*b"MTRN", *b"mtrn", *b"Lr16"] { + let mut other = file.clone(); + other.global_blocks.push(TaggedBlock::new(key, Vec::new())); + assert!(!other.merged_has_alpha(), "{}", String::from_utf8_lossy(&key)); + } + } } diff --git a/crates/psd/src/tagged.rs b/crates/psd/src/tagged.rs index 7728d53fd1..b16e158565 100644 --- a/crates/psd/src/tagged.rs +++ b/crates/psd/src/tagged.rs @@ -155,6 +155,14 @@ impl TaggedBlock { Self::is_foreign_order_signature(&self.signature) } + /// `true` for a "Saving Merged Transparency" marker: the merged image's first extra channel + /// is its transparency. The specification's keys are `Mtrn`, `Mt16` and `Mt32`; Photoshop + /// (27.11) writes `MTrn`, with a positive layer count, in a layered TIFF saved with Save + /// Transparency next to spot and alpha channels. + pub fn is_merged_transparency_marker(&self) -> bool { + matches!(&self.key, b"Mtrn" | b"MTrn" | b"Mt16" | b"Mt32") + } + /// Key as a string (lossy). pub fn key_str(&self) -> String { String::from_utf8_lossy(&self.key).into_owned() diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 938a723b69..3e4cca8330 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); earlier: PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-21 · **Change:** patch (Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency; earlier: DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | patch | PSD and layered TIFF: the `MTrn` block Photoshop writes in a layered TIFF saved with Save Transparency (a positive layer count, `nrTM` in an `II` file) marks the merged image's first extra channel as its transparency, like `Mtrn`/`Mt16`/`Mt32`: such a file no longer opens with its transparency also as an alpha channel (named "Transparency" in Photoshop 27.11's file), and a PSD save no longer writes it twice (split from #7415; credit #3121) | | 2026-10-15 | minor | DDS textures save (#1327): uncompressed or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, optional box-filtered mip chain, legacy header; DDS Options in Save As and Save a Copy, `ddsCompression`/`ddsMipmaps` for scripts, the CLI and MCP | | 2026-10-15 | minor | PNG: a stereo PNG's `sTER` pairing (PNG Extensions 1.4.0 §3.6) is read on open and written back on save, so the file still says it is a stereo pair (#6063) | | 2026-10-15 | minor | PSD guides: image resource 1092 (guides v2) gives guides their own colours on import; 1092 and 1097 (guide GUIDs) are written back only while they still describe the document's guides, so an edit no longer leaves Photoshop a stale second guide list (#5966) | From a8e5cb3e8e79bad623f9177ee21614f4dca7a4ad Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 10:36:14 -0400 Subject: [PATCH 073/229] PSD: drop a kept merged-transparency marker when the composite is opaque A marker kept from the opened file (Photoshop's Mtrn/Mt16/Mt32 PSDs, or the MTrn of its layered TIFFs) was written back verbatim. Saved with an opaque composite and an alpha channel, the file has a positive layer count and the marker, so it reopened with that alpha channel taken as the merged transparency, and the channel was gone. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/psd_export.rs | 30 +++++++++++++++++++++++++++++- docs/file-format-parity.md | 2 +- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index f8605331c8..454f1c5dd8 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1785,7 +1785,13 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if !ex.records.is_empty() && matches!(&key, b"Lr16" | b"Lr32" | b"Layr") { continue; } - global_blocks.push(global_block(block_signature(sig, key, version), key, &data)); + let block = global_block(block_signature(sig, key, version), key, &data); + // A kept merged-transparency marker describes the opened file's composite. Over an opaque + // one it would make the first alpha channel read back as the transparency, and vanish. + if !has_alpha && block.is_merged_transparency_marker() { + continue; + } + global_blocks.push(block); } if let Some(txt2) = txt2 { let mut tb = TaggedBlock::new(*b"Txt2", txt2.clone()); @@ -1988,6 +1994,28 @@ mod tests { assert!(matches!(saved.layer_info_placement, LayerInfoPlacement::GlobalBlock { index: 0, .. })); } + /// A merged-transparency marker kept from the opened file describes that file's composite. + /// Saved with an opaque composite, it would make the first alpha channel read back as the + /// merged transparency, and the channel would be gone: the save drops it. + #[test] + fn an_opaque_save_drops_a_kept_merged_transparency_marker() { + for (depth, marker) in [(SampleType::U8, *b"MTrn"), (SampleType::U8, *b"Mtrn"), (SampleType::U16, *b"Mt16"), (SampleType::F32, *b"Mt32")] { + let case = format!("{depth:?} {}", String::from_utf8_lossy(&marker)); + let mut doc = Document::new("t", photocraft_geom::Size::new(8, 6), ColorMode::Rgb, depth); + let mut s = Surface::new(doc.pixel_format()); + s.fill_rect(photocraft_geom::Rect::new(0, 0, 8, 6), &[0.5, 0.2, 0.9, 1.0]); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(s))); + doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", Surface::new(PixelFormat::GRAY8))); + doc.metadata.psd_global_blocks = vec![(*b"8BIM", marker, std::sync::Arc::new(Vec::new()))]; + let file = document_to_psd(&doc); + assert!(!file.global_blocks.iter().any(TaggedBlock::is_merged_transparency_marker), "{case}"); + assert!(!file.merged_has_alpha(), "{case}"); + let back = crate::import("a.psd", &file.to_bytes().unwrap()).unwrap().document; + let names: Vec<&str> = back.channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["Alpha 1"], "{case}"); + } + } + #[test] fn unreadable_lr16_warns_on_import_and_is_not_saved_twice() { // #2700: a 16-bit file whose Lr16 block claims five layers but holds none. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 3e4cca8330..63fef83486 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -142,7 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| -| 2026-10-21 | patch | PSD and layered TIFF: the `MTrn` block Photoshop writes in a layered TIFF saved with Save Transparency (a positive layer count, `nrTM` in an `II` file) marks the merged image's first extra channel as its transparency, like `Mtrn`/`Mt16`/`Mt32`: such a file no longer opens with its transparency also as an alpha channel (named "Transparency" in Photoshop 27.11's file), and a PSD save no longer writes it twice (split from #7415; credit #3121) | +| 2026-10-21 | patch | PSD and layered TIFF: the `MTrn` block Photoshop writes in a layered TIFF saved with Save Transparency (a positive layer count, `nrTM` in an `II` file) marks the merged image's first extra channel as its transparency, like `Mtrn`/`Mt16`/`Mt32`: such a file no longer opens with its transparency also as an alpha channel (named "Transparency" in Photoshop 27.11's file), and a PSD save no longer writes it twice. A PSD save with an opaque composite drops any of these markers kept from the opened file, so its first alpha channel no longer reopens as transparency and vanishes (split from #7415; credit #3121) | | 2026-10-15 | minor | DDS textures save (#1327): uncompressed or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, optional box-filtered mip chain, legacy header; DDS Options in Save As and Save a Copy, `ddsCompression`/`ddsMipmaps` for scripts, the CLI and MCP | | 2026-10-15 | minor | PNG: a stereo PNG's `sTER` pairing (PNG Extensions 1.4.0 §3.6) is read on open and written back on save, so the file still says it is a stereo pair (#6063) | | 2026-10-15 | minor | PSD guides: image resource 1092 (guides v2) gives guides their own colours on import; 1092 and 1097 (guide GUIDs) are written back only while they still describe the document's guides, so an edit no longer leaves Photoshop a stale second guide list (#5966) | From 8c5ec444dbeea93b5f0a2533c18baba71e2626e2 Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:43:02 +0200 Subject: [PATCH 074/229] Brush test: the parallel threshold goes back to its default however the test ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parallel-vs-serial test moved the process-wide threshold and restored a hard-coded 256² only on success. A drop guard now restores it on every exit path, a panic inside a render included, from the renderer's own `DEFAULT_PARALLEL_MIN_PIXELS` instead of a copy of the literal. Co-Authored-By: Claude Opus 5.5 --- crates/paint/src/brush_tests.rs | 10 +++++++++- crates/paint/src/render.rs | 6 +++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 4c90586b78..8edf99350a 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -1641,9 +1641,17 @@ fn large_tips_render_the_same_in_parallel_and_serially() { }) .collect() }; + // The threshold is process-wide: put the default back however the test ends, a panic + // inside a render included. + struct Restore; + impl Drop for Restore { + fn drop(&mut self) { + crate::render::set_parallel_min_pixels(crate::render::DEFAULT_PARALLEL_MIN_PIXELS); + } + } + let _restore = Restore; let parallel = render(0); let serial = render(usize::MAX); - crate::render::set_parallel_min_pixels(256 * 256); for (i, (p, s)) in parallel.iter().zip(&serial).enumerate() { assert_eq!(p.len(), s.len()); let diff = p.iter().zip(s).filter(|(a, b)| a != b).count(); diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 9afd83c6d1..ab38249bd9 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -44,7 +44,11 @@ pub const COV_TILE: i32 = 64; /// it the threads cost more than they save. A large soft tip is tens of megapixels per dab, and /// every stage of it is independent per row or tile, so the pixels come out the same. #[cfg(not(target_arch = "wasm32"))] -static PARALLEL_MIN_PIXELS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(256 * 256); +pub(crate) const DEFAULT_PARALLEL_MIN_PIXELS: usize = 256 * 256; + +/// The threshold in use: [`DEFAULT_PARALLEL_MIN_PIXELS`] (tests move it). +#[cfg(not(target_arch = "wasm32"))] +static PARALLEL_MIN_PIXELS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(DEFAULT_PARALLEL_MIN_PIXELS); /// Whether `pixels` of independent work are worth the thread pool (the web has none: there every /// stage takes its serial path). From 1017babd354f257adcb303c91dfd1084735ec7ba Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 21 Oct 2026 20:27:49 +0200 Subject: [PATCH 075/229] Fix filter-mask copy, named targets and mask-view switching --- crates/engine/src/edit_cmds.rs | 15 +++--- crates/engine/src/lib.rs | 2 +- .../src/smart_cmds/tests/filter_mask_paint.rs | 51 +++++++++++++++++++ crates/ui-egui/src/control.rs | 8 +-- crates/ui-egui/src/lib.rs | 21 ++++++++ crates/ui-egui/src/panels.rs | 5 +- crates/ui-egui/src/smart_ui.rs | 29 +++++++++++ 7 files changed, 114 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index aa2ce627f7..76330c8088 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -304,15 +304,18 @@ fn copy(s: &mut Session, merged: bool) -> Result { Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()]})) } -/// Copy with the layer mask targeted (`"target":"mask"`, #2928): the mask's values as a +/// Copy the targeted layer mask (`"target":"mask"`, #2928) or smart-filter mask +/// (`"target":"filterMask"`): the mask's values as a /// grayscale clip, inside the selection or the whole canvas — an untouched mask copies at its /// default value, so a plain Reveal All / Hide All mask transfers too. Paste then writes into /// another layer's mask (targeted, #1035), in this or another document. -fn copy_mask(s: &mut Session) -> Result { +fn copy_mask(s: &mut Session, filter: bool) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let canvas = d.doc.bounds(); let id = d.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?; - let mask = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?.mask.as_ref().ok_or_else(|| EngineError::Other("the active layer has no layer mask".into()))?; + let layer = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; + let mask = crate::mask_view_cmds::viewed_mask(layer, filter) + .ok_or_else(|| EngineError::Other(if filter { "the active layer has no filter mask" } else { "the active layer has no layer mask" }.into()))?; let sel = d.doc.selection.as_ref(); let area = sel.map_or(canvas, |m| m.content_bounds().intersect(&canvas)); if area.is_empty() { @@ -344,7 +347,7 @@ fn copy_mask(s: &mut Session) -> Result { let b = bounds; let resolution = Some(d.doc.resolution_dpi); s.clipboard = Some(Clip { surface, bounds: b, layers: None, resolution }); - Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()], "target": "mask"})) + Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()], "target": if filter { "filterMask" } else { "mask" }})) } fn clear_selected(doc: &mut Document, id: LayerId, background: [f32; 4]) -> Result<()> { @@ -975,11 +978,11 @@ pub fn specs() -> Vec { "Copy", &["Edit"], Some("Cmd+C"), - r##"{"target":"mask"? = the targeted mask, copied as grayscale (#2928)} (a targeted colour or alpha channel copies that channel, as grayscale)"##, + r##"{"target":"mask|filterMask"? = the targeted mask, copied as grayscale (#2928)} (a targeted colour or alpha channel copies that channel, as grayscale)"##, can_copy, |s, p| { if crate::commands::is_mask_target(p) { - return copy_mask(s); + return copy_mask(s, crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::FilterMask); } match crate::channel_clip::copy(s)? { Some(r) => Ok(r), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 160d40e5d8..984aa8ccda 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -799,7 +799,7 @@ impl Session { let d = self.active_mut()?; d.doc.layer(target)?; let active = d.active_layer.replace(target); - let r = (spec.enabled)(self); + let r = self.precondition(spec, params); if let Some(d) = self.active_mut() { d.active_layer = active; } diff --git a/crates/engine/src/smart_cmds/tests/filter_mask_paint.rs b/crates/engine/src/smart_cmds/tests/filter_mask_paint.rs index b0061e7da9..1db1fc5793 100644 --- a/crates/engine/src/smart_cmds/tests/filter_mask_paint.rs +++ b/crates/engine/src/smart_cmds/tests/filter_mask_paint.rs @@ -20,7 +20,11 @@ fn painting_the_filter_mask_hides_the_filters_there_in_one_undo_step() { let original = flat(&s); convert(&mut s); // Without smart filters there is no filter mask to paint. + let before = s.active().unwrap().doc.clone(); + let steps = s.active().unwrap().history.past_len(); assert!(matches!(stroke(&mut s, Some("filterMask")), Err(EngineError::Disabled(..))), "depth {depth}"); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before)); + assert_eq!(s.active().unwrap().history.past_len(), steps); s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); let blurred = flat(&s); assert!(active_smart(&s).filter_mask.is_none()); @@ -64,7 +68,9 @@ fn a_shown_filter_mask_takes_the_paint() { paint(&mut s); convert(&mut s); s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + let before = s.active().unwrap().channel_view.clone(); assert!(s.execute("view.layerMask", json!({"mode": "gray", "mask": "filter"})).is_err(), "no filter mask yet"); + assert_eq!(s.active().unwrap().channel_view, before); stroke(&mut s, Some("filterMask")).unwrap(); let v = s.execute("view.layerMask", json!({"mode": "gray", "mask": "filter"})).unwrap(); assert_eq!(v["mode"], "gray"); @@ -75,5 +81,50 @@ fn a_shown_filter_mask_takes_the_paint() { let d = s.active().unwrap(); assert!(matches!(d.doc.layer(d.active_layer.unwrap()).unwrap().content, LayerContent::Smart(_)), "still a smart object"); // Bad params are refused, not a panic. + let before = s.active().unwrap().channel_view.clone(); assert!(s.execute("view.layerMask", json!({"mask": 3})).is_err()); + assert_eq!(s.active().unwrap().channel_view, before); +} + +#[test] +fn copying_the_filter_mask_never_copies_the_layer_mask() { + for depth in DEPTHS { + let mut s = session(depth); + paint(&mut s); + convert(&mut s); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + stroke(&mut s, Some("filterMask")).unwrap(); + for layer_mask in [false, true] { + if layer_mask { + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + } + let before = s.active().unwrap().doc.clone(); + let steps = s.active().unwrap().history.past_len(); + let result = s.execute("edit.copy", json!({"target": "filterMask"})).unwrap(); + assert_eq!(result["target"], "filterMask"); + let clip = s.clipboard.as_ref().unwrap(); + assert_eq!(clip.surface.rgba(8, 20), [0.0, 0.0, 0.0, 1.0]); + assert_eq!(clip.surface.rgba(30, 20), [1.0; 4]); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before)); + assert_eq!(s.active().unwrap().history.past_len(), steps); + } + } +} + +#[test] +fn an_explicit_layer_checks_filter_mask_eligibility() { + let mut s = session(8); + paint(&mut s); + let id = convert(&mut s); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + s.execute("shape.create", json!({"kind": "rect", "rect": [1, 1, 20, 20], "fill": "#ff0000"})).unwrap(); + let active = s.active().unwrap().active_layer; + s.execute("paint.cloneStamp", json!({"layer": id, "target": "filterMask", "source": [8, 8], "points": [[20, 20]], "size": 4})).unwrap(); + assert_eq!(s.active().unwrap().active_layer, active); + // A shape has no filter mask: rejection must precede recoloring its fill. + let before = s.active().unwrap().doc.clone(); + let steps = s.active().unwrap().history.past_len(); + assert!(s.execute("edit.fillForeground", json!({"layer": active.unwrap().0, "target": "filterMask"})).is_err()); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before)); + assert_eq!(s.active().unwrap().history.past_len(), steps); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 43b02be1a6..d73c24f080 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -754,12 +754,8 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) app.sync_mask_targets(); } if let Some(m) = filter_mask_target { - app.ui.filter_mask_target = if m { app.session.active().and_then(|st| st.active_layer).map(|id| id.0) } else { None }; - if m { - app.ui.mask_target = false; - app.ui.vector_mask_target = false; - } - app.sync_mask_targets(); + let layer = if m { app.session.active().and_then(|st| st.active_layer).map(|id| id.0) } else { None }; + app.select_filter_mask_target(layer); } // Selection tools' options-bar mode: 0 New, 1 Add, 2 Subtract, 3 Intersect. if let Some(m) = selection_mode { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 376e7d0e0e..040eb1c9d2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2349,6 +2349,27 @@ impl PhotocraftApp { colors_off && !alpha_on } + /// Explicitly targeting a filter mask ends any conflicting mask view. + pub(crate) fn select_filter_mask_target(&mut self, layer: Option) { + if layer.is_some() { + if let Some(st) = self.session.active_mut() { + if st.channel_view.layer_mask.is_some_and(|v| !v.filter || Some(v.layer.0) != layer) { + st.channel_view.layer_mask = None; + } + if matches!( + st.channel_view.target, + photocraft_engine::channel_cmds::ChannelTarget::LayerMask | photocraft_engine::channel_cmds::ChannelTarget::VectorMask + ) { + st.channel_view.target = photocraft_engine::channel_cmds::ChannelTarget::Composite; + } + } + self.ui.mask_target = false; + self.ui.vector_mask_target = false; + } + self.ui.filter_mask_target = layer; + self.sync_mask_targets(); + } + /// Viewing a layer mask (#196) targets it; a vector-mask target needs a vector mask on the /// active layer (a shape layer's path is its content, not a mask). Targeting a mask or the /// pixels brings back that target's foreground/background pair, as in Photoshop (#2166). diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 281a2a0448..e389c5605f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2839,10 +2839,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { continue; } if id == "ui.filterMaskTarget" { - app.ui.filter_mask_target = p.as_u64(); - app.ui.mask_target = false; - app.ui.vector_mask_target = false; - app.sync_mask_targets(); + app.select_filter_mask_target(p.as_u64()); continue; } // Double-clicking a mask thumbnail opens its settings: show Properties. diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index d1776fd7f4..b35bf0bdb7 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -457,6 +457,35 @@ mod tests { assert_eq!(app.ui.filter_mask_target, None, "a layer mask target replaces it"); } + #[test] + fn selecting_the_filter_mask_replaces_a_layer_mask_view() { + let mut app = PhotocraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + app.run("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("view.layerMask", json!({"mode": "gray"})).unwrap(); + app.sync_mask_targets(); + assert!(app.layer_mask_targeted()); + let id = app.session.active().unwrap().active_layer.unwrap(); + app.select_filter_mask_target(Some(id.0)); + app.sync_views(); + assert!(app.filter_mask_targeted()); + assert!(!app.layer_mask_targeted()); + assert!(photocraft_engine::mask_view_cmds::current(app.session.active().unwrap()).is_none()); + app.run("paint.stroke", json!({"points": [[8, 8]], "size": 6, "hardness": 1, "color": "#000000"})).unwrap(); + let st = app.session.active().unwrap(); + let layer = st.doc.layer(id).unwrap(); + assert_eq!(layer.mask.as_ref().unwrap().value(8, 8), 1.0); + let LayerContent::Smart(sm) = &layer.content else { panic!("smart object") }; + assert_eq!(sm.filter_mask.as_ref().unwrap().value(8, 8), 0.0); + app.run("view.layerMask", json!({"mode": "gray", "mask": "filter"})).unwrap(); + app.select_filter_mask_target(Some(id.0)); + assert!(photocraft_engine::mask_view_cmds::current(app.session.active().unwrap()).unwrap().filter, "a matching filter-mask view stays open"); + } + /// A pixel layer, a shape layer and a smart object, in that order. fn layers() -> [Layer; 3] { let mut s = photocraft_engine::Session::new(); From 9484e9e412d90cff79533fb6b99952677e305598 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 21 Oct 2026 20:27:51 +0200 Subject: [PATCH 076/229] Keep one clone source snapshot across Vanishing Point preview batches --- crates/algo/src/vanishing.rs | 47 +++++++++++++++++++++++++++++++++--- crates/engine/src/vp_cmds.rs | 10 +++++++- crates/ui-egui/src/vp_ui.rs | 44 ++++++++++++++++++++++++++++++--- 3 files changed, 92 insertions(+), 9 deletions(-) diff --git a/crates/algo/src/vanishing.rs b/crates/algo/src/vanishing.rs index 2ac85badb4..461c43a881 100644 --- a/crates/algo/src/vanishing.rs +++ b/crates/algo/src/vanishing.rs @@ -382,8 +382,9 @@ pub fn clone_source(scene: &Scene, source: [f64; 2], anchor: [f64; 2], p: [f64; } /// [`clone_stroke`]'s dabs at `points` for a stroke anchored at `anchor` (its first dab, which -/// fixes the offset and the brush's metric size), so a stroke can be painted in pieces exactly -/// as one. Returns the dabs painted and the area they changed. +/// fixes the offset and the brush's metric size). Samples a snapshot taken at this call. +/// For incremental painting use [`clone_dabs_from`] with one snapshot per stroke. +/// Returns the dabs painted and the area they changed. #[allow(clippy::too_many_arguments)] pub fn clone_dabs( scene: &Scene, @@ -395,9 +396,29 @@ pub fn clone_dabs( hardness: f64, opacity: f32, ) -> (usize, Rect) { + let src_copy = surf.clone(); + clone_dabs_from(scene, surf, &src_copy, source, anchor, points, size, hardness, opacity) +} + +/// Paint a batch of one stroke using the same immutable source snapshot for every batch. +/// Source and destination must have the same pixel format; otherwise no dabs are painted. +#[allow(clippy::too_many_arguments)] +pub fn clone_dabs_from( + scene: &Scene, + surf: &mut Surface, + src_copy: &Surface, + source: [f64; 2], + anchor: [f64; 2], + points: &[[f64; 2]], + size: f64, + hardness: f64, + opacity: f32, +) -> (usize, Rect) { + if surf.format() != src_copy.format() { + return (0, Rect::EMPTY); + } let Some(f) = CloneFrame::new(scene, source, anchor, size) else { return (0, Rect::EMPTY) }; let (lud, lvd) = f.ld; - let src_copy = surf.clone(); let fmt = surf.format(); let n = fmt.channels(); let mut dabs = 0; @@ -434,7 +455,7 @@ pub fn clone_dabs( let k = (fall * fall * (3.0 - 2.0 * fall)) as f32 * opacity; // Source pixel at the same metric offset. let (sx, sy) = f.hs.apply((m[0] + f.offset[0]) / f.ls.0, (m[1] + f.offset[1]) / f.ls.1); - let s = sample(&src_copy, sx - 0.5, sy - 0.5, n); + let s = sample(src_copy, sx - 0.5, sy - 0.5, n); for c in 0..n { px[c] += (s[c] - px[c]) * k; } @@ -555,4 +576,22 @@ mod tests { let back = clone_source(&sc, [sx, sy], [dx, dy], [dx, dy]).unwrap(); assert!((back[0] - sx).abs() < 1e-6 && (back[1] - sy).abs() < 1e-6, "{back:?}"); } + #[test] + fn incremental_clone_uses_one_source_snapshot_per_stroke() { + let sc = Scene::new(VpPlane { corners: [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]] }, [50.0, 50.0], None, 800.0); + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut original = Surface::new(fmt); + for x in 0..100 { + original.fill_rect(Rect::new(x, 0, x + 1, 100), &[x as f32 / 100.0, (x % 7) as f32 / 6.0, 0.3, 1.0]); + } + let points = [[40.0, 50.0], [44.0, 50.0], [48.0, 50.0], [52.0, 50.0]]; + let mut whole = original.clone(); + assert_eq!(clone_stroke(&sc, &mut whole, [36.0, 50.0], &points, 12.0, 0.8, 1.0), 4); + let mut pieces = original.clone(); + for part in points.chunks(2) { + clone_dabs_from(&sc, &mut pieces, &original, [36.0, 50.0], points[0], part, 12.0, 0.8, 1.0); + } + assert_eq!(whole.read_region(Rect::new(0, 0, 100, 100)), pieces.read_region(Rect::new(0, 0, 100, 100))); + } + } } diff --git a/crates/engine/src/vp_cmds.rs b/crates/engine/src/vp_cmds.rs index e1e342a10a..ee9a4c6d7f 100644 --- a/crates/engine/src/vp_cmds.rs +++ b/crates/engine/src/vp_cmds.rs @@ -361,9 +361,13 @@ mod tests { s.execute("file.new", json!({"width": 200, "height": 200, "depth": 32})).unwrap(); s.execute("layer.new.layer", json!({})).unwrap(); let floor = json!({"corners": [[60, 60], [140, 60], [190, 190], [10, 190]]}); + let before = s.active().unwrap().doc.clone(); + let steps = s.active().unwrap().history.past_len(); for torn in [json!({"from": 0, "angle": 1e9}), json!({"from": 0, "depth": -1}), json!({"from": 0, "depth": 1e300})] { let e = s.execute(VP, json!({"planes": [floor, torn]})).unwrap_err().to_string(); assert!(e.contains("plane 1"), "{e}"); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before)); + assert_eq!(s.active().unwrap().history.past_len(), steps); } let many: Vec<[f64; 2]> = vec![[100.0, 150.0]; MAX_CLONE_DABS + 1]; for clone in [ @@ -374,10 +378,14 @@ mod tests { json!({"source": [100, 180]}), ] { let label: String = clone.to_string().chars().take(80).collect(); - assert!(s.execute(VP, json!({"planes": [floor], "clone": clone})).is_err(), "{label}"); + assert!(s.execute(VP, json!({"planes": [floor], "clone": clone, "newLayer": true})).is_err(), "{label}"); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before)); + assert_eq!(s.active().unwrap().history.past_len(), steps); } let planes: Vec = std::iter::repeat_n(floor.clone(), MAX_PLANES + 1).collect(); assert!(s.execute(VP, json!({"planes": planes})).is_err()); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before)); + assert_eq!(s.active().unwrap().history.past_len(), steps); // A clone on a torn-off wall, at 32 bits, sampling the floor. let r = s .execute(VP, json!({"planes": [floor, {"from": 0, "edge": "top", "angle": 90, "depth": 0.5}], "clone": [{"source": [100, 150], "points": [[100, 50], [104, 50]], "size": 8}]})) diff --git a/crates/ui-egui/src/vp_ui.rs b/crates/ui-egui/src/vp_ui.rs index c3dc645dd9..418b9516e0 100644 --- a/crates/ui-egui/src/vp_ui.rs +++ b/crates/ui-egui/src/vp_ui.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use egui::{Color32, Pos2, Stroke, TextureHandle, pos2}; use photocraft_algo::transform::Homography; -use photocraft_algo::vanishing::{Edge, PlaneCheck, Scene, VpPlane, clone_dabs, clone_source}; +use photocraft_algo::vanishing::{Edge, PlaneCheck, Scene, VpPlane, clone_dabs_from, clone_source}; use photocraft_doc::{DocId, Document, Layer, LayerContent, LayerId}; use photocraft_engine::vp_cmds::{CloneSpec, MAX_CLONE_DABS, MAX_CLONE_STROKES, VP, copy_changed, visible_image}; use photocraft_geom::Rect; @@ -97,6 +97,8 @@ struct StampPreview { new_layer: bool, /// On a new layer: the visible image the strokes sample, with the strokes so far. work: Option, + /// The sampled image before the current stroke, retained across frame batches. + source: Option<(usize, Surface)>, /// The target layer's pixels as painted so far. out: Surface, /// Strokes painted: those before `.0`, and the first `.1` points of stroke `.0`. @@ -497,7 +499,8 @@ impl VpSession { // The old preview's area changes back too. let keys = self.preview.take().map(|p| p.keys).unwrap_or_default(); area = keys.iter().fold(Rect::EMPTY, |a, k| union(a, k.1)); - self.preview = Some(StampPreview { rev: self.rev, new_layer: self.new_layer, work, out, done: (0, 0), keys, shown: self.base.clone() }); + self.preview = + Some(StampPreview { rev: self.rev, new_layer: self.new_layer, work, source: None, out, done: (0, 0), keys, shown: self.base.clone() }); } let Some(pv) = self.preview.as_mut() else { return false }; let (from_stroke, from_point) = pv.done; @@ -507,15 +510,19 @@ impl VpSession { if points.is_empty() { continue; } + if pv.source.as_ref().is_none_or(|(stroke, _)| *stroke != si) { + pv.source = Some((si, pv.work.as_ref().unwrap_or(&pv.out).clone())); + } + let Some((_, source)) = pv.source.as_ref() else { continue }; let (hard, opacity) = (c.hardness, c.opacity as f32); let changed = match pv.work.as_mut() { Some(work) => { let before = work.clone(); - let (_, a) = clone_dabs(&sc, work, c.source, *anchor, points, c.size, hard, opacity); + let (_, a) = clone_dabs_from(&sc, work, source, c.source, *anchor, points, c.size, hard, opacity); copy_changed(&mut pv.out, &before, work, a); a } - None => clone_dabs(&sc, &mut pv.out, c.source, *anchor, points, c.size, hard, opacity).1, + None => clone_dabs_from(&sc, &mut pv.out, source, c.source, *anchor, points, c.size, hard, opacity).1, }; area = union(area, changed); } @@ -1329,4 +1336,33 @@ mod tests { assert!(s.torn[1].is_some()); assert!(crate::distort_ui::display_doc(&app, 0).is_some(), "the stroke shows again"); } + #[test] + fn overlapping_stamp_preview_matches_commit_across_batches() { + for new_layer in [false, true] { + let (ctx, mut app) = app_with_layer(); + app.session + .edit("pattern", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + for x in 0..240 { + surf.fill_rect(Rect::new(x, 0, x + 1, 160), &[x as f32 / 240.0, (x % 7) as f32 / 6.0, 0.3, 1.0]); + } + Ok(()) + }) + .unwrap(); + crate::distort_ui::menu(&mut app, &ctx, VP, &json!({})).unwrap().unwrap(); + control(&mut app, &json!({"planes": [{"corners": [[10, 10], [230, 10], [230, 150], [10, 150]]}]})).unwrap(); + let points = [[80.0, 80.0], [84.0, 80.0], [88.0, 80.0], [92.0, 80.0]]; + let s = app.distort.vanishing.as_mut().unwrap(); + s.new_layer = new_layer; + s.strokes.push(CloneSpec { source: [76.0, 80.0], points: points[..2].to_vec(), size: 12.0, hardness: 0.8, opacity: 1.0 }); + assert!(s.paint_preview(|| 1)); + s.strokes[0].points.extend_from_slice(&points[2..]); + assert!(s.paint_preview(|| 2)); + let (shown, _) = s.shown().unwrap(); + let expected = photocraft_compose::flatten(&shown).px; + assert_ne!(expected, photocraft_compose::flatten(&app.session.active().unwrap().doc).px); + commit(&mut app).unwrap(); + assert_eq!(photocraft_compose::flatten(&app.session.active().unwrap().doc).px, expected, "newLayer={new_layer}"); + } + } } From ffc05c3760455c3aecfc8a490057f3df653de77e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 21 Oct 2026 20:30:17 +0200 Subject: [PATCH 077/229] Regenerate filter-mask scorecard after merging main --- docs/scorecard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 5a59aad2a4..a04ae80f04 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 26 | 60 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 22 not run, 4 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 9 | 12 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | +| [File compatibility](#file-compatibility) | 10 | 12 | 5 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 36 | 8 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 7 | 3 | settings that do nothing: 24 of 171 (target 0) | | [Type](#type) | 5 | 3 | 1 | | @@ -104,7 +104,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t |---|---|---|---|---| | FILE-215-1 | Smart Objects export as embedded Smart Objects (SoLd + lnk2), not flat pixels | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd_export.rs smart_blocks: PlLd + SoLd, source in lnk2 (.pcraft -> PSB); engine tests/smart_psd.rs; Photoshop 2026 opens 8/16-bit exports as live smart objects. Linked files are embedded | | FILE-215-2 | Smart Filters survive PSD save and re-open (filterFX descriptor read and written) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_map.rs: 41 filters plus Hue/Saturation, Perspective Warp, Filter Gallery (Chrome) and a verified Camera Raw subset mapped both ways, others kept verbatim, mask in FEid; io tests/smart_corpus.rs. PhotoCraft-only filters (Twirl, Liquify…) and unmapped Camera Raw settings are left out of filterFX with a warning | -| FILE-215-3 | Filter mask can be painted directly, with undo | missing | [#215](https://github.com/storytold/photocraft/issues/215) | smart_cmds.rs:382 mask only from the selection | +| FILE-215-3 | Filter mask can be painted directly, with undo | done | [#215](https://github.com/storytold/photocraft/issues/215) | Clicking the filter mask thumbnail on the Smart Filters row targets it (ui.filterMaskTarget, smart_ui.rs). Painting, fills, filters and adjustments with "target":"filterMask" then edit it (a white mask starts on the first stroke) and re-render the smart object, one undo step per stroke (channel_cmds Target::FilterMask, Session::edit). ⌥-click shows it (view.layerMask "mask":"filter"), ⇧-click disables it. Kept through .pcraft and PSD FEid (smart_cmds/tests/filter_mask_paint.rs) | | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | | FILE-215-6 | Smart Object corner skew and perspective (quad or homography transform) | done | [#215](https://github.com/storytold/photocraft/issues/215) | #426: SmartObject.perspective holds the projective map (transform keeps its affine approximation); Distort / Perspective on a smart object set it (transform_cmds.rs, smart_cmds::set_placement), it re-renders from the source through the homography, Warp composes with it, and the PSD placement reads and writes nonAffineTransform / Trnf (io/blocks.rs placed_projective, smart_map.rs); tests distort_survives_edit_contents, warp_on_a_distorted_smart_object_follows_the_corners, placement survives the save | From 07cfd9b219f0ebd41d5781d6d7d6e019f7573f33 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 21 Oct 2026 20:34:12 +0200 Subject: [PATCH 078/229] Regenerate Vanishing Point scorecard after merging main --- docs/scorecard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 5a59aad2a4..c8ee0c28aa 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 26 | 60 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 22 not run, 4 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 9 | 12 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | -| [Tools](#tools) | 36 | 8 | 0 | | +| [Tools](#tools) | 37 | 7 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 7 | 3 | settings that do nothing: 24 of 171 (target 0) | | [Type](#type) | 5 | 3 | 1 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | @@ -162,7 +162,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | done | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool (Background Eraser, Clone/Pattern Stamp, Healing/Spot Healing Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Mixer Brush: their paint.* command along the path, one step) plus simulatePressure (taper both ends); Stroke Path dialog from the Pen menu, the Paths panel menu and Alt-click on its Stroke button (#2978) | | TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | done | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256); shape Density and Feather (Properties, layer.vectorMask.edit on a shape layer; the path as the fill's soft vector mask, Photoshop's 10 px feather ramp within 1 level; PSD mask parameters and .pcraft, #5846, 2026-10-15) | | TOOL-214-13 | Mask Properties: Density and Feather controls | done | [#214](https://github.com/storytold/photocraft/issues/214) | Properties panel shows Density (%) and Feather (px) sliders for a targeted pixel mask (layer.layerMask.edit) and a targeted vector mask (layer.vectorMask.edit), non-destructive and one undo step per drag, with Photoshop's Refine row (mask_props_ui.rs; mask_props_ui_tests.rs pixel_mask_properties_follow_target_for_raster_and_adjustment_in_both_themes, mask_slider_drags_preview_live_and_undo_as_separate_gestures, vector_mask_properties_edit_density_and_feather) | -| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing | +| TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | done | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas window (vp_ui.rs: Create/Edit Plane, edge nodes, Ctrl/Cmd-drag tear-off planes with depth and Alt-drag angle, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane, Stamp tool cloning in perspective with live preview, all applied on OK as one step); the window's Marquee tool and the Stamp's Heal modes are still missing | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | | TOOL-214-16 | Liquify: redo (Cmd+Shift+Z) | done | [#214](https://github.com/storytold/photocraft/issues/214) | liquify_ui.rs supports bounded redo through Cmd+Shift+Z and the control channel; new strokes clear redo | | TOOL-213-17 | Red Eye tool: click a pupil to neutralize the red reflection | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.redEye (redeye_cmds.rs) + Tool::RedEye in the J flyout; Pupil Size and Darken Amount; catchlights kept; a miss is an error | From da58af974a405d52db31d09ac72b6399e55e143d Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 11:19:55 -0400 Subject: [PATCH 079/229] io: port TIFF alpha and spot channels from #7415 Flat and layered TIFFs write and read alpha channels, spot channels and a Quick Mask as extra samples, with Photoshop's channel resources. This is the io part of #7415 as it stands, on current main; the next commit keeps PSD output as it is on main. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Zer0C00L01 <12090083+Zer0C00L01@users.noreply.github.com> --- Cargo.lock | 1 + book/src/formats/raster-formats.md | 2 +- crates/io/Cargo.toml | 1 + crates/io/src/channel_map.rs | 93 ++++- crates/io/src/flat.rs | 4 + crates/io/src/pixels.rs | 27 ++ crates/io/src/psd_export.rs | 23 +- crates/io/src/psd_import.rs | 28 +- crates/io/src/tiff_layers.rs | 632 ++++++++++++++++++++++++++++- crates/io/tests/tiff_layers.rs | 606 ++++++++++++++++++++++++++- crates/psd/src/file.rs | 8 + crates/psd/tests/roundtrip.rs | 1 + docs/file-format-parity.md | 5 +- docs/gaps.md | 8 +- docs/target-app-parity.md | 5 +- scorecard/file_compat.toml | 8 +- 16 files changed, 1396 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b0ff196c55..6743a397de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4924,6 +4924,7 @@ dependencies = [ "roxmltree 0.20.0", "serde_json", "thiserror 2.0.21", + "tiff", ] [[package]] diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index c333cbc737..2173feccad 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -4,7 +4,7 @@ ## Layered TIFF -A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: a TIFF holds one transparency sample, so extra alpha channels are not written; Lab documents are saved flat. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`. +A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: up to sixteen extra samples (spot colours and alpha channels, plus a Quick Mask) are written in the IFD and round-trip, with Photoshop image resources for the channel name, ink colour and solidity; Lab documents are saved flat, with their extra samples. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`. ## WebP diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index eb8c0b00d7..37ac195d7c 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -55,3 +55,4 @@ flate2 = { workspace = true } photocraft-psd = { workspace = true, features = ["testgen"] } photocraft-raw = { workspace = true, features = ["testgen"] } proptest = { workspace = true } +tiff = { version = "0.11", default-features = false, features = ["deflate", "lzw"] } diff --git a/crates/io/src/channel_map.rs b/crates/io/src/channel_map.rs index bafde26f30..60b671101a 100644 --- a/crates/io/src/channel_map.rs +++ b/crates/io/src/channel_map.rs @@ -1,10 +1,12 @@ //! Alpha / spot channel options <-> PSD `DisplayInfo` image resources (1077, and the older 1007). //! -//! Layout (Adobe PSD spec, "DisplayInfo"): resource 1077 starts with a 4-byte version (1), then -//! one 13-byte record per extra channel: colour space (u16), four u16 colour components, opacity -//! (u16, 0..100) and kind (u8: 0 = colour indicates selected areas, 1 = masked ("protected") -//! areas, 2 = spot channel). Resource 1007 has the same records without the version and the spot -//! kind, each followed by a padding byte (14 bytes). +//! Layout (Adobe PSD spec, "DisplayInfo", and a Photoshop 27 layered TIFF): resource 1077 +//! starts with a 4-byte version (1), then one record per extra channel: colour space (u16), +//! four u16 colour components, opacity (u16, 0..100) and kind (u8: 0 = colour indicates +//! selected areas, 1 = masked ("protected") areas, 2 = spot channel). Photoshop writes that +//! 13-byte record with no pad. Older files pad each record to 14 bytes; both are read. +//! Resource 1007 has the same records without the version, each followed by a padding byte. +//! When the merged image has transparency, Photoshop writes that channel's record first. use photocraft_color::{Color, ColorMode}; use photocraft_doc::{AlphaChannel, ColorIndicates}; @@ -13,6 +15,8 @@ pub(crate) const DISPLAY_INFO: u16 = 1077; pub(crate) const DISPLAY_INFO_OLD: u16 = 1007; /// Quick Mask information: channel id (u16) and an "initially empty" flag (u8). pub(crate) const QUICK_MASK_INFO: u16 = 1022; +/// Alpha channel identifiers (Photoshop 6): one big-endian u32 per extra channel, no count prefix. +pub(crate) const ALPHA_IDENTIFIERS: u16 = 1053; const SPACE_RGB: u16 = 0; const SPACE_HSB: u16 = 1; @@ -20,6 +24,23 @@ const SPACE_CMYK: u16 = 2; const SPACE_LAB: u16 = 7; const SPACE_GRAY: u16 = 8; +/// Resource 1053: channel ids `1..=count`, one big-endian u32 each, with no length prefix. +/// Photoshop, ag-psd and psd-tools read u32s until the resource ends. +pub(crate) fn alpha_identifiers(count: usize) -> Vec { + alpha_identifiers_from(1, count) +} + +/// Resource 1053 starting at `first` (a layered TIFF uses the sample's index in the IFD: +/// 3 for the spot after RGB, not 1). +pub(crate) fn alpha_identifiers_from(first: u32, count: usize) -> Vec { + let mut out = Vec::with_capacity(count.saturating_mul(4)); + for i in 0..count { + let id = first.saturating_add(u32::try_from(i).unwrap_or(u32::MAX)); + out.extend_from_slice(&id.to_be_bytes()); + } + out +} + /// Resource 1077 for `channels` (in file order): a version, then 13 bytes per channel (colour /// space, four components, opacity, kind). Every corpus file, Photoshop's included, uses 13; /// 14-byte records (the older 1007 layout) shift every channel after the first. @@ -46,12 +67,41 @@ pub(crate) fn display_info(channels: &[&AlphaChannel], transparency: bool) -> Ve out } -/// Applies a DisplayInfo resource (`versioned` = 1077: 13-byte records after a version; else the -/// older 1007 with 14-byte records) to the imported channels in order, after skipping `skip` -/// records (the merged transparency channel's). +/// 13 when every record is Photoshop's unpadded form, otherwise the 14-byte padded form. +/// +/// Lengths divisible by both (14, 28, … unpadded records, or 13, 26, … padded ones) take +/// the unpadded stride for resource 1077, which is what Photoshop 27 writes, and the padded +/// stride for the older 1007 resource, whose record includes the pad byte. +fn display_stride(body: &[u8], versioned: bool) -> usize { + let unpadded = !body.is_empty() && body.len().is_multiple_of(13); + let padded = !body.is_empty() && body.len().is_multiple_of(14); + if unpadded && (!padded || versioned) { 13 } else { 14 } +} + +/// Which of the first `count` DisplayInfo records are spot channels (kind 2). +/// Missing records are not spots. Alpha channels (kind 0 or 1) stay false: their samples +/// are stored straight, while a spot's samples are stored inverted. +pub(crate) fn spot_flags(data: &[u8], versioned: bool, count: usize) -> Vec { + let body = if versioned { data.get(4..).unwrap_or(&[]) } else { data }; + let stride = display_stride(body, versioned); + let mut out = vec![false; count]; + for (i, rec) in body.chunks(stride).take(count).enumerate() { + if rec.len() >= 13 { + out[i] = rec[12] == 2; + } + } + out +} + +/// Applies a DisplayInfo resource (`versioned` = 1077: records after a version; else the older +/// 1007) to the imported channels in order, after skipping `skip` records (the merged +/// transparency channel's). Both the 13-byte records Photoshop writes and the 14-byte padded +/// form are read. pub(crate) fn apply_display_info(data: &[u8], versioned: bool, skip: usize, channels: &mut [AlphaChannel]) { - let (body, size) = if versioned { (data.get(4..).unwrap_or(&[]), 13) } else { (data, 14) }; - for (c, rec) in channels.iter_mut().zip(body.chunks_exact(size).skip(skip)) { + let body = if versioned { data.get(4..).unwrap_or(&[]) } else { data }; + let stride = display_stride(body, versioned); + for (c, rec) in channels.iter_mut().zip(body.chunks(stride).skip(skip)) { + let Some(rec) = rec.get(..13) else { break }; let u = |i: usize| u16::from_be_bytes([rec[i], rec[i + 1]]); let comps = [u(2), u(4), u(6), u(8)]; let color = decode_color(u(0), comps); @@ -149,6 +199,14 @@ mod tests { assert!((back[0].opacity - 0.3).abs() < 1e-6); assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas); assert_eq!(back[1].spot, s.spot); + assert_eq!(alpha_identifiers(2), [0, 0, 0, 1, 0, 0, 0, 2]); + assert!(alpha_identifiers(0).is_empty()); + assert_eq!(alpha_identifiers_from(3, 1), [0, 0, 0, 3]); + // Photoshop 27 writes a 13-byte record (no pad): red spot, solidity 60. + let ps = [0, 0, 0, 1, 0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 0x3c, 2]; + let mut spot = vec![AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8))]; + apply_display_info(&ps, true, 0, &mut spot); + assert_eq!(spot[0].spot.map(|(c, s)| (c.to_rgb(), (s * 100.0).round() as u16)), Some(([1.0, 0.0, 0.0], 60))); // With the merged transparency's record first, the channels still get their own. let with_t = display_info(&[&a, &s], true); assert_eq!(with_t.len(), 4 + 3 * 13); @@ -160,6 +218,21 @@ mod tests { let mut c = vec![AlphaChannel::new("x", Surface::new(PixelFormat::GRAY8))]; apply_display_info(&bytes[..10], true, 0, &mut c); assert_eq!(c[0].opacity, 0.5); + + // 14 unpadded records are a multiple of 14 as well (14×13 = 13×14). Resource 1077 + // still reads them as 13-byte records; the last spot's solidity must survive. + let mut many = Vec::new(); + for i in 0..14 { + let mut ch = AlphaChannel::new(format!("{i}"), Surface::new(PixelFormat::GRAY8)); + ch.spot = Some((Color::rgb(1.0, 0.0, 0.0), (i as f32) / 14.0)); + many.push(ch); + } + let refs: Vec<&AlphaChannel> = many.iter().collect(); + let packed = display_info(&refs, false); + assert_eq!(packed.len(), 4 + 14 * 13); + let mut got: Vec = (0..14).map(|i| AlphaChannel::new(format!("{i}"), Surface::new(PixelFormat::GRAY8))).collect(); + apply_display_info(&packed, true, 0, &mut got); + assert_eq!(got[13].spot.map(|(_, s)| (s * 100.0).round() as u16), many[13].spot.map(|(_, s)| (s * 100.0).round() as u16)); } #[test] diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index c9e3a410ae..8139a36a4c 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -237,6 +237,7 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result SampleType { } } +/// Complements every sample in `bytes`. Photoshop stores a spot channel as ink density +/// inverted: 0 is solid ink and the maximum sample is bare paper (the same convention as a +/// CMYK plate and as Multichannel). An alpha channel is not stored this way: 0 is empty and +/// the maximum sample is selected. `big_endian` is the PSD merged-image order; TIFF extra +/// samples are native-endian. +pub fn invert_plane(bytes: &mut [u8], s: SampleType, big_endian: bool) { + let bps = s.bytes(); + if bps == 0 { + return; + } + for c in bytes.chunks_exact_mut(bps) { + match s { + SampleType::U8 => c[0] = 255 - c[0], + SampleType::U16 => { + let v = if big_endian { u16::from_be_bytes([c[0], c[1]]) } else { u16::from_ne_bytes([c[0], c[1]]) }; + let w = 65535 - v; + c.copy_from_slice(&(if big_endian { w.to_be_bytes() } else { w.to_ne_bytes() })); + } + SampleType::F32 => { + let v = if big_endian { f32::from_be_bytes([c[0], c[1], c[2], c[3]]) } else { f32::from_ne_bytes([c[0], c[1], c[2], c[3]]) }; + let w = 1.0 - v; + c.copy_from_slice(&(if big_endian { w.to_be_bytes() } else { w.to_ne_bytes() })); + } + } + } +} + fn invert_sample(bytes: &mut [u8], s: SampleType) { match s { SampleType::U8 => bytes[0] = 255 - bytes[0], diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 454f1c5dd8..81d3ebe5a4 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -484,6 +484,8 @@ impl Ex { } flags.set_hidden(!l.visible); flags.set_transparency_protected(l.locks.transparency); + // Bit 3 (with bit 4 clear) is the flag Photoshop sets on every layer it writes. + flags.0 |= LayerFlags::BIT4_USEFUL; let mut blocks = vec![TaggedBlock::unicode_name(&l.name), TaggedBlock::layer_id(id)]; let lspf = blocks::lspf_from_locks(&l.locks); if lspf != 0 { @@ -1075,6 +1077,8 @@ impl Ex { match &l.content { LayerContent::Group(g) => { let id = self.fresh_id(); + let mut divider_flags = LayerFlags(0); + divider_flags.0 |= LayerFlags::BIT4_USEFUL; self.records.push(LayerRecord { channels: self.empty_channels(), blending_ranges: BlendingRanges::full(self.cc), @@ -1084,6 +1088,7 @@ impl Ex { TaggedBlock::layer_id(id), TaggedBlock::section_divider(SectionType::BoundingDivider, None, None), ], + flags: divider_flags, ..Default::default() }); self.emit(&g.children, tick); @@ -1205,9 +1210,12 @@ pub(crate) fn pascal_names_resource(names: &[&str]) -> Vec { let mut v = Vec::new(); for n in names { let b = legacy_name(n); - v.push(b.len() as u8); - v.extend_from_slice(&b); + let n = b.len().min(255); + v.push(n as u8); + v.extend_from_slice(&b[..n]); } + // No per-string pad: Photoshop's resource 1006 for "Spot Color 1" is 13 bytes + // (length byte + 12 characters). The 8BIM container pads an odd resource separately. v } @@ -1703,7 +1711,9 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c for a in &extra { let s = if a.surface.format() != ex.mask_fmt { a.surface.convert(ex.mask_fmt) } else { a.surface.clone() }; let bytes = s.to_interleaved(canvas); - planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0)); + // Spot ink is stored inverted (0 = solid, max = paper), the same way Multichannel + // and a Photoshop TIFF do. An alpha channel stays straight (max = selected). + planes.extend(deinterleave(&bytes, 1, sample, &[a.spot.is_some()]).remove(0)); } let channels = (cc + usize::from(has_alpha) + extra.len()) as u16; let mode = if duotone_inks.is_some() { PsdMode::Duotone } else { psd_mode(fmt.mode) }; @@ -1734,6 +1744,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c resources.push(ImageResource::new(1006, pascal_names_resource(&names))); resources.push(ImageResource::new(1045, unicode_names_resource(&names))); resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&extra, has_alpha))); + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(extra.len()))); } if let Some(id) = quick_mask_id { let mut data = id.to_be_bytes().to_vec(); @@ -1816,6 +1827,10 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if !keep_imported_resource(doc, *id, data) { continue; } + // A fresh 1053 was written with the extra channels. A preserved copy would be a second one. + if *id == crate::channel_map::ALPHA_IDENTIFIERS && !extra.is_empty() { + continue; + } let mut r = ImageResource::new(*id, data.to_vec()); r.name = legacy_name(name); resources.push(r); @@ -1866,7 +1881,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c resources, layer_info, layer_info_placement: placement, - global_layer_mask: (has_layers || !global_blocks.is_empty()).then(GlobalLayerMask::default), + global_layer_mask: (has_layers || !global_blocks.is_empty()).then(GlobalLayerMask::photoshop_default), global_blocks, layer_mask_trailing: Vec::new(), image_data, diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 0eca6d704f..82f998143b 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -603,7 +603,7 @@ fn be_to_ne_plane(plane: &[u8], s: SampleType, n: usize) -> Vec { interleave(&[Some(plane)], &[zero_sample(s)], n, s, &[false]) } -fn unicode_names(data: &[u8]) -> Vec { +pub(crate) fn unicode_names(data: &[u8]) -> Vec { let mut out = Vec::new(); let mut at = 0; while at + 4 <= data.len() { @@ -832,6 +832,10 @@ pub(crate) fn psd_to_document_decoded( doc.clipping_path = Some(photocraft_doc::ClippingPath { name, flatness: 0.0 }); doc.metadata.psd_resources.push((r.id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))); } + // Resource 1053 is rewritten from the extra channels on a layered save, so keeping + // the copy just written would show up as new metadata. Multichannel has no such + // rewrite: the imported bytes are the channel ids, copied back when they still match. + crate::channel_map::ALPHA_IDENTIFIERS if !multichannel => {} id if MAPPED_RESOURCES.contains(&id) => {} id => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))), } @@ -1048,13 +1052,31 @@ pub(crate) fn psd_to_document_decoded( { let first = cc + usize::from(file.merged_has_alpha()); let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default(); + let extra_n = usize::from(h.channels).saturating_sub(first); // Photoshop lists every extra channel, the merged transparency first when there is one. let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(h.channels).saturating_sub(cc)); - let names = names.get(skip..).unwrap_or_default(); + let names = names.get(skip..).unwrap_or(&[]); + // Spot planes are ink-inverted in the file (0 = solid). Alpha planes are not. + // DisplayInfo lists the transparency record first when `skip` is 1. + let spots = { + let n = extra_n.saturating_add(skip); + let all = if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) { + crate::channel_map::spot_flags(&r.data, true, n) + } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { + crate::channel_map::spot_flags(&r.data, false, n) + } else { + vec![false; n] + }; + all.into_iter().skip(skip).collect::>() + }; for (k, idx) in (first..usize::from(h.channels)).enumerate() { let Some(p) = merged_plane(all, idx, plane) else { break }; + let mut raw = be_to_ne_plane(p, depth, n); + if spots.get(k).copied().unwrap_or(false) { + crate::pixels::invert_plane(&mut raw, depth, false); + } let mut s = Surface::new(cx.mask_fmt); - s.write_interleaved(canvas, &be_to_ne_plane(p, depth, n)); + s.write_interleaved(canvas, &raw); s.prune(); let name = names.get(k).cloned().unwrap_or_else(|| format!("Alpha {}", k + 1)); doc.channels.push(AlphaChannel::new(name, s)); diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 91011c6eb1..826c1557f7 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -11,11 +11,13 @@ use std::sync::Arc; use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType as CSample}; -use photocraft_color::ColorMode; -use photocraft_doc::Document; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{AlphaChannel, Document}; +use photocraft_geom::Rect; use photocraft_psd::resources::ids; use photocraft_psd::tiff::{ByteOrder, ImageSourceData, resources_from_bytes, resources_to_bytes}; use photocraft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, ImageResource, ResolutionInfo}; +use photocraft_raster::Surface; use crate::flat::{csample, encode_image, image_to_document, layout_for, single_layer, try_buffer}; use crate::psd_export::PsdExportOptions; @@ -101,11 +103,31 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result 32, }; let (w, h) = img.dimensions(); - let channels = u16::try_from(img.layout().channels()).map_err(|_| IoError::Unsupported("too many channels".into()))?; + let mut data = planar_big_endian(&img, color_channels, mode == PsdMode::Cmyk)?; + let bps = img.sample_type().bytes(); + let plane_len = (w as usize).saturating_mul(h as usize).saturating_mul(bps); + let mut added = 0usize; + for e in img.meta.extra_samples.iter().take(codecs::MAX_EXTRA_SAMPLES) { + match native_plane_to_be(&e.data, bps) { + Ok(be) if be.len() == plane_len => { + data.extend(be); + added += 1; + } + _ => warnings.push("an extra TIFF sample did not match the image and was dropped".into()), + } + } + let channels = u16::try_from(img.layout().channels().saturating_add(added)).map_err(|_| IoError::Unsupported("too many channels".into()))?; let header = Header::new(isd.version, w, h, channels, depth, mode); - let data = planar_big_endian(&img, color_channels, mode == PsdMode::Cmyk)?; let image_data = ImageData { compression: Compression::Raw, data }; - let file = isd.into_psd(header, resources, image_data); + let mut file = isd.into_psd(header, resources, image_data); + // Empty `Patt` and the default filter mask are written so Photoshop's block list matches + // a file it saved. They carry nothing the document stores, so they are not kept: a + // round trip would otherwise grow `metadata.psd_global_blocks` on every open. + file.global_blocks.retain(|b| { + let empty_pattern = matches!(&b.key, b"Patt" | b"Pat2" | b"Pat3") && b.data.is_empty(); + let default_filter_mask = &b.key == b"FMsk" && b.data.as_slice() == FILTER_MASK_DEFAULT; + !empty_pattern && !default_filter_mask + }); // The same nesting limit as opening a PSD (#2940). crate::psd_import::check_group_depth(&file)?; let (mut doc, w) = psd_to_document(&file); @@ -200,19 +222,24 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr } let mut warnings = Vec::new(); let big = doc.size.width > 30_000 || doc.size.height > 30_000; - // The PSD exporter renders the composite we need for the TIFF itself: unmatted (the TIFF - // keeps straight alpha) and uncompressed (its planes are interleaved below). + // The PSD exporter renders the composite we need for the TIFF itself, uncompressed (its + // planes are interleaved below). A spot (or any user channel) is saved the way Photoshop's + // TIFF dialog does with "Save Transparency" off: the composite is colour plus those + // channels, matted on white, and each layer keeps its own transparency. With no user + // channel the composite stays unmatted, straight RGBA, which is what a plain transparent + // TIFF already round-trips as. // Layer data is written PSD-style ("Adobe Photoshop Document Data Block", 4-byte lengths), as // Photoshop writes it: Photoshop does not read the PSB-style header ("... Data V0002") in a // TIFF and opens only the composite, as one locked Background. The exporter's size estimate // counts layer planes uncompressed, so a document with many canvas-sized layers passes 2 GB // while its real layer data is a small fraction of that; it is not used here. PSB-style data // is written only past 30000 px, when asked for, or when a length really overflows 32 bits. + let user_channels = !doc.channels.is_empty() || doc.quick_mask.is_some(); let force_psb = opts.force_psb || big; let psd_opts = PsdExportOptions { force_psb, size_limit_psb: false, - merged_matte: false, + merged_matte: user_channels, merged_compression: Compression::Raw, maximize_compatibility: true, document_path: opts.document_path.clone(), @@ -232,7 +259,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr ctl.check().map_err(|_| IoError::Cancelled)?; warnings.extend(w); let order = if codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big }; - let (layers, w) = match ImageSourceData::from_psd(&file).to_bytes(order) { + let (layers, w) = match tiff_image_source(doc, &file).to_bytes(order) { Err(photocraft_psd::PsdError::LimitExceeded(_)) if !force_psb => { let psb_opts = PsdExportOptions { force_psb: true, ..psd_opts }; let (psb, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psb_opts, ctl)); @@ -240,7 +267,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr warnings.extend(w); warnings.push("layer data exceeds 4 GB; written with PSB-sized lengths, which some readers do not open".into()); file = psb; - ImageSourceData::from_psd(&file).to_bytes(order)? + tiff_image_source(doc, &file).to_bytes(order)? } r => r?, }; @@ -250,24 +277,58 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr // The profile and XMP are written as TIFF tags, not as resources. EXIF stays a resource // (1058), which the PSD importer reads back unchanged; a block rebuilt from the TIFF's own // Exif directory keeps only the Exif, GPS and descriptive tags. - let resources: Vec = file.resources.iter().filter(|r| !matches!(r.id, ids::ICC_PROFILE | ids::XMP)).cloned().collect(); - let resources = resources_to_bytes(&resources)?; + let mut resources: Vec = file.resources.iter().filter(|r| !matches!(r.id, ids::ICC_PROFILE | ids::XMP)).cloned().collect(); let fmt = doc.pixel_format(); let cc = fmt.mode.color_channels(); let has_alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha); let channels = cc + usize::from(has_alpha); - let extra = usize::from(file.header.channels).saturating_sub(channels); - if extra > 0 { - warnings.push(format!("{extra} alpha channel(s) are not written to TIFF (one transparency channel only)")); + let extra_n = usize::from(file.header.channels).saturating_sub(channels); + let kept = extra_n.min(codecs::MAX_EXTRA_SAMPLES); + if extra_n > kept { + warnings.push(format!("{extra_n} extra channels; TIFF keeps the first {kept}")); } + // With spot or alpha channels, the IFD image is colour + those channels. The merged + // transparency plane stays out of the IFD (see above); a file with no extra channels keeps + // it, so a plain transparent TIFF still opens with alpha. + let write_alpha = has_alpha && kept == 0; + // One identifier per extra sample actually stored, replacing any preserved 1053 whose count + // described the PSD merged image (which still included transparency). + if kept > 0 { + resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS); + // Photoshop's spot TIFF (RGB, Save Transparency off) stores identifier 3: the spot's + // index among the IFD samples, not a 1-based count of user channels. + let first = u32::try_from(cc.saturating_add(usize::from(write_alpha))).unwrap_or(u32::MAX); + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers_from(first, kept))); + } + // Resource 1022 names a channel of the PSD merged image, which counts the transparency plane. + // The IFD below does not, so the id moves back by one. + if has_alpha + && kept > 0 + && let Some(r) = resources.iter_mut().find(|r| r.id == crate::channel_map::QUICK_MASK_INFO) + && r.data.len() >= 2 + { + let id = u16::from_be_bytes([r.data[0], r.data[1]]).saturating_sub(1); + let be = id.to_be_bytes(); + r.data[0] = be[0]; + r.data[1] = be[1]; + } + // The name and DisplayInfo resources list the merged transparency plane first. The IFD + // leaves that plane out, so the resources must too: Photoshop pairs each name with the + // next sample past the colour channels. + if has_alpha && !write_alpha { + drop_merged_transparency(&mut resources); + } + let resources = resources_to_bytes(&resources)?; + let ifd_channels = cc + usize::from(write_alpha); let sample = csample(fmt.sample); let (w, h) = (doc.size.width, doc.size.height); let cmyk = fmt.mode == ColorMode::Cmyk; - let data = interleave(&file.image_data.data, w, h, channels, cc, sample, cmyk)?; + let data = interleave(&file.image_data.data, w, h, ifd_channels, cc, sample, cmyk)?; + let extras = native_extra_planes(&file.image_data.data, w, h, channels, kept, sample)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(TIFF_COMPOSITE_DONE); - let layout = layout_for(fmt.mode, has_alpha); + let layout = layout_for(fmt.mode, write_alpha); let mut img = Image::from_raw(w, h, layout, sample, data)?; img.icc = doc.icc_profile.as_ref().map(|i| i.to_vec()); img.meta = codecs::Metadata { @@ -280,11 +341,133 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr dpi: Some((doc.resolution_dpi, doc.resolution_dpi)), photoshop_resources: Some(resources), photoshop_layers: Some(layers), + extra_samples: extras, ..Default::default() }; encode_image(&img, Format::Tiff, opts, warnings) } +/// The layer data (`ImageSourceData`) of a layered TIFF saved from `file`, adjusted the way +/// Photoshop writes it. +fn tiff_image_source(doc: &Document, file: &photocraft_psd::PsdFile) -> ImageSourceData { + // Photoshop pairs alpha-name resources with samples past the colour channels and ignores + // the ExtraSamples kind. The merged transparency must not occupy that first slot when a + // spot is also stored. Layer transparency stays in each layer's channel −1. + let mut isd = ImageSourceData::from_psd(file); + let spot_extras = file.header.channels as usize > { + let cc = doc.pixel_format().mode.color_channels(); + let alpha = file.layer_info.as_ref().is_some_and(|l| l.merged_alpha); + cc + usize::from(alpha) + }; + // Positive layer count: the merged image's first extra sample is not transparency + // (Photoshop's own spot TIFF does this; a negative count would claim the spot plane is alpha). + if spot_extras && let Some(info) = &mut isd.layer_info { + info.merged_alpha = false; + } + // A merged-transparency marker kept from an opened file (Photoshop's `MTrn`) would make the + // first spot read back as transparency: keep it only while the composite keeps that sample. + if spot_extras || !file.layer_info.as_ref().is_some_and(|l| l.merged_alpha) { + isd.global_blocks.retain(|b| !b.is_merged_transparency_marker()); + } + if let Some(info) = &mut isd.layer_info { + for layer in &mut info.layers { + photoshop_layer_blocks(&mut layer.blocks); + } + } + // Photoshop reads a fixed 14-byte user-mask record from `LMsk` and an empty `Patt` + // before the filter mask. A zero-length `LMsk` is what makes it report that the + // layer data could not be read. + if let Some(mask) = &mut isd.global_layer_mask + && mask.data.len() < 14 + { + *mask = photocraft_psd::GlobalLayerMask::photoshop_default(); + } + if !isd.global_blocks.iter().any(|b| matches!(&b.key, b"Patt" | b"Pat2" | b"Pat3")) { + isd.global_blocks.insert(0, photocraft_psd::TaggedBlock::new(*b"Patt", Vec::new())); + } + if !isd.global_blocks.iter().any(|b| &b.key == b"FMsk") { + isd.global_blocks.push(photocraft_psd::TaggedBlock::new(*b"FMsk", FILTER_MASK_DEFAULT.to_vec())); + } + isd +} + +/// Removes the leading transparency entry from the channel-name resources (1006, 1045) and +/// from DisplayInfo (1077). Each is left unchanged when it is too short to hold that entry. +fn drop_merged_transparency(resources: &mut [ImageResource]) { + for r in resources { + r.data = match r.id { + 1006 => drop_first_pascal(&r.data), + 1045 => drop_first_unicode(&r.data), + id if id == crate::channel_map::DISPLAY_INFO => drop_first_display_record(&r.data), + _ => continue, + }; + } +} + +fn drop_first_pascal(data: &[u8]) -> Vec { + let Some(&n) = data.first() else { return data.to_vec() }; + let end = 1usize.saturating_add(usize::from(n)); + data.get(end..).unwrap_or(data).to_vec() +} + +fn drop_first_unicode(data: &[u8]) -> Vec { + let Some(nbe) = data.get(..4) else { return data.to_vec() }; + let n = u32::from_be_bytes([nbe[0], nbe[1], nbe[2], nbe[3]]) as usize; + let end = 4usize.saturating_add(n.saturating_mul(2)); + data.get(end..).unwrap_or(data).to_vec() +} + +/// Resource 1077: a 4-byte version, then 13-byte records. Drops the first record. +fn drop_first_display_record(data: &[u8]) -> Vec { + const RECORD: usize = 13; + if data.len() < 4 + RECORD { + return data.to_vec(); + } + let mut out = Vec::with_capacity(data.len() - RECORD); + out.extend_from_slice(&data[..4]); + out.extend_from_slice(&data[4 + RECORD..]); + out +} + +/// Filter-mask block (`FMsk`) Photoshop writes next to a layered TIFF: the same red 50% +/// overlay as [`photocraft_psd::GlobalLayerMask::photoshop_default`], without the kind byte. +const FILTER_MASK_DEFAULT: &[u8] = &[0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50]; + +/// Blocks Photoshop writes on every layer of a layered TIFF, when the layer does not +/// already carry them. Defaults match a plain pixel layer (clipped-as-group on, nothing +/// locked, no label). +fn photoshop_layer_blocks(blocks: &mut Vec) { + let has = |k: &[u8; 4]| blocks.iter().any(|b| &b.key == k); + let mut add = Vec::new(); + if !has(b"lnsr") { + add.push(photocraft_psd::TaggedBlock::name_source(*b"layr")); + } + if !has(b"clbl") { + add.push(photocraft_psd::TaggedBlock::blend_clipped_as_group(true)); + } + if !has(b"infx") { + add.push(photocraft_psd::TaggedBlock::blend_interior_elements(false)); + } + if !has(b"knko") { + add.push(photocraft_psd::TaggedBlock::knockout(0)); + } + if !has(b"lspf") { + add.push(photocraft_psd::TaggedBlock::protection(0)); + } + if !has(b"lclr") { + add.push(photocraft_psd::TaggedBlock::sheet_color(0)); + } + let at = blocks + .iter() + .position(|b| &b.key == b"lyid") + .map(|i| i + 1) + .or_else(|| blocks.iter().position(|b| &b.key == b"luni").map(|i| i + 1)) + .unwrap_or(blocks.len()); + for (i, block) in add.into_iter().enumerate() { + blocks.insert(at.saturating_add(i), block); + } +} + /// Removes the blocks a document kept verbatim from a little-endian TIFF because their layout /// is unknown ([`photocraft_psd::TaggedBlock::is_foreign_order`]) before `file` is written as a /// PSD: their bytes are in the wrong order for a PSD and cannot be converted. Returns a warning @@ -314,6 +497,421 @@ pub fn strip_foreign_order_blocks(file: &mut photocraft_psd::PsdFile) -> Vec Result, IoError> { + if bps == 1 { + return Ok(src.to_vec()); + } + if !matches!(bps, 2 | 4) || !src.len().is_multiple_of(bps) { + return Err(IoError::Unsupported("an extra channel plane is incomplete".into())); + } + let mut out = Vec::with_capacity(src.len()); + for c in src.chunks_exact(bps) { + match (bps, to_be, c) { + (2, true, [a, b]) => { + let v = u16::from_ne_bytes([*a, *b]); + out.extend_from_slice(&v.to_be_bytes()); + } + (2, false, [a, b]) => { + let v = u16::from_be_bytes([*a, *b]); + out.extend_from_slice(&v.to_ne_bytes()); + } + (4, true, [a, b, c, d]) => { + let v = f32::from_ne_bytes([*a, *b, *c, *d]); + out.extend_from_slice(&v.to_be_bytes()); + } + (4, false, [a, b, c, d]) => { + let v = f32::from_be_bytes([*a, *b, *c, *d]); + out.extend_from_slice(&v.to_ne_bytes()); + } + _ => return Err(IoError::Unsupported("an extra channel plane is incomplete".into())), + } + } + Ok(out) +} + +/// Native-endian extra-sample plane → big-endian, for the PSD merged image a layered TIFF rebuilds. +fn native_plane_to_be(src: &[u8], bps: usize) -> Result, IoError> { + swap_plane(src, bps, true) +} + +/// Extra planes of a PSD merged image (`skip` colour-and-alpha planes already consumed), as +/// native-endian TIFF extra samples. A spot plane is already Photoshop ink (0 = solid, max = +/// paper); an alpha plane is already straight. Neither is inverted again here. +fn native_extra_planes(planes: &[u8], w: u32, h: u32, skip: usize, count: usize, sample: CSample) -> Result, IoError> { + let bps = sample.bytes(); + let n = (w as usize).checked_mul(h as usize).ok_or_else(|| IoError::Unsupported("image size overflows".into()))?; + let plane = n.checked_mul(bps).ok_or_else(|| IoError::Unsupported("image size overflows".into()))?; + let mut out = Vec::with_capacity(count); + for i in 0..count { + let at = skip.checked_add(i).and_then(|k| k.checked_mul(plane)).ok_or_else(|| IoError::Unsupported("image size overflows".into()))?; + let Some(src) = planes.get(at..at.saturating_add(plane)) else { + return Err(IoError::Unsupported("the merged image is missing an extra channel".into())); + }; + if src.len() != plane { + return Err(IoError::Unsupported("the merged image is missing an extra channel".into())); + } + out.push(codecs::ExtraSample { kind: 0, data: swap_plane(src, bps, false)? }); + } + Ok(out) +} + +/// One grayscale channel as native-endian samples in `img`'s sample type. +fn channel_plane(channel: &AlphaChannel, canvas: Rect, sample: CSample) -> Result, String> { + let depth = match sample { + CSample::U8 => SampleType::U8, + CSample::U16 => SampleType::U16, + CSample::F16 | CSample::F32 => SampleType::F32, + }; + let fmt = PixelFormat::new(ColorMode::Grayscale, depth, false); + let owned; + let surface = if channel.surface.format() == fmt { + &channel.surface + } else { + owned = channel.surface.convert(fmt); + &owned + }; + let mut bytes = surface.to_interleaved(canvas); + let n = (canvas.width() as usize).saturating_mul(canvas.height() as usize); + let expect = n.saturating_mul(depth.bytes()); + if bytes.len() != expect { + return Err(format!("channel \"{}\" does not cover the canvas", channel.name)); + } + // Document density is 1 = solid ink. Photoshop's TIFF stores the opposite for a spot. + if channel.spot.is_some() { + crate::pixels::invert_plane(&mut bytes, depth, false); + } + if sample == CSample::F16 { + let mut out = Vec::with_capacity(n.saturating_mul(2)); + for arr in bytes.as_chunks::<4>().0 { + let v = f32::from_ne_bytes(*arr); + out.extend_from_slice(&codecs::f16::from_f32(v).to_ne_bytes()); + } + return Ok(out); + } + Ok(bytes) +} + +/// Composites straight alpha over white and drops it. Photoshop's TIFF save with +/// "Save Transparency" off stores that matte in the colour samples and keeps transparency +/// only inside each layer. Fully opaque pixels are copied unchanged, so they stay identical +/// to a composite that was quantized before the matte. `false` when there is no alpha or +/// the buffer is not one sample per channel. +fn matte_and_drop_alpha(img: &mut Image) -> bool { + if !img.layout().has_alpha() { + return false; + } + let (w, h) = img.dimensions(); + let sample = img.sample_type(); + let dst = img.layout().without_alpha(); + let bps = sample.bytes(); + let keep = dst.channels().saturating_mul(bps); + let stride = img.layout().channels().saturating_mul(bps); + if stride == 0 || keep == 0 || bps == 0 || stride <= keep || !img.data().len().is_multiple_of(stride) { + return false; + } + let cmyk = img.layout().is_cmyk(); + let white_byte: u8 = if cmyk { 0 } else { 255 }; + let white_u16: u16 = if cmyk { 0 } else { u16::MAX }; + let white_f: f32 = if cmyk { 0.0 } else { 1.0 }; + // Lab's white is L* 100 with a* and b* 0, which PhotoCraft holds mid-range (`(0 + 128) / 255`). + let lab = img.layout().is_lab(); + let mut out = Vec::with_capacity(img.data().len() / stride * keep); + for px in img.data().chunks_exact(stride) { + let Some(color) = px.get(..keep) else { return false }; + let Some(alpha) = px.get(keep..keep.saturating_add(bps)) else { return false }; + let ok = if lab { + let (Some(l), Some(ab)) = (color.get(..bps), color.get(bps..)) else { return false }; + matte_pixel(sample, l, alpha, white_byte, white_u16, white_f, &mut out) && matte_pixel(sample, ab, alpha, 128, 128 * 257, 128.0 / 255.0, &mut out) + } else { + matte_pixel(sample, color, alpha, white_byte, white_u16, white_f, &mut out) + }; + if !ok { + return false; + } + } + let icc = img.icc.clone(); + let meta = img.meta.clone(); + let warnings = img.warnings.clone(); + let Ok(mut next) = Image::from_raw(w, h, dst, sample, out) else { return false }; + next.icc = icc; + next.meta = meta; + next.warnings = warnings; + *img = next; + true +} + +/// One pixel of [`matte_and_drop_alpha`]. Opaque samples are copied; a clear pixel becomes +/// `white`; anything in between is `c·a + white·(1−a)` in normalized space. +fn matte_pixel(sample: CSample, color: &[u8], alpha: &[u8], white_byte: u8, white_u16: u16, white_f: f32, out: &mut Vec) -> bool { + match sample { + CSample::U8 => { + let Some(&a) = alpha.first() else { return false }; + if a == 255 { + out.extend_from_slice(color); + } else if a == 0 { + out.resize(out.len().saturating_add(color.len()), white_byte); + } else { + let af = f32::from(a) / 255.0; + for &c in color { + let m = crate::pixels::matte(f32::from(c) / 255.0, af, white_f); + out.push((m.clamp(0.0, 1.0) * 255.0).round() as u8); + } + } + } + CSample::U16 => { + let Some(a) = ne_u16(alpha) else { return false }; + if a == u16::MAX { + out.extend_from_slice(color); + } else if a == 0 { + for _ in 0..color.len() / 2 { + out.extend_from_slice(&white_u16.to_ne_bytes()); + } + } else { + let af = f32::from(a) / 65535.0; + for pair in color.as_chunks::<2>().0 { + let c = u16::from_ne_bytes(*pair); + let m = crate::pixels::matte(f32::from(c) / 65535.0, af, white_f); + out.extend_from_slice(&((m.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()); + } + } + } + CSample::F32 => { + let Some(a) = ne_f32(alpha) else { return false }; + if a == 1.0 { + out.extend_from_slice(color); + } else if a == 0.0 || !a.is_finite() { + for _ in 0..color.len() / 4 { + out.extend_from_slice(&white_f.to_ne_bytes()); + } + } else { + let af = a.clamp(0.0, 1.0); + for quad in color.as_chunks::<4>().0 { + let m = crate::pixels::matte(f32::from_ne_bytes(*quad), af, white_f); + out.extend_from_slice(&m.to_ne_bytes()); + } + } + } + CSample::F16 => { + let Some(bits) = ne_u16(alpha) else { return false }; + let a = codecs::f16::from_bits(bits).to_f32(); + if a == 1.0 { + out.extend_from_slice(color); + } else if a == 0.0 || !a.is_finite() { + let w = codecs::f16::from_f32(white_f).to_bits().to_ne_bytes(); + for _ in 0..color.len() / 2 { + out.extend_from_slice(&w); + } + } else { + let af = a.clamp(0.0, 1.0); + for pair in color.as_chunks::<2>().0 { + let c = codecs::f16::from_bits(u16::from_ne_bytes(*pair)).to_f32(); + let m = codecs::f16::from_f32(crate::pixels::matte(c, af, white_f)).to_bits(); + out.extend_from_slice(&m.to_ne_bytes()); + } + } + } + } + true +} + +fn ne_u16(b: &[u8]) -> Option { + let arr: [u8; 2] = b.get(..2)?.try_into().ok()?; + Some(u16::from_ne_bytes(arr)) +} + +fn ne_f32(b: &[u8]) -> Option { + let arr: [u8; 4] = b.get(..4)?.try_into().ok()?; + Some(f32::from_ne_bytes(arr)) +} + +/// Writes alpha and spot channels (and a Quick Mask) into a flat TIFF: extra samples in the +/// IFD, plus Photoshop resources for the name, ink colour and solidity. At most +/// [`codecs::MAX_EXTRA_SAMPLES`] are kept. A TIFF with no extra channels is left unchanged. +/// When any are written, the composite's own transparency is left out of the IFD: readers pair +/// the channel resources with every sample past the colour channels, in order. +pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image) -> Vec { + let total = doc.channels.len().saturating_add(usize::from(doc.quick_mask.is_some())); + if total == 0 { + return Vec::new(); + } + let mut warnings = Vec::new(); + let (w, h) = img.dimensions(); + if w != doc.size.width || h != doc.size.height { + warnings.push("extra channels were not written: the image size differs from the document".into()); + return warnings; + } + let max = codecs::MAX_EXTRA_SAMPLES; + let mut picked: Vec<&AlphaChannel> = doc.channels.iter().take(max).collect(); + if let Some(q) = &doc.quick_mask + && picked.len() < max + { + picked.push(q); + } + if total > picked.len() { + warnings.push(format!("{total} extra channels; TIFF keeps the first {}", picked.len())); + } + let canvas = Rect::new(0, 0, w as i32, h as i32); + let mut planes = Vec::with_capacity(picked.len()); + for ch in &picked { + match channel_plane(ch, canvas, img.sample_type()) { + Ok(data) => planes.push(data), + Err(msg) => { + warnings.push(msg); + break; + } + } + } + picked.truncate(planes.len()); + if picked.is_empty() { + return warnings; + } + let names: Vec<&str> = picked.iter().map(|c| c.name.as_str()).collect(); + let quick_last = matches!((picked.last(), doc.quick_mask.as_ref()), (Some(p), Some(q)) if std::ptr::eq(*p, q)); + let color_channels = img.layout().color_channels(); + // Save Transparency off: the colour samples are the composite over white, and the + // alpha plane is not an extra sample (it would be named as the first spot). The + // composite transparency is gone after this; say so, because the user did not pick + // that option themselves. + if img.layout().has_alpha() { + if !matte_and_drop_alpha(img) { + warnings.push("extra channels were not written: the composite transparency could not be separated from them".into()); + return warnings; + } + warnings.push( + "transparency composited over white; a TIFF with alpha or spot channels stores them as extra samples and drops image transparency (Save Transparency off)" + .into(), + ); + } + let quick_id = if quick_last { u16::try_from(color_channels.saturating_add(picked.len()).saturating_sub(1)).ok() } else { None }; + let first_id = u32::try_from(color_channels).unwrap_or(u32::MAX); + match channel_resources(img.meta.photoshop_resources.as_deref(), &names, &picked, quick_id, first_id) { + Ok(bytes) => img.meta.photoshop_resources = Some(bytes), + Err(e) => warnings.push(format!("spot channel options were not written to the TIFF: {e}")), + } + img.meta.extra_samples = planes.into_iter().map(|data| codecs::ExtraSample { kind: 0, data }).collect(); + warnings +} + +/// Existing Photoshop resources with the channel-name, display-info and quick-mask blocks replaced. +fn channel_resources(existing: Option<&[u8]>, names: &[&str], channels: &[&AlphaChannel], quick_id: Option, first_id: u32) -> Result, IoError> { + let mut resources = match existing.filter(|b| !b.is_empty()) { + Some(b) => resources_from_bytes(b)?.0, + None => Vec::new(), + }; + resources.retain(|r| { + !matches!(r.id, 1006 | 1045 | crate::channel_map::DISPLAY_INFO | crate::channel_map::QUICK_MASK_INFO | crate::channel_map::ALPHA_IDENTIFIERS) + }); + resources.push(ImageResource::new(1006, crate::psd_export::pascal_names_resource(names))); + resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(names))); + resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(channels, false))); + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers_from(first_id, names.len()))); + if let Some(id) = quick_id { + let mut data = id.to_be_bytes().to_vec(); + data.push(0); + resources.push(ImageResource::new(crate::channel_map::QUICK_MASK_INFO, data)); + } + Ok(resources_to_bytes(&resources)?) +} + +/// Extra samples of a flat TIFF become alpha / spot channels. Layered TIFFs rebuild those +/// through the PSD importer instead, so this is only for a file with no layer tag. +pub(crate) fn channels_from_extras(doc: &mut Document, img: &Image) -> Vec { + if img.meta.extra_samples.is_empty() { + return Vec::new(); + } + let mut warnings = Vec::new(); + let (w, h) = img.dimensions(); + if w != doc.size.width || h != doc.size.height { + warnings.push("extra TIFF samples were dropped: their size differs from the image".into()); + return warnings; + } + let parsed = img.meta.photoshop_resources.as_deref().filter(|b| !b.is_empty()).and_then(|b| resources_from_bytes(b).ok()); + let none = Vec::new(); + let resources = parsed.as_ref().map(|(r, _)| r.as_slice()).unwrap_or(&none); + let all_names = resources.iter().find(|r| r.id == 1045).map(|r| crate::psd_import::unicode_names(&r.data)).unwrap_or_default(); + // As in the PSD importer: Photoshop lists the image transparency first ("Transparency", with + // its own display record), ahead of the channels the extra samples hold. + let skip = usize::from(img.layout().has_alpha() && all_names.len() == img.meta.extra_samples.len().saturating_add(1)); + let names = all_names.get(skip..).unwrap_or_default(); + let kept = img.meta.extra_samples.len().min(codecs::MAX_EXTRA_SAMPLES); + let spots = if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO) { + crate::channel_map::spot_flags(&r.data, true, kept.saturating_add(skip)).split_off(skip) + } else if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO_OLD) { + crate::channel_map::spot_flags(&r.data, false, kept.saturating_add(skip)).split_off(skip) + } else { + vec![false; kept] + }; + let start = doc.channels.len(); + let n_px = (w as usize).saturating_mul(h as usize); + for (k, e) in img.meta.extra_samples.iter().take(codecs::MAX_EXTRA_SAMPLES).enumerate() { + let invert = spots.get(k).copied().unwrap_or(false); + match mask_from_plane(&e.data, w, h, n_px, img.sample_type(), doc.depth, invert) { + Ok(surface) => { + let name = names.get(k).cloned().filter(|s| !s.is_empty()).unwrap_or_else(|| format!("Alpha {}", k + 1)); + doc.channels.push(AlphaChannel::new(name, surface)); + } + Err(msg) => warnings.push(msg), + } + } + if doc.channels.len() == start { + return warnings; + } + { + let slice = &mut doc.channels[start..]; + if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO) { + crate::channel_map::apply_display_info(&r.data, true, skip, slice); + } else if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::DISPLAY_INFO_OLD) { + crate::channel_map::apply_display_info(&r.data, false, skip, slice); + } + } + let first = img.layout().color_channels().saturating_add(usize::from(img.layout().has_alpha())); + if let Some(r) = resources.iter().find(|r| r.id == crate::channel_map::QUICK_MASK_INFO) + && r.data.len() >= 2 + && let Some(k) = usize::from(u16::from_be_bytes([r.data[0], r.data[1]])).checked_sub(first) + && start.saturating_add(k) < doc.channels.len() + { + doc.quick_mask = Some(doc.channels.remove(start + k)); + } + warnings +} + +/// Native-endian extra-sample bytes → a grayscale surface at the document depth. +/// `invert` turns Photoshop spot ink (0 = solid) into document density (1 = solid). +fn mask_from_plane(data: &[u8], w: u32, h: u32, n: usize, from: CSample, depth: SampleType, invert: bool) -> Result { + let rect = Rect::new(0, 0, w as i32, h as i32); + let mut bytes = match (from, depth) { + (CSample::U8, SampleType::U8) | (CSample::U16, SampleType::U16) | (CSample::F32, SampleType::F32) => { + if data.len() != n.saturating_mul(from.bytes()) { + return Err("an extra TIFF sample did not match the image and was dropped".into()); + } + data.to_vec() + } + (CSample::F16, SampleType::F32) => { + if data.len() != n.saturating_mul(2) { + return Err("an extra TIFF sample did not match the image and was dropped".into()); + } + let mut out = Vec::with_capacity(n.saturating_mul(4)); + for arr in data.as_chunks::<2>().0 { + let v = codecs::f16::from_bits(u16::from_ne_bytes(*arr)).to_f32(); + out.extend_from_slice(&v.to_ne_bytes()); + } + out + } + _ => return Err(format!("an extra TIFF sample ({from:?}) does not match the document depth and was dropped")), + }; + if invert { + crate::pixels::invert_plane(&mut bytes, depth, false); + } + if bytes.len() != n.saturating_mul(depth.bytes()) { + return Err("an extra TIFF sample did not match the image and was dropped".into()); + } + let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)); + s.write_interleaved(rect, &bytes); + s.prune(); + Ok(s) +} + #[cfg(test)] mod tests { use photocraft_doc::{Color, Document, Fill, FillCache, Layer, LayerContent}; diff --git a/crates/io/tests/tiff_layers.rs b/crates/io/tests/tiff_layers.rs index 4cf78aca68..f19ccff7dc 100644 --- a/crates/io/tests/tiff_layers.rs +++ b/crates/io/tests/tiff_layers.rs @@ -34,19 +34,18 @@ fn roundtrip(doc: &Document) -> Document { // The TIFF is a plain image to any other reader: the composite at the document's size. let flat = photocraft_codecs::decode(&r.bytes).expect("flat decode"); assert_eq!(flat.dimensions(), (doc.size.width, doc.size.height)); - // A TIFF holds one transparency channel: extra alpha channels (and a Quick Mask) are not - // kept, and the export says so. + // Extra alpha channels and a Quick Mask travel as TIFF extra samples (at most MAX_EXTRA_SAMPLES). let extra = doc.channels.len() + usize::from(doc.quick_mask.is_some()); - assert_eq!(r.warnings.iter().any(|w| w.contains("alpha channel(s) are not written")), extra > 0, "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.contains("not written")), "{:?}", r.warnings); + if extra > 0 && extra <= photocraft_codecs::MAX_EXTRA_SAMPLES { + assert_eq!(flat.meta.extra_samples.len(), extra, "{:?}", r.warnings); + } import("x.tif", &r.bytes).expect("import").document } -/// `doc` as a layered TIFF round trip returns it: without extra alpha channels or a Quick Mask. +/// `doc` as a layered TIFF round trip returns it, including extra channels. fn expected(doc: &Document) -> Document { - let mut d = doc.clone(); - d.channels.clear(); - d.quick_mask = None; - d + doc.clone() } macro_rules! rt_case { @@ -302,12 +301,11 @@ fn psdtags_fixtures_open_with_their_layers() { // tifffile writes a Software tag; nothing else is worth a warning. assert!(r.warnings.iter().all(|w| w.contains("text metadata")), "{name}: {:?}", r.warnings); // psdtags wrote the composite's alpha sample without the transparency flag, so by the - // PSD rules it is an alpha channel (which a re-save cannot keep). Re-saving keeps - // everything else; the file is then in the encoder's byte order. + // PSD rules it is an alpha channel. A re-save keeps it as a TIFF extra sample. assert_eq!(d.channels.len(), 1, "{name}"); let out = tiff(d, true); let back = import(name, &out.bytes).unwrap().document; - assert_docs_eq(&expected(d), &back); + assert_docs_eq(d, &back); } } @@ -396,6 +394,592 @@ fn damaged_layer_data_opens_flattened() { } } +/// Every sample of a TIFF, read by the `tiff` crate rather than PhotoCraft's decoder. +/// +/// That decoder drops unspecified extra samples when the photometric interpretation is RGB, so +/// the file is retagged MinIsBlack first. The result is then a multiband image of every sample, +/// which is what libtiff's `TIFFReadScanline` returns and what a RIP walks. +fn independent_samples(bytes: &[u8]) -> (u16, Vec, Vec, u32, Vec) { + let mut tags = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).expect("tiff tags"); + let spp: u16 = tags.get_tag_unsigned(tiff::tags::Tag::SamplesPerPixel).expect("spp"); + let bps = tags.get_tag_u16_vec(tiff::tags::Tag::BitsPerSample).expect("bps"); + let extra = tags.get_tag_u16_vec(tiff::tags::Tag::ExtraSamples).unwrap_or_default(); + let rows = tags.find_tag_unsigned::(tiff::tags::Tag::RowsPerStrip).ok().flatten().unwrap_or(0); + let patched = photometric_min_is_black(bytes); + let mut dec = tiff::decoder::Decoder::new(std::io::Cursor::new(patched.as_slice())).expect("tiff decode"); + let tiff::decoder::DecodingResult::U8(samples) = dec.read_image().expect("read image") else { + panic!("expected 8-bit samples"); + }; + (spp, bps, extra, rows, samples) +} + +/// Classic-TIFF photometric tag 262 → 1 (MinIsBlack), value stored inline in the IFD entry. +fn photometric_min_is_black(src: &[u8]) -> Vec { + let mut bytes = src.to_vec(); + let le = bytes.first().copied() == Some(b'I'); + let ru16 = |b: &[u8], i: usize| -> Option { + let s = b.get(i..i + 2)?; + Some(if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) }) + }; + let ru32 = |b: &[u8], i: usize| -> Option { + let s = b.get(i..i + 4)?; + Some(if le { u32::from_le_bytes(s.try_into().ok()?) } else { u32::from_be_bytes(s.try_into().ok()?) }) + }; + let Some(ifd) = ru32(&bytes, 4) else { return bytes }; + let Some(n) = ru16(&bytes, ifd as usize) else { return bytes }; + for e in 0..n as usize { + let at = ifd as usize + 2 + e * 12; + if ru16(&bytes, at) == Some(262) { + let value = if le { 1u16.to_le_bytes() } else { 1u16.to_be_bytes() }; + if let Some(slot) = bytes.get_mut(at + 8..at + 10) { + slot.copy_from_slice(&value); + } + break; + } + } + bytes +} + +fn assert_plane(samples: &[u8], spp: usize, plane: usize, expected: &[u8]) { + assert_eq!(samples.len(), expected.len().saturating_mul(spp)); + let mut bad = 0usize; + let mut first = None; + for (i, want) in expected.iter().enumerate() { + let got = samples.get(i * spp + plane).copied(); + if got != Some(*want) { + bad += 1; + first.get_or_insert((i, got)); + } + } + assert_eq!(bad, 0, "{bad} mismatches, first {first:?}"); +} + +/// A multi-layer transparent document, large enough for several TIFF strips and several +/// raster tiles, with a spot whose every pixel is a known value. +#[test] +fn dtf_like_spot_is_written_in_full() { + use photocraft_color::{Color, PixelFormat}; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_psd::tiff::resources_from_bytes; + use photocraft_raster::Surface; + + let (w, h) = (900u32, 360u32); + let mut doc = Document::new("dtf", Size::new(w, h), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + let px = photocraft_raster::from_rgba(&fmt, [0.2, 0.5, 0.9, 1.0]); + art.fill_rect(Rect::new(30, 20, 870, 340), &px); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + let mut ink = Surface::new(fmt); + let px = photocraft_raster::from_rgba(&fmt, [0.9, 0.1, 0.2, 0.5]); + ink.fill_rect(Rect::new(80, 40, 700, 300), &px); + let mut layer = Layer::new("Ink", LayerContent::Raster(ink)); + layer.opacity = 0.8; + doc.layers.push(layer); + + let n = (w as usize) * (h as usize); + let mut spot_bytes = vec![0u8; n]; + for y in 0..h as usize { + for x in 0..w as usize { + spot_bytes[y * w as usize + x] = ((x * 3 + y * 17) % 251) as u8; + } + } + // Outside the artwork the composite is transparent, but the spot is not zero: a reader that + // binds "Spot Color 1" to the alpha plane fails here. + assert_eq!(spot_bytes[10 * w as usize + 10], 200); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + let spot_surface = Surface::from_interleaved(gray, Rect::new(0, 0, w as i32, h as i32), &spot_bytes); + let mut channel = AlphaChannel::new("Spot Color 1", spot_surface); + channel.spot = Some((Color::rgb(1.0, 0.0, 0.0), 0.6)); + doc.channels.push(channel); + + let mut seen = Vec::new(); + for (name, layers) in [("flat", false), ("layered", true)] { + let opts = ExportOptions { tiff_layers: layers, ..ExportOptions::default() }; + let r = export(&doc, "dtf.tif", &opts).unwrap(); + assert!(!r.warnings.iter().any(|w| w.contains("not written")), "{name}: {:?}", r.warnings); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(!img.layout().has_alpha(), "{name}: {:?}", img.layout()); + assert_eq!(img.layout().color_channels(), 3, "{name}"); + assert_eq!(img.meta.extra_samples.len(), 1, "{name}"); + assert_eq!(img.meta.extra_samples[0].kind, 0, "{name}"); + // Photoshop stores spot ink inverted: document 255 (solid) is file 0. + let stored_spot: Vec = spot_bytes.iter().map(|b| 255 - b).collect(); + assert_eq!(img.meta.extra_samples[0].data, stored_spot, "{name} own decoder"); + + let (spp, bps, extra, rows, samples) = independent_samples(&r.bytes); + assert_eq!(spp, 4, "{name}"); + assert_eq!(bps, vec![8, 8, 8, 8], "{name}"); + assert_eq!(extra, vec![0], "{name} ExtraSamples"); + assert!(rows > 0 && rows < h, "{name} rows_per_strip {rows}"); + assert_plane(&samples, 4, 3, &stored_spot); + seen.push(samples.clone()); + + // Outside the artwork the composite is matted to white (Save Transparency off). The + // spot at that pixel is not zero, so a reader that binds the name to the alpha fails. + let at = (10 * w as usize + 10) * 4; + assert_eq!(&samples[at..at + 3], &[255, 255, 255], "{name} matted rgb"); + let (resources, layer_tag) = tags(&r.bytes); + let resources = resources.expect("photoshop resources"); + let (blocks, warnings) = resources_from_bytes(&resources).unwrap(); + assert!(warnings.is_empty(), "{name} {warnings:?}"); + let block = |id: u16| blocks.iter().find(|b| b.id == id).unwrap_or_else(|| panic!("{name} missing {id}")); + // "Spot Color 1" is 12 characters: Photoshop stores 13 bytes and pads the 8BIM block, not the string. + assert_eq!(block(1006).data, b"\x0cSpot Color 1", "{name}"); + assert_eq!(block(1045).data.len(), 30, "{name}"); + let info = &block(1077).data; + assert_eq!(info.len(), 17, "{name}"); + assert_eq!(u16::from_be_bytes([info[14], info[15]]), 60, "{name} solidity"); + assert_eq!(info[16], 2, "{name} spot kind"); + // RGB occupies samples 0..2, so the spot's identifier is 3. + assert_eq!(block(1053).data, 3u32.to_be_bytes(), "{name} channel id"); + + let back = import("dtf.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels.len(), 1, "{name}"); + assert_eq!(back.channels[0].name, "Spot Color 1"); + let (ink, sol) = back.channels[0].spot.expect("spot"); + assert_eq!(ink.to_rgb(), [1.0, 0.0, 0.0], "{name}"); + assert!((sol - 0.6).abs() < 1e-4, "{name}"); + let area = Rect::new(0, 0, w as i32, h as i32); + assert_eq!(back.channels[0].surface.to_interleaved(area), spot_bytes, "{name} reimport"); + if layers { + let layer_tag = layer_tag.expect("layer tag"); + let (isd, w) = ImageSourceData::from_bytes(&layer_tag).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let info = isd.layer_info.expect("layers"); + assert!(!info.merged_alpha, "{name}"); + assert!(info.layers.iter().all(|l| l.flags.0 & 0x08 != 0), "{name} photoshop flag"); + assert!(info.layers.iter().all(|l| l.blocks.iter().any(|b| &b.key == b"lnsr")), "{name} lnsr"); + let mask = isd.global_layer_mask.expect("LMsk"); + assert_eq!(mask.data.len(), 14, "{name}"); + assert_eq!(mask.opacity(), Some(50)); + assert_eq!(mask.kind(), Some(128)); + assert_photoshop_layered_tiff(&r.bytes, true); + assert_eq!(back.layers.len(), 2, "{name}"); + let ink = back.layers.iter().find(|l| l.name == "Ink").expect("ink layer"); + let px = ink.surface().expect("ink pixels").pixel(100, 100); + assert!((px[3] - 0.5).abs() < 0.02, "{name} layer alpha {px:?}"); + let outside = ink.surface().expect("ink pixels").pixel(0, 0); + assert!(outside[3] < 0.02, "{name} transparent pixel {outside:?}"); + } else { + assert!(layer_tag.is_none(), "{name}"); + } + } + assert_eq!(seen[0], seen[1], "flat and layered IFD samples differ"); +} + +/// A transparent document with no user channels still stores image alpha. +#[test] +fn transparent_tiff_without_channels_keeps_image_alpha() { + use photocraft_doc::Layer; + use photocraft_geom::{Rect, Size}; + use photocraft_raster::Surface; + + let mut doc = Document::new("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + art.fill_rect(Rect::new(1, 1, 6, 6), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 0.5])); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + for layers in [false, true] { + let r = export(&doc, "t.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap(); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(img.layout().has_alpha(), "{layers}"); + assert!(img.meta.extra_samples.is_empty(), "{layers}"); + let mut dec = tiff::decoder::Decoder::new(std::io::Cursor::new(r.bytes.as_slice())).unwrap(); + let extra = dec.get_tag_u16_vec(tiff::tags::Tag::ExtraSamples).unwrap(); + assert_eq!(extra, vec![2], "{layers}"); + assert!(!r.warnings.iter().any(|w| w.contains("Save Transparency")), "{layers}: {:?}", r.warnings); + if layers { + assert_photoshop_layered_tiff(&r.bytes, false); + } + } +} + +/// A flat TIFF that also stores an alpha or spot channel mattes the composite on white +/// and drops image transparency. The export says so. +#[test] +fn flat_tiff_warns_when_extra_channels_matte_transparency() { + use photocraft_color::{Color, PixelFormat}; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_raster::Surface; + + let mut doc = Document::new("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + // (0, 0) stays transparent. (1, 1) is half-red, so the matte is halfway to white. + art.fill_rect(Rect::new(1, 1, 2, 2), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 0.5])); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + let mut spot = AlphaChannel::new("Spot Color 1", Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false))); + spot.spot = Some((Color::rgb(0.0, 1.0, 0.0), 1.0)); + doc.channels.push(spot); + + let r = export(&doc, "t.tif", &ExportOptions { tiff_layers: false, ..ExportOptions::default() }).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("composited over white") && w.contains("Save Transparency")), "{:?}", r.warnings); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert!(!img.layout().has_alpha(), "{:?}", img.layout()); + assert_eq!(img.meta.extra_samples.len(), 1); + let (_spp, _bps, extra, _rows, samples) = independent_samples(&r.bytes); + assert_eq!(extra, vec![0]); + // 4 samples per pixel: RGB + spot. (0, 0) was transparent and is now white. + assert_eq!(&samples[..3], &[255, 255, 255]); + // Half-red over white is about (255, 128, 128). Pixel (1, 1), 4 samples per pixel. + let (x, y, width, spp) = (1usize, 1usize, 4usize, 4usize); + let at = (y * width + x) * spp; + let rgb = &samples[at..at + 3]; + assert!(rgb[0] > 250 && (rgb[1] as i16 - 128).abs() <= 2 && (rgb[2] as i16 - 128).abs() <= 2, "{rgb:?}"); +} + +/// A 4×4 RGB document whose one layer is transparent outside its middle, plus a "White" spot +/// channel with solid ink in its left half: the shape of a UV print's underbase. +fn transparent_art_with_white_spot() -> Document { + use photocraft_color::{Color, PixelFormat}; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_raster::Surface; + + let mut doc = Document::new("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + art.fill_rect(Rect::new(1, 1, 3, 3), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + doc.layers.push(Layer::new("Layer 1", LayerContent::Raster(art))); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + let mut plate = Surface::new(gray); + plate.fill_rect(Rect::new(0, 0, 2, 4), &photocraft_raster::from_rgba(&gray, [1.0, 1.0, 1.0, 1.0])); + let mut white = AlphaChannel::new("White", plate); + white.spot = Some((Color::rgb(0.8, 0.2, 0.2), 1.0)); + doc.channels.push(white); + doc +} + +/// The first channel's ink at (0, 0), inked, and at (3, 0), bare. +fn ink(d: &Document) -> (f32, f32) { + (d.channels[0].surface.pixel(0, 0)[0], d.channels[0].surface.pixel(3, 0)[0]) +} + +/// `doc` laid out as Photoshop 25.12 saves a TIFF with Save Transparency on: R, G, B, +/// transparency and spot samples, with "Transparency" first in the channel names and display +/// records. With `layers`, the layer data has a positive layer count and an `MTrn` block (the +/// specification's key is `Mtrn`); without, the file is flat. Built from the PSD model of `doc`. +fn photoshop_transparency_and_spot_tiff(doc: &Document, layers: bool) -> Vec { + use photocraft_psd::{Compression, TaggedBlock}; + + let opts = PsdExportOptions { merged_matte: false, merged_compression: Compression::Raw, ..Default::default() }; + let (file, _) = document_to_psd_with(doc, &opts); + let planes = file.decode_merged().unwrap(); + let n = 16; + assert_eq!(planes.len(), 5 * n); + let rgba: Vec = (0..n).flat_map(|i| (0..4).map(move |c| (i, c))).map(|(i, c)| planes[c * n + i]).collect(); + let mut img = photocraft_codecs::Image::from_raw(4, 4, photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8, rgba).unwrap(); + img.meta.extra_samples = vec![photocraft_codecs::ExtraSample { kind: 0, data: planes[4 * n..].to_vec() }]; + img.meta.photoshop_resources = Some(photocraft_psd::tiff::resources_to_bytes(&file.resources).unwrap()); + if layers { + let mut isd = ImageSourceData::from_psd(&file); + if let Some(info) = &mut isd.layer_info { + info.merged_alpha = false; + } + isd.global_blocks.insert(0, TaggedBlock::new(*b"MTrn", Vec::new())); + let order = if photocraft_codecs::tiff_writes_little_endian() { ByteOrder::Little } else { ByteOrder::Big }; + img.meta.photoshop_layers = Some(isd.to_bytes(order).unwrap().0); + } + photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &photocraft_codecs::EncodeOptions::default()).unwrap() +} + +/// Photoshop's layered TIFF with transparency next to a spot channel opens with the spot as the +/// only channel. A layered re-save leaves the transparency sample out next to the spot, so it +/// must also drop the `MTrn` marker, or the spot would read back as transparency. +#[test] +fn photoshop_mtrn_tiff_opens_without_a_transparency_channel() { + use photocraft_psd::TaggedBlock; + + let bytes = photoshop_transparency_and_spot_tiff(&transparent_art_with_white_spot(), true); + let opened = import("t.tif", &bytes).unwrap().document; + let names: Vec<&str> = opened.channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["White"]); + assert!(opened.channels[0].spot.is_some()); + assert_eq!(ink(&opened), (1.0, 0.0)); + + let r = tiff(&opened, true); + let (isd, _) = ImageSourceData::from_bytes(&tags(&r.bytes).1.unwrap()).unwrap(); + assert!(!isd.global_blocks.iter().any(TaggedBlock::is_merged_transparency_marker)); + let back = import("t.tif", &r.bytes).unwrap().document; + let names: Vec<&str> = back.channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["White"]); + assert_eq!(ink(&back), (1.0, 0.0)); +} + +/// The same Photoshop layout in a flat TIFF (no layer data): the image keeps its transparency, +/// and the spot gets its own name and display record, not the leading "Transparency" ones, so +/// it opens as a spot with its ink the right way round. +#[test] +fn photoshop_flat_tiff_with_transparency_opens_its_spot_as_a_spot() { + let bytes = photoshop_transparency_and_spot_tiff(&transparent_art_with_white_spot(), false); + assert!(tags(&bytes).1.is_none(), "flat: no layer data"); + let opened = import("t.tif", &bytes).unwrap().document; + let names: Vec<&str> = opened.channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["White"]); + assert!(opened.channels[0].spot.is_some(), "a spot, not an alpha channel"); + assert_eq!(ink(&opened), (1.0, 0.0)); + let LayerContent::Raster(s) = &opened.layers[0].content else { panic!("raster") }; + assert_eq!(s.pixel(0, 0)[3], 0.0, "the image keeps its transparency"); +} + +/// A Lab document with a spot channel keeps the spot as a CIELab extra sample, and its +/// transparency is matted on Lab white (L* 100, a* and b* 0), not on the maximum of every sample. +#[test] +fn lab_tiff_keeps_spot_and_mattes_on_lab_white() { + use photocraft_color::{Color, PixelFormat}; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_raster::Surface; + + for sample in [SampleType::U8, SampleType::U16] { + let mut doc = Document::new("t", Size::new(4, 4), ColorMode::Lab, sample); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + // (0, 0) stays transparent. + art.fill_rect(Rect::new(1, 1, 2, 2), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + let mut plate = Surface::new(PixelFormat::new(ColorMode::Grayscale, sample, false)); + plate.fill_rect(Rect::new(0, 0, 2, 4), &photocraft_raster::from_rgba(&plate.format(), [1.0, 1.0, 1.0, 1.0])); + let mut spot = AlphaChannel::new("Spot Color 1", plate); + spot.spot = Some((Color::rgb(0.0, 1.0, 0.0), 1.0)); + doc.channels.push(spot); + + // Lab is always saved flat, whether layers were asked for or not. + for layers in [false, true] { + let r = export(&doc, "t.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap(); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::Lab, "{sample:?} {layers}"); + assert_eq!(img.meta.extra_samples.len(), 1, "{sample:?} {layers}"); + let white = &img.to_normalized()[..3]; + assert!((white[0] - 1.0).abs() < 1e-3 && (white[1] - 128.0 / 255.0).abs() < 1e-3 && (white[2] - 128.0 / 255.0).abs() < 1e-3, "{white:?}"); + let back = import("t.tif", &r.bytes).unwrap().document; + assert_eq!(back.pixel_format().mode, ColorMode::Lab, "{sample:?} {layers}"); + assert_eq!(back.channels.len(), 1, "{sample:?} {layers}"); + assert_eq!(back.channels[0].name, "Spot Color 1"); + assert!(back.channels[0].spot.is_some(), "{sample:?} {layers}"); + let plate = |c: &AlphaChannel| c.surface.to_interleaved(Rect::new(0, 0, 4, 4)); + assert_eq!(plate(&back.channels[0]), plate(&doc.channels[0]), "{sample:?} {layers}"); + } + } +} + +/// Tag 37724 walked as raw bytes (not through [`ImageSourceData`]). +/// +/// Matches a Photoshop 27 layered TIFF: classic 4-byte block lengths, `Layr` then a 14-byte +/// `LMsk`, no `MTrn` (that block is only for a composite that keeps both transparency and a +/// spot). A 4320×4320 RGB spot plate is 74_649_600 bytes and still fits those u32 lengths; +/// this check uses the same layout on a smaller image. +fn assert_photoshop_layered_tiff(bytes: &[u8], with_spot: bool) { + assert!(bytes.starts_with(b"II"), "little-endian TIFF"); + let u16_at = |i: usize| u16::from_le_bytes([bytes[i], bytes[i + 1]]); + let u32_at = |i: usize| u32::from_le_bytes(bytes[i..i + 4].try_into().unwrap()); + let ifd = u32_at(4) as usize; + let n = u16_at(ifd) as usize; + let mut layers = None; + let mut spp = 0u16; + let mut extra = Vec::new(); + for e in 0..n { + let at = ifd + 2 + e * 12; + let tag = u16_at(at); + let typ = u16_at(at + 2); + let count = u32_at(at + 4) as usize; + let val = u32_at(at + 8) as usize; + let unit: usize = match typ { + 3 => 2, + 4 => 4, + _ => 1, + }; + let size = unit.saturating_mul(count); + let inline = at + 8; + let raw = if size <= 4 { &bytes[inline..inline + size] } else { &bytes[val..val + size] }; + match tag { + 277 => spp = u16::from_le_bytes([raw[0], raw[1]]), + 338 => extra = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(), + 37724 => layers = Some(raw), + _ => {} + } + } + let isd = layers.expect("37724"); + let sig = b"Adobe Photoshop Document Data Block\0"; + assert!(isd.starts_with(sig), "PSB-sized lengths"); + let mut pos = sig.len(); + let mut keys = Vec::new(); + let mut layr: Option> = None; + while pos + 12 <= isd.len() { + assert_eq!(&isd[pos..pos + 4], b"MIB8", "block at {pos}"); + let key = [isd[pos + 7], isd[pos + 6], isd[pos + 5], isd[pos + 4]]; + let len = u32::from_le_bytes(isd[pos + 8..pos + 12].try_into().unwrap()) as usize; + let body = &isd[pos + 12..pos + 12 + len]; + if &key == b"Layr" { + layr = Some(body.to_vec()); + } + if &key == b"LMsk" { + assert_eq!(body, &[0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0x32, 0, 0x80, 0]); + } + keys.push(key); + pos += 12 + len; + let rest = &isd[pos..]; + let pad = (0..=3).find(|&k| k == rest.len() || rest.get(k..k + 4) == Some(b"MIB8")).unwrap_or(0); + pos += pad; + } + assert_eq!(pos, isd.len(), "trailing bytes after the last block"); + assert_eq!(keys.first(), Some(b"Layr")); + assert_eq!(keys.get(1), Some(b"LMsk")); + assert!(!keys.iter().any(|k| k == b"MTrn" || k == b"Mtrn")); + let layr = layr.expect("Layr"); + let count = i16::from_le_bytes([layr[0], layr[1]]); + if with_spot { + assert!(count > 0, "layer count {count}"); + assert_eq!((spp, extra.as_slice()), (4, [0u16].as_slice())); + } else { + assert!(count < 0, "layer count {count}"); + assert_eq!(extra, vec![2]); + } + let nch = u16::from_le_bytes([layr[18], layr[19]]) as usize; + let mut ids = Vec::new(); + let mut p = 20; + for _ in 0..nch { + ids.push(i16::from_le_bytes([layr[p], layr[p + 1]])); + p += 6; + } + assert!(ids.contains(&-1), "channel ids {ids:?}"); +} + +/// Photoshop 27's Kato-Karate TIFF stores spot ink inverted: every non-white artwork pixel +/// has spot 0, and the white background is spot 255. An alpha channel is the other way +/// around (255 = selected). Both polarities round-trip, in TIFF and in PSD. +#[test] +fn spot_ink_is_stored_as_zero_and_alpha_is_not() { + use photocraft_color::{Color, PixelFormat}; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_psd::PsdFile; + use photocraft_raster::Surface; + + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let (w, h) = (8u32, 8u32); + let mut doc = Document::new("pol", Size::new(w, h), ColorMode::Rgb, depth); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + art.fill_rect(Rect::new(2, 2, 6, 6), &photocraft_raster::from_rgba(&fmt, [1.0, 0.2, 0.1, 1.0])); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + let gray = PixelFormat::new(ColorMode::Grayscale, depth, false); + let n = (w as usize) * (h as usize); + let mut ink = vec![0.0f32; n]; + for y in 2..6 { + for x in 2..6 { + ink[y * w as usize + x] = 1.0; + } + } + let mut spot = AlphaChannel::new("Spot Color 1", Surface::new(gray)); + spot.surface.write_region(Rect::new(0, 0, w as i32, h as i32), &ink); + spot.spot = Some((Color::rgb(1.0, 0.0, 0.0), 0.6)); + doc.channels.push(spot); + let mut alpha = AlphaChannel::new("Alpha 1", Surface::new(gray)); + alpha.surface.write_region(Rect::new(0, 0, w as i32, h as i32), &ink); + doc.channels.push(alpha); + + let psd = export(&doc, "pol.psd", &ExportOptions::default()).unwrap(); + let file = PsdFile::from_bytes(&psd.bytes).unwrap(); + let merged = file.decode_merged().unwrap(); + let bps = depth.bytes(); + let plane = n * bps; + let first = 3 + usize::from(file.merged_has_alpha()); + let spot_plane = &merged[first * plane..(first + 1) * plane]; + let alpha_plane = &merged[(first + 1) * plane..(first + 2) * plane]; + // Background (0,0) and artwork (3,3). Spot: 0 = solid ink, max = paper. + // Alpha: max = selected, 0 = empty. + assert_stored(spot_plane, 0, depth, false, "psd spot background"); + assert_stored(spot_plane, (3 * w as usize + 3) * bps, depth, true, "psd spot art"); + assert_stored(alpha_plane, 0, depth, true, "psd alpha background"); + assert_stored(alpha_plane, (3 * w as usize + 3) * bps, depth, false, "psd alpha art"); + let back = import("pol.psd", &psd.bytes).unwrap().document; + let area = Rect::new(0, 0, w as i32, h as i32); + assert_eq!(back.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{depth:?} psd spot"); + assert_eq!(back.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{depth:?} psd alpha"); + assert!(back.channels[0].spot.is_some(), "{depth:?}"); + assert!(back.channels[1].spot.is_none(), "{depth:?}"); + + if depth == SampleType::U8 { + for layers in [false, true] { + let r = export(&doc, "pol.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap(); + let (spp, _bps, extra, _rows, samples) = independent_samples(&r.bytes); + assert_eq!((spp, extra.as_slice()), (5, [0u16, 0].as_slice()), "{layers}"); + // Reference TIFF: artwork spot sample is 0, background spot sample is 255. + assert_eq!(samples[3], 255, "{layers} background spot"); + assert_eq!(samples[(3 * w as usize + 3) * 5 + 3], 0, "{layers} art spot"); + assert_eq!(samples[4], 0, "{layers} background alpha"); + assert_eq!(samples[(3 * w as usize + 3) * 5 + 4], 255, "{layers} art alpha"); + let back = import("pol.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{layers}"); + assert_eq!(back.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{layers}"); + } + } + } +} + +/// `at` addresses one sample of a big-endian PSD plane. `zero` expects 0; otherwise the +/// maximum sample (255 / 65535 / 1.0). +fn assert_stored(plane: &[u8], at: usize, depth: SampleType, zero: bool, what: &str) { + match depth { + SampleType::U8 => { + let v = plane[at]; + assert_eq!(v, if zero { 0 } else { 255 }, "{what} {v}"); + } + SampleType::U16 => { + let v = u16::from_be_bytes([plane[at], plane[at + 1]]); + assert_eq!(v, if zero { 0 } else { 65535 }, "{what} {v}"); + } + SampleType::F32 => { + let v = f32::from_be_bytes([plane[at], plane[at + 1], plane[at + 2], plane[at + 3]]); + assert_eq!(v, if zero { 0.0 } else { 1.0 }, "{what} {v}"); + } + } +} + +/// Quick Mask stays the last channel after the merged transparency plane is omitted. +#[test] +fn spot_tiff_keeps_quick_mask_when_composite_alpha_is_omitted() { + use photocraft_color::{Color, PixelFormat}; + use photocraft_doc::{AlphaChannel, Layer}; + use photocraft_geom::{Rect, Size}; + use photocraft_raster::Surface; + + let mut doc = Document::new("q", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + let fmt = doc.pixel_format(); + let mut art = Surface::new(fmt); + art.fill_rect(Rect::new(1, 1, 6, 6), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 0.5])); + doc.layers.push(Layer::new("Art", LayerContent::Raster(art))); + let gray = PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false); + let area = Rect::new(0, 0, 8, 8); + let mut spot_bytes = vec![9u8; 64]; + spot_bytes[0] = 3; + let mut channel = AlphaChannel::new("Spot Color 1", Surface::from_interleaved(gray, area, &spot_bytes)); + channel.spot = Some((Color::rgb(1.0, 0.0, 0.0), 0.6)); + doc.channels.push(channel); + let mut qbytes = vec![7u8; 64]; + qbytes[1] = 4; + doc.quick_mask = Some(AlphaChannel::new("Quick Mask", Surface::from_interleaved(gray, area, &qbytes))); + + for layers in [false, true] { + let r = export(&doc, "q.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap(); + let (spp, _bps, extra, _rows, samples) = independent_samples(&r.bytes); + assert_eq!((spp, extra.as_slice()), (5, [0, 0].as_slice()), "{layers}"); + let stored_spot: Vec = spot_bytes.iter().map(|b| 255 - b).collect(); + assert_plane(&samples, 5, 3, &stored_spot); + // A Quick Mask is an alpha channel: stored straight, not as spot ink. + assert_plane(&samples, 5, 4, &qbytes); + let back = import("q.tif", &r.bytes).unwrap().document; + assert_eq!(back.channels.len(), 1, "{layers}"); + assert_eq!(back.channels[0].surface.to_interleaved(area), spot_bytes, "{layers}"); + let qm = back.quick_mask.as_ref().unwrap_or_else(|| panic!("{layers}: no quick mask")); + assert_eq!(qm.surface.to_interleaved(area), qbytes, "{layers}"); + } +} + #[test] fn default_export_options_write_a_flat_tiff() { // Scripted and agent saves (CLI, batch, MCP) build on the defaults: layers only on request. diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index 740d44f8bf..eec73ed072 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -39,6 +39,14 @@ pub struct GlobalLayerMask { } impl GlobalLayerMask { + /// The mask Photoshop writes for a layered file that has no global mask of its own: + /// an RGB red overlay at 50% opacity, kind 128 (use the per-layer setting), plus the + /// filler byte. A zero-length `LMsk` block in a TIFF makes Photoshop give up on the + /// layer data (it reads this 14-byte record from the block). + pub fn photoshop_default() -> Self { + GlobalLayerMask { data: vec![0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50, 128, 0] } + } + fn be16(&self, at: usize) -> Option { self.data.get(at..at + 2).map(|b| u16::from_be_bytes([b[0], b[1]])) } diff --git a/crates/psd/tests/roundtrip.rs b/crates/psd/tests/roundtrip.rs index 1f23ee6284..49ad412e7c 100644 --- a/crates/psd/tests/roundtrip.rs +++ b/crates/psd/tests/roundtrip.rs @@ -232,6 +232,7 @@ fn global_mask_accessors() { assert_eq!(gm.opacity(), Some(50)); assert_eq!(gm.kind(), Some(128)); assert_eq!(GlobalLayerMask::default().kind(), None); + assert_eq!(GlobalLayerMask::photoshop_default().data, gm.data); } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 63fef83486..e2fc72c8ed 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-21 · **Change:** patch (Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency; earlier: DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-21 · **Change:** minor (TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; earlier the same day: Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency; earlier: DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -24,7 +24,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | -| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | +| TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7); alpha and spot channels as extra samples (at most 16) with Photoshop's names, ink colours and solidity, spot ink stored 0 = solid (#7415); no Save Transparency choice yet (channels are written as Photoshop does with it off) | | JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) | | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225); palette PNGs open in Indexed Color mode with their transparent entry (#4804); Save keeps a stereo PNG's `sTER` pairing (#6063) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | TIFF: alpha and spot channels are written and read as extra samples (at most 16) with Photoshop's channel resources (1006, 1045, 1077, 1053), spot ink stored 0 = solid as Photoshop 27.11's TIFFs store it; PSD output is unchanged. FILE-215-7 done, FILE-215-17 partial (its TIFF part) (#7415, supersedes #3121) | | 2026-10-21 | patch | PSD and layered TIFF: the `MTrn` block Photoshop writes in a layered TIFF saved with Save Transparency (a positive layer count, `nrTM` in an `II` file) marks the merged image's first extra channel as its transparency, like `Mtrn`/`Mt16`/`Mt32`: such a file no longer opens with its transparency also as an alpha channel (named "Transparency" in Photoshop 27.11's file), and a PSD save no longer writes it twice. A PSD save with an opaque composite drops any of these markers kept from the opened file, so its first alpha channel no longer reopens as transparency and vanishes (split from #7415; credit #3121) | | 2026-10-15 | minor | DDS textures save (#1327): uncompressed or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, optional box-filtered mip chain, legacy header; DDS Options in Save As and Save a Copy, `ddsCompression`/`ddsMipmaps` for scripts, the CLI and MCP | | 2026-10-15 | minor | PNG: a stereo PNG's `sTER` pairing (PNG Extensions 1.4.0 §3.6) is read on open and written back on save, so the file still says it is a stereo pair (#6063) | diff --git a/docs/gaps.md b/docs/gaps.md index ab42d0f8ef..647c121998 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -230,9 +230,12 @@ what an agent can't finish alone. write (FILE-215-10, #2541; a single flattened page now saves via Save As / Save a Copy / CLI, 2026-10-11, #3401), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), Cineon write (read landed), DICOM, EPS/DCS, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop - Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, + Raw; spot plates beyond TIFF: spot ink preview in RGB/CMYK documents and PDF Separation + (FILE-215-17); .atn, .asl, .shc, .csh presets (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). +- **Progress (2026-10-21, #7415):** TIFF writes and reads alpha and spot channels as extra samples + with Photoshop's names, ink colours and solidity (FILE-215-7 done; FILE-215-17's TIFF part). - **Progress (2026-10-13):** Cineon (`.cin`) 10-bit gray/RGB frames open read-only, sharing the DPX header/unpacking (`crates/codecs/src/codecs/dpx.rs`); writing is still open. - **Estimate:** 120–220 h. @@ -274,6 +277,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | G10: TIFF alpha and spot channels written and read as extra samples with Photoshop's channel resources, PSD output unchanged (#7415); FILE-215-7 leaves the list, FILE-215-17 narrows to RGB/CMYK spot preview and PDF Separation | | 2026-10-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open | | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3eca518c27..6e8449e38e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-21 · **Change:** minor (TIFF spot plates, FILE-215-17's TIFF part, #7415; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -175,7 +175,7 @@ parity for the area and are already contained in the dimension rows above. | Adjustments and filters | 70% | 120–200 | All present; smart-filter re-render matches Photoshop on 5 of 30 files; Filter Gallery looks approximate; no Neural Filters | | Type | 40% | 120–200 | Engine works; no type on a path, manual kerning, RTL paragraphs, OpenType panel UI, live Warp Text preview; Type tool freeze on macOS (#2518) | | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); missing path-point nudge; shape parity tracking issue #2496 | -| Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | +| Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, spot plates in TIFF only (FILE-215-17: no RGB/CMYK spot preview or PDF Separation), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | | Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Colour management and modes: spot plates now save and open in TIFF (#7415); estimates unchanged | | 2026-10-18 | minor | Layers: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial; percentages unchanged | | 2026-10-13 | minor | Vector shapes: on-canvas corner radius widgets on live rectangles (TOOL-214-12); estimates unchanged | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the Pen flyout) are live toolbar tools and Frame landed, so Art History Brush is the only tool still missing; percentages unchanged | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index d7e82c9aea..84738c1455 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -50,9 +50,9 @@ note = "#426: SmartObject.perspective holds the projective map (transform keeps [[item]] id = "FILE-215-7" target = "Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref" -status = "partial" +status = "done" issue = 215 -note = "psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels and a Quick Mask are not written (the TIFF codec holds one transparency sample)" +note = "psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha/spot channels and a Quick Mask are written as TIFF extra samples (at most 16) plus Photoshop resources for name, colour and solidity; Photoshop's MTrn marker is read (#7415). The TIFF Options Save Transparency choice isn't offered: channels are written as Photoshop does with it off" [[item]] id = "FILE-215-8" @@ -120,9 +120,9 @@ note = "doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm v [[item]] id = "FILE-215-17" target = "CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF)" -status = "missing" +status = "partial" issue = 215 -note = "compose multichannel.rs:1 spot preview for Multichannel only" +note = "TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation" [[item]] id = "FILE-215-18" From f7f8fdcd89196ee98cdca46aed546a38ecb78160 Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 11:36:39 -0400 Subject: [PATCH 080/229] io: keep PSD output as on main; Photoshop spot polarity for TIFF only #7415 changed every PSD export (spot planes inverted, bit 3 on group dividers, a 14-byte LMsk, a fresh 1053) and dropped 1053 on PSD open. PSD export and import are back to main's behaviour; the layered TIFF asks for Photoshop's spot polarity through an opt-in switch (PsdExportOptions/PsdImportOptions::photoshop_spot_polarity), and the LMsk and flag bit 3 move into tiff_layers. - One helper, channel_map::photoshop_spot_plane, converts every spot plane in Photoshop's polarity (flat TIFF save/open, layered TIFF via the PSD paths). - DisplayInfo is read as on main (13-byte 1077, 14-byte 1007 records); Photoshop's PSDs and TIFFs all use 13. - 1053 is numbered from 3 in every mode, as all 12 Photoshop TIFFs are. A layered TIFF's 1053 is not kept on open, and a TIFF save never copies a PSD's. - Tests: PSD keeps spot and alpha planes straight with no 1053, an empty LMsk and main's flags; a PSD's 1053 survives open and save; a TIFF saved as PSD writes straight planes and no 1053. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Zer0C00L01 <12090083+Zer0C00L01@users.noreply.github.com> --- crates/io/src/channel_map.rs | 111 +++++++++++++++++++-------------- crates/io/src/lib.rs | 3 +- crates/io/src/pixels.rs | 29 ++------- crates/io/src/psd_export.rs | 90 +++++++++++++++++++++----- crates/io/src/psd_import.rs | 45 ++++++------- crates/io/src/tiff_layers.rs | 55 ++++++++-------- crates/io/tests/tiff_layers.rs | 19 +++--- 7 files changed, 211 insertions(+), 141 deletions(-) diff --git a/crates/io/src/channel_map.rs b/crates/io/src/channel_map.rs index 60b671101a..990027441a 100644 --- a/crates/io/src/channel_map.rs +++ b/crates/io/src/channel_map.rs @@ -1,14 +1,13 @@ //! Alpha / spot channel options <-> PSD `DisplayInfo` image resources (1077, and the older 1007). //! -//! Layout (Adobe PSD spec, "DisplayInfo", and a Photoshop 27 layered TIFF): resource 1077 -//! starts with a 4-byte version (1), then one record per extra channel: colour space (u16), -//! four u16 colour components, opacity (u16, 0..100) and kind (u8: 0 = colour indicates -//! selected areas, 1 = masked ("protected") areas, 2 = spot channel). Photoshop writes that -//! 13-byte record with no pad. Older files pad each record to 14 bytes; both are read. -//! Resource 1007 has the same records without the version, each followed by a padding byte. -//! When the merged image has transparency, Photoshop writes that channel's record first. +//! Layout (Adobe PSD spec, "DisplayInfo"): resource 1077 starts with a 4-byte version (1), then +//! one 13-byte record per extra channel: colour space (u16), four u16 colour components, opacity +//! (u16, 0..100) and kind (u8: 0 = colour indicates selected areas, 1 = masked ("protected") +//! areas, 2 = spot channel). Resource 1007 has the same records without the version and the spot +//! kind, each followed by a padding byte (14 bytes). Photoshop 27's TIFFs use the same 13-byte +//! 1077 records. -use photocraft_color::{Color, ColorMode}; +use photocraft_color::{Color, ColorMode, SampleType}; use photocraft_doc::{AlphaChannel, ColorIndicates}; pub(crate) const DISPLAY_INFO: u16 = 1077; @@ -24,18 +23,24 @@ const SPACE_CMYK: u16 = 2; const SPACE_LAB: u16 = 7; const SPACE_GRAY: u16 = 8; -/// Resource 1053: channel ids `1..=count`, one big-endian u32 each, with no length prefix. -/// Photoshop, ag-psd and psd-tools read u32s until the resource ends. -pub(crate) fn alpha_identifiers(count: usize) -> Vec { - alpha_identifiers_from(1, count) +/// Converts one spot channel's plane (native-endian samples) between the document's ink density +/// (1 = solid ink) and the way Photoshop stores a spot channel (0 = solid ink, the maximum = +/// bare paper; every TIFF Photoshop 27 saves stores it so). The conversion is its own inverse, +/// so it serves both open and save. Every reader and writer of Photoshop-polarity spot planes +/// goes through here: a change to the in-memory convention changes only this function. Alpha +/// channels are stored straight (the maximum = selected) and never come here. +pub(crate) fn photoshop_spot_plane(plane: &mut [u8], sample: SampleType) { + crate::pixels::invert_plane(plane, sample); } -/// Resource 1053 starting at `first` (a layered TIFF uses the sample's index in the IFD: -/// 3 for the spot after RGB, not 1). -pub(crate) fn alpha_identifiers_from(first: u32, count: usize) -> Vec { +/// Resource 1053 for `count` channels: one big-endian u32 identifier each, with no length prefix +/// (Photoshop, ag-psd and psd-tools read u32s until the resource ends). Photoshop 27.11 numbers a +/// new document's channels 3, 4, 5, … in every colour mode (RGB, CMYK, Gray and Lab, in its PSDs +/// and TIFFs alike), so these do too. +pub(crate) fn alpha_identifiers(count: usize) -> Vec { let mut out = Vec::with_capacity(count.saturating_mul(4)); for i in 0..count { - let id = first.saturating_add(u32::try_from(i).unwrap_or(u32::MAX)); + let id = 3u32.saturating_add(u32::try_from(i).unwrap_or(u32::MAX)); out.extend_from_slice(&id.to_be_bytes()); } out @@ -67,41 +72,25 @@ pub(crate) fn display_info(channels: &[&AlphaChannel], transparency: bool) -> Ve out } -/// 13 when every record is Photoshop's unpadded form, otherwise the 14-byte padded form. -/// -/// Lengths divisible by both (14, 28, … unpadded records, or 13, 26, … padded ones) take -/// the unpadded stride for resource 1077, which is what Photoshop 27 writes, and the padded -/// stride for the older 1007 resource, whose record includes the pad byte. -fn display_stride(body: &[u8], versioned: bool) -> usize { - let unpadded = !body.is_empty() && body.len().is_multiple_of(13); - let padded = !body.is_empty() && body.len().is_multiple_of(14); - if unpadded && (!padded || versioned) { 13 } else { 14 } +/// The records of a DisplayInfo resource (`versioned` = 1077: 13-byte records after a version; +/// else the older 1007 with 14-byte records). A trailing partial record is left out. +fn display_records(data: &[u8], versioned: bool) -> std::slice::ChunksExact<'_, u8> { + let (body, size) = if versioned { (data.get(4..).unwrap_or(&[]), 13) } else { (data, 14) }; + body.chunks_exact(size) } -/// Which of the first `count` DisplayInfo records are spot channels (kind 2). -/// Missing records are not spots. Alpha channels (kind 0 or 1) stay false: their samples -/// are stored straight, while a spot's samples are stored inverted. -pub(crate) fn spot_flags(data: &[u8], versioned: bool, count: usize) -> Vec { - let body = if versioned { data.get(4..).unwrap_or(&[]) } else { data }; - let stride = display_stride(body, versioned); - let mut out = vec![false; count]; - for (i, rec) in body.chunks(stride).take(count).enumerate() { - if rec.len() >= 13 { - out[i] = rec[12] == 2; - } - } - out +/// Which of the extra channels are spot channels (kind 2), after skipping `skip` records (the +/// merged transparency channel's), in the same records [`apply_display_info`] reads. A channel +/// without a record is not a spot. +pub(crate) fn spot_flags(data: &[u8], versioned: bool, skip: usize) -> Vec { + display_records(data, versioned).skip(skip).map(|rec| rec.get(12) == Some(&2)).collect() } -/// Applies a DisplayInfo resource (`versioned` = 1077: records after a version; else the older -/// 1007) to the imported channels in order, after skipping `skip` records (the merged -/// transparency channel's). Both the 13-byte records Photoshop writes and the 14-byte padded -/// form are read. +/// Applies a DisplayInfo resource (`versioned` = 1077: 13-byte records after a version; else the +/// older 1007 with 14-byte records) to the imported channels in order, after skipping `skip` +/// records (the merged transparency channel's). pub(crate) fn apply_display_info(data: &[u8], versioned: bool, skip: usize, channels: &mut [AlphaChannel]) { - let body = if versioned { data.get(4..).unwrap_or(&[]) } else { data }; - let stride = display_stride(body, versioned); - for (c, rec) in channels.iter_mut().zip(body.chunks(stride).skip(skip)) { - let Some(rec) = rec.get(..13) else { break }; + for (c, rec) in channels.iter_mut().zip(display_records(data, versioned).skip(skip)) { let u = |i: usize| u16::from_be_bytes([rec[i], rec[i + 1]]); let comps = [u(2), u(4), u(6), u(8)]; let color = decode_color(u(0), comps); @@ -199,9 +188,9 @@ mod tests { assert!((back[0].opacity - 0.3).abs() < 1e-6); assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas); assert_eq!(back[1].spot, s.spot); - assert_eq!(alpha_identifiers(2), [0, 0, 0, 1, 0, 0, 0, 2]); + assert_eq!(spot_flags(&bytes, true, 0), [false, true]); + assert_eq!(alpha_identifiers(2), [0, 0, 0, 3, 0, 0, 0, 4]); assert!(alpha_identifiers(0).is_empty()); - assert_eq!(alpha_identifiers_from(3, 1), [0, 0, 0, 3]); // Photoshop 27 writes a 13-byte record (no pad): red spot, solidity 60. let ps = [0, 0, 0, 1, 0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 0x3c, 2]; let mut spot = vec![AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8))]; @@ -214,10 +203,13 @@ mod tests { apply_display_info(&with_t, true, 1, &mut back); assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas); assert_eq!(back[1].spot, s.spot); + assert_eq!(spot_flags(&with_t, true, 1), [false, true]); // Truncated data is ignored rather than misread. let mut c = vec![AlphaChannel::new("x", Surface::new(PixelFormat::GRAY8))]; apply_display_info(&bytes[..10], true, 0, &mut c); assert_eq!(c[0].opacity, 0.5); + assert!(spot_flags(&bytes[..10], true, 0).is_empty()); + assert!(spot_flags(&bytes, true, 5).is_empty()); // 14 unpadded records are a multiple of 14 as well (14×13 = 13×14). Resource 1077 // still reads them as 13-byte records; the last spot's solidity must survive. @@ -235,6 +227,29 @@ mod tests { assert_eq!(got[13].spot.map(|(_, s)| (s * 100.0).round() as u16), many[13].spot.map(|(_, s)| (s * 100.0).round() as u16)); } + /// Photoshop's spot ink (0 = solid) and the document's density (1 = solid) at every depth, + /// both ways: the 25 % / 75 % columns of the Photoshop oracles store 191 / 64. + #[test] + fn photoshop_spot_plane_converts_both_ways() { + let mut p8 = vec![255u8, 64, 191, 0]; + photoshop_spot_plane(&mut p8, SampleType::U8); + assert_eq!(p8, [0, 191, 64, 255]); + photoshop_spot_plane(&mut p8, SampleType::U8); + assert_eq!(p8, [255, 64, 191, 0]); + let mut p16: Vec = [65535u16, 16448, 0].iter().flat_map(|v| v.to_ne_bytes()).collect(); + photoshop_spot_plane(&mut p16, SampleType::U16); + let back: Vec = p16.as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes(*c)).collect(); + assert_eq!(back, [0, 49087, 65535]); + let mut p32: Vec = [1.0f32, 0.25].iter().flat_map(|v| v.to_ne_bytes()).collect(); + photoshop_spot_plane(&mut p32, SampleType::F32); + let back: Vec = p32.as_chunks::<4>().0.iter().map(|c| f32::from_ne_bytes(*c)).collect(); + assert_eq!(back, [0.0, 0.75]); + // A trailing partial sample is left alone rather than misread. + let mut odd = vec![0u8, 0, 7]; + photoshop_spot_plane(&mut odd, SampleType::U16); + assert_eq!(odd, [0xff, 0xff, 7]); + } + #[test] fn decodes_other_spaces() { let g = decode_color(SPACE_GRAY, [10000, 0, 0, 0]); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 946abfbc75..70d66f8c5c 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -321,7 +321,8 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m psd_import::check_group_depth(&file)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(0.05); - let (mut document, warnings) = psd_import::psd_to_document_decoded(&file, ctl, merged).ok_or(IoError::Cancelled)?; + let (mut document, warnings) = + psd_import::psd_to_document_decoded(&file, ctl, merged, psd_import::PsdImportOptions::default()).ok_or(IoError::Cancelled)?; document.name = name.to_string(); text_import::prepare(&mut document); return Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false }); diff --git a/crates/io/src/pixels.rs b/crates/io/src/pixels.rs index 7d58109714..23ecf88053 100644 --- a/crates/io/src/pixels.rs +++ b/crates/io/src/pixels.rs @@ -21,30 +21,11 @@ pub fn sample_for_depth(depth: u16) -> SampleType { } } -/// Complements every sample in `bytes`. Photoshop stores a spot channel as ink density -/// inverted: 0 is solid ink and the maximum sample is bare paper (the same convention as a -/// CMYK plate and as Multichannel). An alpha channel is not stored this way: 0 is empty and -/// the maximum sample is selected. `big_endian` is the PSD merged-image order; TIFF extra -/// samples are native-endian. -pub fn invert_plane(bytes: &mut [u8], s: SampleType, big_endian: bool) { - let bps = s.bytes(); - if bps == 0 { - return; - } - for c in bytes.chunks_exact_mut(bps) { - match s { - SampleType::U8 => c[0] = 255 - c[0], - SampleType::U16 => { - let v = if big_endian { u16::from_be_bytes([c[0], c[1]]) } else { u16::from_ne_bytes([c[0], c[1]]) }; - let w = 65535 - v; - c.copy_from_slice(&(if big_endian { w.to_be_bytes() } else { w.to_ne_bytes() })); - } - SampleType::F32 => { - let v = if big_endian { f32::from_be_bytes([c[0], c[1], c[2], c[3]]) } else { f32::from_ne_bytes([c[0], c[1], c[2], c[3]]) }; - let w = 1.0 - v; - c.copy_from_slice(&(if big_endian { w.to_be_bytes() } else { w.to_ne_bytes() })); - } - } +/// Complements every native-endian sample of one plane (`max − v`, `1 − v` for float). A +/// trailing partial sample is left unchanged. +pub(crate) fn invert_plane(bytes: &mut [u8], s: SampleType) { + for c in bytes.chunks_exact_mut(s.bytes().max(1)) { + invert_sample(c, s); } } diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 81d3ebe5a4..483b305f00 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -50,6 +50,11 @@ pub struct PsdExportOptions { /// The path of the document's own file: linked smart objects are resolved against it /// ([`crate::doc_path::resolve`]) before they are read and embedded. pub document_path: Option, + /// Store spot channels as Photoshop's TIFFs do, 0 = solid ink (see + /// [`crate::channel_map::photoshop_spot_plane`]). Only the layered TIFF sets it: a PSD keeps + /// writing the document's density as it is (1 = solid ink), so files saved by earlier + /// versions keep reading the same. + pub photoshop_spot_polarity: bool, } impl Default for PsdExportOptions { @@ -61,6 +66,7 @@ impl Default for PsdExportOptions { merged_compression: Compression::Rle, maximize_compatibility: true, document_path: None, + photoshop_spot_polarity: false, } } } @@ -484,8 +490,6 @@ impl Ex { } flags.set_hidden(!l.visible); flags.set_transparency_protected(l.locks.transparency); - // Bit 3 (with bit 4 clear) is the flag Photoshop sets on every layer it writes. - flags.0 |= LayerFlags::BIT4_USEFUL; let mut blocks = vec![TaggedBlock::unicode_name(&l.name), TaggedBlock::layer_id(id)]; let lspf = blocks::lspf_from_locks(&l.locks); if lspf != 0 { @@ -1077,8 +1081,6 @@ impl Ex { match &l.content { LayerContent::Group(g) => { let id = self.fresh_id(); - let mut divider_flags = LayerFlags(0); - divider_flags.0 |= LayerFlags::BIT4_USEFUL; self.records.push(LayerRecord { channels: self.empty_channels(), blending_ranges: BlendingRanges::full(self.cc), @@ -1088,7 +1090,6 @@ impl Ex { TaggedBlock::layer_id(id), TaggedBlock::section_divider(SectionType::BoundingDivider, None, None), ], - flags: divider_flags, ..Default::default() }); self.emit(&g.children, tick); @@ -1710,10 +1711,11 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c }; for a in &extra { let s = if a.surface.format() != ex.mask_fmt { a.surface.convert(ex.mask_fmt) } else { a.surface.clone() }; - let bytes = s.to_interleaved(canvas); - // Spot ink is stored inverted (0 = solid, max = paper), the same way Multichannel - // and a Photoshop TIFF do. An alpha channel stays straight (max = selected). - planes.extend(deinterleave(&bytes, 1, sample, &[a.spot.is_some()]).remove(0)); + let mut bytes = s.to_interleaved(canvas); + if opts.photoshop_spot_polarity && a.spot.is_some() { + crate::channel_map::photoshop_spot_plane(&mut bytes, sample); + } + planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0)); } let channels = (cc + usize::from(has_alpha) + extra.len()) as u16; let mode = if duotone_inks.is_some() { PsdMode::Duotone } else { psd_mode(fmt.mode) }; @@ -1744,7 +1746,6 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c resources.push(ImageResource::new(1006, pascal_names_resource(&names))); resources.push(ImageResource::new(1045, unicode_names_resource(&names))); resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&extra, has_alpha))); - resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(extra.len()))); } if let Some(id) = quick_mask_id { let mut data = id.to_be_bytes().to_vec(); @@ -1827,10 +1828,6 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if !keep_imported_resource(doc, *id, data) { continue; } - // A fresh 1053 was written with the extra channels. A preserved copy would be a second one. - if *id == crate::channel_map::ALPHA_IDENTIFIERS && !extra.is_empty() { - continue; - } let mut r = ImageResource::new(*id, data.to_vec()); r.name = legacy_name(name); resources.push(r); @@ -1881,7 +1878,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c resources, layer_info, layer_info_placement: placement, - global_layer_mask: (has_layers || !global_blocks.is_empty()).then(GlobalLayerMask::photoshop_default), + global_layer_mask: (has_layers || !global_blocks.is_empty()).then(GlobalLayerMask::default), global_blocks, layer_mask_trailing: Vec::new(), image_data, @@ -1958,6 +1955,69 @@ mod tests { assert!(back.layers.len() == 1 && bg.name == "Background" && bg.locks.transparency && bg.locks.position, "{:?}", bg.locks); } + /// A layered document with a spot and an alpha channel. Each channel holds 100 %, 25 %, 75 % + /// and 0 % in four columns, the layout of the Photoshop spot oracles. + fn spot_and_alpha_document() -> Document { + let mut doc = document(4, 1); + doc.layers.push(Layer::group("Group", vec![Layer::raster("Layer 1", doc.pixel_format())])); + let channel = |name: &str| { + let mut s = Surface::new(PixelFormat::GRAY8); + s.write_interleaved(doc.bounds(), &[255, 64, 191, 0]); + photocraft_doc::AlphaChannel::new(name, s) + }; + let mut spot = channel("Spot Color 1"); + spot.spot = Some((photocraft_doc::Color::rgb(0.0, 0.6, 1.0), 0.0)); + let alpha = channel("Alpha 1"); + doc.channels.extend([spot, alpha]); + doc + } + + /// Photoshop's spot polarity is for the layered TIFF only: a PSD stores a spot plane and an + /// alpha plane straight (255 = solid ink, 255 = selected) as before, so files saved by earlier + /// versions and this one read the same. It writes no resource 1053, an empty global layer + /// mask, and flag bit 3 on the layers but not on the group's closing divider. Opening it gives + /// the same ink back. + #[test] + fn psd_stores_spot_and_alpha_planes_straight() { + let doc = spot_and_alpha_document(); + let written = |photoshop_spot_polarity| { + let opts = PsdExportOptions { photoshop_spot_polarity, ..Default::default() }; + PsdFile::from_bytes(&document_to_psd_with(&doc, &opts).0.to_bytes().unwrap()).unwrap() + }; + let extra_planes = |file: &PsdFile| { + let planes = file.decode_merged().unwrap(); + planes[planes.len() - 2 * 4..].to_vec() + }; + let file = written(false); + assert_eq!(extra_planes(&file), [255, 64, 191, 0, 255, 64, 191, 0], "spot, then alpha"); + assert!(file.resource(crate::channel_map::ALPHA_IDENTIFIERS).is_none()); + assert_eq!(file.global_layer_mask.as_ref().map(|m| m.data.len()), Some(0)); + let flags: Vec = file.layers().iter().map(|l| l.flags.0 & LayerFlags::BIT4_USEFUL).collect(); + assert_eq!(flags, [0, LayerFlags::BIT4_USEFUL, LayerFlags::BIT4_USEFUL], "closing divider, layer, group"); + let back = crate::import("spot.psd", &file.to_bytes().unwrap()).unwrap().document; + assert!(back.channels[0].spot.is_some() && back.channels[1].spot.is_none()); + for c in &back.channels { + assert_eq!(c.surface.to_interleaved(back.bounds()), [255, 64, 191, 0], "{}", c.name); + } + // The layered TIFF's switch inverts the spot plane only. + assert_eq!(extra_planes(&written(true)), [0, 191, 64, 255, 255, 64, 191, 0]); + } + + /// Resource 1053 (alpha identifiers, in every Photoshop file with an extra channel) is kept + /// from an opened PSD and written back unchanged, as before. + #[test] + fn psd_alpha_identifiers_survive_open_and_save() { + let mut file = document_to_psd(&spot_and_alpha_document()); + let ids = [0, 0, 0, 3, 0, 0, 0, 2]; + file.resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, ids.to_vec())); + let opened = crate::import("ps.psd", &file.to_bytes().unwrap()).unwrap().document; + let saved = crate::export(&opened, "x.psd", &crate::ExportOptions::default()).unwrap(); + let again = PsdFile::from_bytes(&saved.bytes).unwrap(); + let kept: Vec<&ImageResource> = again.resources.iter().filter(|r| r.id == crate::channel_map::ALPHA_IDENTIFIERS).collect(); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].data, ids); + } + #[test] fn group_divider_id_wraps_without_colliding_with_imported_layer_ids() { let mut doc = document(8, 8); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 82f998143b..cf7c522e41 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -751,7 +751,16 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec) { /// [`psd_to_document`] for a background open: checks `ctl` per layer record and reports /// progress. `None` when cancelled. pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) -> Option<(Document, Vec)> { - psd_to_document_decoded(file, ctl, None) + psd_to_document_decoded(file, ctl, None, PsdImportOptions::default()) +} + +/// Options for [`psd_to_document_decoded`]. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct PsdImportOptions { + /// Read spot channels as Photoshop's TIFFs store them, 0 = solid ink (see + /// [`crate::channel_map::photoshop_spot_plane`]). Only the layered TIFF sets it: a PSD's spot + /// planes are read as the document's density, as earlier versions saved them. + pub photoshop_spot_polarity: bool, } /// Whether the import reads the merged composite: for a flattened or multichannel file (it *is* @@ -776,6 +785,7 @@ pub(crate) fn psd_to_document_decoded( file: &PsdFile, ctl: &photocraft_raster::Interrupt, decoded: Option>>, + opts: PsdImportOptions, ) -> Option<(Document, Vec)> { let h = &file.header; let mut warnings = Vec::new(); @@ -832,10 +842,6 @@ pub(crate) fn psd_to_document_decoded( doc.clipping_path = Some(photocraft_doc::ClippingPath { name, flatness: 0.0 }); doc.metadata.psd_resources.push((r.id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))); } - // Resource 1053 is rewritten from the extra channels on a layered save, so keeping - // the copy just written would show up as new metadata. Multichannel has no such - // rewrite: the imported bytes are the channel ids, copied back when they still match. - crate::channel_map::ALPHA_IDENTIFIERS if !multichannel => {} id if MAPPED_RESOURCES.contains(&id) => {} id => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))), } @@ -1052,28 +1058,25 @@ pub(crate) fn psd_to_document_decoded( { let first = cc + usize::from(file.merged_has_alpha()); let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default(); - let extra_n = usize::from(h.channels).saturating_sub(first); // Photoshop lists every extra channel, the merged transparency first when there is one. let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(h.channels).saturating_sub(cc)); - let names = names.get(skip..).unwrap_or(&[]); - // Spot planes are ink-inverted in the file (0 = solid). Alpha planes are not. - // DisplayInfo lists the transparency record first when `skip` is 1. - let spots = { - let n = extra_n.saturating_add(skip); - let all = if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) { - crate::channel_map::spot_flags(&r.data, true, n) - } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { - crate::channel_map::spot_flags(&r.data, false, n) - } else { - vec![false; n] - }; - all.into_iter().skip(skip).collect::>() + let names = names.get(skip..).unwrap_or_default(); + // Which planes hold spot ink in Photoshop's polarity: the DisplayInfo records the + // channel options are read from below, the transparency's first when `skip` is 1. + let photoshop_spots = if !opts.photoshop_spot_polarity { + Vec::new() + } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) { + crate::channel_map::spot_flags(&r.data, true, skip) + } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { + crate::channel_map::spot_flags(&r.data, false, skip) + } else { + Vec::new() }; for (k, idx) in (first..usize::from(h.channels)).enumerate() { let Some(p) = merged_plane(all, idx, plane) else { break }; let mut raw = be_to_ne_plane(p, depth, n); - if spots.get(k).copied().unwrap_or(false) { - crate::pixels::invert_plane(&mut raw, depth, false); + if photoshop_spots.get(k).copied().unwrap_or(false) { + crate::channel_map::photoshop_spot_plane(&mut raw, depth); } let mut s = Surface::new(cx.mask_fmt); s.write_interleaved(canvas, &raw); diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 826c1557f7..932d88c82b 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -21,7 +21,7 @@ use photocraft_raster::Surface; use crate::flat::{csample, encode_image, image_to_document, layout_for, single_layer, try_buffer}; use crate::psd_export::PsdExportOptions; -use crate::psd_import::psd_to_document; +use crate::psd_import::{PsdImportOptions, psd_to_document_decoded}; use crate::{ExportOptions, ExportResult, ImportResult, IoError}; /// `true` when saving `doc` as TIFF writes layer data: anything beyond a single Background @@ -130,8 +130,13 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result 0 { - resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS); - // Photoshop's spot TIFF (RGB, Save Transparency off) stores identifier 3: the spot's - // index among the IFD samples, not a 1-based count of user channels. - let first = u32::try_from(cc.saturating_add(usize::from(write_alpha))).unwrap_or(u32::MAX); - resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers_from(first, kept))); + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(kept))); } // Resource 1022 names a channel of the PSD merged image, which counts the transparency plane. // The IFD below does not, so the id moves back by one. @@ -371,6 +375,10 @@ fn tiff_image_source(doc: &Document, file: &photocraft_psd::PsdFile) -> ImageSou } if let Some(info) = &mut isd.layer_info { for layer in &mut info.layers { + // Photoshop sets flag bit 3 on every record it writes, group dividers included (299 of + // the 305 dividers in the corpus's Photoshop files). PhotoCraft's PSDs set it on + // layers only, unchanged here. + layer.flags.0 |= photocraft_psd::layer::LayerFlags::BIT4_USEFUL; photoshop_layer_blocks(&mut layer.blocks); } } @@ -577,9 +585,8 @@ fn channel_plane(channel: &AlphaChannel, canvas: Rect, sample: CSample) -> Resul if bytes.len() != expect { return Err(format!("channel \"{}\" does not cover the canvas", channel.name)); } - // Document density is 1 = solid ink. Photoshop's TIFF stores the opposite for a spot. if channel.spot.is_some() { - crate::pixels::invert_plane(&mut bytes, depth, false); + crate::channel_map::photoshop_spot_plane(&mut bytes, depth); } if sample == CSample::F16 { let mut out = Vec::with_capacity(n.saturating_mul(2)); @@ -784,8 +791,7 @@ pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image) -> Vec img.meta.photoshop_resources = Some(bytes), Err(e) => warnings.push(format!("spot channel options were not written to the TIFF: {e}")), } @@ -794,7 +800,7 @@ pub(crate) fn stamp_flat_channels(doc: &Document, img: &mut Image) -> Vec, names: &[&str], channels: &[&AlphaChannel], quick_id: Option, first_id: u32) -> Result, IoError> { +fn channel_resources(existing: Option<&[u8]>, names: &[&str], channels: &[&AlphaChannel], quick_id: Option) -> Result, IoError> { let mut resources = match existing.filter(|b| !b.is_empty()) { Some(b) => resources_from_bytes(b)?.0, None => Vec::new(), @@ -805,7 +811,7 @@ fn channel_resources(existing: Option<&[u8]>, names: &[&str], channels: &[&Alpha resources.push(ImageResource::new(1006, crate::psd_export::pascal_names_resource(names))); resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(names))); resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(channels, false))); - resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers_from(first_id, names.len()))); + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(names.len()))); if let Some(id) = quick_id { let mut data = id.to_be_bytes().to_vec(); data.push(0); @@ -834,19 +840,18 @@ pub(crate) fn channels_from_extras(doc: &mut Document, img: &Image) -> Vec { let name = names.get(k).cloned().filter(|s| !s.is_empty()).unwrap_or_else(|| format!("Alpha {}", k + 1)); doc.channels.push(AlphaChannel::new(name, surface)); @@ -877,8 +882,8 @@ pub(crate) fn channels_from_extras(doc: &mut Document, img: &Image) -> Vec Result { +/// `photoshop_spot`: the sample is a spot channel in Photoshop's polarity (0 = solid ink). +fn mask_from_plane(data: &[u8], w: u32, h: u32, n: usize, from: CSample, depth: SampleType, photoshop_spot: bool) -> Result { let rect = Rect::new(0, 0, w as i32, h as i32); let mut bytes = match (from, depth) { (CSample::U8, SampleType::U8) | (CSample::U16, SampleType::U16) | (CSample::F32, SampleType::F32) => { @@ -900,8 +905,8 @@ fn mask_from_plane(data: &[u8], w: u32, h: u32, n: usize, from: CSample, depth: } _ => return Err(format!("an extra TIFF sample ({from:?}) does not match the document depth and was dropped")), }; - if invert { - crate::pixels::invert_plane(&mut bytes, depth, false); + if photoshop_spot { + crate::channel_map::photoshop_spot_plane(&mut bytes, depth); } if bytes.len() != n.saturating_mul(depth.bytes()) { return Err("an extra TIFF sample did not match the image and was dropped".into()); diff --git a/crates/io/tests/tiff_layers.rs b/crates/io/tests/tiff_layers.rs index f19ccff7dc..49f0076728 100644 --- a/crates/io/tests/tiff_layers.rs +++ b/crates/io/tests/tiff_layers.rs @@ -532,7 +532,7 @@ fn dtf_like_spot_is_written_in_full() { assert_eq!(info.len(), 17, "{name}"); assert_eq!(u16::from_be_bytes([info[14], info[15]]), 60, "{name} solidity"); assert_eq!(info[16], 2, "{name} spot kind"); - // RGB occupies samples 0..2, so the spot's identifier is 3. + // Photoshop numbers a new document's channels from 3. assert_eq!(block(1053).data, 3u32.to_be_bytes(), "{name} channel id"); let back = import("dtf.tif", &r.bytes).unwrap().document; @@ -665,7 +665,7 @@ fn ink(d: &Document) -> (f32, f32) { fn photoshop_transparency_and_spot_tiff(doc: &Document, layers: bool) -> Vec { use photocraft_psd::{Compression, TaggedBlock}; - let opts = PsdExportOptions { merged_matte: false, merged_compression: Compression::Raw, ..Default::default() }; + let opts = PsdExportOptions { merged_matte: false, merged_compression: Compression::Raw, photoshop_spot_polarity: true, ..Default::default() }; let (file, _) = document_to_psd_with(doc, &opts); let planes = file.decode_merged().unwrap(); let n = 16; @@ -850,7 +850,8 @@ fn assert_photoshop_layered_tiff(bytes: &[u8], with_spot: bool) { /// Photoshop 27's Kato-Karate TIFF stores spot ink inverted: every non-white artwork pixel /// has spot 0, and the white background is spot 255. An alpha channel is the other way -/// around (255 = selected). Both polarities round-trip, in TIFF and in PSD. +/// around (255 = selected). A PSD keeps storing both planes straight, as earlier versions did, +/// and a TIFF opened and saved as PSD does the same. Everything round-trips. #[test] fn spot_ink_is_stored_as_zero_and_alpha_is_not() { use photocraft_color::{Color, PixelFormat}; @@ -890,10 +891,9 @@ fn spot_ink_is_stored_as_zero_and_alpha_is_not() { let first = 3 + usize::from(file.merged_has_alpha()); let spot_plane = &merged[first * plane..(first + 1) * plane]; let alpha_plane = &merged[(first + 1) * plane..(first + 2) * plane]; - // Background (0,0) and artwork (3,3). Spot: 0 = solid ink, max = paper. - // Alpha: max = selected, 0 = empty. - assert_stored(spot_plane, 0, depth, false, "psd spot background"); - assert_stored(spot_plane, (3 * w as usize + 3) * bps, depth, true, "psd spot art"); + // Background (0,0) and artwork (3,3), both straight: max = solid ink, max = selected. + assert_stored(spot_plane, 0, depth, true, "psd spot background"); + assert_stored(spot_plane, (3 * w as usize + 3) * bps, depth, false, "psd spot art"); assert_stored(alpha_plane, 0, depth, true, "psd alpha background"); assert_stored(alpha_plane, (3 * w as usize + 3) * bps, depth, false, "psd alpha art"); let back = import("pol.psd", &psd.bytes).unwrap().document; @@ -916,6 +916,11 @@ fn spot_ink_is_stored_as_zero_and_alpha_is_not() { let back = import("pol.tif", &r.bytes).unwrap().document; assert_eq!(back.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{layers}"); assert_eq!(back.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{layers}"); + // Saved as PSD: straight planes, and no 1053 (the TIFF's isn't kept on open). + let psd = PsdFile::from_bytes(&export(&back, "pol.psd", &ExportOptions::default()).unwrap().bytes).unwrap(); + let again = psd.decode_merged().unwrap(); + assert_eq!(again[again.len() - 2 * plane..], merged[(first * plane)..(first + 2) * plane], "{layers} tiff to psd"); + assert!(psd.resource(1053).is_none(), "{layers} tiff to psd"); } } } From 3db1ae122335d2313400303a63459ff5f1122512 Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 14:56:01 -0400 Subject: [PATCH 081/229] docs: regenerate the scorecard for TIFF channels FILE-215-7 (layered TIFF) done, FILE-215-17 (spot plates) partial. Co-Authored-By: Claude Opus 5.5 --- docs/scorecard.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 42ae824b4d..f92ff987b2 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 25 | 59 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 20 not run, 5 not measurable yet (baseline 2026-10-05) | -| [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | +| [File compatibility](#file-compatibility) | 8 | 14 | 5 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 170 (target 0) | | [Type](#type) | 4 | 3 | 2 | | @@ -107,7 +107,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-4 | Adjustments (Hue/Sat + Colorize, Curves, Levels, Shadows/Highlights) apply as Smart Filters | done | [#215](https://github.com/storytold/photocraft/issues/215) | Image › Adjustments on a smart object records image.adjustments. as a smart filter (adjust_cmds.rs adjust_as_smart_filter; the selection becomes the filter mask), re-rendered from the source by adjust_as_filter and re-editable with layer.smartFilter.setParams; PSD maps Levels/Curves/Shadows-Highlights both ways (smart_map.rs). Opening the editor from the smart-filter row is FILE-215-5's row work | | FILE-215-5 | Smart Filter rows: collapse, mask thumb, blend icon, drag reorder, context menu, live preview | partial | [#215](https://github.com/storytold/photocraft/issues/215) | smart_ui.rs:31 rows + visibility + editor; no collapse, drag or context menu | | FILE-215-6 | Smart Object corner skew and perspective (quad or homography transform) | done | [#215](https://github.com/storytold/photocraft/issues/215) | #426: SmartObject.perspective holds the projective map (transform keeps its affine approximation); Distort / Perspective on a smart object set it (transform_cmds.rs, smart_cmds::set_placement), it re-renders from the source through the homography, Warp composes with it, and the PSD placement reads and writes nonAffineTransform / Trnf (io/blocks.rs placed_projective, smart_map.rs); tests distort_survives_edit_contents, warp_on_a_distorted_smart_object_follows_the_corners, placement survives the save | -| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | partial | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha channels and a Quick Mask are not written (the TIFF codec holds one transparency sample) | +| FILE-215-7 | Layered TIFF read and write (tag 37724), honouring the ask-before-saving pref | done | [#215](https://github.com/storytold/photocraft/issues/215) | psd tiff.rs (ImageSourceData, both byte orders), io tiff_layers.rs, ui tiff_options_ui.rs (Discard Layers and Save a Copy); extra alpha/spot channels and a Quick Mask are written as TIFF extra samples (at most 16) plus Photoshop resources for name, colour and solidity; Photoshop's MTrn marker is read (#7415). The TIFF Options Save Transparency choice isn't offered: channels are written as Photoshop does with it off | | FILE-215-8 | Large and BigTIFF import with banded decode across all IFDs | done | [#215](https://github.com/storytold/photocraft/issues/215) | codecs tiff_ifd.rs (bounds-checked IFD chain + SubIFD walk, classic and BigTIFF, cycle-safe; tiff_info / decode_tiff_page), tiff.rs banded parallel strip/tile decode, planar 1 and 2, BigTIFF write (tiff_bigtiff or > 4 GiB); io import_tiff_page; 30 MP RGB8 Deflate 345 -> 32 ms; no page picker in the UI yet | | FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | done | [#215](https://github.com/storytold/photocraft/issues/215) | io svg.rs: paths, groups, text outlines as shape layers (gradients approximated; clip/mask/filter/pattern/image subtrees rasterised); smart_cmds.rs renders an SVG source at its placement scale | | FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | partial | [#215](https://github.com/storytold/photocraft/issues/215) | io pdf.rs: Save As / Save a Copy / CLI write one flattened RGB/Gray/CMYK page with ICC (#3401); print PDFs in print_cmds. Multi-page save, presentation, comps, vector/text output and PDF import (#2541) remain | @@ -117,7 +117,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-14 | User swatch model with ACO/ASE import and export | done | [#215](https://github.com/storytold/photocraft/issues/215) | engine presets/swatches.rs (RGB/HSB/CMYK/Lab/Gray + verbatim colour books, persisted in prefs) + swatch_cmds.rs (swatches.*); psd aco.rs (v1/v2) and ase.rs (groups, spot/global) read+write; ui swatches_ui.rs, Color Picker Add to Swatches | | FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. Missing: a user contour library (New…, picker Load/Save/Replace Contours), Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | -| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only | +| FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | partial | [#215](https://github.com/storytold/photocraft/issues/215) | TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation | | FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, metadata, TGA bits) and confirm remembers them as used. Missing: Export As nearest-neighbour scaling | | FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | ExportSettings has jpeg_quality 1-100 only (lib.rs:118) | | FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only | From 04211f3bf0a80441725ad7ea0f058723530f7ebc Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 18:12:41 -0400 Subject: [PATCH 082/229] io: leave the opaque-composite marker drop to the PSD export PR 2's psd_export already drops a kept merged-transparency marker when the composite is opaque. The layered TIFF still drops it when extra channels are stored, because there the composite keeps its transparency in the PSD model but the TIFF leaves that sample out of the IFD. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/tiff_layers.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 932d88c82b..4d9489d54a 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -369,8 +369,9 @@ fn tiff_image_source(doc: &Document, file: &photocraft_psd::PsdFile) -> ImageSou info.merged_alpha = false; } // A merged-transparency marker kept from an opened file (Photoshop's `MTrn`) would make the - // first spot read back as transparency: keep it only while the composite keeps that sample. - if spot_extras || !file.layer_info.as_ref().is_some_and(|l| l.merged_alpha) { + // first spot read back as transparency. The PSD export already drops it over an opaque + // composite; a transparent one keeps it there, but the TIFF leaves that sample out here. + if spot_extras { isd.global_blocks.retain(|b| !b.is_merged_transparency_marker()); } if let Some(info) = &mut isd.layer_info { From d26d34f9670b4412c01be707df66c5d99cf0d05a Mon Sep 17 00:00:00 2001 From: SBaksa Date: Thu, 22 Oct 2026 10:52:35 +1100 Subject: [PATCH 083/229] Raw: read floating-point JPEG XL raw data, as lossy DNGs store it (fixes #8125) A DNG converter's lossy output stores demosaiced LinearRaw as JPEG XL with SampleFormat 3 (floating point). The reader refused any floating-point raw data, so such a file opened its small preview instead ("floating-point raw data is not supported yet"). read_plane now accepts 16, 24 or 32-bit floats when they are JPEG XL compressed: jxl::decode reads them as f32 and scales them so the file's white level (default 1.0) becomes 65535, clipping outside 0..=65535. Plane carries that factor, and the DNG reader scales the stated black and white levels by it. Floating-point data in any other compression is still refused, now saying so. DngSpec gains raw_extra (extra raw-IFD entries) for the tests. --- crates/io/tests/raw.rs | 22 +++++++++++++++++ crates/raw/src/dng.rs | 12 +++++---- crates/raw/src/jxl.rs | 14 ++++++++--- crates/raw/src/nefc.rs | 4 +-- crates/raw/src/sensor.rs | 21 +++++++++++++--- crates/raw/src/sony.rs | 2 +- crates/raw/src/testgen.rs | 4 +++ crates/raw/tests/dng.rs | 51 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 115 insertions(+), 15 deletions(-) diff --git a/crates/io/tests/raw.rs b/crates/io/tests/raw.rs index 9a8a0798ca..27f4915ce6 100644 --- a/crates/io/tests/raw.rs +++ b/crates/io/tests/raw.rs @@ -22,6 +22,28 @@ fn dng_opens_as_16_bit_prophoto() { assert!(r.warnings.iter().any(|w| w.contains("DNG") && w.contains("ProPhoto")), "{:?}", r.warnings); } +/// #8125: a lossy DNG's floating-point LinearRaw data, JPEG XL-compressed, is developed (not +/// replaced by a preview). The test codestream is an integer one the reader takes as floats. +#[test] +fn floating_point_jpeg_xl_dng_is_developed() { + use photocraft_raw::testgen::DngStorage; + use zune_core::{bit_depth::BitDepth, colorspace::ColorSpace, options::EncoderOptions}; + let (w, h) = (24, 16); + let data: Vec = scene(w, h).iter().flat_map(|p| p.map(|v| (v * 20000.0) as u16)).collect(); + let bytes: Vec = data.iter().flat_map(|v| v.to_ne_bytes()).collect(); + let mut segment = Vec::new(); + zune_jpegxl::JxlSimpleEncoder::new(&bytes, EncoderOptions::new(w, h, ColorSpace::RGB, BitDepth::Sixteen)).encode(&mut segment).unwrap(); + let mut spec = DngSpec::cfa(w, h, data); + spec.samples = 3; + spec.white = 0; + spec.storage = DngStorage::JxlStrips { rows: h }; + spec.jxl_segments = vec![segment]; + spec.raw_extra = vec![(339, Val::Short(vec![3; 3]))]; + let r = import("lossy.dng", &spec.build()).unwrap(); + assert_eq!((r.document.size.width, r.document.size.height, r.document.depth), (24, 16, SampleType::U16)); + assert!(r.warnings.iter().all(|w| !w.contains("preview")), "{:?}", r.warnings); +} + /// #3497: a monochrome (one-sample LinearRaw) DNG, as VueScan writes film scans, opens as a /// 16-bit RGB document (neutral grey) instead of failing as unsupported. #[test] diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index e8422b5419..a17ab41a32 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -221,8 +221,10 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { Some(l) => f32::from(l.iter().copied().max().unwrap_or(u16::MAX)), None => max, }; + // Floating-point levels are in the floats' units: scale them as the samples were. + let unit = plane.float_scale.unwrap_or(1.0); let wl = t.tag_floats(&raw, tag::WHITE_LEVEL); - let white = [0, 1, 2].map(|s| wl.get(s).or_else(|| wl.first()).map(|v| *v as f32).filter(|v| *v > 0.0).unwrap_or(white_default)); + let white = [0, 1, 2].map(|s| wl.get(s).or_else(|| wl.first()).map(|v| *v as f32 * unit).filter(|v| *v > 0.0).unwrap_or(white_default)); let (rows, cols) = match t.tag_uints(&raw, tag::BLACK_LEVEL_REPEAT_DIM).as_slice() { [r, c] if (1..=16).contains(r) && (1..=16).contains(c) => (*r as usize, *c as usize), @@ -230,17 +232,17 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { }; let bl = t.tag_floats(&raw, tag::BLACK_LEVEL); let mut black = if bl.len() == rows * cols * samples { - BlackLevels { rows, cols, values: bl.iter().map(|v| *v as f32).collect(), delta_h: Vec::new(), delta_v: Vec::new() } + BlackLevels { rows, cols, values: bl.iter().map(|v| *v as f32 * unit).collect(), delta_h: Vec::new(), delta_v: Vec::new() } } else { - BlackLevels::uniform(bl.first().copied().unwrap_or(0.0) as f32) + BlackLevels::uniform(bl.first().copied().unwrap_or(0.0) as f32 * unit) }; let dh = t.tag_floats(&raw, tag::BLACK_LEVEL_DELTA_H); if dh.len() == active.width { - black.delta_h = dh.iter().map(|v| *v as f32).collect(); + black.delta_h = dh.iter().map(|v| *v as f32 * unit).collect(); } let dv = t.tag_floats(&raw, tag::BLACK_LEVEL_DELTA_V); if dv.len() == active.height { - black.delta_v = dv.iter().map(|v| *v as f32).collect(); + black.delta_v = dv.iter().map(|v| *v as f32 * unit).collect(); } let color = color_info(t, &ifd0); diff --git a/crates/raw/src/jxl.rs b/crates/raw/src/jxl.rs index 0717dbb3cd..5eec769211 100644 --- a/crates/raw/src/jxl.rs +++ b/crates/raw/src/jxl.rs @@ -18,14 +18,15 @@ use crate::error::{RawError, Result}; /// The browser build has no JPEG XL decoder (see the module docs). #[cfg(target_arch = "wasm32")] -pub(crate) fn decode(_src: &[u8], _w: usize, _h: usize, _samples: usize, _bits: u32, _limits: &Limits) -> Result> { +pub(crate) fn decode(_src: &[u8], _w: usize, _h: usize, _samples: usize, _bits: u32, _float_scale: Option, _limits: &Limits) -> Result> { Err(RawError::unsupported("JPEG XL-compressed DNG (DNG 1.7) can't be opened in the browser version of PhotoCraft; open it in the desktop app")) } /// Decodes one segment into `w * h * samples` interleaved samples at `bits` per sample (the -/// TIFF BitsPerSample, which DNG requires the codestream's bit depth to match). +/// TIFF BitsPerSample, which DNG requires the codestream's bit depth to match). Floating-point +/// samples (SampleFormat 3) come back multiplied by `float_scale`, clipped to 0..=65535. #[cfg(not(target_arch = "wasm32"))] -pub(crate) fn decode(src: &[u8], w: usize, h: usize, samples: usize, bits: u32, limits: &Limits) -> Result> { +pub(crate) fn decode(src: &[u8], w: usize, h: usize, samples: usize, bits: u32, float_scale: Option, limits: &Limits) -> Result> { limits.check(w as u64, h as u64, samples as u64 * 2)?; // jxl-oxide decodes into f32/i32 planes: allow a few times the output size, and never more // than the caller's per-buffer limit. @@ -50,6 +51,13 @@ pub(crate) fn decode(src: &[u8], w: usize, h: usize, samples: usize, bits: u32, return Err(RawError::unsupported("rotated JPEG XL raw tile")); } let n = w.checked_mul(h).and_then(|p| p.checked_mul(samples)).ok_or_else(|| RawError::malformed("JPEG XL raw tile is too large"))?; + if let Some(scale) = float_scale { + let mut f = vec![0f32; n]; + if stream.write_to_buffer(&mut f) != n { + return Err(RawError::malformed("JPEG XL raw data is truncated")); + } + return Ok(f.into_iter().map(|v| if v.is_nan() { 0 } else { (v * scale).clamp(0.0, 65535.0).round() as u16 }).collect()); + } let mut out = vec![0u16; n]; if stream.write_to_buffer(&mut out) != n { return Err(RawError::malformed("JPEG XL raw data is truncated")); diff --git a/crates/raw/src/nefc.rs b/crates/raw/src/nefc.rs index db1f8b1d74..2ae312917d 100644 --- a/crates/raw/src/nefc.rs +++ b/crates/raw/src/nefc.rs @@ -69,7 +69,7 @@ pub(crate) fn read_packed(t: &Tiff, ifd: &Ifd, bits: u32, limits: &Limits) -> Re }; let src = t.bytes(*off as usize, *len as usize).ok_or_else(|| RawError::malformed("packed NEF strip lies outside the file"))?; let data = unpack_little_rows(src, w, h, bits)?; - Ok(Plane { width: w, height: h, samples: 1, bits, data }) + Ok(Plane { width: w, height: h, samples: 1, bits, data, float_scale: None }) } fn unpack_little_rows(src: &[u8], w: usize, h: usize, bits: u32) -> Result> { @@ -377,7 +377,7 @@ pub(crate) fn read_compressed(t: &Tiff, raw: &Ifd, note: Option<&(Tiff, Ifd)>, l let end = (last as usize).saturating_add(last_len as usize).min(t.data.len()); let src = t.data.get(start..end).filter(|s| !s.is_empty()).ok_or_else(|| RawError::malformed("raw data lies outside the file"))?; let data = decode(src, width, height, bits, &table)?; - Ok(Plane { width, height, samples: 1, bits, data }) + Ok(Plane { width, height, samples: 1, bits, data, float_scale: None }) } #[cfg(test)] diff --git a/crates/raw/src/sensor.rs b/crates/raw/src/sensor.rs index 8defea86ca..f45ba8738e 100644 --- a/crates/raw/src/sensor.rs +++ b/crates/raw/src/sensor.rs @@ -189,6 +189,9 @@ pub(crate) struct Plane { pub samples: usize, pub bits: u32, pub data: Vec, + /// For floating-point samples, the factor they were multiplied by (their white level becomes + /// 65535, `bits` is then 16): the file's black and white levels are scaled by it too. + pub float_scale: Option, } pub(crate) struct Segment { @@ -237,9 +240,19 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou if !(1..=4).contains(&samples) { return Err(RawError::unsupported(format!("{samples} samples per pixel"))); } - if t.tag_uint(ifd, tag::SAMPLE_FORMAT) == Some(3) { - return Err(RawError::unsupported("floating-point raw data")); + // Floating-point data is read when it is JPEG XL-compressed, as converters write lossy + // LinearRaw (DNG 1.7, #8125): 16, 24 or 32-bit floats, read as 16-bit samples. + let float = t.tag_uint(ifd, tag::SAMPLE_FORMAT) == Some(3); + if float && compression != JPEG_XL { + return Err(RawError::unsupported("floating-point raw data that isn't JPEG XL-compressed")); } + if float && !matches!(bits, 16 | 24 | 32) { + return Err(RawError::unsupported(format!("{bits}-bit floating-point raw samples"))); + } + let bits = if float { 16 } else { bits }; + // The floats' white level (default 1.0, DNG 1.7 WhiteLevel) becomes 65535. + let float_scale = + float.then(|| 65535.0 / t.tag_floats(ifd, tag::WHITE_LEVEL).first().map(|v| *v as f32).filter(|v| v.is_finite() && *v > 0.0).unwrap_or(1.0)); if !(1..=16).contains(&bits) { return Err(RawError::unsupported(format!("{bits}-bit raw samples"))); } @@ -288,7 +301,7 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou // A JPEG XL segment codes only the rows inside the image (strips) or the whole tile. JPEG_XL => { let coded_rows = if tiled { s.h } else { rows }; - let mut v = jxl::decode(src, s.w, coded_rows, samples, bits, limits)?; + let mut v = jxl::decode(src, s.w, coded_rows, samples, bits, float_scale, limits)?; v.resize(s.w * s.h * samples, 0); Ok(v) } @@ -331,7 +344,7 @@ pub(crate) fn read_plane(t: &Tiff, ifd: &Ifd, limits: &Limits, layout: JpegLayou data[at..at + cols * samples].copy_from_slice(src); } } - Ok(Plane { width, height, samples, bits, data }) + Ok(Plane { width, height, samples, bits, data, float_scale }) } pub(crate) fn segments(t: &Tiff, ifd: &Ifd, width: usize, height: usize) -> Result> { diff --git a/crates/raw/src/sony.rs b/crates/raw/src/sony.rs index e1961680df..06919f62ee 100644 --- a/crates/raw/src/sony.rs +++ b/crates/raw/src/sony.rs @@ -143,7 +143,7 @@ pub(crate) fn read_craw(t: &Tiff, ifd: &Ifd, limits: &Limits) -> Result { } } }); - Ok(Plane { width, height, samples: 1, bits: 14, data }) + Ok(Plane { width, height, samples: 1, bits: 14, data, float_scale: None }) } #[cfg(test)] diff --git a/crates/raw/src/testgen.rs b/crates/raw/src/testgen.rs index 9d8b8fb94c..db43cc26bd 100644 --- a/crates/raw/src/testgen.rs +++ b/crates/raw/src/testgen.rs @@ -487,6 +487,8 @@ pub struct DngSpec { pub model: String, /// Extra IFD0 entries, written after the generated ones (e.g. malformed colour tags). pub ifd0_extra: Vec<(u16, Val)>, + /// Extra raw-IFD entries, written after the generated ones (e.g. SampleFormat). + pub raw_extra: Vec<(u16, Val)>, } impl DngSpec { @@ -524,6 +526,7 @@ impl DngSpec { make: "Photocraft".into(), model: "Synthetic".into(), ifd0_extra: Vec::new(), + raw_extra: Vec::new(), } } @@ -772,6 +775,7 @@ impl DngSpec { if let Some(l) = &self.linearization { raw.push((50712, Val::Short(l.clone()))); } + raw.extend(self.raw_extra.iter().cloned()); let raw_ifd = t.ifd(raw); // IFD0: a tiny RGB thumbnail plus the DNG metadata. let thumb = t.blob(vec![128; 3 * 4]); diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index 7cc04d2a92..71f9111865 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -685,6 +685,57 @@ fn jpeg_xl_linear_raw_develops() { assert_eq!(develop(&spec.build(), &DevelopOptions::default()).unwrap().rgb, d.rgb); } +/// A LinearRaw JPEG XL strip of `data` (16-bit samples) whose raw IFD says the samples are +/// floating-point (SampleFormat 3), as converters write lossy DNGs (#8125). The codestream is +/// an integer one (the test encoder writes no floats): the decoder reads it as floats in 0..=1, +/// the same values a float codestream of `data / 65535` holds. +fn float_jxl_dng(data: &[u16], w: usize, h: usize, white: Option) -> DngSpec { + let mut spec = DngSpec::cfa(w, h, data.to_vec()); + spec.samples = 3; + spec.white = 0; + spec.storage = DngStorage::JxlStrips { rows: h }; + spec.jxl_segments = vec![jxl(data, w, h, 3, 16)]; + spec.raw_extra = vec![(339, Val::Short(vec![3; 3]))]; + if let Some(white) = white { + spec.raw_extra.push((50717, Val::Double(vec![white; 3]))); + } + spec +} + +#[test] +fn floating_point_jpeg_xl_linear_raw_decodes() { + let (w, h) = (24, 16); + let data = noise(w * h * 3, 16); + // White level 1.0 (the default) becomes 65535: the samples read back as stored. + let s = sensor(&float_jxl_dng(&data, w, h, None).build()); + assert_eq!((s.width, s.height, s.samples), (w, h, 3)); + assert_eq!(s.data, data); + assert_eq!(s.white, [65535.0; 3]); + // A stated white level of 0.5 becomes 65535 too: samples double, those above it clip. + let s = sensor(&float_jxl_dng(&data, w, h, Some(0.5)).build()); + assert_eq!(s.white, [65535.0; 3]); + assert_eq!(s.data, data.iter().map(|&v| (u32::from(v) * 2).min(65535) as u16).collect::>()); + // It develops like the same samples stored as integers. + let pixels: Vec = scene(w, h).iter().flat_map(|p| p.map(|v| (v * 20000.0) as u16)).collect(); + let float = develop(&float_jxl_dng(&pixels, w, h, None).build(), &DevelopOptions::default()).unwrap(); + let mut ints = DngSpec::cfa(w, h, pixels.clone()); + ints.samples = 3; + ints.white = 65535; + ints.storage = DngStorage::Strips { rows: h }; + assert_eq!(float.rgb, develop(&ints.build(), &DevelopOptions::default()).unwrap().rgb); +} + +#[test] +fn floating_point_raw_data_that_isnt_jpeg_xl_is_unsupported() { + let (w, h) = (8, 4); + let mut spec = float_jxl_dng(&noise(w * h * 3, 16), w, h, None); + spec.storage = DngStorage::Strips { rows: h }; + match decode(&spec.build(), &Limits::default()) { + Err(RawError::Unsupported(m)) => assert!(m.contains("floating-point"), "{m}"), + other => panic!("expected unsupported, got {other:?}"), + } +} + #[test] fn jpeg_xl_mismatches_are_errors() { let (w, h) = (16, 8); From b79475b65bc4b8190a481c6be1dec72402d3bd7a Mon Sep 17 00:00:00 2001 From: SBaksa Date: Thu, 22 Oct 2026 12:16:46 +1100 Subject: [PATCH 084/229] Raw: charge a float JPEG XL tile's f32 buffer to the limits; try smaller JPEG XL previews when the largest doesn't decode Floating-point samples are read into four bytes each before they become 16-bit, but jxl::decode charged the limits for two. It now charges four. embedded_jxl_preview tried only the largest JPEG XL preview, so one over the limits or damaged left the import with the small JPEG thumbnail even when a smaller JPEG XL preview decodes. It now tries them from the largest down. --- crates/raw/src/jxl.rs | 4 +++- crates/raw/src/preview.rs | 12 ++++++----- crates/raw/tests/dng.rs | 44 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/crates/raw/src/jxl.rs b/crates/raw/src/jxl.rs index 5eec769211..8dc2ff064f 100644 --- a/crates/raw/src/jxl.rs +++ b/crates/raw/src/jxl.rs @@ -27,7 +27,9 @@ pub(crate) fn decode(_src: &[u8], _w: usize, _h: usize, _samples: usize, _bits: /// samples (SampleFormat 3) come back multiplied by `float_scale`, clipped to 0..=65535. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn decode(src: &[u8], w: usize, h: usize, samples: usize, bits: u32, float_scale: Option, limits: &Limits) -> Result> { - limits.check(w as u64, h as u64, samples as u64 * 2)?; + // Floating-point samples are read into an f32 buffer first: four bytes a sample, not two. + let sample_bytes = if float_scale.is_some() { 4 } else { 2 }; + limits.check(w as u64, h as u64, samples as u64 * sample_bytes)?; // jxl-oxide decodes into f32/i32 planes: allow a few times the output size, and never more // than the caller's per-buffer limit. let budget = (w as u64).saturating_mul(h as u64).saturating_mul(samples as u64).saturating_mul(16).min(limits.max_alloc); diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 489eea3b9e..9ff07c3115 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -120,8 +120,9 @@ pub struct DecodedPreview { /// DNG PreviewColorSpace (DNG Specification 1.7). const PREVIEW_COLOR_SPACE: u16 = 50970; -/// The largest JPEG XL-compressed RGB preview of a DNG, decoded within `limits`. `None` when the -/// file has none, or it doesn't decode (always in the browser build, which has no JPEG XL decoder). +/// The largest JPEG XL-compressed RGB preview of a DNG that decodes within `limits`: larger ones +/// that don't (over the limits, or damaged) give way to the next. `None` when none decodes +/// (always in the browser build, which has no JPEG XL decoder). pub fn embedded_jxl_preview(bytes: &[u8], limits: &Limits) -> Option { let t = Tiff::new(bytes)?; let ifd0 = t.ifd_at(t.first_ifd, 0)?; @@ -129,13 +130,14 @@ pub fn embedded_jxl_preview(bytes: &[u8], limits: &Limits) -> Option = t .all_ifds() .into_iter() .filter(|i| t.tag_uint(i, tag::NEW_SUBFILE_TYPE) == Some(1)) .filter(|i| t.tag_uint(i, tag::COMPRESSION) == Some(JPEG_XL) && t.tag_uint(i, tag::SAMPLES_PER_PIXEL) == Some(3)) - .max_by_key(area)?; - let plane = read_plane(&t, &ifd, limits, JpegLayout::Flat).ok()?; + .collect(); + previews.sort_by_key(|i| std::cmp::Reverse(area(i))); + let (ifd, plane) = previews.into_iter().find_map(|i| read_plane(&t, &i, limits, JpegLayout::Flat).ok().map(|p| (i, p)))?; let color_space = match t.tag_uint(&ifd, PREVIEW_COLOR_SPACE).or_else(|| t.tag_uint(&ifd0, PREVIEW_COLOR_SPACE)) { Some(2) => Some(PreviewColorSpace::Srgb), Some(3) => Some(PreviewColorSpace::AdobeRgb), diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index 71f9111865..c336c97484 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -736,6 +736,50 @@ fn floating_point_raw_data_that_isnt_jpeg_xl_is_unsupported() { } } +/// Floating-point samples are read into four bytes each before they become 16-bit: the limits +/// are charged for that buffer, not for the two-byte result. +#[test] +fn floating_point_jpeg_xl_is_charged_four_bytes_a_sample() { + let (w, h) = (24, 16); + let bytes = float_jxl_dng(&noise(w * h * 3, 16), w, h, None).build(); + let limits = Limits { max_alloc: (w * h * 3 * 2) as u64, ..Limits::default() }; + match decode(&bytes, &limits) { + Err(RawError::LimitExceeded(_)) => {} + other => panic!("expected the limits to refuse the f32 buffer, got {other:?}"), + } +} + +/// When the largest JPEG XL preview doesn't decode, the next largest is used. +#[test] +fn a_jpeg_xl_preview_that_does_not_decode_gives_way_to_a_smaller_one() { + use photocraft_raw::testgen::TiffBuilder; + let mut t = TiffBuilder::default(); + let preview = |t: &mut TiffBuilder, w: usize, h: usize, stream: Vec| { + let len = stream.len() as u32; + let strip = t.blob(stream); + t.ifd(vec![ + (254, Val::Long(vec![1])), + (256, Val::Long(vec![w as u32])), + (257, Val::Long(vec![h as u32])), + (258, Val::Short(vec![8, 8, 8])), + (259, Val::Short(vec![52546])), + (262, Val::Short(vec![2])), + (273, Val::Blobs(vec![strip])), + (277, Val::Short(vec![3])), + (278, Val::Long(vec![h as u32])), + (279, Val::Long(vec![len])), + ]) + }; + let small = noise(20 * 15 * 3, 8); + let damaged = preview(&mut t, 40, 30, vec![0xFF, 0x0A, 0, 0, 0, 0, 0, 0]); + let valid = preview(&mut t, 20, 15, jxl(&small, 20, 15, 3, 8)); + let ifd0 = t.ifd(vec![(254, Val::Long(vec![1])), (330, Val::Ifds(vec![damaged, valid])), (50706, Val::Byte(vec![1, 7, 0, 0]))]); + t.chain = vec![ifd0]; + let p = embedded_jxl_preview(&t.build(), &Limits::default()).expect("the 20×15 preview"); + assert_eq!((p.width, p.height, p.bits), (20, 15, 8)); + assert_eq!(p.rgb, small); +} + #[test] fn jpeg_xl_mismatches_are_errors() { let (w, h) = (16, 8); From b92ac6fab075025799558b7880f4b82f9a10b23b Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 13:31:01 -0400 Subject: [PATCH 085/229] PSD: spot channels in Photoshop's polarity; earlier PhotoCraft PSDs keep their ink Photoshop 27.11 stores spot ink 0 = solid in every PSD it saves (its 7 spot PSDs store the 100/25/75/0 % columns as 0/191/64/255) and numbers the extra channels 3, 4, 5 in resource 1053, after 0 for the merged transparency. PhotoCraft's PSDs stored the ink straight and wrote no 1053. - PSD save and open use Photoshop's polarity through channel_map::photoshop_spot_plane; alpha channels stay straight and the in-memory convention (1 = solid ink) is unchanged. - A fresh 1053 is written with the extra channels; a kept one is never written next to it. - psd_import::earlier_photocraft_spot_planes: a PSD whose 1077 marks a spot, with no 1053 and the exact 1057 PhotoCraft 0.1.0-0.7.0 (CS6, NUL) or 0.8.2-0.11.1 (2026) wrote, is read straight, so it keeps its ink. - Part 3's switch (PsdExportOptions::photoshop_spot_polarity, PsdImportOptions) is gone: PSD and the layered TIFF share one path. - PSD keeps main's empty LMsk and layer flags; Multichannel is unchanged. - Docs: file-format parity, gaps G10, target-app parity, FILE-215-17 note. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Zer0C00L01 <12090083+Zer0C00L01@users.noreply.github.com> --- crates/io/src/channel_map.rs | 59 ++++++++-- crates/io/src/lib.rs | 3 +- crates/io/src/psd_export.rs | 96 +++++++++------ crates/io/src/psd_import.rs | 209 +++++++++++++++++++++++++++++++-- crates/io/src/tiff_layers.rs | 22 ++-- crates/io/tests/tiff_layers.rs | 51 ++++++-- docs/file-format-parity.md | 5 +- docs/gaps.md | 8 +- docs/target-app-parity.md | 5 +- scorecard/file_compat.toml | 2 +- 10 files changed, 369 insertions(+), 91 deletions(-) diff --git a/crates/io/src/channel_map.rs b/crates/io/src/channel_map.rs index 990027441a..c32553324f 100644 --- a/crates/io/src/channel_map.rs +++ b/crates/io/src/channel_map.rs @@ -25,10 +25,10 @@ const SPACE_GRAY: u16 = 8; /// Converts one spot channel's plane (native-endian samples) between the document's ink density /// (1 = solid ink) and the way Photoshop stores a spot channel (0 = solid ink, the maximum = -/// bare paper; every TIFF Photoshop 27 saves stores it so). The conversion is its own inverse, -/// so it serves both open and save. Every reader and writer of Photoshop-polarity spot planes -/// goes through here: a change to the in-memory convention changes only this function. Alpha -/// channels are stored straight (the maximum = selected) and never come here. +/// bare paper; every PSD and TIFF Photoshop 27.11 saves stores it so). The conversion is its own +/// inverse, so it serves both open and save. Every reader and writer of Photoshop-polarity spot +/// planes goes through here: a change to the in-memory convention changes only this function. +/// Alpha channels are stored straight (the maximum = selected) and never come here. pub(crate) fn photoshop_spot_plane(plane: &mut [u8], sample: SampleType) { crate::pixels::invert_plane(plane, sample); } @@ -36,9 +36,13 @@ pub(crate) fn photoshop_spot_plane(plane: &mut [u8], sample: SampleType) { /// Resource 1053 for `count` channels: one big-endian u32 identifier each, with no length prefix /// (Photoshop, ag-psd and psd-tools read u32s until the resource ends). Photoshop 27.11 numbers a /// new document's channels 3, 4, 5, … in every colour mode (RGB, CMYK, Gray and Lab, in its PSDs -/// and TIFFs alike), so these do too. -pub(crate) fn alpha_identifiers(count: usize) -> Vec { - let mut out = Vec::with_capacity(count.saturating_mul(4)); +/// and TIFFs alike), so these do too. `transparency`: the merged image's transparency channel +/// comes first, with identifier 0 (Photoshop's transparent PSD lists `0, 3, 4, 5`). +pub(crate) fn alpha_identifiers(transparency: bool, count: usize) -> Vec { + let mut out = Vec::with_capacity(count.saturating_add(1).saturating_mul(4)); + if transparency { + out.extend_from_slice(&0u32.to_be_bytes()); + } for i in 0..count { let id = 3u32.saturating_add(u32::try_from(i).unwrap_or(u32::MAX)); out.extend_from_slice(&id.to_be_bytes()); @@ -86,6 +90,14 @@ pub(crate) fn spot_flags(data: &[u8], versioned: bool, skip: usize) -> Vec display_records(data, versioned).skip(skip).map(|rec| rec.get(12) == Some(&2)).collect() } +/// Whether a DisplayInfo resource 1077 marks any channel as a spot (kind 2), in 13-byte records +/// (Photoshop's, and PhotoCraft's since 0.8.2) or in the 14-byte padded records PhotoCraft wrote +/// up to 0.7.0. +pub(crate) fn display_info_marks_spot(data: &[u8]) -> bool { + let body = data.get(4..).unwrap_or_default(); + [13, 14].into_iter().any(|size| body.chunks_exact(size).any(|rec| rec.get(12) == Some(&2))) +} + /// Applies a DisplayInfo resource (`versioned` = 1077: 13-byte records after a version; else the /// older 1007 with 14-byte records) to the imported channels in order, after skipping `skip` /// records (the merged transparency channel's). @@ -189,8 +201,9 @@ mod tests { assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas); assert_eq!(back[1].spot, s.spot); assert_eq!(spot_flags(&bytes, true, 0), [false, true]); - assert_eq!(alpha_identifiers(2), [0, 0, 0, 3, 0, 0, 0, 4]); - assert!(alpha_identifiers(0).is_empty()); + assert!(display_info_marks_spot(&bytes)); + assert!(!display_info_marks_spot(&display_info(&[&a], true)), "alpha and transparency records only"); + assert!(!display_info_marks_spot(&bytes[..10])); // Photoshop 27 writes a 13-byte record (no pad): red spot, solidity 60. let ps = [0, 0, 0, 1, 0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 0x3c, 2]; let mut spot = vec![AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8))]; @@ -227,6 +240,34 @@ mod tests { assert_eq!(got[13].spot.map(|(_, s)| (s * 100.0).round() as u16), many[13].spot.map(|(_, s)| (s * 100.0).round() as u16)); } + /// Resource 1053 as Photoshop 27.11 numbers a new document's channels: 3, 4, 5 in every colour + /// mode, with the merged transparency first as 0 (its transparent PSD lists `0, 3, 4, 5`). + #[test] + fn alpha_identifiers_are_numbered_as_photoshop_does() { + let ids = |transparency, count| alpha_identifiers(transparency, count).as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect::>(); + assert_eq!(ids(false, 3), [3, 4, 5]); + assert_eq!(ids(true, 3), [0, 3, 4, 5]); + assert_eq!(ids(false, 1), [3]); + assert!(ids(false, 0).is_empty()); + assert_eq!(ids(true, 0), [0]); + assert_eq!(alpha_identifiers(false, 2), [0, 0, 0, 3, 0, 0, 0, 4]); + } + + /// PhotoCraft 0.1.0 to 0.7.0 padded each 1077 record to 14 bytes, so a spot after the first + /// record sits where a 13-byte reader finds no kind 2; it is still recognised. + #[test] + fn a_spot_is_found_in_padded_records() { + let alpha = |kind: u8| [0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 50, kind, 0]; + let mut old = 1u32.to_be_bytes().to_vec(); + old.extend_from_slice(&alpha(1)); + old.extend_from_slice(&alpha(0)); + old.extend_from_slice(&alpha(2)); + assert_eq!(spot_flags(&old, true, 0), [false, false, false], "13-byte records misread the padded ones"); + assert!(display_info_marks_spot(&old)); + old.truncate(old.len() - 14); + assert!(!display_info_marks_spot(&old), "two alpha records"); + } + /// Photoshop's spot ink (0 = solid) and the document's density (1 = solid) at every depth, /// both ways: the 25 % / 75 % columns of the Photoshop oracles store 191 / 64. #[test] diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 70d66f8c5c..946abfbc75 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -321,8 +321,7 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m psd_import::check_group_depth(&file)?; ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(0.05); - let (mut document, warnings) = - psd_import::psd_to_document_decoded(&file, ctl, merged, psd_import::PsdImportOptions::default()).ok_or(IoError::Cancelled)?; + let (mut document, warnings) = psd_import::psd_to_document_decoded(&file, ctl, merged).ok_or(IoError::Cancelled)?; document.name = name.to_string(); text_import::prepare(&mut document); return Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false }); diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 483b305f00..daa25ca2bc 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -50,11 +50,6 @@ pub struct PsdExportOptions { /// The path of the document's own file: linked smart objects are resolved against it /// ([`crate::doc_path::resolve`]) before they are read and embedded. pub document_path: Option, - /// Store spot channels as Photoshop's TIFFs do, 0 = solid ink (see - /// [`crate::channel_map::photoshop_spot_plane`]). Only the layered TIFF sets it: a PSD keeps - /// writing the document's density as it is (1 = solid ink), so files saved by earlier - /// versions keep reading the same. - pub photoshop_spot_polarity: bool, } impl Default for PsdExportOptions { @@ -66,7 +61,6 @@ impl Default for PsdExportOptions { merged_compression: Compression::Rle, maximize_compatibility: true, document_path: None, - photoshop_spot_polarity: false, } } } @@ -1712,7 +1706,8 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c for a in &extra { let s = if a.surface.format() != ex.mask_fmt { a.surface.convert(ex.mask_fmt) } else { a.surface.clone() }; let mut bytes = s.to_interleaved(canvas); - if opts.photoshop_spot_polarity && a.spot.is_some() { + // Spot ink as Photoshop stores it (0 = solid); an alpha channel stays straight. + if a.spot.is_some() { crate::channel_map::photoshop_spot_plane(&mut bytes, sample); } planes.extend(deinterleave(&bytes, 1, sample, &[false]).remove(0)); @@ -1746,6 +1741,9 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c resources.push(ImageResource::new(1006, pascal_names_resource(&names))); resources.push(ImageResource::new(1045, unicode_names_resource(&names))); resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&extra, has_alpha))); + // Photoshop writes 1053 in every file with an extra channel. Its absence is how a file from + // an earlier PhotoCraft is told apart (`psd_import::earlier_photocraft_spot_planes`). + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(has_alpha, extra.len()))); } if let Some(id) = quick_mask_id { let mut data = id.to_be_bytes().to_vec(); @@ -1828,6 +1826,10 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if !keep_imported_resource(doc, *id, data) { continue; } + // A fresh 1053 was written with the extra channels; a preserved copy would be a second one. + if *id == crate::channel_map::ALPHA_IDENTIFIERS && !extra.is_empty() { + continue; + } let mut r = ImageResource::new(*id, data.to_vec()); r.name = legacy_name(name); resources.push(r); @@ -1972,25 +1974,28 @@ mod tests { doc } - /// Photoshop's spot polarity is for the layered TIFF only: a PSD stores a spot plane and an - /// alpha plane straight (255 = solid ink, 255 = selected) as before, so files saved by earlier - /// versions and this one read the same. It writes no resource 1053, an empty global layer - /// mask, and flag bit 3 on the layers but not on the group's closing divider. Opening it gives - /// the same ink back. + /// The extra planes after the colour channels (and the merged transparency) of `file`. + fn extra_planes(file: &PsdFile, count: usize) -> Vec { + let planes = file.decode_merged().unwrap(); + let plane = (file.header.width * file.header.height) as usize * usize::from(file.header.depth / 8); + planes[planes.len() - count * plane..].to_vec() + } + + /// Resource 1053's identifiers. + fn alpha_ids(file: &PsdFile) -> Option> { + file.resource(crate::channel_map::ALPHA_IDENTIFIERS).map(|r| r.data.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect()) + } + + /// A PSD stores spot ink as Photoshop's own PSDs do (0 = solid ink: the 100, 25, 75 and 0 % + /// columns are 0, 191, 64, 255) and an alpha channel straight (255 = selected). The global + /// layer mask stays empty and flag bit 3 stays on the layers only, not on the group's closing + /// divider: Photoshop opens those as they are, and 509 of the corpus's 721 Photoshop PSDs have + /// an empty mask too. Opening the file gives the same ink back. #[test] - fn psd_stores_spot_and_alpha_planes_straight() { + fn psd_stores_spot_ink_as_photoshop_does() { let doc = spot_and_alpha_document(); - let written = |photoshop_spot_polarity| { - let opts = PsdExportOptions { photoshop_spot_polarity, ..Default::default() }; - PsdFile::from_bytes(&document_to_psd_with(&doc, &opts).0.to_bytes().unwrap()).unwrap() - }; - let extra_planes = |file: &PsdFile| { - let planes = file.decode_merged().unwrap(); - planes[planes.len() - 2 * 4..].to_vec() - }; - let file = written(false); - assert_eq!(extra_planes(&file), [255, 64, 191, 0, 255, 64, 191, 0], "spot, then alpha"); - assert!(file.resource(crate::channel_map::ALPHA_IDENTIFIERS).is_none()); + let file = PsdFile::from_bytes(&document_to_psd(&doc).to_bytes().unwrap()).unwrap(); + assert_eq!(extra_planes(&file, 2), [0, 191, 64, 255, 255, 64, 191, 0], "spot, then alpha"); assert_eq!(file.global_layer_mask.as_ref().map(|m| m.data.len()), Some(0)); let flags: Vec = file.layers().iter().map(|l| l.flags.0 & LayerFlags::BIT4_USEFUL).collect(); assert_eq!(flags, [0, LayerFlags::BIT4_USEFUL, LayerFlags::BIT4_USEFUL], "closing divider, layer, group"); @@ -1999,23 +2004,46 @@ mod tests { for c in &back.channels { assert_eq!(c.surface.to_interleaved(back.bounds()), [255, 64, 191, 0], "{}", c.name); } - // The layered TIFF's switch inverts the spot plane only. - assert_eq!(extra_planes(&written(true)), [0, 191, 64, 255, 255, 64, 191, 0]); } - /// Resource 1053 (alpha identifiers, in every Photoshop file with an extra channel) is kept - /// from an opened PSD and written back unchanged, as before. + /// Resource 1053 numbers the extra channels as Photoshop 27.11 does in a new document: 3, 4, 5 + /// in every colour mode, after 0 for the merged transparency when there is one (its + /// transparent PSD lists `0, 3, 4, 5`). A Quick Mask is an extra channel too. Without extra + /// channels there is none. #[test] - fn psd_alpha_identifiers_survive_open_and_save() { + fn psd_alpha_identifiers_number_the_channels_as_photoshop_does() { + use photocraft_color::Color; + let size = photocraft_geom::Size::new(4, 1); + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale, ColorMode::Lab] { + let mut doc = Document::with_background("bg", size, mode, SampleType::U8, Color::rgb(1.0, 1.0, 1.0)); + assert_eq!(alpha_ids(&document_to_psd(&doc)), None, "{mode:?} without channels"); + doc.channels = spot_and_alpha_document().channels; + let file = document_to_psd(&doc); + assert!(!file.merged_has_alpha(), "{mode:?}"); + assert_eq!(alpha_ids(&file), Some(vec![3, 4]), "{mode:?}"); + doc.quick_mask = Some(photocraft_doc::AlphaChannel::new("Quick Mask", Surface::new(PixelFormat::GRAY8))); + assert_eq!(alpha_ids(&document_to_psd(&doc)), Some(vec![3, 4, 5]), "{mode:?} with a Quick Mask"); + } + // The empty layer leaves the composite transparent. + let file = document_to_psd(&spot_and_alpha_document()); + assert!(file.merged_has_alpha()); + assert_eq!(alpha_ids(&file), Some(vec![0, 3, 4])); + } + + /// A Photoshop file's own 1053 is kept on open (as every unmapped resource is), but a save + /// writes one fresh 1053 for the channels the document has, never the kept one as well. The + /// identifiers here, 3 and 2, are those of psd-tools' `cmyk-alpha-spot.psd` (Photoshop 24.7). + #[test] + fn a_kept_alpha_identifiers_resource_is_not_written_twice() { let mut file = document_to_psd(&spot_and_alpha_document()); - let ids = [0, 0, 0, 3, 0, 0, 0, 2]; - file.resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, ids.to_vec())); + file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS); + file.resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, [0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 2].to_vec())); let opened = crate::import("ps.psd", &file.to_bytes().unwrap()).unwrap().document; + assert!(opened.metadata.psd_resources.iter().any(|(id, ..)| *id == crate::channel_map::ALPHA_IDENTIFIERS)); let saved = crate::export(&opened, "x.psd", &crate::ExportOptions::default()).unwrap(); let again = PsdFile::from_bytes(&saved.bytes).unwrap(); - let kept: Vec<&ImageResource> = again.resources.iter().filter(|r| r.id == crate::channel_map::ALPHA_IDENTIFIERS).collect(); - assert_eq!(kept.len(), 1); - assert_eq!(kept[0].data, ids); + assert_eq!(again.resources.iter().filter(|r| r.id == crate::channel_map::ALPHA_IDENTIFIERS).count(), 1); + assert_eq!(alpha_ids(&again), Some(vec![0, 3, 4])); } #[test] diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index cf7c522e41..14118c2c86 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -751,16 +751,48 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec) { /// [`psd_to_document`] for a background open: checks `ctl` per layer record and reports /// progress. `None` when cancelled. pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) -> Option<(Document, Vec)> { - psd_to_document_decoded(file, ctl, None, PsdImportOptions::default()) + psd_to_document_decoded(file, ctl, None) } -/// Options for [`psd_to_document_decoded`]. -#[derive(Debug, Clone, Copy, Default)] -pub(crate) struct PsdImportOptions { - /// Read spot channels as Photoshop's TIFFs store them, 0 = solid ink (see - /// [`crate::channel_map::photoshop_spot_plane`]). Only the layered TIFF sets it: a PSD's spot - /// planes are read as the document's density, as earlier versions saved them. - pub photoshop_spot_polarity: bool, +/// Whether `file` stores its spot planes the way earlier PhotoCraft versions (releases 0.1.0 to +/// 0.11.1) wrote them: straight (the maximum = solid ink), the opposite of Photoshop, which +/// stores 0 = solid ink in every PSD and TIFF it saves. Their files are recognised by three +/// things together: +/// +/// - DisplayInfo (1077) marks at least one spot channel; +/// - there is no Alpha Identifiers resource (1053). Photoshop has written it in every file with +/// an extra channel since version 6, and PhotoCraft writes it now, but no earlier PhotoCraft did; +/// - the version info (1057) is byte for byte what those versions wrote: writer "Adobe Photoshop" +/// with reader "Adobe Photoshop CS6", both NUL-terminated (0.1.0 to 0.7.0), or reader "Adobe +/// Photoshop 2026" without a NUL (0.8.2 on), with either merged-data flag. +/// +/// Any other file is read in Photoshop's polarity. psd-tools' `cmyk-spot.psd`, for one, also has +/// spots and no 1053, but names psd-tools in 1057 and stores Photoshop's polarity. A Photoshop +/// file that an earlier PhotoCraft opened and saved again kept Photoshop's 1053 and its planes as +/// Photoshop stored them, so it is read in Photoshop's polarity too. +/// +/// Keeping these files' ink is the only reason this exists: deleting it (and its one caller) +/// reads every file in Photoshop's polarity, at the cost of inverting the spot ink of PSDs saved +/// by those versions. +pub(crate) fn earlier_photocraft_spot_planes(file: &PsdFile) -> bool { + use photocraft_psd::descriptor::UnicodeString; + use photocraft_psd::resources::VersionInfo; + + let marks_spot = file.resource(crate::channel_map::DISPLAY_INFO).is_some_and(|r| crate::channel_map::display_info_marks_spot(&r.data)); + if !marks_spot || file.resource(crate::channel_map::ALPHA_IDENTIFIERS).is_some() { + return false; + } + let Some(r) = file.resource(ids::VERSION_INFO) else { return false }; + // Byte for byte: a resource with anything after the fields is someone else's. + let Ok(v) = VersionInfo::from_bytes(&r.data) else { return false }; + if v.to_bytes() != r.data { + return false; + } + let writers = [ + (UnicodeString::new_nul("Adobe Photoshop"), UnicodeString::new_nul("Adobe Photoshop CS6")), + (UnicodeString::new("Adobe Photoshop"), UnicodeString::new("Adobe Photoshop 2026")), + ]; + v.version == 1 && v.file_version == 1 && writers.iter().any(|(writer, reader)| v.writer == *writer && v.reader == *reader) } /// Whether the import reads the merged composite: for a flattened or multichannel file (it *is* @@ -785,7 +817,6 @@ pub(crate) fn psd_to_document_decoded( file: &PsdFile, ctl: &photocraft_raster::Interrupt, decoded: Option>>, - opts: PsdImportOptions, ) -> Option<(Document, Vec)> { let h = &file.header; let mut warnings = Vec::new(); @@ -1061,9 +1092,10 @@ pub(crate) fn psd_to_document_decoded( // Photoshop lists every extra channel, the merged transparency first when there is one. let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(h.channels).saturating_sub(cc)); let names = names.get(skip..).unwrap_or_default(); - // Which planes hold spot ink in Photoshop's polarity: the DisplayInfo records the - // channel options are read from below, the transparency's first when `skip` is 1. - let photoshop_spots = if !opts.photoshop_spot_polarity { + // Which planes hold spot ink in Photoshop's polarity (0 = solid): the DisplayInfo records + // the channel options are read from below, the transparency's first when `skip` is 1. + // A file from an earlier PhotoCraft stores them straight and is read as it was written. + let photoshop_spots = if earlier_photocraft_spot_planes(file) { Vec::new() } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) { crate::channel_map::spot_flags(&r.data, true, skip) @@ -1121,7 +1153,9 @@ pub(crate) fn psd_to_document_decoded( #[cfg(test)] mod tests { use super::*; - use photocraft_psd::{Compression, PsdError, Version, testgen}; + use photocraft_psd::descriptor::UnicodeString; + use photocraft_psd::resources::VersionInfo; + use photocraft_psd::{Compression, ImageData, ImageResource, PsdError, Version, testgen}; /// #5200: the Multichannel fallback checks its RGBA output against the budget before /// allocating it (here a 2x2 image, 16 bytes, against caps of 15 and 16 bytes). @@ -1167,4 +1201,153 @@ mod tests { assert_eq!(merged_plane(&all, 2, 2), None); assert_eq!(merged_plane(&all, usize::MAX, 2), None); } + + /// Resource 1057's data. + fn version_info(writer: UnicodeString, reader: UnicodeString, has_real_merged_data: bool) -> Vec { + VersionInfo { version: 1, has_real_merged_data, writer, reader, file_version: 1 }.to_bytes() + } + + /// The 1057 PhotoCraft 0.1.0 to 0.7.0 wrote: Photoshop CS6's names, NUL-terminated. + fn version_info_cs6(has_real_merged_data: bool) -> Vec { + version_info(UnicodeString::new_nul("Adobe Photoshop"), UnicodeString::new_nul("Adobe Photoshop CS6"), has_real_merged_data) + } + + /// The 1057 PhotoCraft writes since 0.8.2: Photoshop 2026's bytes, no NUL. + fn version_info_2026(has_real_merged_data: bool) -> Vec { + photocraft_psd::resources::version_info_resource(has_real_merged_data).data + } + + /// An opaque 4 × 1 RGB document with the Photoshop spot oracles' channels in their order: + /// "Spot A", "Alpha 1" and "Spot B", each holding 100, 25, 75 and 0 % in four columns. + fn channels_document(depth: SampleType) -> Document { + let mut doc = Document::with_background("old", Size::new(4, 1), ColorMode::Rgb, depth, photocraft_color::Color::rgb(1.0, 1.0, 1.0)); + let gray = PixelFormat::new(ColorMode::Grayscale, depth, false); + let canvas = doc.bounds(); + let channel = |name: &str, spot: Option<(photocraft_color::Color, f32)>| { + let mut s = Surface::new(gray); + s.write_region(canvas, &[1.0, 0.25, 0.75, 0.0]); + AlphaChannel { spot, ..AlphaChannel::new(name, s) } + }; + doc.channels.push(channel("Spot A", Some((photocraft_color::Color::rgb(0.0, 0.627, 1.0), 0.0)))); + doc.channels.push(channel("Alpha 1", None)); + doc.channels.push(channel("Spot B", Some((photocraft_color::Color::rgb(1.0, 0.0, 0.627), 0.5)))); + doc + } + + /// Each channel's values on an 8-bit scale. + fn ink(doc: &Document) -> Vec> { + doc.channels.iter().map(|c| c.surface.read_region(doc.bounds()).iter().map(|v| (v * 255.0).round() as u8).collect()).collect() + } + + fn spots(doc: &Document) -> Vec { + doc.channels.iter().map(|c| c.spot.is_some()).collect() + } + + /// `doc` (opaque, 8 or 16-bit) saved the way PhotoCraft 0.1.0 to 0.11.1 saved it: spot planes + /// straight (the maximum = solid ink), no 1053, and `version_info` as resource 1057. `padded` + /// lays DisplayInfo out in the 14-byte records of 0.1.0 to 0.7.0. + fn as_earlier_photocraft_saved(doc: &Document, version_info: Vec, padded: bool) -> Vec { + let mut file = crate::psd_export::document_to_psd(doc); + assert!(!file.merged_has_alpha()); + file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS); + for r in &mut file.resources { + match r.id { + ids::VERSION_INFO => r.data = version_info.clone(), + crate::channel_map::DISPLAY_INFO if padded => { + let (version, records) = r.data.split_at(4); + r.data = version.iter().copied().chain(records.chunks(13).flat_map(|rec| rec.iter().copied().chain([0]))).collect(); + } + _ => {} + } + } + let mut planes = file.decode_merged().unwrap(); + let plane = planes.len() / usize::from(file.header.channels); + for (k, c) in doc.channels.iter().enumerate() { + if c.spot.is_some() { + // At 8 and 16 bits the complement of a sample is that of each of its bytes. + planes[(3 + k) * plane..(4 + k) * plane].iter_mut().for_each(|b| *b = !*b); + } + } + file.image_data = ImageData::encode(Compression::Raw, &planes, &file.header).unwrap(); + file.to_bytes().unwrap() + } + + /// PSDs that PhotoCraft 0.1.0 to 0.11.1 saved with spot channels store them straight. They + /// open with the ink they had in those versions (which read every plane straight), whichever + /// 1057 they carry. Saved again, they get Photoshop's polarity and a 1053, and reopen with the + /// same ink. (A 0.1.0 to 0.7.0 file's padded records misread every channel's options after + /// the first, here Spot B's kind, as those versions' successors always have; its ink is kept.) + #[test] + fn spot_planes_from_earlier_photocraft_keep_their_ink() { + for depth in [SampleType::U8, SampleType::U16] { + let doc = channels_document(depth); + let cases = [ + ("0.1.0 to 0.7.0", version_info_cs6(true), true), + ("0.8.2 on", version_info_2026(true), false), + ("0.8.2 on, Maximize Compatibility off", version_info_2026(false), false), + ("0.1.0 to 0.7.0, 13-byte records", version_info_cs6(true), false), + ]; + for (what, version_info, padded) in cases { + let bytes = as_earlier_photocraft_saved(&doc, version_info, padded); + assert!(earlier_photocraft_spot_planes(&PsdFile::from_bytes(&bytes).unwrap()), "{what}"); + let opened = crate::import("old.psd", &bytes).unwrap().document; + assert_eq!(ink(&opened), ink(&doc), "{depth:?} {what}: the ink earlier versions opened"); + assert!(spots(&opened)[0], "{depth:?} {what}"); + if !padded { + assert_eq!(spots(&opened), spots(&doc), "{depth:?} {what}"); + } + let saved = crate::export(&opened, "old.psd", &crate::ExportOptions::default()).unwrap(); + let file = PsdFile::from_bytes(&saved.bytes).unwrap(); + assert!(file.resource(crate::channel_map::ALPHA_IDENTIFIERS).is_some() && !earlier_photocraft_spot_planes(&file), "{what}"); + let again = crate::import("again.psd", &saved.bytes).unwrap().document; + assert_eq!(ink(&again), ink(&doc), "{depth:?} {what}: saved again"); + assert_eq!(spots(&again), spots(&opened), "{depth:?} {what}: saved again"); + } + } + } + + /// A file with spots and no 1053 that no earlier PhotoCraft wrote is read in Photoshop's + /// polarity (0 = solid ink). psd-tools' `cmyk-spot.psd` is one: its 1057 names psd-tools as + /// writer and reader. So is a file with no 1057, with Photoshop CS6's names without the NUL, + /// or with bytes after PhotoCraft's. + #[test] + fn other_files_without_alpha_identifiers_use_photoshop_polarity() { + let doc = channels_document(SampleType::U8); + let mut trailing = version_info_2026(true); + trailing.extend([0, 0]); + let cases = [ + ("psd-tools", Some(version_info(UnicodeString::new("psd-tools 1.10.0"), UnicodeString::new("psd-tools 1.10.0"), true))), + ("no 1057", None), + ("CS6 without a NUL", Some(version_info(UnicodeString::new("Adobe Photoshop"), UnicodeString::new("Adobe Photoshop CS6"), true))), + ("bytes after PhotoCraft's 1057", Some(trailing)), + ]; + for (what, info) in cases { + // Photoshop's polarity, as this version writes it, without 1053. + let mut file = crate::psd_export::document_to_psd(&doc); + file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS && r.id != ids::VERSION_INFO); + file.resources.extend(info.map(|d| ImageResource::new(ids::VERSION_INFO, d))); + let bytes = file.to_bytes().unwrap(); + assert!(!earlier_photocraft_spot_planes(&PsdFile::from_bytes(&bytes).unwrap()), "{what}"); + let opened = crate::import("x.psd", &bytes).unwrap().document; + assert_eq!(ink(&opened), ink(&doc), "{what}"); + assert_eq!(spots(&opened), spots(&doc), "{what}"); + } + } + + /// The rule needs a spot in DisplayInfo and no 1053, besides PhotoCraft's 1057. + #[test] + fn the_earlier_photocraft_rule_needs_a_spot_and_no_alpha_identifiers() { + let mut doc = channels_document(SampleType::U8); + let old = PsdFile::from_bytes(&as_earlier_photocraft_saved(&doc, version_info_2026(true), false)).unwrap(); + assert!(earlier_photocraft_spot_planes(&old)); + let mut with_ids = old.clone(); + with_ids.resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, 3))); + assert!(!earlier_photocraft_spot_planes(&with_ids), "a 1053"); + let mut no_info = old.clone(); + no_info.resources.retain(|r| r.id != crate::channel_map::DISPLAY_INFO); + assert!(!earlier_photocraft_spot_planes(&no_info), "no DisplayInfo"); + doc.channels.retain(|c| c.spot.is_none()); + let alpha_only = PsdFile::from_bytes(&as_earlier_photocraft_saved(&doc, version_info_2026(true), false)).unwrap(); + assert!(!earlier_photocraft_spot_planes(&alpha_only), "alpha channels only"); + } } diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 4d9489d54a..2764ad83ce 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -21,7 +21,7 @@ use photocraft_raster::Surface; use crate::flat::{csample, encode_image, image_to_document, layout_for, single_layer, try_buffer}; use crate::psd_export::PsdExportOptions; -use crate::psd_import::{PsdImportOptions, psd_to_document_decoded}; +use crate::psd_import::psd_to_document; use crate::{ExportOptions, ExportResult, ImportResult, IoError}; /// `true` when saving `doc` as TIFF writes layer data: anything beyond a single Background @@ -130,13 +130,11 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result 0 { - resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(kept))); + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, kept))); } // Resource 1022 names a channel of the PSD merged image, which counts the transparency plane. // The IFD below does not, so the id moves back by one. @@ -812,7 +808,7 @@ fn channel_resources(existing: Option<&[u8]>, names: &[&str], channels: &[&Alpha resources.push(ImageResource::new(1006, crate::psd_export::pascal_names_resource(names))); resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(names))); resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(channels, false))); - resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(names.len()))); + resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, names.len()))); if let Some(id) = quick_id { let mut data = id.to_be_bytes().to_vec(); data.push(0); diff --git a/crates/io/tests/tiff_layers.rs b/crates/io/tests/tiff_layers.rs index 49f0076728..8caf15c7c8 100644 --- a/crates/io/tests/tiff_layers.rs +++ b/crates/io/tests/tiff_layers.rs @@ -665,7 +665,7 @@ fn ink(d: &Document) -> (f32, f32) { fn photoshop_transparency_and_spot_tiff(doc: &Document, layers: bool) -> Vec { use photocraft_psd::{Compression, TaggedBlock}; - let opts = PsdExportOptions { merged_matte: false, merged_compression: Compression::Raw, photoshop_spot_polarity: true, ..Default::default() }; + let opts = PsdExportOptions { merged_matte: false, merged_compression: Compression::Raw, ..Default::default() }; let (file, _) = document_to_psd_with(doc, &opts); let planes = file.decode_merged().unwrap(); let n = 16; @@ -850,8 +850,8 @@ fn assert_photoshop_layered_tiff(bytes: &[u8], with_spot: bool) { /// Photoshop 27's Kato-Karate TIFF stores spot ink inverted: every non-white artwork pixel /// has spot 0, and the white background is spot 255. An alpha channel is the other way -/// around (255 = selected). A PSD keeps storing both planes straight, as earlier versions did, -/// and a TIFF opened and saved as PSD does the same. Everything round-trips. +/// around (255 = selected). Photoshop's PSDs store both the same way, and so does a PSD saved +/// here. Everything round-trips, PSD → TIFF → PSD included. #[test] fn spot_ink_is_stored_as_zero_and_alpha_is_not() { use photocraft_color::{Color, PixelFormat}; @@ -891,11 +891,14 @@ fn spot_ink_is_stored_as_zero_and_alpha_is_not() { let first = 3 + usize::from(file.merged_has_alpha()); let spot_plane = &merged[first * plane..(first + 1) * plane]; let alpha_plane = &merged[(first + 1) * plane..(first + 2) * plane]; - // Background (0,0) and artwork (3,3), both straight: max = solid ink, max = selected. - assert_stored(spot_plane, 0, depth, true, "psd spot background"); - assert_stored(spot_plane, (3 * w as usize + 3) * bps, depth, false, "psd spot art"); + // Background (0,0) and artwork (3,3). Spot: 0 = solid ink, max = paper. + // Alpha: max = selected, 0 = empty. + assert_stored(spot_plane, 0, depth, false, "psd spot background"); + assert_stored(spot_plane, (3 * w as usize + 3) * bps, depth, true, "psd spot art"); assert_stored(alpha_plane, 0, depth, true, "psd alpha background"); assert_stored(alpha_plane, (3 * w as usize + 3) * bps, depth, false, "psd alpha art"); + // The merged transparency (0), then the spot and the alpha channel (3, 4). + assert_eq!(file.resource(1053).map(|r| identifiers(&r.data)), Some(vec![0, 3, 4]), "{depth:?}"); let back = import("pol.psd", &psd.bytes).unwrap().document; let area = Rect::new(0, 0, w as i32, h as i32); assert_eq!(back.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{depth:?} psd spot"); @@ -905,7 +908,8 @@ fn spot_ink_is_stored_as_zero_and_alpha_is_not() { if depth == SampleType::U8 { for layers in [false, true] { - let r = export(&doc, "pol.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap(); + // The document as opened from the PSD, so the PSD's own resources come along. + let r = export(&back, "pol.tif", &ExportOptions { tiff_layers: layers, ..ExportOptions::default() }).unwrap(); let (spp, _bps, extra, _rows, samples) = independent_samples(&r.bytes); assert_eq!((spp, extra.as_slice()), (5, [0u16, 0].as_slice()), "{layers}"); // Reference TIFF: artwork spot sample is 0, background spot sample is 255. @@ -913,19 +917,40 @@ fn spot_ink_is_stored_as_zero_and_alpha_is_not() { assert_eq!(samples[(3 * w as usize + 3) * 5 + 3], 0, "{layers} art spot"); assert_eq!(samples[4], 0, "{layers} background alpha"); assert_eq!(samples[(3 * w as usize + 3) * 5 + 4], 255, "{layers} art alpha"); - let back = import("pol.tif", &r.bytes).unwrap().document; - assert_eq!(back.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{layers}"); - assert_eq!(back.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{layers}"); - // Saved as PSD: straight planes, and no 1053 (the TIFF's isn't kept on open). - let psd = PsdFile::from_bytes(&export(&back, "pol.psd", &ExportOptions::default()).unwrap().bytes).unwrap(); + // One identifier per extra sample, numbered from 3 (no transparency sample). + assert_eq!(tiff_alpha_identifiers(&r.bytes), [3, 4], "{layers}"); + let from_tiff = import("pol.tif", &r.bytes).unwrap().document; + assert_eq!(from_tiff.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{layers}"); + assert_eq!(from_tiff.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{layers}"); + assert!(from_tiff.channels[0].spot.is_some() && from_tiff.channels[1].spot.is_none(), "{layers}"); + // Saved as PSD again: the first PSD's planes, and one 1053 of its own. The flat TIFF's + // composite was matted on white, so only the layered one brings transparency back. + let psd = PsdFile::from_bytes(&export(&from_tiff, "pol.psd", &ExportOptions::default()).unwrap().bytes).unwrap(); let again = psd.decode_merged().unwrap(); assert_eq!(again[again.len() - 2 * plane..], merged[(first * plane)..(first + 2) * plane], "{layers} tiff to psd"); - assert!(psd.resource(1053).is_none(), "{layers} tiff to psd"); + assert_eq!(psd.merged_has_alpha(), layers, "{layers} tiff to psd"); + let want: Vec = if layers { vec![0, 3, 4] } else { vec![3, 4] }; + assert_eq!(psd.resources.iter().filter(|r| r.id == 1053).map(|r| identifiers(&r.data)).collect::>(), [want], "{layers} tiff to psd"); + let reopened = import("pol.psd", &psd.to_bytes().unwrap()).unwrap().document; + assert_eq!(reopened.channels[0].surface.to_interleaved(area), doc.channels[0].surface.to_interleaved(area), "{layers}"); + assert_eq!(reopened.channels[1].surface.to_interleaved(area), doc.channels[1].surface.to_interleaved(area), "{layers}"); } } } } +/// The big-endian u32s of a resource 1053. +fn identifiers(data: &[u8]) -> Vec { + data.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect() +} + +/// The identifiers in a TIFF's resource 1053 (none without one). +fn tiff_alpha_identifiers(bytes: &[u8]) -> Vec { + let resources = tags(bytes).0.expect("photoshop resources"); + let (blocks, _) = photocraft_psd::tiff::resources_from_bytes(&resources).unwrap(); + blocks.iter().find(|b| b.id == 1053).map(|b| identifiers(&b.data)).unwrap_or_default() +} + /// `at` addresses one sample of a big-endian PSD plane. `zero` expects 0; otherwise the /// maximum sample (255 / 65535 / 1.0). fn assert_stored(plane: &[u8], at: usize, depth: SampleType, zero: bool, what: &str) { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e2fc72c8ed..a2752b1346 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-21 · **Change:** minor (TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; earlier the same day: Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency; earlier: DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-21 · **Change:** minor (PSD stores spot channels as Photoshop does, ink 0 = solid, and numbers them in 1053; PSDs from earlier PhotoCraft versions keep their ink; earlier the same day: TIFF writes and reads alpha and spot channels as extra samples with Photoshop's channel resources, PSD output unchanged, FILE-215-7, FILE-215-17's TIFF part, #7415; earlier the same day: Photoshop's `MTrn` merged-transparency marker, from a layered TIFF saved with Save Transparency, marks the merged transparency; earlier: DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped plus Filter Gallery with Chrome, rest verbatim), slices, comps, Advanced Blending; alpha and spot channels as Photoshop stores them (spot ink 0 = solid, channel identifiers in 1053), and PSDs with spot channels from PhotoCraft 0.1.0–0.11.1, which stored the ink the other way round, keep their ink. Oracle floors: Photoshop-authored 188/258, io 148/170, psd-tools 249/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7); alpha and spot channels as extra samples (at most 16) with Photoshop's names, ink colours and solidity, spot ink stored 0 = solid (#7415); no Save Transparency choice yet (channels are written as Photoshop does with it off) | @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | PSD: spot channels are stored and read as Photoshop 27.11's PSDs store them (ink 0 = solid; all 7 of its spot PSDs store the 100/25/75/0 % columns as 0/191/64/255), and resource 1053 numbers the extra channels 3, 4, 5, … after 0 for the merged transparency. A PSD with spot channels and no 1053 whose 1057 is exactly what PhotoCraft 0.1.0–0.11.1 wrote is read as those versions stored it (straight), so it keeps its ink. Multichannel is unchanged (#7415 split, part 4) | | 2026-10-21 | minor | TIFF: alpha and spot channels are written and read as extra samples (at most 16) with Photoshop's channel resources (1006, 1045, 1077, 1053), spot ink stored 0 = solid as Photoshop 27.11's TIFFs store it; PSD output is unchanged. FILE-215-7 done, FILE-215-17 partial (its TIFF part) (#7415, supersedes #3121) | | 2026-10-21 | patch | PSD and layered TIFF: the `MTrn` block Photoshop writes in a layered TIFF saved with Save Transparency (a positive layer count, `nrTM` in an `II` file) marks the merged image's first extra channel as its transparency, like `Mtrn`/`Mt16`/`Mt32`: such a file no longer opens with its transparency also as an alpha channel (named "Transparency" in Photoshop 27.11's file), and a PSD save no longer writes it twice. A PSD save with an opaque composite drops any of these markers kept from the opened file, so its first alpha channel no longer reopens as transparency and vanishes (split from #7415; credit #3121) | | 2026-10-15 | minor | DDS textures save (#1327): uncompressed or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, optional box-filtered mip chain, legacy header; DDS Options in Save As and Save a Copy, `ddsCompression`/`ddsMipmaps` for scripts, the CLI and MCP | diff --git a/docs/gaps.md b/docs/gaps.md index 647c121998..bfeb37cd29 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (G10: PSD spot channels in Photoshop's polarity, earlier PhotoCraft PSDs keep their ink; earlier the same day: G10: TIFF alpha and spot channels written and read as extra samples, FILE-215-7 done, FILE-215-17's TIFF part, #7415; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -230,10 +230,13 @@ what an agent can't finish alone. write (FILE-215-10, #2541; a single flattened page now saves via Save As / Save a Copy / CLI, 2026-10-11, #3401), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), Cineon write (read landed), DICOM, EPS/DCS, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop - Raw; spot plates beyond TIFF: spot ink preview in RGB/CMYK documents and PDF Separation + Raw; spot plates beyond PSD and TIFF: spot ink preview in RGB/CMYK documents and PDF Separation (FILE-215-17); .atn, .asl, .shc, .csh presets (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). +- **Progress (2026-10-21, #7415 split):** PSD stores and reads spot channels as Photoshop does (ink + 0 = solid) and writes their identifiers (1053); PSDs from PhotoCraft 0.1.0–0.11.1, which stored + spot ink the other way round, keep their ink. - **Progress (2026-10-21, #7415):** TIFF writes and reads alpha and spot channels as extra samples with Photoshop's names, ink colours and solidity (FILE-215-7 done; FILE-215-17's TIFF part). - **Progress (2026-10-13):** Cineon (`.cin`) 10-bit gray/RGB frames open read-only, sharing the @@ -277,6 +280,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | G10: PSD stores and reads spot channels as Photoshop does (ink 0 = solid) with their identifiers (1053); PSDs from PhotoCraft 0.1.0–0.11.1 keep their ink (#7415 split, part 4); FILE-215-17 stays partial; estimate unchanged | | 2026-10-21 | minor | G10: TIFF alpha and spot channels written and read as extra samples with Photoshop's channel resources, PSD output unchanged (#7415); FILE-215-7 leaves the list, FILE-215-17 narrows to RGB/CMYK spot preview and PDF Separation | | 2026-10-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open | | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6e8449e38e..b76db16467 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-21 · **Change:** minor (TIFF spot plates, FILE-215-17's TIFF part, #7415; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-21 · **Change:** minor (PSD spot plates in Photoshop's polarity, #7415 split; earlier the same day: TIFF spot plates, FILE-215-17's TIFF part, #7415; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -175,7 +175,7 @@ parity for the area and are already contained in the dimension rows above. | Adjustments and filters | 70% | 120–200 | All present; smart-filter re-render matches Photoshop on 5 of 30 files; Filter Gallery looks approximate; no Neural Filters | | Type | 40% | 120–200 | Engine works; no type on a path, manual kerning, RTL paragraphs, OpenType panel UI, live Warp Text preview; Type tool freeze on macOS (#2518) | | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); missing path-point nudge; shape parity tracking issue #2496 | -| Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, spot plates in TIFF only (FILE-215-17: no RGB/CMYK spot preview or PDF Separation), monitor profile only at launch | +| Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, spot plates in PSD and TIFF only (FILE-215-17: no RGB/CMYK spot preview or PDF Separation), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | | Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Colour management and modes: PSD spot plates are stored as Photoshop stores them (ink 0 = solid), and PSDs from earlier PhotoCraft versions keep their ink (#7415 split, part 4); estimates unchanged | | 2026-10-21 | minor | Colour management and modes: spot plates now save and open in TIFF (#7415); estimates unchanged | | 2026-10-18 | minor | Layers: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial; percentages unchanged | | 2026-10-13 | minor | Vector shapes: on-canvas corner radius widgets on live rectangles (TOOL-214-12); estimates unchanged | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 84738c1455..b9a2c1a744 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -122,7 +122,7 @@ id = "FILE-215-17" target = "CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF)" status = "partial" issue = 215 -note = "TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation" +note = "TIFF: spot channels written and read as extra samples with DisplayInfo (1077) ink colour and solidity, ink stored 0 = solid (#7415). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.11.1 PSDs (straight planes) keep their ink. Missing: spot ink preview in RGB/CMYK documents (compose multichannel.rs:1 previews Multichannel only) and PDF Separation" [[item]] id = "FILE-215-18" From 9ec668e86d7395e92a6b5e0e41c7d0b46e6030cb Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 13:33:58 -0400 Subject: [PATCH 086/229] psd_import: test an early PhotoCraft PSD whose first extra channel is an alpha In 0.1.0-0.7.0's 14-byte DisplayInfo records the spots after an alpha channel are found only by the padded reading; the planes are read straight and keep their ink. A 2 % spot's opacity byte reads as kind 2 in 13-byte records: without the rule that plane would be inverted. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/psd_import.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 14118c2c86..0fa5470f4b 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -1306,6 +1306,38 @@ mod tests { } } + /// A 0.1.0 to 0.7.0 file whose first extra channel is an alpha channel, spots after it. In its + /// 14-byte DisplayInfo records the spots' kind bytes sit where a 13-byte reader (main's, and + /// this one's) doesn't look, so only the padded reading finds a spot for the rule. Every plane + /// is then read straight, as those versions read it, and keeps its ink. The 13-byte reader + /// opens each channel's options from shifted bytes, as on main: here every channel opens as + /// an alpha channel. A spot of 2 % solidity puts a 2 where that reader looks for the kind, so + /// that channel opens as a spot: without the rule its plane would be inverted. Saved again and + /// reopened, every channel keeps its ink and kind. + #[test] + fn an_early_file_with_an_alpha_channel_first_keeps_its_ink() { + for solidity in [0.0, 0.02] { + let mut doc = channels_document(SampleType::U8); + doc.channels.swap(0, 1); + doc.channels[1].spot = Some((photocraft_color::Color::rgb(0.0, 0.627, 1.0), solidity)); + let names: Vec<&str> = doc.channels.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, ["Alpha 1", "Spot A", "Spot B"]); + let bytes = as_earlier_photocraft_saved(&doc, version_info_cs6(true), true); + let file = PsdFile::from_bytes(&bytes).unwrap(); + let info = &file.resource(crate::channel_map::DISPLAY_INFO).unwrap().data; + let misread = solidity > 0.0; + assert_eq!(crate::channel_map::spot_flags(info, true, 0), [false, misread, false], "{solidity}: 13-byte records"); + assert!(earlier_photocraft_spot_planes(&file), "{solidity}: found in the 14-byte records"); + let opened = crate::import("old.psd", &bytes).unwrap().document; + assert_eq!(ink(&opened), ink(&doc), "{solidity}: the ink 0.7.0 opened"); + assert_eq!(spots(&opened), [false, misread, false], "{solidity}: kinds as main reads them"); + let saved = crate::export(&opened, "old.psd", &crate::ExportOptions::default()).unwrap(); + let again = crate::import("again.psd", &saved.bytes).unwrap().document; + assert_eq!(ink(&again), ink(&doc), "{solidity}: saved again"); + assert_eq!(spots(&again), spots(&opened), "{solidity}: saved again"); + } + } + /// A file with spots and no 1053 that no earlier PhotoCraft wrote is read in Photoshop's /// polarity (0 = solid ink). psd-tools' `cmyk-spot.psd` is one: its 1057 names psd-tools as /// writer and reader. So is a file with no 1057, with Photoshop CS6's names without the NUL, From 6e3fe3dd9e3f0b147cc6512b4e4b1300a7e2455a Mon Sep 17 00:00:00 2001 From: Jason D Date: Wed, 21 Oct 2026 16:26:58 -0400 Subject: [PATCH 087/229] io: drop 1053 on PSD open; never copy a kept one on save Every save now writes 1053 for the extra channels, so a copy kept on open grew the document's metadata on each round trip (the roundtrip and tiff_layers suites caught it), as #7415 found. Multichannel keeps its 1053. The layered writer never copies a kept 1053: next to the fresh one it would be a second, and without channels it would list channels that are gone. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/psd_export.rs | 27 +++++++++++++++++---------- crates/io/src/psd_import.rs | 4 ++++ crates/io/src/tiff_layers.rs | 2 +- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index daa25ca2bc..256f578236 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1826,8 +1826,10 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if !keep_imported_resource(doc, *id, data) { continue; } - // A fresh 1053 was written with the extra channels; a preserved copy would be a second one. - if *id == crate::channel_map::ALPHA_IDENTIFIERS && !extra.is_empty() { + // 1053 is written afresh with the extra channels. A kept copy (from a Multichannel file, or + // a document saved by an earlier version) would be a second one, or list channels that + // are gone. + if *id == crate::channel_map::ALPHA_IDENTIFIERS { continue; } let mut r = ImageResource::new(*id, data.to_vec()); @@ -2030,20 +2032,25 @@ mod tests { assert_eq!(alpha_ids(&file), Some(vec![0, 3, 4])); } - /// A Photoshop file's own 1053 is kept on open (as every unmapped resource is), but a save - /// writes one fresh 1053 for the channels the document has, never the kept one as well. The + /// A save writes one fresh 1053 for the channels the document has. A Photoshop file's own is + /// not kept on open (it would be new metadata after every round trip), and one a document + /// carries anyway (kept by an earlier version) is never written: next to the fresh one it + /// would be a second, and without channels it would list channels that are gone. The /// identifiers here, 3 and 2, are those of psd-tools' `cmyk-alpha-spot.psd` (Photoshop 24.7). #[test] - fn a_kept_alpha_identifiers_resource_is_not_written_twice() { + fn a_kept_alpha_identifiers_resource_is_not_written() { + let kept = || (crate::channel_map::ALPHA_IDENTIFIERS, String::new(), std::sync::Arc::new([0, 0, 0, 3, 0, 0, 0, 2].to_vec())); let mut file = document_to_psd(&spot_and_alpha_document()); file.resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS); - file.resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, [0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 2].to_vec())); - let opened = crate::import("ps.psd", &file.to_bytes().unwrap()).unwrap().document; - assert!(opened.metadata.psd_resources.iter().any(|(id, ..)| *id == crate::channel_map::ALPHA_IDENTIFIERS)); - let saved = crate::export(&opened, "x.psd", &crate::ExportOptions::default()).unwrap(); - let again = PsdFile::from_bytes(&saved.bytes).unwrap(); + file.resources.push(ImageResource::new(kept().0, kept().2.to_vec())); + let mut opened = crate::import("ps.psd", &file.to_bytes().unwrap()).unwrap().document; + assert!(!opened.metadata.psd_resources.iter().any(|(id, ..)| *id == crate::channel_map::ALPHA_IDENTIFIERS)); + opened.metadata.psd_resources.push(kept()); + let again = PsdFile::from_bytes(&crate::export(&opened, "x.psd", &crate::ExportOptions::default()).unwrap().bytes).unwrap(); assert_eq!(again.resources.iter().filter(|r| r.id == crate::channel_map::ALPHA_IDENTIFIERS).count(), 1); assert_eq!(alpha_ids(&again), Some(vec![0, 3, 4])); + opened.channels.clear(); + assert_eq!(alpha_ids(&document_to_psd(&opened)), None, "no channels left"); } #[test] diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 0fa5470f4b..fbfa691c62 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -873,6 +873,10 @@ pub(crate) fn psd_to_document_decoded( doc.clipping_path = Some(photocraft_doc::ClippingPath { name, flatness: 0.0 }); doc.metadata.psd_resources.push((r.id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))); } + // Resource 1053 is written afresh with the extra channels on every save, so a kept copy + // would be new metadata after each round trip. Multichannel has no such rewrite: the + // imported bytes are the channel ids, copied back while they still match. + crate::channel_map::ALPHA_IDENTIFIERS if !multichannel => {} id if MAPPED_RESOURCES.contains(&id) => {} id => doc.metadata.psd_resources.push((id, String::from_utf8_lossy(&r.name).into_owned(), Arc::new(r.data.clone()))), } diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 2764ad83ce..401495a1ec 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -296,7 +296,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr // it, so a plain transparent TIFF still opens with alpha. let write_alpha = has_alpha && kept == 0; // One identifier per extra sample actually stored. The PSD model's 1053 lists the merged - // transparency too, and a kept one lists an opened file's channels, so neither is copied. + // transparency too, which the IFD leaves out next to extra samples, so it is not copied. resources.retain(|r| r.id != crate::channel_map::ALPHA_IDENTIFIERS); if kept > 0 { resources.push(ImageResource::new(crate::channel_map::ALPHA_IDENTIFIERS, crate::channel_map::alpha_identifiers(false, kept))); From 4571843c27f668de1c38d4de29b60d19264c068c Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Fri, 9 Oct 2026 01:22:02 +0000 Subject: [PATCH 088/229] Add Art History Brush to the Y-group flyout paint.artHistoryBrush stamps oriented dabs sampled from a history state. Styles cover Tight/Loose Short, Medium, Long, Dab, Tight Curl and Loose Curl. Area sets spacing; Tolerance skips dabs whose footprint already matches the source. Scatter is seeded so a stroke replays bit-for-bit. The Y flyout lists History Brush then Art History Brush. The options bar exposes Style, Area and Tolerance. Includes a 24 MP release bench. --- README.md | 4 +- crates/algo/src/art_history.rs | 973 ++++++++++++++++++ crates/algo/src/lib.rs | 1 + crates/engine/examples/bench_art_history.rs | 60 ++ crates/engine/src/channel_cmds.rs | 1 + crates/engine/src/hidden_target.rs | 1 + crates/engine/src/retouch_cmds.rs | 16 +- crates/engine/src/retouch_cmds/art_history.rs | 478 +++++++++ crates/engine/src/retouch_cmds/tests.rs | 8 +- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/i18n/cs.tsv | 13 + crates/ui-egui/src/i18n/de.tsv | 13 + crates/ui-egui/src/i18n/el.tsv | 12 + crates/ui-egui/src/i18n/es.tsv | 12 + crates/ui-egui/src/i18n/fr.tsv | 13 + crates/ui-egui/src/i18n/id.tsv | 15 +- crates/ui-egui/src/i18n/it.tsv | 12 + crates/ui-egui/src/i18n/ja.tsv | 12 + crates/ui-egui/src/i18n/ko.tsv | 13 + crates/ui-egui/src/i18n/nl.tsv | 12 + crates/ui-egui/src/i18n/pl.tsv | 12 + crates/ui-egui/src/i18n/pt-br.tsv | 12 + crates/ui-egui/src/i18n/ru.tsv | 12 + crates/ui-egui/src/i18n/uk.tsv | 12 + crates/ui-egui/src/i18n/vi.tsv | 13 + crates/ui-egui/src/i18n/zh-hans.tsv | 14 + crates/ui-egui/src/i18n/zh-hant.tsv | 14 + crates/ui-egui/src/icons.rs | 1 + crates/ui-egui/src/panels.rs | 42 +- crates/ui-egui/src/retouch_ui.rs | 82 +- crates/ui-egui/src/state.rs | 29 +- crates/ui-egui/src/tool_reset.rs | 1 + docs/scorecard.md | 3 +- docs/target-app-parity.md | 2 +- scorecard/tools.toml | 7 + 35 files changed, 1911 insertions(+), 15 deletions(-) create mode 100644 crates/algo/src/art_history.rs create mode 100644 crates/engine/examples/bench_art_history.rs create mode 100644 crates/engine/src/retouch_cmds/art_history.rs diff --git a/README.md b/README.md index 1de756458a..e80617155f 100644 --- a/README.md +++ b/README.md @@ -181,8 +181,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
-

🧰 67 tools

- Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom +

🧰 68 tools

+ Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Art History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom

🖌️ A real brush engine

diff --git a/crates/algo/src/art_history.rs b/crates/algo/src/art_history.rs new file mode 100644 index 0000000000..394b825b0f --- /dev/null +++ b/crates/algo/src/art_history.rs @@ -0,0 +1,973 @@ +//! Oriented history-state dabs: sample a snapshot patch, pose it along the stroke, and stamp. +//! +//! Tile processing reads a halo around each destination tile so a dab whose centre sits one pixel +//! inside a tile still sees the same source as an untiled pass. Scatter is seeded, so the same +//! stroke is replayable. + +#![allow(clippy::needless_range_loop)] + +/// Mixes the history-state index into dab scatter so two documents with different snapshots diverge. +pub const RNG_SEED: u32 = 0xA7B1_57E0; + +/// Largest number of dabs one stroke may emit. +pub const MAX_DABS: usize = 4096; + +/// Largest coverage rectangle, in pixels, a stroke may allocate. +pub const MAX_COVERAGE: u64 = 4096 * 4096; + +/// Why [`stamp`] refused to write. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StampError { + /// A zero-sized buffer, no dabs, or nothing to cover. + Empty, + /// Source and destination disagree on channel count. + Mismatched, + /// The read/write window would exceed [`MAX_COVERAGE`]. + TooLarge, +} + +/// Named dab layout. Multipliers are documented in the command params; keep them stable. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Style { + TightShort, + TightMedium, + TightLong, + LooseMedium, + LooseLong, + Dab, + TightCurl, + LooseCurl, +} + +impl Style { + /// Parse a command-style identifier (`tightMedium`, …). + pub fn parse(name: &str) -> Option { + Some(match name { + "tightShort" => Self::TightShort, + "tightMedium" => Self::TightMedium, + "tightLong" => Self::TightLong, + "looseMedium" => Self::LooseMedium, + "looseLong" => Self::LooseLong, + "dab" => Self::Dab, + "tightCurl" => Self::TightCurl, + "looseCurl" => Self::LooseCurl, + _ => return None, + }) + } + + /// Stable id used in command params. + pub fn as_str(self) -> &'static str { + match self { + Self::TightShort => "tightShort", + Self::TightMedium => "tightMedium", + Self::TightLong => "tightLong", + Self::LooseMedium => "looseMedium", + Self::LooseLong => "looseLong", + Self::Dab => "dab", + Self::TightCurl => "tightCurl", + Self::LooseCurl => "looseCurl", + } + } + + fn spacing_mul(self) -> f32 { + match self { + Self::TightShort => 0.35, + Self::TightMedium => 0.55, + Self::TightLong => 0.85, + Self::LooseMedium => 0.70, + Self::LooseLong => 1.10, + Self::Dab => 1.60, + Self::TightCurl => 0.50, + Self::LooseCurl => 0.80, + } + } + + fn scatter_mul(self) -> f32 { + match self { + Self::TightShort => 0.00, + Self::TightMedium => 0.00, + Self::TightLong => 0.05, + Self::LooseMedium => 0.45, + Self::LooseLong => 0.70, + Self::Dab => 0.10, + Self::TightCurl => 0.05, + Self::LooseCurl => 0.40, + } + } + + fn curl_deg(self) -> f32 { + match self { + Self::TightCurl => 18.0, + Self::LooseCurl => 28.0, + _ => 0.0, + } + } + + fn size_mul(self) -> f32 { + match self { + Self::TightShort => 0.7, + Self::TightMedium => 0.9, + Self::TightLong => 1.1, + Self::LooseMedium => 1.0, + Self::LooseLong => 1.2, + Self::Dab => 1.0, + Self::TightCurl => 0.9, + Self::LooseCurl => 1.1, + } + } +} + +/// One posed dab in document pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Dab { + pub x: f32, + pub y: f32, + pub theta: f32, + pub radius: f32, + pub pressure: f32, +} + +/// Spacing along the stroke, in pixels. +pub fn spacing_px(size: f32, area: f32, style: Style) -> Option { + if !(size.is_finite() && area.is_finite()) || size <= 0.0 { + return None; + } + let s = size * style.spacing_mul() * (0.25 + 0.02 * area); + if s.is_finite() && s > 0.0 { Some(s) } else { None } +} + +/// Extra pixels each destination tile must read from the source. +pub fn halo_radius(size: f32, style: Style) -> Option { + if !size.is_finite() || size <= 0.0 { + return None; + } + let v = (size * style.size_mul() / 2.0 + style.scatter_mul() * size + 2.0).ceil(); + if !v.is_finite() || v > 1_000_000.0 { + return None; + } + Some(v as i32) +} + +/// Side of the rotated source square (`ceil(size · size_mul) + 2`). +pub fn sample_side(size: f32, style: Style) -> Option { + if !size.is_finite() || size <= 0.0 { + return None; + } + let v = (size * style.size_mul()).ceil() + 2.0; + if !v.is_finite() || v > 1_000_000.0 { + return None; + } + Some(v as i32) +} + +fn mix32(seed: u32, dab: u32, lane: u32) -> u32 { + let mut x = seed ^ dab.wrapping_mul(0x9E37_79B9) ^ lane.wrapping_mul(0x85EB_CA6B); + x = x.wrapping_add(0x9E37_79B9); + x ^= x >> 16; + x = x.wrapping_mul(0x7FEB_352D); + x ^= x >> 15; + x = x.wrapping_mul(0x846C_A68B); + x ^= x >> 16; + x +} + +fn signed01(seed: u32, dab: u32, lane: u32) -> f32 { + let u = mix32(seed, dab, lane) >> 8; + (u as f32) * (2.0 / 16_777_215.0) - 1.0 +} + +fn dab_radius(size: f32, style: Style) -> Option { + let r = size * style.size_mul() * 0.5; + if r.is_finite() && r > 0.0 { Some(r) } else { None } +} + +/// Walk `points` and emit posed dabs. `seed` is typically [`RNG_SEED`] XOR the history index. +pub fn place_dabs(points: &[[f32; 2]], size: f32, area: f32, style: Style, seed: u32) -> Result, StampError> { + if points.is_empty() { + return Err(StampError::Empty); + } + let spacing = spacing_px(size, area, style).ok_or(StampError::Empty)?; + let radius = dab_radius(size, style).ok_or(StampError::TooLarge)?; + let curl = style.curl_deg().to_radians(); + let scatter = style.scatter_mul(); + let mut out = Vec::new(); + let mut emit = |i: u32, x: f32, y: f32, tx: f32, ty: f32, pressure: f32| -> Result<(), StampError> { + if !(x.is_finite() && y.is_finite() && tx.is_finite() && ty.is_finite()) { + return Err(StampError::Empty); + } + let u = signed01(seed, i, 1); + let v = signed01(seed, i, 2); + let w = signed01(seed, i, 3); + let dx = scatter * size * u; + let dy = scatter * size * v; + let (cx, cy) = (x + dx, y + dy); + if !(cx.is_finite() && cy.is_finite()) { + return Err(StampError::TooLarge); + } + let theta = ty.atan2(tx) + (i as f32) * curl + scatter * w; + if !theta.is_finite() { + return Err(StampError::Empty); + } + if out.len() >= MAX_DABS { + return Err(StampError::TooLarge); + } + out.push(Dab { x: cx, y: cy, theta, radius, pressure: pressure.clamp(0.0, 1.0) }); + Ok(()) + }; + + let Some(first) = points.first() else { + return Err(StampError::Empty); + }; + if !first[0].is_finite() || !first[1].is_finite() { + return Err(StampError::Empty); + } + let (mut px, mut py) = (first[0], first[1]); + let (mut tx, mut ty) = (1.0f32, 0.0f32); + if let Some(second) = points.get(1) { + tx = second[0] - px; + ty = second[1] - py; + if tx == 0.0 && ty == 0.0 { + tx = 1.0; + } + } + emit(0, px, py, tx, ty, 1.0)?; + + let mut carry = 0.0f32; + let mut i = 1u32; + for w in points.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + if !(a[0].is_finite() && a[1].is_finite() && b[0].is_finite() && b[1].is_finite()) { + return Err(StampError::Empty); + } + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len = dx.hypot(dy); + if !len.is_finite() { + return Err(StampError::Empty); + } + if len < 1.0e-6 { + continue; + } + tx = dx; + ty = dy; + let mut consumed = 0.0f32; + while carry + (len - consumed) >= spacing { + let need = spacing - carry; + if !need.is_finite() || need < 0.0 { + return Err(StampError::Empty); + } + consumed += need; + if consumed > len { + break; + } + let t = consumed / len; + px = a[0] + dx * t; + py = a[1] + dy * t; + emit(i, px, py, tx, ty, 1.0)?; + i = i.saturating_add(1); + carry = 0.0; + } + carry += len - consumed; + if !carry.is_finite() { + return Err(StampError::Empty); + } + } + if out.is_empty() { Err(StampError::Empty) } else { Ok(out) } +} + +/// Bounding rectangle of a dab, in document pixels (1 px of anti-alias slack). +pub fn dab_bounds(dab: &Dab) -> Option<(i32, i32, i32, i32)> { + let r = dab.radius + 1.0; + if !r.is_finite() { + return None; + } + let x0 = (dab.x - r).floor(); + let y0 = (dab.y - r).floor(); + let x1 = (dab.x + r).ceil(); + let y1 = (dab.y + r).ceil(); + if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) { + return None; + } + let clamp = |v: f32| v.clamp(i32::MIN as f32, i32::MAX as f32) as i32; + Some((clamp(x0), clamp(y0), clamp(x1), clamp(y1))) +} + +fn coverage_at(dab: &Dab, x: i32, y: i32, hardness: f32) -> f32 { + let dx = x as f32 + 0.5 - dab.x; + let dy = y as f32 + 0.5 - dab.y; + let d = dx.hypot(dy); + if !d.is_finite() || d >= dab.radius + 0.5 { + return 0.0; + } + let edge = (dab.radius + 0.5 - d).clamp(0.0, 1.0); + let h = hardness.clamp(0.0, 1.0); + let inner = dab.radius * h; + let falloff = if d <= inner || dab.radius - inner < 1.0e-3 { + 1.0 + } else { + let t = ((d - inner) / (dab.radius - inner)).clamp(0.0, 1.0); + 1.0 - t * t * (3.0 - 2.0 * t) + }; + edge * falloff +} + +fn pixel_index(x: usize, y: usize, w: usize, h: usize, ch: usize) -> Option { + if x >= w || y >= h || ch == 0 { + return None; + } + y.checked_mul(w)?.checked_add(x)?.checked_mul(ch) +} + +fn origin_index(origin: (i32, i32), x: i32, y: i32, w: usize, h: usize, ch: usize) -> Option { + let dx = x.checked_sub(origin.0)?; + let dy = y.checked_sub(origin.1)?; + pixel_index(usize::try_from(dx).ok()?, usize::try_from(dy).ok()?, w, h, ch) +} + +fn floor_i32(v: f32) -> i32 { + v.clamp(-2_147_483_648.0, 2_147_483_647.0).floor() as i32 +} + +struct SrcView<'a> { + data: &'a [f32], + w: usize, + h: usize, + origin: (i32, i32), + ch: usize, +} + +fn sample_bilinear(src: SrcView<'_>, sx: f32, sy: f32, out: &mut [f32]) -> bool { + let ch = src.ch; + if ch == 0 || out.len() < ch { + return false; + } + let dst = &mut out[..ch]; + dst.fill(0.0); + if !(sx.is_finite() && sy.is_finite()) { + return false; + } + let lx = sx - src.origin.0 as f32; + let ly = sy - src.origin.1 as f32; + if !(lx.is_finite() && ly.is_finite()) { + return false; + } + let x0 = floor_i32(lx); + let y0 = floor_i32(ly); + let fx = (lx - x0 as f32).clamp(0.0, 1.0); + let fy = (ly - y0 as f32).clamp(0.0, 1.0); + let mut any = false; + for (iy, wy) in [(0i32, 1.0 - fy), (1i32, fy)] { + for (ix, wx) in [(0i32, 1.0 - fx), (1i32, fx)] { + let wt = wx * wy; + if wt <= 0.0 { + continue; + } + let px = x0.saturating_add(ix); + let py = y0.saturating_add(iy); + if px < 0 || py < 0 { + continue; + } + let (Ok(ux), Ok(uy)) = (usize::try_from(px), usize::try_from(py)) else { continue }; + let Some(base) = pixel_index(ux, uy, src.w, src.h, ch) else { continue }; + let Some(pxs) = src.data.get(base..base.saturating_add(ch)) else { continue }; + for (d, s) in dst.iter_mut().zip(pxs.iter()) { + *d += *s * wt; + } + any = true; + } + } + any +} + +fn rotated_source(dab: &Dab, x: i32, y: i32) -> (f32, f32) { + let dx = x as f32 + 0.5 - dab.x; + let dy = y as f32 + 0.5 - dab.y; + let (c, s) = (dab.theta.cos(), dab.theta.sin()); + (dab.x + c * dx + s * dy, dab.y - s * dx + c * dy) +} + +fn over_native(dst: &mut [f32], src: &[f32], k: f32, alpha: Option, lock: bool) { + if k <= 0.0 { + return; + } + match alpha { + Some(a) => { + let Some((&da, &sa0)) = dst.get(a).zip(src.get(a)) else { return }; + let sa = sa0 * k; + let oa = sa + da * (1.0 - sa); + if oa > 0.0 { + for c in 0..a { + let Some(d) = dst.get_mut(c) else { continue }; + let Some(&s) = src.get(c) else { continue }; + *d = (s * sa + *d * da * (1.0 - sa)) / oa; + } + } + if !lock && let Some(d) = dst.get_mut(a) { + *d = oa; + } + } + None => { + for (d, s) in dst.iter_mut().zip(src.iter()) { + *d += (*s - *d) * k; + } + } + } +} + +fn buf_len(w: usize, h: usize, ch: usize) -> Option { + w.checked_mul(h)?.checked_mul(ch) +} + +/// Parameters for [`stamp`]. +pub struct StampParams<'a> { + pub dest: &'a mut [f32], + pub dest_w: usize, + pub dest_h: usize, + pub dest_origin: (i32, i32), + pub src: &'a [f32], + pub src_w: usize, + pub src_h: usize, + pub src_origin: (i32, i32), + pub ch: usize, + pub dabs: &'a [Dab], + pub hardness: f32, + pub opacity: f32, + pub flow: f32, + pub tolerance: f32, + /// Destination tile edge; `0` processes the whole buffer as one tile. + pub tile: i32, + pub halo: i32, + pub selection: Option<&'a [f32]>, + pub lock_transparent: bool, + pub alpha: Option, +} + +fn dest_pixels(p: &StampParams<'_>) -> Result { + let n = buf_len(p.dest_w, p.dest_h, p.ch).ok_or(StampError::TooLarge)?; + if p.dest.len() < n { + return Err(StampError::Empty); + } + let sn = buf_len(p.src_w, p.src_h, p.ch).ok_or(StampError::TooLarge)?; + if p.src.len() < sn { + return Err(StampError::Empty); + } + if p.ch == 0 || p.dest_w == 0 || p.dest_h == 0 { + return Err(StampError::Empty); + } + Ok(n as u64) +} + +fn tile_grid(w: usize, h: usize, origin: (i32, i32), tile: i32) -> Vec<(i32, i32, i32, i32)> { + let (x0, y0) = origin; + let x1 = x0.saturating_add(w.min(i32::MAX as usize) as i32); + let y1 = y0.saturating_add(h.min(i32::MAX as usize) as i32); + if tile <= 0 { + return vec![(x0, y0, x1, y1)]; + } + let mut tiles = Vec::new(); + let mut y = y0; + while y < y1 { + let ny = y.saturating_add(tile).min(y1); + let mut x = x0; + while x < x1 { + let nx = x.saturating_add(tile).min(x1); + tiles.push((x, y, nx, ny)); + x = nx; + } + y = ny; + } + tiles +} + +fn stamp_dab_on_rect(p: &mut StampParams<'_>, dab: &Dab, rect: (i32, i32, i32, i32), src_rect: (i32, i32, i32, i32)) -> Result { + let (x0, y0, x1, y1) = rect; + if x1 <= x0 || y1 <= y0 { + return Ok(0); + } + let (sx0, sy0, sx1, sy1) = src_rect; + let Some(tw) = (x1 as i64 - x0 as i64).try_into().ok().filter(|w: &usize| *w > 0) else { + return Err(StampError::TooLarge); + }; + let Some(th) = (y1 as i64 - y0 as i64).try_into().ok().filter(|h: &usize| *h > 0) else { + return Err(StampError::TooLarge); + }; + let area = (tw as u64).saturating_mul(th as u64); + if area > MAX_COVERAGE { + return Err(StampError::TooLarge); + } + let n = tw.checked_mul(th).and_then(|m| m.checked_mul(p.ch)).ok_or(StampError::TooLarge)?; + let mut local = vec![0.0f32; n]; + let mut cov = vec![0.0f32; tw.checked_mul(th).ok_or(StampError::TooLarge)?]; + // Copy dest tile into local. + for y in y0..y1 { + for x in x0..x1 { + let lx = (x - x0) as usize; + let ly = (y - y0) as usize; + let Some(di) = origin_index(p.dest_origin, x, y, p.dest_w, p.dest_h, p.ch) else { continue }; + let Some(li) = pixel_index(lx, ly, tw, th, p.ch) else { continue }; + if let (Some(dst), Some(src)) = (local.get_mut(li..li + p.ch), p.dest.get(di..di + p.ch)) { + dst.copy_from_slice(src); + } + if let Some(c) = cov.get_mut(ly.saturating_mul(tw).saturating_add(lx)) { + *c = coverage_at(dab, x, y, p.hardness); + } + } + } + + // Cropped source window (halo). Samples outside it look empty — that is the seam if halo is short. + let sw = (sx1 - sx0).max(0) as usize; + let sh = (sy1 - sy0).max(0) as usize; + let sn = sw.checked_mul(sh).and_then(|m| m.checked_mul(p.ch)).ok_or(StampError::TooLarge)?; + let mut src_win = vec![0.0f32; sn]; + for y in sy0..sy1 { + for x in sx0..sx1 { + let Some(si) = origin_index(p.src_origin, x, y, p.src_w, p.src_h, p.ch) else { continue }; + let Some(wi) = origin_index((sx0, sy0), x, y, sw, sh, p.ch) else { continue }; + if let (Some(d), Some(s)) = (src_win.get_mut(wi..wi + p.ch), p.src.get(si..si + p.ch)) { + d.copy_from_slice(s); + } + } + } + + let mut sampled = vec![0.0f32; p.ch]; + let mut written = 0u32; + let k_stroke = (p.opacity * p.flow * dab.pressure).clamp(0.0, 1.0); + for y in y0..y1 { + for x in x0..x1 { + let lx = (x - x0) as usize; + let ly = (y - y0) as usize; + let Some(&c) = cov.get(ly.saturating_mul(tw).saturating_add(lx)) else { continue }; + if c <= 0.0 { + continue; + } + let mut k = c * k_stroke; + if let Some(sel) = p.selection { + let Some(si) = origin_index(p.dest_origin, x, y, p.dest_w, p.dest_h, 1) else { continue }; + let Some(&sv) = sel.get(si) else { continue }; + k *= sv.clamp(0.0, 1.0); + } + if k <= 0.0 { + continue; + } + let (sx, sy) = rotated_source(dab, x, y); + if !sample_bilinear(SrcView { data: &src_win, w: sw, h: sh, origin: (sx0, sy0), ch: p.ch }, sx, sy, &mut sampled) { + continue; + } + let Some(li) = pixel_index(lx, ly, tw, th, p.ch) else { continue }; + let Some(dst) = local.get_mut(li..li + p.ch) else { continue }; + if p.lock_transparent && p.alpha.is_some_and(|a| dst.get(a).is_some_and(|&v| v <= 0.0)) { + continue; + } + over_native(dst, &sampled, k, p.alpha, p.lock_transparent); + written = written.saturating_add(1); + } + } + + // Write the dest tile back (not the halo). + for y in y0..y1 { + for x in x0..x1 { + let lx = (x - x0) as usize; + let ly = (y - y0) as usize; + let Some(di) = origin_index(p.dest_origin, x, y, p.dest_w, p.dest_h, p.ch) else { continue }; + let Some(li) = pixel_index(lx, ly, tw, th, p.ch) else { continue }; + if let (Some(dst), Some(src)) = (p.dest.get_mut(di..di + p.ch), local.get(li..li + p.ch)) { + dst.copy_from_slice(src); + } + } + } + Ok(written) +} + +fn dab_intersects(dab: &Dab, tile: (i32, i32, i32, i32)) -> bool { + let Some((x0, y0, x1, y1)) = dab_bounds(dab) else { return false }; + x0 < tile.2 && x1 > tile.0 && y0 < tile.3 && y1 > tile.1 +} + +/// Mean `|src − dest|` over the dab's covered pixels (working-space peak is 1). `None` if nothing is covered. +fn dab_mean_diff(p: &StampParams<'_>, dab: &Dab) -> Option { + let (x0, y0, x1, y1) = dab_bounds(dab)?; + let mut sampled = vec![0.0f32; p.ch]; + let mut sum = 0.0f32; + let mut count = 0u32; + for y in y0..y1 { + for x in x0..x1 { + if coverage_at(dab, x, y, p.hardness) <= 0.0 { + continue; + } + let Some(di) = origin_index(p.dest_origin, x, y, p.dest_w, p.dest_h, p.ch) else { continue }; + let Some(dst) = p.dest.get(di..di.saturating_add(p.ch)) else { continue }; + let (sx, sy) = rotated_source(dab, x, y); + if !sample_bilinear(SrcView { data: p.src, w: p.src_w, h: p.src_h, origin: p.src_origin, ch: p.ch }, sx, sy, &mut sampled) { + continue; + } + let nch = p.ch.min(dst.len()).min(sampled.len()); + if nch == 0 { + continue; + } + let mut mad = 0.0f32; + for i in 0..nch { + let Some((&s, &d)) = sampled.get(i).zip(dst.get(i)) else { continue }; + mad += (s - d).abs(); + } + sum += mad / nch as f32; + count = count.saturating_add(1); + } + } + if count == 0 { + return None; + } + let mean = sum / count as f32; + mean.is_finite().then_some(mean) +} + +/// Stamp posed dabs into `dest`, reading `src` with a halo around each destination tile. +/// +/// Sequential dabs overlap: dab *n* sees dab *n − 1*. Returns how many destination pixels were +/// written (tolerance-skipped dabs count as zero). +pub fn stamp(p: &mut StampParams<'_>) -> Result { + dest_pixels(p)?; + if p.src_w == 0 || p.src_h == 0 { + return Err(StampError::Empty); + } + if p.dabs.is_empty() { + return Err(StampError::Empty); + } + let dest_area = (p.dest_w as u64).saturating_mul(p.dest_h as u64); + if dest_area > MAX_COVERAGE { + return Err(StampError::TooLarge); + } + let tiles = tile_grid(p.dest_w, p.dest_h, p.dest_origin, p.tile); + let halo = p.halo.max(0); + let mut written = 0u32; + let dabs: Vec = p.dabs.to_vec(); + for dab in &dabs { + let Some(bounds) = dab_bounds(dab) else { continue }; + if p.tolerance > 0.0 && dab_mean_diff(p, dab).is_some_and(|m| m < p.tolerance) { + continue; + } + let overlapping: Vec<(i32, i32, i32, i32)> = tiles.iter().copied().filter(|t| dab_intersects(dab, *t)).collect(); + if overlapping.is_empty() { + continue; + } + // Sequential tiles: each writes dest before the next dab. Parallel tiles of one dab do not + // share destination pixels, so they can run independently; we still apply them in order + // here so overlapping dabs stay deterministic. + for tile in overlapping { + let src_rect = (tile.0.saturating_sub(halo), tile.1.saturating_sub(halo), tile.2.saturating_add(halo), tile.3.saturating_add(halo)); + let (bx0, by0, bx1, by1) = bounds; + let dest_rect = (tile.0.max(bx0), tile.1.max(by0), tile.2.min(bx1), tile.3.min(by1)); + written = written.saturating_add(stamp_dab_on_rect(p, dab, dest_rect, src_rect)?); + } + } + Ok(written) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fill(w: usize, h: usize, ch: usize, f: impl Fn(i32, i32, usize) -> f32) -> Vec { + let mut v = vec![0.0; w * h * ch]; + for y in 0..h { + for x in 0..w { + for c in 0..ch { + v[(y * w + x) * ch + c] = f(x as i32, y as i32, c); + } + } + } + v + } + + fn horizontal(len: f32) -> Vec<[f32; 2]> { + vec![[10.0, 32.0], [10.0 + len, 32.0]] + } + + #[test] + fn tight_short_emits_more_dabs_than_tight_long() { + let pts = horizontal(100.0); + let short = place_dabs(&pts, 10.0, 50.0, Style::TightShort, RNG_SEED).unwrap(); + let long = place_dabs(&pts, 10.0, 50.0, Style::TightLong, RNG_SEED).unwrap(); + assert!(short.len() > long.len(), "short {} long {}", short.len(), long.len()); + let s = spacing_px(10.0, 50.0, Style::TightShort).unwrap(); + let l = spacing_px(10.0, 50.0, Style::TightLong).unwrap(); + assert!(s < l, "{s} {l}"); + } + + #[test] + fn tight_curl_angles_increase_and_loose_curl_scatters() { + let pts = horizontal(80.0); + let tight = place_dabs(&pts, 12.0, 50.0, Style::TightCurl, RNG_SEED).unwrap(); + let loose = place_dabs(&pts, 12.0, 50.0, Style::LooseCurl, RNG_SEED).unwrap(); + assert!(tight.len() >= 3 && loose.len() >= 3); + let dt: Vec = tight.windows(2).map(|w| w[1].theta - w[0].theta).collect(); + assert!(dt.iter().all(|d| *d > 0.05), "tight curl steps {dt:?}"); + let dl: Vec = loose.windows(2).map(|w| w[1].theta - w[0].theta).collect(); + let mean = |v: &[f32]| v.iter().sum::() / v.len() as f32; + assert!(mean(&dl) > mean(&dt), "loose curl {dl:?} vs tight {dt:?}"); + let scatter_t: f32 = tight.iter().map(|d| (d.y - 32.0).abs()).sum(); + let scatter_l: f32 = loose.iter().map(|d| (d.y - 32.0).abs()).sum(); + assert!(scatter_l > scatter_t, "loose scatter {scatter_l} tight {scatter_t}"); + } + + #[test] + fn seeded_scatter_is_replayable() { + let pts = horizontal(60.0); + let a = place_dabs(&pts, 16.0, 40.0, Style::LooseMedium, RNG_SEED ^ 3).unwrap(); + let b = place_dabs(&pts, 16.0, 40.0, Style::LooseMedium, RNG_SEED ^ 3).unwrap(); + assert_eq!(a, b); + let c = place_dabs(&pts, 16.0, 40.0, Style::LooseMedium, RNG_SEED ^ 4).unwrap(); + assert_ne!(a, c); + } + + fn fixture(ch: usize) -> (usize, usize, Vec, Vec, Dab) { + let (w, h) = (128usize, 96usize); + let src = fill(w, h, ch, |x, y, c| if c + 1 == ch && ch > 1 { 1.0 } else { ((x * 13 + y * 7 + c as i32 * 3).rem_euclid(251) as f32) / 255.0 }); + let dest0 = fill(w, h, ch, |_, _, c| if c + 1 == ch && ch > 1 { 1.0 } else { 0.1 }); + // Centre 1 px inside a 64-tile so the rotated read crosses the seam. + let dab = Dab { x: 63.0, y: 40.0, theta: 0.4, radius: 18.0, pressure: 1.0 }; + (w, h, src, dest0, dab) + } + + #[allow(clippy::too_many_arguments)] + fn stamp_run(w: usize, h: usize, ch: usize, src: &[f32], dest: &mut [f32], dab: &Dab, tile: i32, halo: i32) -> u32 { + let mut p = StampParams { + dest, + dest_w: w, + dest_h: h, + dest_origin: (0, 0), + src, + src_w: w, + src_h: h, + src_origin: (0, 0), + ch, + dabs: std::slice::from_ref(dab), + hardness: 1.0, + opacity: 1.0, + flow: 1.0, + tolerance: 0.0, + tile, + halo, + selection: None, + lock_transparent: false, + alpha: (ch > 1).then_some(ch - 1), + }; + stamp(&mut p).unwrap() + } + + fn max_abs(a: &[f32], b: &[f32]) -> f32 { + a.iter().zip(b).fold(0.0f32, |m, (x, y)| m.max((x - y).abs())) + } + + fn max_u8(a: &[f32], b: &[f32]) -> i32 { + a.iter() + .zip(b) + .map(|(x, y)| { + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as i32; + (q(*x) - q(*y)).abs() + }) + .max() + .unwrap_or(0) + } + + #[test] + fn tiled_stamp_matches_untiled_at_a_tile_seam() { + let halo = halo_radius(40.0, Style::LooseMedium).unwrap(); + let (w, h, src, dest0, dab) = fixture(4); + let mut tiled = dest0.clone(); + let mut flat = dest0.clone(); + let n_t = stamp_run(w, h, 4, &src, &mut tiled, &dab, 64, halo); + let n_f = stamp_run(w, h, 4, &src, &mut flat, &dab, 0, halo); + assert!(n_t > 0 && n_f > 0, "wrote {n_t} tiled / {n_f} flat"); + assert_eq!(max_u8(&tiled, &flat), 0, "8-bit seam"); + assert!(max_abs(&tiled, &flat) <= 1.0 / 32_767.0, "16-bit seam {}", max_abs(&tiled, &flat)); + assert!(max_abs(&tiled, &flat) <= 1.0e-5, "32f seam {}", max_abs(&tiled, &flat)); + + let (w, h, src, dest0, dab) = fixture(1); + let mut tiled = dest0.clone(); + let mut flat = dest0; + stamp_run(w, h, 1, &src, &mut tiled, &dab, 64, halo); + stamp_run(w, h, 1, &src, &mut flat, &dab, 0, halo); + assert_eq!(max_u8(&tiled, &flat), 0, "gray 8-bit seam"); + } + + #[test] + fn short_halo_disagrees_with_untiled_reference() { + let (w, h, src, dest0, dab) = fixture(4); + let halo = halo_radius(40.0, Style::LooseMedium).unwrap(); + let mut short = dest0.clone(); + let mut full = dest0; + stamp_run(w, h, 4, &src, &mut short, &dab, 64, 1); + stamp_run(w, h, 4, &src, &mut full, &dab, 0, halo); + assert!(max_abs(&short, &full) > 1.0e-3, "a 1 px halo must miss the rotated read"); + } + + #[test] + fn halo_covers_rotated_read_reach() { + let size = 40.0; + for style in + [Style::TightShort, Style::TightMedium, Style::TightLong, Style::LooseMedium, Style::LooseLong, Style::Dab, Style::TightCurl, Style::LooseCurl] + { + let halo = halo_radius(size, style).unwrap(); + let side = sample_side(size, style).unwrap(); + let reach = (side + 1) / 2; + assert!(halo >= reach, "{:?}: halo {halo} < reach {reach} (side {side})", style.as_str()); + } + } + + #[test] + fn tolerance_skips_an_unchanged_layer() { + let (w, h, ch) = (48usize, 48usize, 4); + let src = fill(w, h, ch, |x, y, c| if c == 3 { 1.0 } else { (x + y) as f32 / 100.0 }); + let mut dest = src.clone(); + let dab = Dab { x: 24.0, y: 24.0, theta: 0.0, radius: 8.0, pressure: 1.0 }; + let mut p = StampParams { + dest: &mut dest, + dest_w: w, + dest_h: h, + dest_origin: (0, 0), + src: &src, + src_w: w, + src_h: h, + src_origin: (0, 0), + ch, + dabs: std::slice::from_ref(&dab), + hardness: 1.0, + opacity: 1.0, + flow: 1.0, + tolerance: 1.0, + tile: 0, + halo: 4, + selection: None, + lock_transparent: false, + alpha: Some(3), + }; + let n = stamp(&mut p).unwrap(); + assert_eq!(n, 0); + assert_eq!(dest, src); + } + + #[test] + fn tolerance_zero_writes_where_history_differs() { + let (w, h, ch) = (48usize, 48usize, 4); + let src = fill(w, h, ch, |_, _, c| if c == 3 { 1.0 } else { 0.9 }); + let mut dest = fill(w, h, ch, |_, _, c| if c == 3 { 1.0 } else { 0.1 }); + let dab = Dab { x: 24.0, y: 24.0, theta: 0.0, radius: 8.0, pressure: 1.0 }; + let mut p = StampParams { + dest: &mut dest, + dest_w: w, + dest_h: h, + dest_origin: (0, 0), + src: &src, + src_w: w, + src_h: h, + src_origin: (0, 0), + ch, + dabs: std::slice::from_ref(&dab), + hardness: 1.0, + opacity: 1.0, + flow: 1.0, + tolerance: 0.0, + tile: 0, + halo: 4, + selection: None, + lock_transparent: false, + alpha: Some(3), + }; + let n = stamp(&mut p).unwrap(); + assert!(n > 0); + let i = (24 * w + 24) * ch; + assert!((dest[i] - 0.9).abs() < 1.0e-5, "{}", dest[i]); + } + + #[test] + fn empty_or_mismatched_buffers_error() { + let mut dest = vec![0.0f32; 16]; + let src = vec![0.0f32; 16]; + let dab = Dab { x: 1.0, y: 1.0, theta: 0.0, radius: 1.0, pressure: 1.0 }; + let mut p = StampParams { + dest: &mut dest, + dest_w: 0, + dest_h: 4, + dest_origin: (0, 0), + src: &src, + src_w: 4, + src_h: 4, + src_origin: (0, 0), + ch: 1, + dabs: std::slice::from_ref(&dab), + hardness: 1.0, + opacity: 1.0, + flow: 1.0, + tolerance: 0.0, + tile: 0, + halo: 1, + selection: None, + lock_transparent: false, + alpha: None, + }; + assert_eq!(stamp(&mut p), Err(StampError::Empty)); + let mut dest = vec![0.0f32; 16]; + let src = vec![0.0f32; 8]; + let mut p = StampParams { + dest: &mut dest, + dest_w: 4, + dest_h: 4, + dest_origin: (0, 0), + src: &src, + src_w: 2, + src_h: 2, + src_origin: (0, 0), + ch: 4, + dabs: std::slice::from_ref(&dab), + hardness: 1.0, + opacity: 1.0, + flow: 1.0, + tolerance: 0.0, + tile: 0, + halo: 1, + selection: None, + lock_transparent: false, + alpha: None, + }; + assert!(stamp(&mut p).is_err()); + assert!(place_dabs(&[], 10.0, 50.0, Style::TightMedium, 0).is_err()); + assert!(Style::parse("nope").is_none()); + assert_eq!(Style::parse("tightMedium"), Some(Style::TightMedium)); + } + + #[test] + fn two_stamps_with_the_same_seed_match() { + let pts = horizontal(50.0); + let dabs = place_dabs(&pts, 12.0, 50.0, Style::LooseCurl, RNG_SEED).unwrap(); + let (w, h, ch) = (80usize, 64usize, 4); + let src = fill(w, h, ch, |x, y, c| if c == 3 { 1.0 } else { (x + 3 * y) as f32 / 200.0 }); + let dest0 = fill(w, h, ch, |_, _, c| if c == 3 { 1.0 } else { 0.2 }); + let run = |dest: &mut [f32]| { + let mut p = StampParams { + dest, + dest_w: w, + dest_h: h, + dest_origin: (0, 0), + src: &src, + src_w: w, + src_h: h, + src_origin: (0, 0), + ch, + dabs: &dabs, + hardness: 0.8, + opacity: 1.0, + flow: 1.0, + tolerance: 0.0, + tile: 64, + halo: halo_radius(12.0, Style::LooseCurl).unwrap(), + selection: None, + lock_transparent: false, + alpha: Some(3), + }; + stamp(&mut p).unwrap() + }; + let mut a = dest0.clone(); + let mut b = dest0; + assert_eq!(run(&mut a), run(&mut b)); + assert_eq!(a, b); + } +} diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 35c195f399..41a453b6b3 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -18,6 +18,7 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod art_history; mod artistic; mod artistic_fx; mod blur; diff --git a/crates/engine/examples/bench_art_history.rs b/crates/engine/examples/bench_art_history.rs new file mode 100644 index 0000000000..e66949eb96 --- /dev/null +++ b/crates/engine/examples/bench_art_history.rs @@ -0,0 +1,60 @@ +//! 24 MP timing of Art History Brush on a representative stroke. +//! `cargo run --release -p photocraft-engine --example bench_art_history` +use std::time::Instant; + +use photocraft_engine::Session; +use photocraft_geom::Rect; +use serde_json::json; + +fn main() { + let (w, h) = (6016u32, 4000u32); + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h, "depth": 8, "mode": "rgb"})).unwrap(); + s.edit("texture", |doc, active| { + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let fmt = surf.format(); + let row: Vec = (0..w) + .flat_map(|x| { + let t = (x % 256) as f32 / 255.0; + photocraft_raster::from_rgba(&fmt, [t, 0.35, 1.0 - t, 1.0]) + }) + .collect(); + for y in 0..h as i32 { + surf.write_region(Rect::new(0, y, w as i32, y + 1), &row); + } + Ok(()) + }) + .unwrap(); + s.execute("edit.fill", json!({"color": "#606060"})).unwrap(); + + let points: Vec<[i32; 2]> = (0..200).map(|i| [2800 + i, 2000]).collect(); + let run = |s: &mut Session, style: &str| { + s.execute( + "paint.artHistoryBrush", + json!({ + "points": points, + "size": 40, + "hardness": 100, + "area": 50, + "tolerance": 0, + "style": style, + "state": 0 + }), + ) + .unwrap(); + }; + // Warm the allocator / tile cache. + run(&mut s, "tightMedium"); + s.undo(); + + for style in ["tightMedium", "looseCurl"] { + let t = Instant::now(); + run(&mut s, style); + let ms = t.elapsed().as_secs_f64() * 1000.0; + println!( + "artHistoryBrush document {w}×{h} ({:.1} MP) stroke 200 px size 40 area 50 style {style} {ms:.3} ms", + (w as u64 * h as u64) as f64 / 1e6 + ); + s.undo(); + } +} diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 345abc377f..b9f1512a48 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -731,6 +731,7 @@ fn routed(id: &str) -> bool { | "paint.sharpen" | "paint.smudge" | "paint.historyBrush" + | "paint.artHistoryBrush" | "paint.redEye" | "paint.remove" ) diff --git a/crates/engine/src/hidden_target.rs b/crates/engine/src/hidden_target.rs index 249317354a..977d642f21 100644 --- a/crates/engine/src/hidden_target.rs +++ b/crates/engine/src/hidden_target.rs @@ -32,6 +32,7 @@ const GATED: &[&str] = &[ "paint.sharpen", "paint.smudge", "paint.historyBrush", + "paint.artHistoryBrush", "layer.translate", ]; diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 005377328f..ead6665946 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -1,5 +1,6 @@ //! Retouching tools as commands: Clone Stamp, Pattern Stamp, Healing Brush, Spot Healing Brush, -//! Patch, Content-Aware Move, Remove, Dodge, Burn, Sponge, Blur, Sharpen, Smudge and History Brush. +//! Patch, Content-Aware Move, Remove, Dodge, Burn, Sponge, Blur, Sharpen, Smudge, History Brush and +//! Art History Brush. //! //! Every command takes a Photoshop-style brush (`points`, `size`, `hardness`, `opacity`, `flow`, //! `spacing`, `layer`), respects the active selection as a mask and the layer's transparency lock, and @@ -24,6 +25,7 @@ use serde_json::{Value, json}; use crate::commands::{CommandSpec, blend_from_str}; use crate::{EngineError, Result, Session}; +mod art_history; mod content_aware_move; mod patch; mod pattern_stamp; @@ -1522,6 +1524,18 @@ pub fn specs() -> Vec { run: history_brush, journal: true, }, + CommandSpec { + id: "paint.artHistoryBrush", + label: "Art History Brush", + menu: &[], + shortcut: None, + params: brush_params!( + r#","state":index=0 (oldest held snapshot),"style":"tightShort|tightMedium|tightLong|looseMedium|looseLong|dab|tightCurl|looseCurl"="tightMedium","area":0..100=50,"tolerance":0..100=50"# + ), + enabled: has_pixel_layer, + run: art_history::run, + journal: true, + }, ] } diff --git a/crates/engine/src/retouch_cmds/art_history.rs b/crates/engine/src/retouch_cmds/art_history.rs new file mode 100644 index 0000000000..32d6962f9d --- /dev/null +++ b/crates/engine/src/retouch_cmds/art_history.rs @@ -0,0 +1,478 @@ +//! Art History Brush: stylized, oriented dabs sampled from a history state. + +use photocraft_algo::art_history::{self, Dab, MAX_COVERAGE, MAX_DABS, RNG_SEED, StampError, StampParams, Style}; +use photocraft_color::BlendMode; +use photocraft_geom::Rect; +use photocraft_paint::MAX_BRUSH_SIZE; +use photocraft_paint::retouch::{Region, alpha_index}; +use photocraft_raster::{from_rgba_into, to_rgba}; +use serde_json::{Value, json}; + +use super::*; + +const CMD: &str = "paint.artHistoryBrush"; +const MAX_POINTS: usize = 4096; + +fn err(msg: impl Into) -> EngineError { + bad(CMD, msg) +} + +fn object(p: &Value) -> Result<&serde_json::Map> { + p.as_object().ok_or_else(|| err("expected an object")) +} + +fn finite(v: Option<&Value>, _key: &str) -> Result> { + match v { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_f64().filter(|x| x.is_finite()).map(|x| x as f32).ok_or_else(|| err("invalid number")).map(Some), + } +} + +fn finite_key(p: &Value, key: &str, default: f32) -> Result { + match finite(p.get(key), key)? { + None => Ok(default), + Some(v) => Ok(v), + } +} + +fn ranged(p: &Value, key: &str, default: f32, lo: f32, hi: f32) -> Result { + let v = finite_key(p, key, default)?; + if (lo..=hi).contains(&v) { Ok(v) } else { Err(err(format!("`{key}` must be a number {lo}..{hi}"))) } +} + +fn points(p: &Value) -> Result> { + let Some(arr) = p.get("points").and_then(Value::as_array) else { + return Err(err("no stroke points")); + }; + if arr.is_empty() { + return Err(err("no stroke points")); + } + if arr.len() > MAX_POINTS { + return Err(err("too many points")); + } + let mut out = Vec::with_capacity(arr.len()); + for q in arr { + let Some(a) = q.as_array() else { + return Err(err("invalid number")); + }; + let (Some(x), Some(y)) = (a.first(), a.get(1)) else { + return Err(err("invalid number")); + }; + let x = x.as_f64().filter(|v| v.is_finite()).ok_or_else(|| err("invalid number"))?; + let y = y.as_f64().filter(|v| v.is_finite()).ok_or_else(|| err("invalid number"))?; + if let Some(pr) = a.get(2) + && pr.as_f64().filter(|v| v.is_finite()).is_none() + { + return Err(err("invalid number")); + } + out.push([x as f32, y as f32]); + } + if out.is_empty() { + return Err(err("no stroke points")); + } + Ok(out) +} + +fn style_of(p: &Value) -> Result

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∋dL(#YwN)K+lJ>&E;yV^ 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