diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11b4b9ef05..cea1326c0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,7 +125,7 @@ jobs: libgtk-3-dev # The tests build with the optional craft-fonts input (crates/text/build.rs), so the # Japanese font tests that skip without it run. Same commit as release.yml. - craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 # Cargo.toml's dev profile already keeps line tables only. dev-debug: "" env: | @@ -156,11 +156,24 @@ jobs: # A panic still names its file and line; only backtrace frames lose theirs (Linux keeps # line-tables-only, so a backtrace with lines is one OS away). CARGO_PROFILE_DEV_DEBUG: "0" + # The runner has no Japanese or Simplified Chinese system font, and egui 0.37 panics on a + # missing glyph: the localized UI tests need the same craft-fonts input the gate and FreeBSD + # build with (crates/text/build.rs). Same commit as the gate above and release.yml. + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Check out craft-fonts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: storytold/craft-fonts + ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + path: craft-fonts + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: clippy @@ -385,7 +398,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # nightly - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: workspaces: crates/${{ matrix.target.crate }}/fuzz diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dca0dacfbb..e93efc1018 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -31,7 +31,7 @@ jobs: name: mdBook build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - name: Install mdBook run: cargo install mdbook --version 0.4.52 --locked diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index f15ec0e8d8..2b98d8c11d 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -32,7 +32,7 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: - craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 cargo-build-extra-args: "--features heif" # The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a device at # once, so photocraft-gpu's tests run one at a time, like ui-egui's integration tests. diff --git a/.github/workflows/perf-nightly.yml b/.github/workflows/perf-nightly.yml index 4b09278804..64592d9cf0 100644 --- a/.github/workflows/perf-nightly.yml +++ b/.github/workflows/perf-nightly.yml @@ -40,7 +40,7 @@ jobs: runs-on: macos-15 timeout-minutes: 120 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: persist-credentials: false @@ -72,7 +72,7 @@ jobs: - name: Upload results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: perf-results path: | @@ -83,7 +83,7 @@ jobs: - name: Upload baseline if: always() && inputs.update_baseline - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: perf-baseline path: perf/baseline.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfdca9e3e9..4b78fac526 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: with: app-name: photocraft app-title: PhotoCraft - craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 5b2b1e1470..fdec613767 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -26,6 +26,7 @@ next to them. | `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | | `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | @@ -115,7 +116,7 @@ next to the files: | Path (fetched) | Title | Author | Source | License | |---|---|---|---|---| -| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 | +| `corpus/photoshop/` (258 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 | | `corpus/gimp/` (28 files, 251 KB) | GIMP XCF oracles: 40×30 procedural layers written by GIMP 3.2.6 through the corpus generator `tools/gimp-xcf/xcf_fixtures.py`, each with GIMP's merged rendering as a PNG | PhotoCraft contributors (authored with GIMP 3.2.6 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`gimp/`, with its generator and README) | MIT OR Apache-2.0 | | `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi | | `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) | diff --git a/Cargo.lock b/Cargo.lock index b90e1f5cd5..128d0f35bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "enumn", "serde", @@ -15,12 +15,12 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", "phf", "serde", @@ -29,43 +29,37 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -73,9 +67,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -91,13 +85,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -105,11 +99,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -179,6 +174,15 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -277,7 +281,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -288,7 +292,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -305,21 +309,21 @@ checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -655,15 +659,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.10.0" @@ -674,10 +669,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "bit-set" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] [[package]] name = "bit-vec" @@ -685,6 +683,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bit_field" version = "0.10.3" @@ -758,7 +766,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -774,6 +782,30 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "brotli-decompressor" version = "5.0.3" @@ -1309,12 +1341,20 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" + +[[package]] +name = "craft-jpeg" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +dependencies = [ + "thiserror 2.0.21", +] [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=3d37da389a3618ab9b76109da11416e9ca4ea719#3d37da389a3618ab9b76109da11416e9ca4ea719" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.8.9", @@ -1327,12 +1367,41 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" dependencies = [ "egui", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", ] +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip", +] + [[package]] name = "craft-winprint-gdi" version = "0.1.0" @@ -1343,32 +1412,31 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] [[package]] name = "criterion" -version = "0.5.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" dependencies = [ + "alloca", "anes", "cast", "ciborium", "clap", "criterion-plot", - "is-terminal", - "itertools 0.10.5", + "itertools 0.13.0", "num-traits", - "once_cell", "oorandom", + "page_size", "regex", "serde", - "serde_derive", "serde_json", "tinytemplate", "walkdir", @@ -1376,12 +1444,12 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.5.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", - "itertools 0.10.5", + "itertools 0.13.0", ] [[package]] @@ -1516,6 +1584,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "dify" version = "0.8.0" @@ -1554,7 +1628,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1667,9 +1741,8 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "ecolor" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1678,9 +1751,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1694,7 +1766,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "parking_lot", @@ -1703,6 +1775,7 @@ dependencies = [ "profiling", "raw-window-handle", "ron", + "send_wrapper", "serde", "static_assertions", "wasm-bindgen", @@ -1715,9 +1788,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1737,9 +1809,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1757,31 +1828,34 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "serde", "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_extras" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "egui", @@ -1794,9 +1868,8 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1808,9 +1881,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "dify", "eframe", @@ -1835,9 +1907,8 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "serde", @@ -1865,22 +1936,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enum-map" -version = "2.7.3" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" dependencies = [ "enum-map-derive", ] [[package]] name = "enum-map-derive" -version = "0.17.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -1917,9 +1988,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1936,16 +2006,17 @@ dependencies = [ "serde", "skrifa", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equator" @@ -1980,7 +2051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2161,7 +2232,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", ] @@ -2225,7 +2296,7 @@ dependencies = [ "hashbrown 0.17.1", "linebender_resource_handle", "memmap2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-text", "objc2-foundation 0.3.2", @@ -2526,9 +2597,9 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", "foldhash 0.2.0", @@ -2572,7 +2643,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3099,7 +3170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" dependencies = [ "io-lifetimes 3.0.1", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -3129,17 +3200,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "is-wsl" version = "0.4.0" @@ -3165,6 +3225,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -3481,12 +3550,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] @@ -3532,9 +3601,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libfuzzer-sys" @@ -3792,14 +3861,14 @@ dependencies = [ [[package]] name = "muda" -version = "0.21.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7b000cca0783af26837fe2ebd9ddf1c42279463c085b97622ee1d2f444b89e" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" dependencies = [ "crossbeam-channel", "dpi", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3888,6 +3957,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "no_std_io2" version = "0.9.4" @@ -3948,6 +4029,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -4029,9 +4116,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -4061,7 +4148,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-cloud-kit 0.3.2", "objc2-core-data 0.3.2", "objc2-core-foundation", @@ -4082,7 +4169,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-avf-audio", "objc2-core-audio-types", "objc2-core-foundation", @@ -4101,7 +4188,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -4125,7 +4212,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -4147,7 +4234,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio-types", "objc2-core-foundation", ] @@ -4159,7 +4246,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -4181,7 +4268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -4195,7 +4282,7 @@ dependencies = [ "block2 0.6.2", "dispatch2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -4208,7 +4295,7 @@ dependencies = [ "block2 0.6.2", "dispatch2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", "objc2-metal 0.3.2", @@ -4232,7 +4319,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -4257,7 +4344,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", @@ -4271,7 +4358,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", ] @@ -4284,7 +4371,7 @@ checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", @@ -4319,7 +4406,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -4329,7 +4416,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", ] @@ -4341,7 +4428,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -4363,7 +4450,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd9fdde720df3da7046bb9097811000c1e7ab5cd579fa89d96b27d56781fb30" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio-types", "objc2-core-foundation", "objc2-core-media", @@ -4389,7 +4476,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -4413,7 +4500,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -4458,7 +4545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -4507,9 +4594,9 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "open" -version = "5.4.4" +version = "5.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" dependencies = [ "is-wsl", "libc", @@ -4557,7 +4644,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", ] [[package]] @@ -4691,9 +4788,9 @@ dependencies = [ [[package]] name = "phf" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -4702,9 +4799,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -4712,9 +4809,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", @@ -4725,22 +4822,23 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] [[package]] name = "photocraft" -version = "0.15.1" +version = "0.16.0" dependencies = [ "arboard", "async-io", "chrono", "clap", "core-graphics2", + "craft-update", "dispatch2", "eframe", "egui", @@ -4748,7 +4846,7 @@ dependencies = [ "futures-lite", "log", "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "open", @@ -4768,10 +4866,10 @@ dependencies = [ "resvg", "rfd", "ron", + "serde", "serde_json", "tempfile", "thiserror 2.0.21", - "ureq", "winit", "winresource", "winsafe", @@ -4783,7 +4881,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.15.1" +version = "0.16.0" dependencies = [ "crc32fast", "flate2", @@ -4793,7 +4891,7 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.15.1" +version = "0.16.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4807,9 +4905,9 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.15.1" +version = "0.16.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "cap-std", "clap", "getrandom 0.3.4", @@ -4831,7 +4929,7 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.15.1" +version = "0.16.0" dependencies = [ "clap", "clap_complete", @@ -4850,7 +4948,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.15.1" +version = "0.16.0" dependencies = [ "moxcms", "rayon", @@ -4858,8 +4956,9 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.15.1" +version = "0.16.0" dependencies = [ + "craft-jpeg", "exr", "flate2", "half", @@ -4879,7 +4978,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.15.1" +version = "0.16.0" dependencies = [ "photocraft-cms", "serde", @@ -4887,7 +4986,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.15.1" +version = "0.16.0" dependencies = [ "criterion", "photocraft-cms", @@ -4903,7 +5002,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.15.1" +version = "0.16.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4914,7 +5013,7 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.15.1" +version = "0.16.0" dependencies = [ "blake3", "chrono", @@ -4955,7 +5054,7 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.15.1" +version = "0.16.0" dependencies = [ "blake3", "crc32fast", @@ -4978,17 +5077,19 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.15.1" +version = "0.16.0" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.15.1" +version = "0.16.0" dependencies = [ + "bytemuck", "half", "log", + "photocraft-algo", "photocraft-cms", "photocraft-color", "photocraft-compose", @@ -5003,14 +5104,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.15.1" +version = "0.16.0" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.15.1" +version = "0.16.0" dependencies = [ "blake3", "craft-docpath", @@ -5037,33 +5138,38 @@ dependencies = [ "roxmltree 0.21.1", "serde_json", "thiserror 2.0.21", + "tiff", + "zune-core 0.5.3", + "zune-jpegxl", ] [[package]] name = "photocraft-ipp" -version = "0.15.1" +version = "0.16.0" [[package]] name = "photocraft-ops" -version = "0.15.1" +version = "0.16.0" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.15.1" +version = "0.16.0" dependencies = [ + "photocraft-cms", "photocraft-color", "photocraft-geom", "photocraft-raster", + "rayon", "serde", "serde_json", ] [[package]] name = "photocraft-plugins" -version = "0.15.1" +version = "0.16.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -5079,7 +5185,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.15.1" +version = "0.16.0" dependencies = [ "flate2", "photocraft-psd", @@ -5090,7 +5196,7 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.15.1" +version = "0.16.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -5101,7 +5207,7 @@ dependencies = [ [[package]] name = "photocraft-raw" -version = "0.15.1" +version = "0.16.0" dependencies = [ "flate2", "image", @@ -5116,10 +5222,10 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.15.1" +version = "0.16.0" dependencies = [ "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -5128,7 +5234,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.15.1" +version = "0.16.0" dependencies = [ "memory-stats", "serde_json", @@ -5136,7 +5242,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.15.1" +version = "0.16.0" dependencies = [ "flate2", "parley", @@ -5151,11 +5257,12 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.15.1" +version = "0.16.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "craft-print", "craft-ui", + "craft-update", "eframe", "egui", "egui_extras", @@ -5189,12 +5296,13 @@ dependencies = [ "tempfile", "toml", "unicode-bidi", + "web-time", "winsafe", ] [[package]] name = "photocraft-vector" -version = "0.15.1" +version = "0.16.0" dependencies = [ "photocraft-color", "photocraft-doc", @@ -5205,7 +5313,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.15.1" +version = "0.16.0" dependencies = [ "eframe", "egui", @@ -5227,7 +5335,7 @@ dependencies = [ [[package]] name = "photocraft-web-boot" -version = "0.15.1" +version = "0.16.0" dependencies = [ "flate2", "js-sys", @@ -5316,6 +5424,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.17.16" @@ -5403,6 +5524,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -5490,16 +5617,17 @@ dependencies = [ [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha", + "rand 0.10.3", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -5566,6 +5694,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.12" @@ -5620,7 +5757,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5707,11 +5844,11 @@ dependencies = [ [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -5790,7 +5927,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -5986,7 +6123,7 @@ dependencies = [ "js-sys", "libc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -6027,13 +6164,14 @@ dependencies = [ [[package]] name = "rmcp" -version = "3.5.0" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fae7019994ae0fe4ada40b732f798f3ff26f0f04facb1477f1bf37eb4f18a2d3" +checksum = "ceb42e9e47443cd83a834dea57b5816d35481417bae72ade5dc5615c59122e26" dependencies = [ "chrono", "futures", "indexmap", + "libc", "pastey 0.2.3", "pin-project-lite", "rmcp-macros", @@ -6050,9 +6188,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.5.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e66877136e969d5a2d561ee5841b17717a0619aebd64abb47a2a2f46e34683" +checksum = "967fa7f7935d7d6217d593df0e8e5c9307bcaf256974a5a9ee64476c97735389" dependencies = [ "darling", "proc-macro2", @@ -6154,7 +6292,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6223,7 +6361,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6321,9 +6459,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "chrono", "dyn-clone", @@ -6335,9 +6473,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" dependencies = [ "proc-macro2", "quote", @@ -6391,6 +6529,16 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" @@ -6435,9 +6583,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -7088,10 +7236,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7157,6 +7305,36 @@ dependencies = [ "zune-jpeg 0.5.15", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -7233,9 +7411,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.1" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -7471,6 +7649,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typeid" version = "1.0.3" @@ -7579,6 +7763,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" dependencies = [ "base64 0.23.1", + "flate2", "log", "percent-encoding", "rustls", @@ -7586,7 +7771,6 @@ dependencies = [ "rustls-platform-verifier", "ureq-proto", "utf8-zero", - "webpki-roots", ] [[package]] @@ -7683,9 +7867,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -7698,9 +7882,9 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "glifo", @@ -7708,6 +7892,16 @@ dependencies = [ "vello_common", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -7821,12 +8015,12 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.261.0" +version = "0.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" dependencies = [ "leb128fmt", - "wasmparser 0.261.0", + "wasmparser 0.262.0", ] [[package]] @@ -7881,9 +8075,9 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.261.0" +version = "0.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" dependencies = [ "bitflags 2.13.2", "indexmap", @@ -7892,9 +8086,9 @@ dependencies = [ [[package]] name = "wast" -version = "261.0.0" +version = "262.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a" +checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23" dependencies = [ "bumpalo", "leb128fmt", @@ -7905,9 +8099,9 @@ dependencies = [ [[package]] name = "wat" -version = "1.261.0" +version = "1.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f" +checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1" dependencies = [ "wast", ] @@ -8031,7 +8225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -8091,15 +8285,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "weezl" version = "0.1.12" @@ -8234,7 +8419,7 @@ dependencies = [ "naga", "naga-types", "ndk-sys", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -8295,15 +8480,37 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] +[[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" @@ -8422,7 +8629,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -8431,16 +8638,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -8458,31 +8656,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -8500,101 +8681,52 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -8672,10 +8804,20 @@ dependencies = [ ] [[package]] -name = "winresource" -version = "0.1.31" +name = "winreg" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winresource" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", @@ -8804,7 +8946,7 @@ dependencies = [ "image", "libwayshot-xcap", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-av-foundation", "objc2-core-foundation", @@ -8833,7 +8975,7 @@ checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" dependencies = [ "bitflags 2.13.2", "libc", - "quick-xml", + "quick-xml 0.41.0", ] [[package]] @@ -8881,7 +9023,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.15.1" +version = "0.16.0" dependencies = [ "chrono", "serde", @@ -9131,6 +9273,23 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zip" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -9143,6 +9302,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.4.12" @@ -9231,3 +9402,18 @@ dependencies = [ "syn 3.0.6", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 0787db33f8..a3b0ca4b36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,13 +4,13 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"] [workspace.package] -version = "0.15.1" +version = "0.16.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" [workspace.dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["serde"] } # internal photocraft-geom = { path = "crates/geom" } photocraft-color = { path = "crates/color" } @@ -39,8 +39,10 @@ photocraft-tablet = { path = "crates/tablet" } photocraft-ipp = { path = "crates/ipp" } # external -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" } -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } +craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" @@ -55,18 +57,20 @@ image = { version = "0.25", default-features = false } zune-core = "0.5" zune-jpeg = "0.5" proptest = "1" -eframe = { version = "0.36", default-features = false } +eframe = { version = "0.37", default-features = false } # Without `default_fonts`, which embeds egui's fonts uncompressed: ui-egui ships them deflated # (egui_fonts.rs; the web build's size gate). -egui = { version = "0.36", default-features = false } -egui_kittest = "0.36" +egui = { version = "0.37", default-features = false } +egui_kittest = "0.37" rfd = "0.17" log = "0.4" # All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" -criterion = { version = "0.5", default-features = false } +criterion = { version = "0.8", default-features = false } getrandom = "0.3" +# Monotonic clock on native and wasm (screenshot deadlines: a wall clock can jump). +web-time = "1" [workspace.lints.rust] unsafe_code = "forbid" @@ -175,3 +179,20 @@ opt-level = "z" [profile.wasm-release.package.photocraft-paint] opt-level = 3 +# Keep the complete egui family and native event bridge on reviewed fork commits. +[patch.crates-io] +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index a7302a940c..6250865673 100644 --- a/NOTICE +++ b/NOTICE @@ -22,3 +22,20 @@ Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and BIZ UDMincho; not the web build), under the SIL Open Font License 1.1. Their licence texts ship with the package as OFL-.txt. + +The egui-icons font subset is embedded from epaint_default_fonts by crates/ui-egui/build.rs +and registered in the native and web fallback stacks by crates/ui-egui/src/egui_fonts.rs. +It is a subset of emoji-icon-font by John Slegers; the same upstream MIT license applies. +Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts +Original: https://github.com/jslegers/emoji-icon-font +The following notice is reproduced verbatim from emoji-icon-font-mit-license.txt: + +MIT License + +Copyright (c) 2014 John Slegers + +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/README.md b/README.md index 74a5c78f36..a22f1aee26 100644 --- a/README.md +++ b/README.md @@ -185,8 +185,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/apps/photocraft-cli/tests/it/orientation.rs b/apps/photocraft-cli/tests/it/orientation.rs index 101195581f..3895df367a 100644 --- a/apps/photocraft-cli/tests/it/orientation.rs +++ b/apps/photocraft-cli/tests/it/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)); diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index 279d13b453..0176e0fe84 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -29,7 +29,7 @@ log = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. -eframe = { workspace = true, features = ["wgpu", "accesskit"] } +eframe = { workspace = true, features = ["wgpu", "accesskit", "clipboard"] } rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" diff --git a/apps/photocraft-web/src/preset_bridge.rs b/apps/photocraft-web/src/preset_bridge.rs index a7445bbc14..a39fdfb4b4 100644 --- a/apps/photocraft-web/src/preset_bridge.rs +++ b/apps/photocraft-web/src/preset_bridge.rs @@ -9,8 +9,8 @@ use photocraft_engine::{ }; pub type Batch = BTreeMap>>; -/// Three sampled images per preset (tip, dual tip, texture), plus group/index/action records. -pub const MAX_RECORDS: usize = MAX_GROUPS + MAX_PRESETS_PER_GROUP * 3 + 2; +/// Three sampled images per preset (tip, dual tip, texture), plus group/index/action/pattern records. +pub const MAX_RECORDS: usize = MAX_GROUPS + MAX_PRESETS_PER_GROUP * 3 + 3; #[derive(Default)] struct State { @@ -45,6 +45,7 @@ pub fn record_limit(name: &str) -> Result { match name { INDEX_FILE => Ok(MAX_INDEX_BYTES), ACTIONS_FILE => Ok(MAX_ACTIONS_BYTES), + PATTERNS_FILE => Ok(MAX_PATTERNS_BYTES), _ if name.starts_with("tips/") && leaf.ends_with(".pctip") => Ok(MAX_TIP_BYTES), _ if !name.contains('/') && name.ends_with(".pcbrushes") => Ok(MAX_GROUP_BYTES), _ => Err("unknown preset record type".into()), diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 20e64fa046..f82051dbf9 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -4,7 +4,7 @@ use std::cell::Cell; use std::collections::{HashMap, HashSet}; use std::rc::Rc; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, PoisonError}; use photocraft_codecs::{ChannelLayout, EncodeOptions, Image}; use photocraft_doc::Document; @@ -80,6 +80,7 @@ pub fn start() { canvas, options, Box::new(move |cc| { + PhotocraftApp::install_type_reset_hooks(&cc.egui_ctx); PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro); let inbox: Inbox = Arc::default(); let place_inbox: Inbox = Arc::default(); @@ -218,7 +219,7 @@ fn load_font_manifest(served: Served, ctx: egui::Context) { files.entry(f.family.clone()).or_default().push((f.file.clone(), f.integrity.clone())); } log::info!("photocraft-web: {} served font families", files.len()); - served.lock().unwrap_or_else(|e| e.into_inner()).files = files; + served.lock().unwrap_or_else(PoisonError::into_inner).files = files; // The families for the font menus, and the script fallbacks (the manifest's `scripts`). served_fonts::add_fonts(&fonts); // Fetch now the fallbacks of the scripts the browser's languages use (an Arabic reader gets @@ -295,7 +296,7 @@ impl WebShell { fn serve_fonts(&mut self, ctx: &egui::Context) { let requested = served_fonts::take_requests(); let (fetch, arrived) = { - let mut s = self.served.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = self.served.lock().unwrap_or_else(PoisonError::into_inner); let mut fetch = Vec::new(); for family in requested { if let Some(files) = s.files.get(&family).cloned() @@ -312,7 +313,7 @@ impl WebShell { wasm_bindgen_futures::spawn_local(async move { match fetch_bytes(&file, integrity.as_deref()).await { Ok(bytes) => { - served.lock().unwrap_or_else(|e| e.into_inner()).arrived.push((family, bytes)); + served.lock().unwrap_or_else(PoisonError::into_inner).arrived.push((family, bytes)); ctx.request_repaint(); } Err(e) => log::error!("couldn't fetch served font {file}: {e}"), @@ -328,6 +329,10 @@ impl eframe::App for WebShell { // A tap reaches eframe as Pointer Events and Touch Events: keep one press per tap, or the // twin press closes the menu the first one opened (#2095). photocraft_ui_egui::touch_twins::drop_twins(ctx, raw_input); + // The browser build has no TCP control server, so `inject_synthetic` finds an empty + // queue, but the clipboard key mapping (`Event::PasteImage`), legacy pinch, pen-hold and + // titlebar normalization live in the app hook and must run here too. + eframe::App::raw_input_hook(&mut self.app, ctx, raw_input); } fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { @@ -347,7 +352,7 @@ impl eframe::App for WebShell { match f.bytes_async().await { Ok(bytes) => { let queue = if place { &place_inbox } else { &inbox }; - queue.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)); + queue.lock().unwrap_or_else(PoisonError::into_inner).push((name, bytes)); ctx.request_repaint(); } Err(e) => log::error!("couldn't read dropped file {name}: {e}"), @@ -356,8 +361,9 @@ impl eframe::App for WebShell { }); } self.serve_fonts(ctx); - self.app.logic(ctx, frame); + self.app.logic_frame(ctx, frame); self.save_presets(ctx); + self.app.finish_type_reset_logic(ctx); } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { @@ -384,8 +390,9 @@ impl eframe::App for WebShell { }); }); } - self.app.ui(ui, frame); + self.app.ui_frame(ui, frame); self.save_presets(ui.ctx()); + self.app.finish_type_reset_pass(ui.ctx()); } } diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index d8e7568300..5de916c250 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -29,8 +29,9 @@ photocraft-automation = { workspace = true } photocraft-ui-egui = { workspace = true } # Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate). photocraft-tablet = { workspace = true } -eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence"] } +eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence", "clipboard"] } egui = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } chrono = { workspace = true } @@ -38,13 +39,13 @@ clap = { workspace = true } thiserror = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +craft-update = { workspace = true } rfd = { workspace = true } -arboard = { version = "3.6", features = ["wayland-data-control"] } +arboard = { version = "3.7", features = ["wayland-data-control"] } open = "5" # Reads the saved window layout (eframe persistence) to drop values that would abort startup. ron = "0.12" # Background release checks; HTTPS with Rust TLS, no async runtime. -ureq = { version = "3.4", default-features = false, features = ["rustls", "platform-verifier"] } [target.'cfg(target_os = "macos")'.dependencies] # Safe CoreGraphics bindings, compatible with the existing macOS 11 deployment target. @@ -58,12 +59,12 @@ fmv-macos-events = "=0.1.0" # pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position), and play the # system alert sound for Beep When Done (services.rs, NSBeep). Already in # Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions. -objc2 = "=0.6.4" +objc2 = "=0.6.5" 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", "NSGraphics", "NSResponder", "NSView", "NSWindow"] } # 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 } +muda = { version = "=0.21.2", default-features = false } # Quit with `terminate:` from the main queue, outside winit's event handler (mac_window.rs). # Already in Cargo.lock (winit, objc2-app-kit); pinned exactly to the same version. dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] } diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 7fbf765129..a4fe847cda 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -92,6 +92,53 @@ 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. +/// +/// The section is read on its own, and when it doesn't read as a whole each value on its own, +/// as the app's lenient load reads them (#6611): a wrong-typed value elsewhere in the file, or +/// in the other field, must not turn Allow Agent Control back on while the dialog shows it off. +fn integrations(prefs_file: Option<&std::path::Path>) -> photocraft_engine::prefs::Integrations { + use serde_json::Value; + let mut out = photocraft_engine::prefs::Preferences::default().integrations; + let file: Option = prefs_file.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()); + let Some(section) = file.as_ref().and_then(|v| v.get("integrations")) else { + return out; + }; + if let Ok(whole) = serde_json::from_value(section.clone()) { + return whole; + } + if let Some(allow) = section.get("allowAgentControl").and_then(Value::as_bool) { + out.allow_agent_control = allow; + } + if let Some(port) = section.get("controlPort").and_then(Value::as_u64).and_then(|p| u32::try_from(p).ok()) { + out.control_port = port; + } + out +} + +/// 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 @@ -111,6 +158,9 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { .with_titlebar_shown(false) .with_title_shown(false), centered: true, + // PhotoCraft owns its fallback fonts (`cjk_fonts`, craft-fonts): eframe's system-font + // provider would compete with it and make glyph choice machine-dependent (#8512). + system_font_fallback: false, // eframe saves native window geometry and egui panel/window sizes on exit. // Keep that state beside preferences, including config overrides and portable mode. persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")), @@ -147,6 +197,15 @@ fn main() -> eframe::Result { // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); crash_guard::install_hook(); + #[cfg(not(target_arch = "wasm32"))] + if let Some(done) = craft_update::self_update::finish(&craft_update::suite::PHOTOCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Err(e) = done { + eprintln!("PhotoCraft update helper failed: {e}"); + std::process::exit(1); + } + return Ok(()); + } + // The system font scan can take seconds (minutes on some Macs): run it off the UI thread. photocraft_ui_egui::type_tool::warm_fonts(); // `--help` and `--version` print to standard output and exit 0. A usage error (an unknown @@ -262,6 +321,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) = (auth.control_token, auth.control_token_file); let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) { @@ -411,6 +474,7 @@ fn main() -> eframe::Result { }; #[cfg(not(target_os = "linux"))] let session = Session::new(); + PhotocraftApp::install_type_reset_hooks(&cc.egui_ctx); let mut app = PhotocraftApp::new(session, services); app.min_frame_ms = uncapped.then_some(1000.0 / 240.0); app.integrated_titlebar = cfg!(target_os = "macos"); @@ -668,10 +732,101 @@ mod tests { egui::Panel::right("dock").default_size(290.0).show(ui, |ui| ui.set_min_width(ui.available_width())); }); output.textures_delta.clear(); - let panel = egui::containers::panel::PanelState::load(&restored, egui::Id::new("dock")).unwrap(); + let panel = egui::containers::panel::PanelState::load(&restored, photocraft_ui_egui::panels::panel_state_id(&restored, "dock")).unwrap(); assert_eq!(panel.size().x, 410.0); } + /// A 0.36 `ui.ron` saved panel widths under `Id::new("dock")`; the first 0.37 launch copies + /// them to the parent-scoped key, leaves the old one for a rollback, and a later resize + /// survives two more restarts. + #[test] + fn saved_036_panel_widths_migrate_and_survive_two_restarts() { + #[derive(Default)] + struct Storage(std::collections::BTreeMap); + impl eframe::Storage for Storage { + fn get_string(&self, key: &str) -> Option { + self.0.get(key).cloned() + } + fn set_string(&mut self, key: &str, value: String) { + self.0.insert(key.into(), value); + } + fn remove_string(&mut self, key: &str) { + self.0.remove(key); + } + fn flush(&mut self) {} + } + let state = |width: f32| egui::containers::panel::PanelState { outer_rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 600.0)) }; + let input = || egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))), ..Default::default() }; + let draw = |ctx: &egui::Context| { + let mut output = ctx.run_ui(input(), |ui| { + egui::Panel::right("dock").default_size(290.0).show(ui, |ui| ui.set_min_width(ui.available_width())); + }); + output.textures_delta.clear(); + }; + let save = |ctx: &egui::Context| { + let mut storage = Storage::default(); + ctx.memory(|memory| eframe::set_value(&mut storage, "egui", memory)); + storage + }; + let restore = |storage: &Storage| { + let ctx = egui::Context::default(); + ctx.memory_mut(|memory| *memory = eframe::get_value(storage, "egui").expect("the saved memory")); + ctx + }; + + // The real 0.14/0.15 shape: the panel state under the old key inside the egui memory. + let ctx = egui::Context::default(); + ctx.data_mut(|d| d.insert_persisted(egui::Id::unique("dock"), state(410.0))); + let storage = save(&ctx); + + let first = restore(&storage); + photocraft_ui_egui::panels::migrate_panel_state(&first); + draw(&first); + let key = photocraft_ui_egui::panels::panel_state_id(&first, "dock"); + assert_eq!(egui::containers::panel::PanelState::load(&first, key).map(|p| p.size().x), Some(410.0)); + assert!(egui::containers::panel::PanelState::load(&first, egui::Id::unique("dock")).is_some(), "the 0.36 key is kept"); + // Resize in 0.37: the new key wins from now on. + first.data_mut(|d| d.insert_persisted(key, state(500.0))); + let storage = save(&first); + + for restart in 0..2 { + let next = restore(&storage); + photocraft_ui_egui::panels::migrate_panel_state(&next); + draw(&next); + let key = photocraft_ui_egui::panels::panel_state_id(&next, "dock"); + let width = egui::containers::panel::PanelState::load(&next, key).map(|p| p.size().x).unwrap_or_default(); + assert!((width - 500.0).abs() < 0.5, "restart {restart}: the old 410 key must not win (got {width})"); + } + } + + /// Schema-probe `ui.ron` shapes *reconstructed* from the documented 0.14/0.15/0.37 layouts, + /// not recorded machine saves: 0.14 and 0.15 both store the flat window entry, which the + /// first 0.37 launch rewrites; a 0.37 file is left alone. Keeping all three probes makes a + /// future change to any migration rule fail here first. They do not prove eframe's real + /// `Memory`/`PanelState` deserialization. + #[test] + fn the_ui_ron_schema_probe_fixtures_migrate() { + for (name, fixture) in [("0.14", include_str!("../tests/fixtures/ui-0.14.ron")), ("0.15", include_str!("../tests/fixtures/ui-0.15.ron"))] { + let text = super::ui_state::migrate_for_test(fixture).unwrap_or_else(|e| panic!("{name}: {e}")).unwrap_or_else(|| panic!("{name}: not rewritten")); + assert!(text.contains("normal_geometry"), "{name}: the flat window entry is gone: {text}"); + assert!(!super::ui_state::saved_position_for_test(fixture), "{name}: a flat entry is not what 0.37 restores"); + } + let old = include_str!("../tests/fixtures/ui-0.14.ron"); + assert!(super::ui_state::migrate_for_test(old).expect("migratable").expect("rewritten").contains("x:1100.0"), "the 0.14 size is preserved"); + let current = include_str!("../tests/fixtures/ui-0.15.ron"); + assert!( + super::ui_state::migrate_for_test(current).expect("migratable").expect("rewritten").contains("maximized:true"), + "0.15 maximized state survives" + ); + // Already 0.37-shaped: no rewrite, and (with no saved position) the window centres. + let current = include_str!("../tests/fixtures/ui-0.37.ron"); + assert_eq!(super::ui_state::migrate_for_test(current), Ok(None)); + assert!(!super::ui_state::saved_position_for_test(current)); + // And a 0.37 file with a restorable outer position is a saved position. + let with_position = current.replace("outer_position_pixels:None", "outer_position_pixels:Some((x:2048.0,y:1228.0))"); + assert!(super::ui_state::saved_position_for_test(&with_position)); + } + #[test] fn the_window_opens_centred_at_its_default_size() { let o = super::native_options(super::CUSTOM_TITLEBAR); @@ -740,6 +895,91 @@ 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); + } + + /// The app loads preferences leniently (#6611): one wrong-typed value resets only itself. So + /// a bad value elsewhere, or in the port, must not turn Allow Agent Control back on. + #[test] + fn a_bad_value_elsewhere_keeps_agent_control_off() { + let dir = std::env::temp_dir().join(format!("pc-integrations-lenient-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("prefs.json"); + // What the Preferences dialog shows for the same file. + let shown = |text: &str| { + let mut session = photocraft_engine::Session::new(); + session.load_prefs_json(text).unwrap(); + session.prefs().integrations.clone() + }; + let read = |text: &str| { + std::fs::write(&file, text).unwrap(); + let read = super::integrations(Some(&file)); + assert_eq!(read, shown(text), "{text}"); + read + }; + + let r = read(r#"{"interface":{"enableAnimations":"nope"},"integrations":{"allowAgentControl":false,"controlPort":7878}}"#); + assert!(!r.allow_agent_control, "a bad value in another section"); + assert_eq!(r.control_port, 7878); + let r = read(r#"{"integrations":{"allowAgentControl":false,"controlPort":"x"}}"#); + assert!(!r.allow_agent_control, "a bad port"); + assert_eq!(r.control_port, 0, "the bad port alone takes its default"); + // A bad Allow Agent Control alone takes its default. + let r = read(r#"{"integrations":{"allowAgentControl":"no","controlPort":7878}}"#); + assert_eq!(r, photocraft_engine::prefs::Integrations { allow_agent_control: true, control_port: 7878 }); + read(r#"{"integrations":{"allowAgentControl":false,"controlPort":-1}}"#); + read(r#"{"integrations":{"allowAgentControl":false,"controlPort":4294967296}}"#); + read(r#"{"integrations":"off"}"#); + // serde reads a struct from a list too; the app does. + assert!(!read(r#"{"integrations":[false]}"#).allow_agent_control, "a list"); + assert_eq!(read(r#"{"integrations":[false,7878]}"#).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/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 51cf217faf..2966eee8ed 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -105,6 +105,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[ // Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327). ("DDS", &["dds"]), ("Cineon", &["cin"]), + ("Amiga IFF", &["iff"]), ]; /// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog diff --git a/apps/photocraft/src/ui_state.rs b/apps/photocraft/src/ui_state.rs index c4de67a994..b356fb26c2 100644 --- a/apps/photocraft/src/ui_state.rs +++ b/apps/photocraft/src/ui_state.rs @@ -6,16 +6,32 @@ //! hand-edited file would then crash every launch. [`sanitize`] runs first and removes a file //! holding such values (the window and panels open at their defaults; nothing else is lost). //! A file that isn't RON at all is left alone: eframe already ignores it. +//! +//! egui 0.37 also replaced the flat `WindowSettings` (`inner_position_pixels`, +//! `inner_size_points: Option`, …) with `normal_geometry: { outer_position_pixels, +//! scale_factor, inner_size_points }`. eframe reads the file before any application code, so an +//! old value cannot be interpreted lazily: [`sanitize`] rewrites it before eframe loads it and +//! keeps a one-time `ui.ron.pre-0.37` backup. The old panel keys in the egui memory part are +//! untouched (`panels::panel_state_id` migrates them additively), but this window rewrite is +//! one-way: after it, a 0.36 build ignores the window entry and opens at its default geometry. +//! Restoring the backup is the documented rollback. -use std::path::Path; +use std::{collections::HashMap, path::Path}; /// Window positions, sizes and panel rects beyond this many points or pixels are corrupt. const MAX_COORD: f64 = 1.0e6; /// egui's zoom factor is the UI scale over the display scale (75% on a 2× display is 0.375, /// 300% on a 1× display is 3). const ZOOM: std::ops::RangeInclusive = 0.1..=10.0; +/// The size [`crate::native_options`] opens a window with when an old save has no usable size. +const DEFAULT_SIZE: f32 = 1440.0; +const DEFAULT_HEIGHT: f32 = 900.0; +/// Kept beside `ui.ron` before the one-way 0.36 → 0.37 window rewrite. +const BACKUP_SUFFIX: &str = "pre-0.37"; -/// Remove `path` if it holds state that would crash startup. Never fails: errors are logged. +/// Remove `path` if it holds state that would crash startup, and migrate an old window entry to +/// the 0.37 schema before eframe reads it. Never fails: errors are logged and leave the file +/// alone (eframe then ignores what it cannot read). pub fn sanitize(path: Option<&Path>) { let Some(path) = path else { return }; let Ok(text) = std::fs::read_to_string(path) else { return }; @@ -24,30 +40,72 @@ pub fn sanitize(path: Option<&Path>) { if let Err(e) = std::fs::remove_file(path) { log::warn!("could not remove {}: {e}", path.display()); } + return; + } + match migrate_window(text) { + Ok(Some(text)) => write_migrated(path, &text), + Ok(None) => {} + Err(why) => log::warn!("not rewriting the saved window geometry in {}: {why}", path.display()), } } -/// Whether `path` holds a window position from an earlier session, which eframe restores. Then -/// `NativeOptions::centered` must be off: eframe applies it after restoring the saved geometry, +/// Write the migrated file, keeping a one-time backup. A failed backup aborts the rewrite: the +/// pre-0.37 window geometry is the documented rollback, so never destroy the only copy. +fn write_migrated(path: &Path, text: &str) { + let backup = backup_path(path); + if !backup.exists() + && let Err(e) = std::fs::copy(path, &backup) + { + log::warn!("could not back up {} to {}: {e}; leaving it unchanged", path.display(), backup.display()); + return; + } + if let Err(e) = std::fs::write(path, text) { + log::warn!("could not write the migrated window layout to {}: {e}", path.display()); + } +} + +fn backup_path(path: &Path) -> std::path::PathBuf { + let mut name = path.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_else(|| "ui.ron".into()); + name.push(format!(".{BACKUP_SUFFIX}")); + path.with_file_name(name) +} + +/// Whether `path` holds a window position that eframe restores, which means +/// `NativeOptions::centered` must be off: eframe applies `centered` after the restored geometry, /// so it moved the window back to the centre of the main monitor on every launch (#4826). +/// +/// Only the 0.37 `normal_geometry.outer_position_pixels` counts: `egui_winit::WindowGeometry` +/// sets it and `initialize_viewport_builder` applies it. A 0.36 flat entry is not restored by +/// 0.37 (`sanitize` rewrites it first), and its macOS `inner_position_pixels` was never an outer +/// position. pub fn has_saved_position(path: Option<&Path>) -> bool { let Some(text) = path.and_then(|p| std::fs::read_to_string(p).ok()) else { return false }; saved_position(&text) } fn saved_position(text: &str) -> bool { - let Ok(kv) = ron::from_str::>(text) else { return false }; + let Ok(kv) = ron::from_str::>(text) else { return false }; let Some(window) = kv.get("window").and_then(|w| ron::from_str::(w).ok()) else { return false }; - // eframe restores the inner position on macOS and the outer position elsewhere - // (`egui_winit::WindowSettings::initialize_viewport_builder`). - let key = if cfg!(target_os = "macos") { "inner_position_pixels" } else { "outer_position_pixels" }; - matches!(field(&window, key), Some(ron::Value::Option(Some(_)))) + let Some(geometry) = field(&window, "normal_geometry") else { return false }; + matches!(field(geometry, "outer_position_pixels"), Some(ron::Value::Option(Some(_)))) +} + +/// The 0.36 → 0.37 rewrite of one `ui.ron` text, for the recorded 0.14/0.15 fixtures. +#[cfg(test)] +pub(crate) fn migrate_for_test(text: &str) -> Result, String> { + migrate_window(text.to_owned()) +} + +/// [`saved_position`] for the recorded fixtures. +#[cfg(test)] +pub(crate) fn saved_position_for_test(text: &str) -> bool { + saved_position(text) } /// `Err` names the first value that would crash startup. fn check(text: &str) -> Result<(), String> { // eframe stores a map of key → RON string; a file that doesn't parse is ignored by eframe. - let Ok(kv) = ron::from_str::>(text) else { return Ok(()) }; + let Ok(kv) = ron::from_str::>(text) else { return Ok(()) }; for (key, value) in &kv { // eframe also skips an entry that doesn't parse. let Ok(v) = ron::from_str::(value) else { continue }; @@ -60,6 +118,108 @@ fn check(text: &str) -> Result<(), String> { Ok(()) } +/// `Some(new_text)` when `text` is an old-schema file that should be rewritten; `None` when it +/// is already 0.37-shaped or holds no window entry; `Err` when the old window entry is unusable +/// and must be left untouched. +fn migrate_window(text: String) -> Result, String> { + let Ok(mut kv) = ron::from_str::>(&text) else { return Ok(None) }; + let Some(value) = kv.get("window").cloned() else { return Ok(None) }; + let Ok(window) = ron::from_str::(&value) else { return Ok(None) }; + if field(&window, "normal_geometry").is_some() { + return Ok(None); + } + if field(&window, "inner_size_points").is_none() && field(&window, "inner_position_pixels").is_none() && field(&window, "outer_position_pixels").is_none() { + return Ok(None); + } + let migrated = migrate_window_value(&window)?; + kv.insert("window".into(), migrated); + ron::to_string(&kv).map(Some).map_err(|e| format!("cannot write the new window entry: {e}")) +} + +/// The RON payload eframe 0.37 deserializes as `egui_winit::WindowSettings`. +/// +/// The field order and names mirror `WindowGeometry`; the types are local because +/// `egui_winit::WindowGeometry` keeps its fields private. +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq)] +struct PointV37 { + x: f32, + y: f32, +} + +impl PointV37 { + fn sane(self) -> Option { + let ok = |v: f32| v.is_finite() && (v.abs() as f64) <= MAX_COORD; + (ok(self.x) && ok(self.y)).then_some(self) + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq)] +struct GeometryV37 { + outer_position_pixels: Option, + scale_factor: f32, + inner_size_points: PointV37, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq)] +struct WindowV37 { + normal_geometry: GeometryV37, + fullscreen: bool, + maximized: bool, +} + +fn migrate_window_value(window: &ron::Value) -> Result { + let size = old_size(window)?; + // The old macOS entry stored an inner (not outer) position in logical points and Linux saved + // no scale factor, so a faithful restore is only possible for the Windows physical outer + // position. Everywhere else the window opens centred with its saved size. + let position = if cfg!(target_os = "windows") { old_position(window, "outer_position_pixels") } else { None }; + let window = WindowV37 { + normal_geometry: GeometryV37 { + outer_position_pixels: position, + // Physical pixels of the old Windows position: 0.37 divides by zoom × scale. + scale_factor: 1.0, + inner_size_points: size, + }, + fullscreen: old_bool(window, "fullscreen"), + maximized: old_bool(window, "maximized"), + }; + ron::to_string(&window).map_err(|e| format!("cannot serialize the new window entry: {e}")) +} + +fn old_size(window: &ron::Value) -> Result { + let value = field(window, "inner_size_points"); + let Some(ron::Value::Option(Some(size))) = value else { + // A missing size is the app's own opening size, not corrupt data. + return Ok(PointV37 { x: DEFAULT_SIZE, y: DEFAULT_HEIGHT }); + }; + let point = point_value(size).ok_or_else(|| "inner_size_points is not a finite (x, y) point".to_string())?; + let point = point.sane().ok_or_else(|| "inner_size_points is not finite or is absurdly large".to_string())?; + if point.x < 1.0 || point.y < 1.0 { + return Err("inner_size_points has a zero or negative side".into()); + } + Ok(point) +} + +/// One dimension of an old `Option` field, rejecting corrupt values rather than guessing. +fn old_position(window: &ron::Value, key: &str) -> Option { + match field(window, key) { + Some(ron::Value::Option(Some(p))) => point_value(p).and_then(PointV37::sane), + // `None`, missing, and malformed all mean "no position"; corrupt positions elsewhere are + // caught by `check`, which removes a file with non-finite values before this runs. + _ => None, + } +} + +fn old_bool(window: &ron::Value, key: &str) -> bool { + matches!(field(window, key), Some(ron::Value::Bool(true))) +} + +fn point_value(v: &ron::Value) -> Option { + let x = field(v, "x").and_then(as_f32)?; + let y = field(v, "y").and_then(as_f32)?; + Some(PointV37 { x, y }) +} + /// Every float finite and within [`MAX_COORD`]; depth-limited (the file is untrusted). fn floats_sane(v: &ron::Value, depth: usize) -> Result<(), String> { if depth > 64 { @@ -86,6 +246,13 @@ fn as_f64(v: &ron::Value) -> Option { } } +fn as_f32(v: &ron::Value) -> Option { + as_f64(v).and_then(|f| { + let f = f as f32; + f.is_finite().then_some(f) + }) +} + fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> { match v { ron::Value::Map(m) => m.get(&ron::Value::String(name.into())), @@ -95,7 +262,8 @@ fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> { #[cfg(test)] mod tests { - use super::{check, saved_position}; + use super::{DEFAULT_HEIGHT, DEFAULT_SIZE, WindowV37, backup_path, check, migrate_window, saved_position}; + use std::{collections::HashMap, path::PathBuf}; fn file(window: &str, zoom: &str) -> String { let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))"); @@ -103,6 +271,17 @@ mod tests { } const WINDOW: &str = "(inner_position_pixels:Some((x:616.0,y:430.0)),fullscreen:false,inner_size_points:Some((x:1100.0,y:760.0)))"; + const WINDOW_WITH_OUTER: &str = "(inner_position_pixels:Some((x:2048.0,y:1265.0)),outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))"; + + /// Read the window payload back as eframe 0.37 would. + fn window_of(text: &str) -> WindowV37 { + let kv: HashMap = ron::from_str(text).expect("the migrated file is a RON map"); + ron::from_str(kv.get("window").expect("a window entry")).expect("0.37 WindowSettings shape") + } + + fn migrated(window: &str) -> String { + migrate_window(file(window, "1.0")).expect("migratable").expect("rewritten") + } #[test] fn a_saved_layout_is_kept() { @@ -122,13 +301,83 @@ mod tests { } } + #[test] + fn an_old_size_and_maximized_state_survive() { + let text = migrated(WINDOW_WITH_OUTER); + let window = window_of(&text); + assert_eq!(window.normal_geometry.inner_size_points.x, 880.0); + assert_eq!(window.normal_geometry.inner_size_points.y, 480.0); + assert!(!window.fullscreen); + assert!(!window.maximized); + let maximized = migrated(&WINDOW.replace("fullscreen:false", "fullscreen:true,maximized:true")); + assert!(window_of(&maximized).fullscreen && window_of(&maximized).maximized); + } + + #[test] + fn a_missing_size_uses_the_default_opening_size() { + // A window entry with a position but no size migrates and keeps the app's opening size. + let window = window_of(&migrated("(inner_position_pixels:Some((x:616.0,y:430.0)),fullscreen:false,maximized:false)")); + assert_eq!(window.normal_geometry.inner_size_points.x, DEFAULT_SIZE); + assert_eq!(window.normal_geometry.inner_size_points.y, DEFAULT_HEIGHT); + // A window entry with no geometry fields at all is left alone: it is not a 0.36 window. + assert_eq!(migrate_window(file("(fullscreen:false,maximized:false)", "1.0")), Ok(None)); + // So is a file with no window-like fields at all. + assert_eq!(migrate_window(file("(something:1)", "1.0")), Ok(None)); + } + + #[test] + fn the_old_position_is_kept_only_on_windows() { + let text = migrated(WINDOW_WITH_OUTER); + let pos = window_of(&text).normal_geometry.outer_position_pixels; + if cfg!(target_os = "windows") { + let pos = pos.expect("the Windows physical outer position survives"); + assert_eq!((pos.x, pos.y), (2048.0, 1228.0)); + assert_eq!(window_of(&text).normal_geometry.scale_factor, 1.0); + } else { + assert!(pos.is_none(), "an old macOS/Linux position is not a restorable outer position"); + } + } + + #[test] + fn a_corrupt_old_size_is_not_rewritten() { + let bad = WINDOW.replace("(x:1100.0,y:760.0)", "(x:1100.0,y:0.0)"); + assert!(migrate_window(file(&bad, "1.0")).is_err()); + let huge = WINDOW.replace("(x:1100.0,y:760.0)", "(x:1100.0,y:9000000.0)"); + assert!(migrate_window(file(&huge, "1.0")).is_err()); + } + + #[test] + fn an_already_migrated_file_is_left_alone() { + let text = migrated(WINDOW); + assert_eq!(migrate_window(text), Ok(None)); + // A 0.36 build accepts the entry, but its missing flat geometry defaults to None. + // It cannot recover position or size from the nested 0.37 geometry. + let kv: HashMap = ron::from_str(&migrated(WINDOW)).unwrap(); + let old: OldWindow = ron::from_str(kv.get("window").unwrap()).unwrap(); + assert!(old.inner_position_pixels.is_none() && old.outer_position_pixels.is_none()); + assert!(old.inner_size_points.is_none(), "documented rollback loss of saved geometry"); + // Restoring the original backup restores both the old position and size. + let restored: OldWindow = ron::from_str(WINDOW).unwrap(); + let position = restored.inner_position_pixels.unwrap(); + let size = restored.inner_size_points.unwrap(); + assert_eq!((position.x, position.y), (616.0, 430.0)); + assert_eq!((size.x, size.y), (1100.0, 760.0)); + } + #[test] fn a_saved_window_position_is_detected() { - // #4826: the position from the report (Linux Mint, window moved to the third monitor). - let window = "(inner_position_pixels:Some((x:2048.0,y:1265.0)),outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))"; - assert!(saved_position(&file(window, "1.0"))); - let no_position = "(inner_position_pixels:None,outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))"; + // A 0.36 flat entry is not what eframe 0.37 restores (it would open at the OS default); + // `sanitize` rewrites it before eframe runs, and the migrated nested value is detected. + assert!(!saved_position(&file(WINDOW_WITH_OUTER, "1.0")), "an unmigrated flat entry is not a restorable position"); + // An old macOS inner position is not an outer position either. + assert!(!saved_position(&file(WINDOW, "1.0"))); + let no_position = + "(normal_geometry:(outer_position_pixels:None,scale_factor:1.0,inner_size_points:(x:880.0,y:480.0)),fullscreen:false,maximized:false)"; assert!(!saved_position(&file(no_position, "1.0"))); + // The migrated nested geometry is what a 0.37 save holds (on Windows only, from the + // migration; a later 0.37 save on macOS/Linux has one too and eframe restores it). + let text = migrated(WINDOW_WITH_OUTER); + assert_eq!(saved_position(&text), cfg!(target_os = "windows")); assert!(!saved_position(&format!("{{\"egui\":{:?}}}", "()")), "no window entry"); for text in ["", "not ron {{{", "{\"window\":\"(((\"}"] { assert!(!saved_position(text), "{text:?}"); @@ -148,7 +397,7 @@ mod tests { } #[test] - fn sanitize_removes_only_a_bad_file() { + fn sanitize_removes_only_a_bad_file_and_migrates_a_good_one() { let dir = std::env::temp_dir().join(format!("photocraft-ui-state-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let good = dir.join("good.ron"); @@ -159,7 +408,58 @@ mod tests { super::sanitize(Some(&bad)); super::sanitize(Some(&dir.join("missing.ron"))); super::sanitize(None); - assert!(good.exists() && !bad.exists()); + assert!(!bad.exists()); + assert!(good.exists()); + // Migrated in place, with the one-time pre-0.37 backup beside it. + let text = std::fs::read_to_string(&good).unwrap(); + assert!(window_of(&text).normal_geometry.inner_size_points.x == 1100.0); + let backup = backup_path(&good); + assert!(backup.exists(), "the pre-0.37 layout is kept for rollback"); + assert_eq!(std::fs::read_to_string(&backup).unwrap(), file(WINDOW, "1.0")); + // A second run leaves both untouched. + super::sanitize(Some(&good)); + assert_eq!(std::fs::read_to_string(&good).unwrap(), text); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn the_backup_is_not_overwritten_by_a_later_migration() { + let dir = std::env::temp_dir().join(format!("photocraft-ui-state-backup-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("ui.ron"); + std::fs::write(&path, file(WINDOW, "1.0")).unwrap(); + super::sanitize(Some(&path)); + let backup = backup_path(&path); + let original = std::fs::read_to_string(&backup).unwrap(); + // Pretend a 0.36 run wrote its old schema again; the backup must stay the first one. + std::fs::write(&path, file(&WINDOW.replace("1100.0", "1111.0"), "1.0")).unwrap(); + super::sanitize(Some(&path)); + assert_eq!(std::fs::read_to_string(&backup).unwrap(), original); + assert!(std::fs::read_to_string(&path).unwrap().contains("1111")); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The 0.36 flat schema, kept to prove the new nested value is a one-way migration. + #[derive(Default, serde::Deserialize)] + #[serde(default)] + #[allow(dead_code)] + struct OldWindow { + inner_position_pixels: Option, + outer_position_pixels: Option, + fullscreen: bool, + maximized: bool, + inner_size_points: Option, + } + + #[derive(serde::Deserialize)] + #[allow(dead_code)] + struct Point { + x: f32, + y: f32, + } + + #[test] + fn the_backup_path_sits_beside_ui_ron() { + assert_eq!(backup_path(&PathBuf::from("/tmp/x/ui.ron")), PathBuf::from("/tmp/x/ui.ron.pre-0.37")); + } } diff --git a/apps/photocraft/src/updates.rs b/apps/photocraft/src/updates.rs index bbe4a9f3dc..40ac9ae016 100644 --- a/apps/photocraft/src/updates.rs +++ b/apps/photocraft/src/updates.rs @@ -1,10 +1,7 @@ -//! Native HTTPS transport for release checks. Only public release metadata is requested. +//! Release checks through `craft-update`, the library every Craft App shares. Only public release +//! metadata is requested. -use photocraft_engine::updates_cmds::{CheckFn, CheckResult, parse_release}; -use std::time::Duration; - -const LATEST: &str = "https://api.github.com/repos/storytold/photocraft/releases/latest"; -const MAX_RESPONSE: u64 = 1024 * 1024; +use photocraft_engine::updates_cmds::{CheckFn, CheckResult, Release}; pub fn service() -> CheckFn { Box::new(|| { @@ -12,7 +9,7 @@ pub fn service() -> CheckFn { std::thread::Builder::new() .name("update check".into()) .spawn(move || { - let result = fetch(LATEST, Duration::from_secs(15)); + let result = check(); if let Err(e) = &result { log::warn!("Update check: {e}"); } @@ -23,104 +20,8 @@ pub fn service() -> CheckFn { }) } -fn fetch(url: &str, timeout: Duration) -> CheckResult { - // Respect the OS trust store (including managed-network certificates), while still - // validating the certificate chain and server name. - let agent = ureq::Agent::config_builder() - .tls_config(ureq::tls::TlsConfig::builder().root_certs(ureq::tls::RootCerts::PlatformVerifier).build()) - .timeout_global(Some(timeout)) - .max_redirects(0) - .build() - .new_agent(); - let mut response = match agent - .get(url) - .header("User-Agent", "PhotoCraft-update-check") - .header("Accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2026-03-10") - .call() - { - Ok(response) => response, - // The repository may not have published a stable release yet. - Err(ureq::Error::StatusCode(404)) => return Ok(None), - Err(ureq::Error::StatusCode(403 | 429)) => return Err("GitHub is limiting update checks. Please try again later.".into()), - Err(e) => return Err(format!("Could not contact the release server: {e}")), - }; - if response.status() != 200 { - return Err(format!("Unexpected release server status: {}", response.status())); - } - let bytes = response.body_mut().with_config().limit(MAX_RESPONSE).read_to_vec().map_err(|e| format!("Could not read release response: {e}"))?; - parse_release(&bytes, photocraft_engine::build_info::VERSION) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::{Read, Write}; - use std::net::TcpListener; - - fn server(status: &str, body: &str, extra: &str) -> (String, std::thread::JoinHandle) { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let url = format!("http://{}/latest", listener.local_addr().unwrap()); - let response = format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\n{extra}Connection: close\r\n\r\n{body}", body.len()); - let worker = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); - let mut request = Vec::new(); - let mut chunk = [0; 1024]; - while !request.windows(4).any(|w| w == b"\r\n\r\n") { - let n = stream.read(&mut chunk).unwrap(); - if n == 0 { - break; - } - request.extend_from_slice(&chunk[..n]); - } - let _ = stream.write_all(response.as_bytes()); - String::from_utf8(request).unwrap() - }); - (url, worker) - } - - #[test] - fn fetches_release_metadata_with_generic_headers() { - let (url, worker) = server("200 OK", r#"{"tag_name":"v99.0.0","draft":false,"prerelease":false}"#, ""); - let result = fetch(&url, Duration::from_secs(5)).unwrap().unwrap(); - assert_eq!(result.version, "99.0.0"); - let request = worker.join().unwrap().to_ascii_lowercase(); - assert!(request.contains("user-agent: photocraft-update-check\r\n")); - assert!(request.contains("accept: application/vnd.github+json")); - assert!(!request.contains("authorization:") && !request.contains("cookie:")); - } - - #[test] - fn handles_missing_release_rate_limits_and_bad_responses() { - for (status, body, extra, expected_none) in [ - ("404 Not Found", "", "", true), - ("403 Forbidden", "", "", false), - ("429 Too Many Requests", "", "", false), - ("500 Internal Server Error", "", "", false), - ("200 OK", "invalid JSON", "", false), - ("302 Found", "", "Location: https://untrusted.example/\r\n", false), - ] { - let (url, worker) = server(status, body, extra); - let result = fetch(&url, Duration::from_secs(5)); - if expected_none { - assert_eq!(result, Ok(None)); - } else { - assert!(result.is_err(), "{status}"); - } - worker.join().unwrap(); - } - } - - #[test] - fn caps_response_size_and_times_out() { - let (url, worker) = server("200 OK", &" ".repeat(MAX_RESPONSE as usize + 1), ""); - assert!(fetch(&url, Duration::from_secs(5)).is_err()); - worker.join().unwrap(); - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let url = format!("http://{}/latest", listener.local_addr().unwrap()); - let start = std::time::Instant::now(); - assert!(fetch(&url, Duration::from_millis(100)).is_err()); - assert!(start.elapsed() < Duration::from_secs(2)); - } +fn check() -> CheckResult { + let http = craft_update::Http::new("PhotoCraft/Update")?; + let found = craft_update::check_for_update(&craft_update::suite::PHOTOCRAFT, &http, photocraft_engine::build_info::VERSION)?; + Ok(found.map(|release| Release { version: release.tag.trim_start_matches(['v', 'V']).to_string(), url: release.html_url })) } diff --git a/apps/photocraft/tests/fixtures/README.md b/apps/photocraft/tests/fixtures/README.md new file mode 100644 index 0000000000..1eb90452f0 --- /dev/null +++ b/apps/photocraft/tests/fixtures/README.md @@ -0,0 +1,7 @@ +These files are **schema probes**, not recorded machine saves. + +`ui-0.14.ron`, `ui-0.15.ron` and `ui-0.37.ron` are minimal payloads reconstructed from the +documented eframe/egui layouts. They exercise `apps/photocraft/src/ui_state.rs` (the flat +window entry versus the nested `normal_geometry` entry) only. They do not prove that eframe +and egui deserialize a real `Memory` or `PanelState`; a captured 0.14/0.15/0.37 `ui.ron` +would be needed for that, with the machine-specific data reviewed first. diff --git a/apps/photocraft/tests/fixtures/ui-0.14.ron b/apps/photocraft/tests/fixtures/ui-0.14.ron new file mode 100644 index 0000000000..619999cf2b --- /dev/null +++ b/apps/photocraft/tests/fixtures/ui-0.14.ron @@ -0,0 +1 @@ +{"window":"(inner_position_pixels:Some((x:616.0,y:430.0)),outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:1100.0,y:760.0)))","egui":"()"} diff --git a/apps/photocraft/tests/fixtures/ui-0.15.ron b/apps/photocraft/tests/fixtures/ui-0.15.ron new file mode 100644 index 0000000000..7edccd600b --- /dev/null +++ b/apps/photocraft/tests/fixtures/ui-0.15.ron @@ -0,0 +1 @@ +{"window":"(inner_position_pixels:None,outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:true,inner_size_points:Some((x:1440.0,y:900.0)))","egui":"()"} diff --git a/apps/photocraft/tests/fixtures/ui-0.37.ron b/apps/photocraft/tests/fixtures/ui-0.37.ron new file mode 100644 index 0000000000..d5973c734f --- /dev/null +++ b/apps/photocraft/tests/fixtures/ui-0.37.ron @@ -0,0 +1 @@ +{"window":"(normal_geometry:(outer_position_pixels:None,scale_factor:2.0,inner_size_points:(x:1440.0,y:900.0)),fullscreen:false,maximized:false)","egui":"()"} diff --git a/book/src/development/release-security.md b/book/src/development/release-security.md index 742ab11095..99a95f1f82 100644 --- a/book/src/development/release-security.md +++ b/book/src/development/release-security.md @@ -6,6 +6,7 @@ PhotoCraft's release workflow builds platform packages and a draft GitHub Releas - platform-specific signing/notarization steps exist for macOS and Windows; - signing secrets are scoped through the GitHub `release` environment; +- the Azure Trusted Signing client that Windows signing loads is pinned to a version and SHA-256, and refused on a mismatch; - release artifacts include `SHA256SUMS.txt`; - the final release job receives `contents: write`, while build jobs use narrower access; - artifacts remain in a draft release until a maintainer publishes them. diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index 590260962e..e1a2cc23fa 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/contributors/people.toml b/contributors/people.toml index 242fb5ac80..ac36371f38 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -264,3 +264,21 @@ sync_github_name = false [people.drunkeninja42] real_name = "Kushagra Sharma" + +[people.WebWork-rrs] +display_name = "Ratul Paul" + +[people.softwaregames1] +sync_github_name = true + +[people.VoiceLessQ] +display_name = "VoiceLessQ" +sync_github_name = false + +[people.erenova] +display_name = "Erenova" +sync_github_name = false + +[people.jasallen] +real_name = "Jason DeBoever" +sync_github_name = true diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs index 0250cc4f77..76ffb69f7d 100644 --- a/crates/affinity/src/container.rs +++ b/crates/affinity/src/container.rs @@ -17,6 +17,9 @@ const DOCUMENT_KIND: &[u8; 4] = b"nsrP"; const MAX_TABLES: usize = 4096; /// Distinct ids the allocation tables may name; each costs a map slot even when only deleted. const MAX_ENTRIES: usize = 1 << 22; +/// Bytes of entry names the allocation tables may name. A name is retained twice (in the entry +/// and as its map key), and tables can point many names at the same bytes. +const MAX_NAME_BYTES: usize = 32 << 20; /// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes. const MAX_ZSTD_WINDOW: u64 = 64 << 20; @@ -168,6 +171,7 @@ impl<'a> Archive<'a> { // Newest record per id: (table date, table index, record). let mut latest: HashMap>)> = HashMap::new(); let mut names: HashMap = HashMap::new(); + let mut name_bytes = 0usize; let mut seen = HashSet::new(); let mut next = header.fat_offset; let mut tables = 0usize; @@ -231,8 +235,16 @@ impl<'a> Archive<'a> { } if flag == 0 { let len = usize::from(c.u16()?); - let name = String::from_utf8_lossy(c.take(len)?).into_owned(); - names.entry(id).or_insert(name); + let raw = c.take(len)?; + // Only the first name per id is kept: charge and copy just that one. + if let std::collections::hash_map::Entry::Vacant(slot) = names.entry(id) { + let name = String::from_utf8_lossy(raw).into_owned(); + name_bytes = name_bytes.saturating_add(name.len()); + if name_bytes > MAX_NAME_BYTES { + return Err(Error::Limit("archive entry names too large")); + } + slot.insert(name); + } } // Equal dates: the first table in the chain (the newest) wins, as in afread. let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d); @@ -284,6 +296,9 @@ impl<'a> Archive<'a> { if total > self.limits.max_total { return Err(Error::Limit("document too large")); } + // Charge before decoding: a corrupt entry that fails its checksum has already cost the + // decompression, and an importer that recovers must not get it again for free. + self.extracted = total; let mut c = Cursor::new(self.bytes, offset(e.offset)?); if c.take(4)? != b"#Fil" { return Err(Error::Malformed("archive entry has no data block")); @@ -303,7 +318,6 @@ impl<'a> Archive<'a> { if crc32fast::hash(&out) != e.crc { return Err(Error::Malformed("archive entry checksum mismatch")); } - self.extracted = total; Ok(out) } } diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs index c62c206f58..fa45cf6213 100644 --- a/crates/affinity/src/geometry.rs +++ b/crates/affinity/src/geometry.rs @@ -148,7 +148,21 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option { Value::UInt(n) => usize::try_from(*n).ok()?, _ => return None, }; - let corners = s.obj(crvs, b"CnrD").and_then(|c| corner_radii(s.positional(c).collect())); + let corners = match s.obj(crvs, b"CnrD") { + Some(c) => { + let fields: Vec<&Value> = s.positional(c).collect(); + // The corner arrays are scanned for every occurrence of a shared curve, so charge + // them like records before they are walked. + if let Some(Value::Array(idx)) = fields.get(1) + && let Err(reason) = r.geometry_budget.records(idx.len()) + { + r.warn(reason); + return None; + } + corner_radii(fields) + } + None => None, + }; let mut path = Path::default(); let mut seen = 0usize; for index in 0..count { diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs index 1d8e5918be..c59afa1cac 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -290,6 +290,10 @@ const MAX_NODES: usize = 500_000; const MAX_CHILD_SLOTS: usize = 8_000_000; /// Bytes of layer names the model may retain across all occurrences. const MAX_NAME_BYTES: usize = 64 << 20; +/// Page rectangles the model may retain across all spreads (real books have a few hundred). +const MAX_PAGES: usize = 1 << 18; +/// Bytes of font names the text runs may retain across the document. +const MAX_FONT_NAME_BYTES: usize = 16 << 20; /// Read a document: archive, `doc.dat` and the model. pub fn read(bytes: &[u8], limits: Limits) -> Result { @@ -307,6 +311,8 @@ pub(crate) struct Reader<'s, 'a, 'b> { nodes: usize, child_slots: usize, name_bytes: usize, + pages: usize, + font_name_bytes: usize, active: HashSet, pub(crate) geometry_budget: crate::geometry::Budget, /// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so @@ -324,6 +330,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { nodes: 0, child_slots: 0, name_bytes: 0, + pages: 0, + font_name_bytes: 0, active: HashSet::new(), geometry_budget: crate::geometry::Budget::default(), pixels_left: limits.max_pixel_bytes, @@ -365,7 +373,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { self.warn("an unknown kind of page"); continue; } - let pages = pages(s, spread); + let pages = self.pages(spread)?; let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h }); let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect { x0: 0.0, @@ -421,7 +429,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { } /// Counts raw array slots (null ones included) against [`MAX_CHILD_SLOTS`]. - fn charge_slots(&mut self, n: usize) -> Result<(), Error> { + pub(crate) fn charge_slots(&mut self, n: usize) -> Result<(), Error> { self.child_slots = self.child_slots.saturating_add(n); if self.child_slots > MAX_CHILD_SLOTS { return Err(Error::Limit("too many child slots in the layer tree")); @@ -553,6 +561,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { /// an attached mask layer is a pixel mask. Adjustments attached the same way are reported. fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option, Option), Error> { let s = self.s; + // Every occurrence of a shared layer scans its whole attachment array, null slots included. + self.charge_slots(s.array_len(id, b"AdCh").unwrap_or(0))?; let attached = s.objs(id, b"AdCh"); let mut mask: Option = None; let mut pixels: Option = None; @@ -601,21 +611,51 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { } } -/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC` -/// equal pages (Publisher 1 facing spreads). -fn pages(s: &Stream, spread: ObjId) -> Vec { - if let Some(md) = s.obj(spread, b"SpMd") { - let v: Vec = s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect(); - if !v.is_empty() { - return v; +impl Reader<'_, '_, '_> { + /// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC` + /// equal pages (Publisher 1 facing spreads). A spread listed several times, or sharing one + /// large page array, is expanded into a new vector each time, so the slots scanned and the + /// rectangles retained are charged across the document. + fn pages(&mut self, spread: ObjId) -> Result, Error> { + let s = self.s; + if let Some(md) = s.obj(spread, b"SpMd") { + self.charge_slots(s.array_len(md, b"PagR").unwrap_or(0))?; + let remaining = MAX_PAGES.saturating_sub(self.pages); + let v: Vec = s + .objs(md, b"PagR") + .into_iter() + .filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)) + .filter(|r| r.x1 > r.x0 && r.y1 > r.y0) + .take(remaining.saturating_add(1)) + .collect(); + if !v.is_empty() { + self.charge_pages(v.len())?; + return Ok(v); + } } + Ok(match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) { + (Some(b), Some(n @ 2..=16)) => { + self.charge_pages(n as usize)?; + let w = (b.x1 - b.x0) / n as f64; + (0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect() + } + _ => Vec::new(), + }) } - match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) { - (Some(b), Some(n @ 2..=16)) => { - let w = (b.x1 - b.x0) / n as f64; - (0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect() + + /// Counts retained font-name bytes against [`MAX_FONT_NAME_BYTES`]; false once it is spent. + pub(crate) fn charge_font_name(&mut self, n: usize) -> bool { + self.font_name_bytes = self.font_name_bytes.saturating_add(n); + self.font_name_bytes <= MAX_FONT_NAME_BYTES + } + + /// Counts retained page rectangles against [`MAX_PAGES`]. + fn charge_pages(&mut self, n: usize) -> Result<(), Error> { + self.pages = self.pages.saturating_add(n); + if self.pages > MAX_PAGES { + return Err(Error::Limit("too many pages")); } - _ => Vec::new(), + Ok(()) } } diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index 343c276b60..1847fad90f 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -232,9 +232,15 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option> = match s.field(grad, b"Posn") { - Some(Value::Array(a)) => a.iter().filter_map(floats).collect(), + Some(Value::Array(a)) => a.iter().filter_map(floats).take(MAX_STOPS).collect(), _ => Vec::new(), }; let mut stops = Vec::new(); diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index f2b99c195e..226c66a459 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -193,6 +193,8 @@ fn plane( } .iter() .filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }); + // The scan below runs for every occurrence of a shared bitmap, whatever it produces. + r.charge_slots(states.len().min(tw.saturating_mul(th)))?; let row = (w as usize) * bps; let mut out = vec![0u8; row * h as usize]; let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps); diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index c932aac399..ab59c440b4 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -258,6 +258,20 @@ mod archive { assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); } + #[test] + fn a_failed_read_still_spends_the_extraction_allowance() { + let d = doc(); + let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None); + let mut broken = file.clone(); + broken[90] ^= 0x55; + // Room for exactly one read: the failed attempt must use it up, so a caller that + // recovers from the checksum error cannot make the same entry decode again. + let mut a = Archive::open(&broken, Limits { max_entry: usize::MAX, max_total: d.len(), ..Limits::default() }).unwrap(); + assert!(a.read("doc.dat").is_err()); + assert_eq!(a.extracted(), d.len()); + assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); + } + #[test] fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() { let big = vec![7u8; 1 << 20]; diff --git a/crates/affinity/src/text.rs b/crates/affinity/src/text.rs index 3b09c75124..bfad07217f 100644 --- a/crates/affinity/src/text.rs +++ b/crates/affinity/src/text.rs @@ -106,6 +106,24 @@ fn int(v: &Value) -> Option { } } +/// Longest font name kept; real PostScript and family names are far shorter. +const MAX_FONT_NAME: usize = 256; + +/// A font name for one run. Runs repeat the same shared string, so its copies are charged +/// against a document-wide byte budget; an overlong name or an exhausted budget leaves it empty. +fn font_name(r: &mut Reader, font: Option, tag: &[u8; 4]) -> String { + let name = font.and_then(|f| r.s.str(f, tag)).unwrap_or_default(); + if name.len() > MAX_FONT_NAME { + r.warn("font names longer than 256 bytes (left empty)"); + return String::new(); + } + if !r.charge_font_name(name.len()) { + r.warn("font names beyond the document metadata budget (left empty)"); + return String::new(); + } + name.to_string() +} + fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextRun { let s = r.s; let doubles = s.field(attrs, b"Doub"); @@ -125,11 +143,12 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextR r.warn("horizontally scaled text"); } let font = s.obj(attrs, b"DFnt"); - let get = |tag: &[u8; 4]| font.and_then(|f| s.str(f, tag)).unwrap_or_default().to_string(); + let postscript = font_name(r, font, b"Post"); + let family = font_name(r, font, b"Famy"); TextRun { text, - postscript: get(b"Post"), - family: get(b"Famy"), + postscript, + family, weight: font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400), italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false), size, diff --git a/crates/algo/examples/bench_multiband.rs b/crates/algo/examples/bench_multiband.rs index 9d58b81a54..2e18f9457e 100644 --- a/crates/algo/examples/bench_multiband.rs +++ b/crates/algo/examples/bench_multiband.rs @@ -1,42 +1,161 @@ -//! 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 (`photocraft_testkit::perf`: the kernel's mark on Linux, sampled elsewhere). +use std::path::PathBuf; use std::time::Instant; use photocraft_algo::panorama::{self, RoiImage}; +use photocraft_testkit::perf::{self, RssSampler, Summary}; +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); +/// 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(); + let mut it = args.into_iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "--json" => { + let path = it.next().filter(|p| !p.starts_with('-')).ok_or("--json needs a file path")?; + out.json = Some(PathBuf::from(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..=36.0).contains(&out.mp) { + return Err("expected 1..=16 images, 0.01..=36 MP per image and 1..=25 repetitions".into()); + } + Ok(out) +} + +/// 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); 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 = perf::process_peak_rss_bytes().or_else(perf::current_rss_bytes); + let sampler = RssSampler::start(std::time::Duration::from_millis(2)); + 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 = perf::process_peak_rss_bytes().or(sampler.peak()); + let delta = before.zip(peak).map(|(a, b)| b.saturating_sub(a) as f64 / (1024.0 * 1024.0)); + // The same nearest-rank p50 that `cargo xtask perf` reports for these samples. + let p50 = Summary::of(&samples_ms).map_or(f64::NAN, |s| s.p50); + println!( + "{} × {}×{} ({:.2} MP) RGB, canvas {}×{}, {} levels: {:.1} ms p50 ({} runs), peak +{}", + o.n, + sw, + sh, + o.mp, + cw, + ch, + levels, + p50, + o.reps, + delta.map_or("n/a".to_string(), |m| format!("{m:.0} MiB")) + ); + if let Some(path) = o.json { + let context = json!({"sources": o.n, "mp_per_source": o.mp, "canvas_width": cw, "canvas_height": ch, "levels": levels}); + let report = perf::report("bench_multiband", context, vec![perf::row(ROW, &samples_ms, peak, None)], Some(&sampler)); + 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(()) +} + +/// Runs the benchmark, reporting argument or output errors with a failing exit status. +fn main() { + if let Err(e) = run() { + eprintln!("bench_multiband: {e}"); + std::process::exit(2); + } +} + +#[cfg(test)] +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); + 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")) } + ); + // The 24-36 MP range AGENTS.md benchmarks at, as before the flags were added. + assert_eq!(parse(&["4", "36"]).unwrap().mp, 36.0); + } + + /// Checks invalid counts, non-finite sizes, repetitions, and missing flag values. + #[test] + fn rejects_bad_benchmark_args() { + for args in [ + vec!["0"], + vec!["17"], + vec!["2", "0"], + vec!["2", "NaN"], + vec!["2", "inf"], + vec!["2", "37"], + vec!["--reps", "0"], + vec!["--reps", "26"], + vec!["--json"], + vec!["--json", "--reps", "5"], + vec!["--reps"], + vec!["--unknown"], + ] { + assert!(parse(&args).is_err(), "{args:?}"); + } + } } diff --git a/crates/algo/examples/bench_seam_cas.rs b/crates/algo/examples/bench_seam_cas.rs new file mode 100644 index 0000000000..b8acbfe3f2 --- /dev/null +++ b/crates/algo/examples/bench_seam_cas.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-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_cas [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/art_history.rs b/crates/algo/src/art_history.rs new file mode 100644 index 0000000000..7fcb286c22 --- /dev/null +++ b/crates/algo/src/art_history.rs @@ -0,0 +1,1053 @@ +//! Oriented history-state dabs: sample a snapshot patch, pose it along the stroke, and stamp. +//! +//! Tile processing reads each dab's rotated source straight from the history state, so a dab +//! that crosses a tile edge paints the same 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 } +} + +/// Reach of a dab around its stroke point: half the dab plus its scatter, plus a margin. +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; + } + // `sx, sy` are continuous coordinates; pixel `i` has its centre at `i + 0.5`. + let lx = sx - src.origin.0 as f32 - 0.5; + let ly = sy - src.origin.1 as f32 - 0.5; + 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 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)) -> Result { + let (x0, y0, x1, y1) = rect; + if x1 <= x0 || y1 <= y0 { + return Ok(0); + } + 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); + } + } + } + + // The rotated read keeps the distance to the dab centre, so it stays inside the dab's bounds: + // sample the source directly, whichever tile the destination pixel is in. + 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: p.src, w: p.src_w, h: p.src_h, origin: p.src_origin, 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. + 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 difference between `src` and `dest` over the dab's covered pixels (working-space peak is +/// 1, so a tolerance of 50% is `0.5`). Per pixel it is the larger of the mean colour change and the +/// alpha change: an opaque pixel's unchanged alpha must not water a colour change down, and the +/// Eraser keeps the colour and lowers only the alpha. `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 colour, mut colours, mut alpha) = (0.0f32, 0u32, 0.0f32); + for i in 0..nch { + let Some((&s, &d)) = sampled.get(i).zip(dst.get(i)) else { continue }; + if p.alpha == Some(i) { + alpha = (s - d).abs(); + } else { + colour += (s - d).abs(); + colours += 1; + } + } + let colour = if colours == 0 { 0.0 } else { colour / colours as f32 }; + sum += colour.max(alpha); + 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`, tile by tile, reading `src` at each dab's rotated positions. +/// +/// 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 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 (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)?); + } + } + 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) -> u32 { + stamp_dabs(w, h, ch, src, dest, std::slice::from_ref(dab), tile) + } + + #[allow(clippy::too_many_arguments)] + fn stamp_dabs(w: usize, h: usize, ch: usize, src: &[f32], dest: &mut [f32], dabs: &[Dab], tile: 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, + hardness: 1.0, + opacity: 1.0, + flow: 1.0, + tolerance: 0.0, + tile, + 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 (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); + let n_f = stamp_run(w, h, 4, &src, &mut flat, &dab, 0); + 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); + stamp_run(w, h, 1, &src, &mut flat, &dab, 0); + assert_eq!(max_u8(&tiled, &flat), 0, "gray 8-bit seam"); + } + + #[test] + fn leftward_and_curl_strokes_match_untiled_across_a_tile_edge() { + let (w, h, ch) = (128usize, 96usize, 4); + let src = fill(w, h, ch, |x, y, c| if c == 3 { 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 == 3 { 1.0 } else { 0.1 }); + for style in [Style::TightMedium, Style::TightCurl, Style::LooseCurl] { + let dabs = place_dabs(&[[110.0, 40.0], [20.0, 40.0]], 40.0, 50.0, style, RNG_SEED).unwrap(); + let mut tiled = dest0.clone(); + let mut flat = dest0.clone(); + stamp_dabs(w, h, ch, &src, &mut tiled, &dabs, 64); + stamp_dabs(w, h, ch, &src, &mut flat, &dabs, 0); + assert_eq!(max_abs(&tiled, &flat), 0.0, "{} seam at x = 64", style.as_str()); + } + } + + /// A dab at angle 0 copies the history pixel under each covered pixel, not a blend of it + /// and its lower-right neighbours. + #[test] + fn unrotated_dab_restores_the_source_pixel() { + let (w, h, ch) = (48usize, 48usize, 4); + let src = fill(w, h, ch, |x, y, c| if c == 3 { 1.0 } else { ((x * 37 + y * 11 + c as i32 * 5).rem_euclid(97) as f32) / 96.0 }); + let mut dest = fill(w, h, ch, |_, _, c| if c == 3 { 1.0 } else { 0.5 }); + let dab = Dab { x: 24.0, y: 24.0, theta: 0.0, radius: 8.0, pressure: 1.0 }; + stamp_run(w, h, ch, &src, &mut dest, &dab, 0); + for (x, y) in [(24usize, 24usize), (20, 22), (27, 26)] { + let i = (y * w + x) * ch; + assert_eq!(&dest[i..i + ch], &src[i..i + ch], "pixel {x},{y}"); + } + } + + #[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, + 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, + 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 tolerance_ignores_unchanged_alpha() { + // Colour moved by 0.3 and alpha did not: the 0.25 threshold must not see a 0.225 average. + let (w, h, ch) = (48usize, 48usize, 4); + let src = fill(w, h, ch, |_, _, c| if c == 3 { 1.0 } else { 0.6 }); + let dest0 = fill(w, h, ch, |_, _, c| if c == 3 { 1.0 } else { 0.3 }); + let dab = Dab { x: 24.0, y: 24.0, theta: 0.0, radius: 8.0, pressure: 1.0 }; + for (tolerance, paints) in [(0.25, true), (0.35, false)] { + let mut dest = dest0.clone(); + 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, + tile: 0, + selection: None, + lock_transparent: false, + alpha: Some(3), + }; + let n = stamp(&mut p).unwrap(); + assert_eq!(n > 0, paints, "tolerance {tolerance}"); + } + } + + /// The Eraser keeps the colour and lowers only the alpha: an erased patch differs from the + /// history state and is painted back with a tolerance above 0. + #[test] + fn tolerance_sees_an_erased_patch() { + let (w, h, ch) = (48usize, 48usize, 4); + let src = fill(w, h, ch, |_, _, c| if c == 0 || c == 3 { 1.0 } else { 0.0 }); + let mut dest = fill(w, h, ch, |_, _, c| match c { + 0 => 1.0, + 3 => 0.6, + _ => 0.0, + }); + 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.25, + tile: 0, + selection: None, + lock_transparent: false, + alpha: Some(3), + }; + assert!(stamp(&mut p).unwrap() > 0, "alpha 1 -> 0.6 is over tolerance 0.25"); + let i = (24 * w + 24) * ch; + assert!((dest[i + 3] - 1.0).abs() < 1.0e-5, "alpha restored, got {}", dest[i + 3]); + } + #[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, + 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, + 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, + 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/camera_raw.rs b/crates/algo/src/camera_raw.rs index 3cadd1d20c..5bd0c8ab06 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)] @@ -166,6 +169,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 { @@ -222,6 +232,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], } } } @@ -229,6 +242,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] } @@ -534,6 +631,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)) @@ -541,6 +642,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 @@ -580,14 +682,27 @@ 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); + } + }); + } + // 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)); } } }); @@ -606,7 +721,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 +733,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 +752,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 +805,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 +830,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 +853,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); } @@ -818,23 +948,25 @@ fn dehaze(px: &mut [[f32; 4]], w: usize, h: usize, amount: f32) { *s = s.map(|v| v / c.max(1.0)); } let r = (sw.max(sh) / 60).max(2); - let min_filter = |v: &[f32]| -> Vec { + // The minimum (or, with `f32::max`, maximum) over each (2r + 1)² window. + let rank_filter = |v: &[f32], start: f32, pick: fn(f32, f32) -> f32| -> Vec { let mut a = vec![0.0f32; sw * sh]; for y in 0..sh { for x in 0..sw { let (x0, x1) = (x.saturating_sub(r), (x + r).min(sw - 1)); - a[y * sw + x] = (x0..=x1).map(|xx| v[y * sw + xx]).fold(f32::MAX, f32::min); + a[y * sw + x] = (x0..=x1).map(|xx| v[y * sw + xx]).fold(start, pick); } } let mut b = vec![0.0f32; sw * sh]; for y in 0..sh { for x in 0..sw { let (y0, y1) = (y.saturating_sub(r), (y + r).min(sh - 1)); - b[y * sw + x] = (y0..=y1).map(|yy| a[yy * sw + x]).fold(f32::MAX, f32::min); + b[y * sw + x] = (y0..=y1).map(|yy| a[yy * sw + x]).fold(start, pick); } } b }; + let min_filter = |v: &[f32]| rank_filter(v, f32::MAX, f32::min); let dark = min_filter(&small.iter().map(|c| c[0].min(c[1]).min(c[2])).collect::>()); // Atmospheric light: mean colour of the brightest 0.1 % of the dark channel. let mut idx: Vec = (0..sw * sh).collect(); @@ -848,7 +980,15 @@ fn dehaze(px: &mut [[f32; 4]], w: usize, h: usize, amount: f32) { } let air = air.map(|v| v.max(0.2)); let norm_dark = min_filter(&small.iter().map(|c| (c[0] / air[0]).min(c[1] / air[1]).min(c[2] / air[2])).collect::>()); - let t_raw: Vec = norm_dark.iter().map(|d| 1.0 - 0.95 * d).collect(); + // Opened (a maximum filter of the same radius after the minimum): the minimum grows every + // dark object by `r`, so sky beside a dark building took its dark channel, counted as + // haze-free and kept a bright halo (#9273). Opening shrinks dark regions back to their + // outline and is still a lower bound of the dark channel over the window. It raises the dark + // channel on average, so the 0.95 weight is scaled to keep the slider's overall strength. + let opened = rank_filter(&norm_dark, f32::MIN, f32::max); + let mean = |v: &[f32]| v.iter().sum::() / v.len().max(1) as f32; + let weight = 0.95 * mean(&norm_dark) / mean(&opened).max(1e-6); + let t_raw: Vec = opened.iter().map(|d| 1.0 - weight * d).collect(); let guide: Vec = small.iter().map(|c| luma(*c)).collect(); let t = guided(&guide, &t_raw, sw, sh, r * 2, 1e-3); let ks = k as f32; @@ -923,24 +1063,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 +1074,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 +1088,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; @@ -985,6 +1108,37 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) { mod tests { use super::*; + /// #9273: Dehaze clears the haze evenly over a sky, right up to a dark building, instead of + /// leaving a bright halo of uncorrected haze around it. Synthetic scene: a clear sky and a + /// dark tower, hazed with `I = J·t + A·(1 − t)`. + #[test] + fn dehaze_leaves_no_halo_beside_dark_objects() { + let (w, h) = (600usize, 400usize); + let tower = |x: usize, y: usize| (250..350).contains(&x) && y >= 120; + let (t, air) = (0.55f32, [0.9f32, 0.9, 0.9]); + let mut px: Vec<[f32; 4]> = (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let j = if tower(x, y) { [0.06, 0.06, 0.07] } else { [0.35 + 0.1 * y as f32 / h as f32, 0.5, 0.8] }; + let c: [f32; 3] = std::array::from_fn(|k| j[k] * t + air[k] * (1.0 - t)); + [c[0], c[1], c[2], 1.0] + }) + .collect(); + dehaze(&mut px, w, h, 1.0); + let sky = |xs: std::ops::Range| { + let v: Vec = + (150..390).flat_map(|y| xs.clone().map(move |x| (x, y))).map(|(x, y)| luma([px[y * w + x][0], px[y * w + x][1], px[y * w + x][2]])).collect(); + v.iter().sum::() / v.len() as f32 + }; + // Sky 1–8 px from the tower against open sky: the haze left beside it, in 8-bit levels. + let halo = (sky(242..250) - sky(20..80)) * 255.0; + // 6.4 levels before the dark channel was opened, under 1 after. + assert!(halo < 3.0, "a {halo:.1}-level halo beside the tower"); + // The open sky is still cleared: darker than the hazy input. + let hazy = luma(std::array::from_fn(|k| [0.4, 0.5, 0.8][k] * t + air[k] * (1.0 - t))); + assert!(sky(20..80) < hazy - 0.02, "{} vs hazy {hazy}", sky(20..80)); + } + #[test] fn new_curves_are_validated_but_saved_curves_still_load_and_render() { for field in ["pointCurve", "pointCurveRed", "pointCurveGreen", "pointCurveBlue"] { @@ -1144,6 +1298,105 @@ 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_keeps_the_develop_off_the_per_pixel_gpu_stages() { + // The per-pixel stages (the Camera Raw dialog's GPU preview) don't run Calibration, so + // settings with it must fall back to the CPU develop instead of previewing without it. + for p in [ + CameraRaw { shadow_tint: 40.0, ..Default::default() }, + CameraRaw { calibration_hue: [0.0, 0.0, 30.0], whites: 20.0, ..Default::default() }, + CameraRaw { calibration_sat: [-50.0, 0.0, 0.0], exposure: 0.5, ..Default::default() }, + ] { + assert!(!p.spatial()); + assert!(per_pixel(&p, 2, 2, false, Some((0.0, 1.0))).is_none(), "{p:?}"); + } + assert!(per_pixel(&CameraRaw { exposure: 0.5, ..Default::default() }, 2, 2, false, None).is_some()); + } + + #[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/algo/src/camera_raw/per_pixel.rs b/crates/algo/src/camera_raw/per_pixel.rs new file mode 100644 index 0000000000..6400db8a25 --- /dev/null +++ b/crates/algo/src/camera_raw/per_pixel.rs @@ -0,0 +1,240 @@ +//! 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 Calibration (these +/// stages don't run it), or Whites or Blacks is set without `points`. The points themselves +/// leave Calibration out, so a caller may cache them across Calibration changes. +pub fn per_pixel(p: &CameraRaw, w: usize, h: usize, float: bool, points: Option<(f32, f32)>) -> Option { + if p.spatial() || !p.calibration_neutral() { + 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/algo/src/lib.rs b/crates/algo/src/lib.rs index 665f6fd6e9..9e8fa5301d 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/algo/src/paint.rs b/crates/algo/src/paint.rs index 8146af2582..4862304786 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,12 +97,18 @@ fn composite_area( let i = ((y - r.y0) as usize * w + (x - r.x0) as usize) * n; let px = &mut region[i..i + n]; let d = photocraft_raster::to_rgba(&fmt, px); - let o = if lock_transparency { blend::composite_atop(blend_mode, d, color(x, y), k) } else { blend::composite(blend_mode, d, color(x, y), k) }; + let o = match mode { + FillMode::Blend(m) if lock_transparency => blend::composite_atop(m, d, color(x, y), k), + FillMode::Blend(m) => blend::composite(m, d, color(x, y), k), + // Locked transparency keeps the alpha, so Clear leaves the pixel as it is. + FillMode::Clear if lock_transparency => d, + FillMode::Clear => [d[0], d[1], d[2], d[3] * (1.0 - k.min(1.0))], + }; from_rgba_into(&fmt, o, &mut enc); // A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is. if let Some(i) = inks && fmt.mode == photocraft_color::ColorMode::Cmyk - && blend_mode == BlendMode::Normal + && mode == FillMode::Blend(BlendMode::Normal) && k >= 1.0 { enc[..4].copy_from_slice(&i); @@ -135,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); @@ -187,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, @@ -205,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; @@ -212,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 } @@ -226,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, @@ -238,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)] @@ -333,4 +343,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/algo/src/seam.rs b/crates/algo/src/seam.rs index 0cbd9a66a2..b85fba5bab 100644 --- a/crates/algo/src/seam.rs +++ b/crates/algo/src/seam.rs @@ -9,16 +9,26 @@ //! //! 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 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}; /// Energy added per unit of protection: far above any gradient magnitude. const PROTECT_ENERGY: f32 = 1.0e4; -/// 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. +/// 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. 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; @@ -33,7 +43,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,20 +56,25 @@ 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. An energy that isn't a number is the +/// highest energy (`+inf`). +#[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 + let e = s * 0.5; + if e.is_nan() { f32::INFINITY } else { e } } /// The lowest-energy vertical seam: one x per row. @@ -67,57 +82,178 @@ 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. +/// [`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 { + 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 [`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 { + 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,121 +262,223 @@ 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 { - for (e, p) in e.iter_mut().zip(p) { - *e += p.clamp(0.0, 1.0) * PROTECT_ENERGY; +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 += protection(p); } } - 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 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); + 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 += protection(p[y * w + col(x, y)]); } - 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 +} + +/// 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. @@ -250,15 +488,23 @@ 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 { + // Nothing to carve: no pixels or channels, or fewer values than the size says. + 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. + 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 { 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 +518,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,103 +575,65 @@ 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; - // Removing a seam shifts the panel left, so a panel column is not an original one. Tracking - // the original column per pixel costs a third buffer that every seam has to shift (24 MB a - // seam at 24 MP); counting the columns already taken from that row is exact and costs a - // binary search per removed column. - let mut carver = Carver::new(w, proxy_h, ch, proxy, proxy_protect, false); - let mut removed_by_row: Vec> = vec![Vec::new(); proxy_h]; - let mut taken = 0usize; + 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()?; - record_removed(&mut removed_by_row, &carver.remove_seam()); - taken += 1; - ctl.progress(taken as f32 / seam_count as f32); + removed.push(carver.remove_seam()); + ctl.progress(removed.len() as f32 / seam_count as f32); } - let mut out = Vec::with_capacity(new_w * h * ch); - let mut columns = vec![0; seam_count]; - for y in 0..h { + + let mut removed_by_row = Vec::with_capacity(proxy_h); + for proxy_y in 0..proxy_h { + let mut columns: Vec = removed.iter().map(|path| path[proxy_y]).collect(); + columns.sort_unstable(); + removed_by_row.push(columns); + } + // 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); } - } - ctl.progress(1.0); - Ok(out) -} - -/// Add one seam, given in panel columns, to each row's sorted list of removed original columns. -/// For the `i`-th column already taken from a row, `o - i` is the panel column it occupied, and -/// that never decreases along the list, so the taken columns at or left of panel column `x` are a -/// prefix found by binary search. `x` plus that prefix's length is the original column, and every -/// removal the search passed is left of it, so it inserts where the search stopped. The rows are -/// independent, so they update in parallel like the pixel shift. -fn record_removed(removed_by_row: &mut [Vec], seam: &[usize]) { - // The entries are the original columns still taken from one row, sorted, so entry `i` is at - // least `i` and `o - i` cannot underflow. - let add = |(row, &x): (&mut Vec, &usize)| { - let (mut lo, mut hi) = (0, row.len()); - while lo < hi { - let mid = lo + (hi - lo) / 2; - if row[mid] - mid <= x { lo = mid + 1 } else { hi = mid } - } - row.insert(lo, x + lo); + 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::*; - removed_by_row.par_iter_mut().zip(seam.par_iter()).for_each(add); + out.par_chunks_exact_mut(new_w * ch).enumerate().try_for_each_init(|| Vec::with_capacity(seam_count), rebuild)?; } #[cfg(target_arch = "wasm32")] - removed_by_row.iter_mut().zip(seam.iter()).for_each(add); + { + 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) } /// Content-aware resize of `img` to `new_w × new_h` (width first, then height by transposing). @@ -448,6 +656,11 @@ 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 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); let split = if sw + sh > 0.0 { sw / (sw + sh) } else { 1.0 }; @@ -457,21 +670,40 @@ 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 mut out = vec![0.0f32; nw * nh * ch]; - if w == 0 || h == 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; } - 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); @@ -483,10 +715,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 } @@ -515,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::{PROTECT_ENERGY, 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]; @@ -530,7 +769,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 @@ -583,8 +823,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); } } }; @@ -667,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) @@ -708,6 +948,161 @@ 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); + } + // 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![ + (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), + (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() { + 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 += protection(p[i]); + } + } + 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(); + } + } + } + + /// 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]); + // 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 + /// 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); @@ -786,33 +1181,6 @@ mod tests { assert_eq!(carve_width_with(w, h, ch, &img, None, 64, &Interrupt::cancel_only(&cancelled)), Err(Cancelled)); } - /// The proxy path counts each removed original column instead of shifting an index map, so a - /// tracking and a counting carver must agree: run both side by side down to one column and - /// check every row's list after every seam (tie-heavy noise gives rows different histories; - /// 1-4 channels, with and without protection). - #[test] - fn counted_removed_columns_match_the_tracked_map() { - let cases: [(usize, usize, usize, u32, bool); 5] = - [(37, 9, 1, 2, false), (40, 12, 2, 3, true), (53, 7, 3, 255, false), (29, 16, 4, 1000, true), (64, 5, 3, 2, true)]; - for (i, &(w, h, ch, levels, prot)) in cases.iter().enumerate() { - let img = noise(w * h * ch, levels, 31 + i as u32); - let protect = prot.then(|| noise(w * h, 3, 77 + i as u32)); - let mut tracked = Carver::new(w, h, ch, img.clone(), protect.clone(), true); - let mut counted = Carver::new(w, h, ch, img, protect, false); - let mut expected: Vec> = vec![Vec::new(); h]; - let mut got: Vec> = vec![Vec::new(); h]; - while tracked.cw > 1 { - let seam = tracked.remove_seam(); - for (row, &x) in expected.iter_mut().zip(seam.iter()) { - let at = row.partition_point(|&o| o < x); - row.insert(at, x); - } - record_removed(&mut got, &counted.remove_seam()); - assert_eq!(got, expected, "case {i} after {} seams", w - tracked.cw); - } - } - } - #[test] fn enlarging_duplicates_flat_areas() { let (w, h) = (30, 8); @@ -904,6 +1272,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 diff --git a/crates/algo/src/selection/lasso.rs b/crates/algo/src/selection/lasso.rs index 3ca4eacd52..2471a379b5 100644 --- a/crates/algo/src/selection/lasso.rs +++ b/crates/algo/src/selection/lasso.rs @@ -123,7 +123,7 @@ pub fn polygon_surface(points: &[(f32, f32)], clip: Rect) -> Option { let crossings = |e: &[&Edge], y: f32, xs: &mut Vec| { xs.clear(); xs.extend(e.iter().map(|&&Edge { a, b, .. }| a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0))); - xs.sort_by(|a, b| a.total_cmp(b)); + xs.sort_by(f32::total_cmp); }; let full = s.solid_tile(&[1.0]); let blank = s.solid_tile(&[0.0]); diff --git a/crates/algo/src/smart_blur_tests.rs b/crates/algo/src/smart_blur_tests.rs index 47290e2425..505764e42e 100644 --- a/crates/algo/src/smart_blur_tests.rs +++ b/crates/algo/src/smart_blur_tests.rs @@ -1,6 +1,6 @@ //! The pre-#7921 Smart Blur (every neighbour read per channel through `Image::get`) stays as an //! exact oracle: the offset fast path must give the same bits for every mode, depth and layout. -use super::*; +use super::{BlurQuality, Ctx, Image, MAXC, Rect, SmartBlurMode, native, ncol, smart_blur, unpremul_px, xy}; use photocraft_color::ColorMode; #[allow(clippy::too_many_arguments)] diff --git a/crates/algo/src/vanishing.rs b/crates/algo/src/vanishing.rs index 1cf39b23ec..25ae5609ef 100644 --- a/crates/algo/src/vanishing.rs +++ b/crates/algo/src/vanishing.rs @@ -324,33 +324,114 @@ 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, None).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). Samples a snapshot taken at this call. +/// For incremental painting use [`clone_dabs_from`] with one snapshot per stroke. +/// With `clip`, every dab is cut to it before any read or write (a document passes its canvas, +/// so far off-canvas points allocate no tiles). 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, + clip: Option, +) -> (usize, Rect) { let src_copy = surf.clone(); + clone_dabs_from(scene, surf, &src_copy, source, anchor, points, size, hardness, opacity, clip) +} + +/// 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. +/// `clip` as in [`clone_dabs`]. +#[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, + clip: Option, +) -> (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 CloneFrame { hs, ls: (lus, lvs), hd, hd_inv, ld: (lud, lvd), offset, r_m } = f; let fmt = surf.format(); let n = fmt.channels(); // Scratch for `sample`: the dab's source pixels, the four bilinear corners, the result. let mut win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() }; let (mut corners, mut s) = (vec![0.0f32; 4 * n], vec![0.0f32; n]); let mut dabs = 0; + let mut changed_area = Rect::EMPTY; for p in points { - let mc = metric(&hd_inv, lud, lvd, *p); + let mc = metric(&hd_inv, (lud, lvd), *p); // Image-space footprint: map a metric circle's bounding points. let footprint = |h: &Homography, lu: f64, lv: f64, c: [f64; 2]| { let mut bb = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; @@ -359,12 +440,19 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: let (x, y) = h.apply((c[0] + r_m * a.cos()) / lu, (c[1] + r_m * a.sin()) / lv); bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x), bb[3].max(y)]; } + if bb.iter().any(|v| !v.is_finite()) { + return Rect::EMPTY; + } Rect::new(bb[0].floor() as i32, bb[1].floor() as i32, (bb[2].ceil() as i32).saturating_add(1), (bb[3].ceil() as i32).saturating_add(1)) }; let area = footprint(&hd, lud, lvd, mc); if area.is_empty() || area.width() > 4096 || area.height() > 4096 { continue; } + let area = clip.map_or(area, |c| area.intersect(&c)); + if area.is_empty() { + continue; + } // The source footprint, read once; `sample` reads any pixel outside it from the surface. // Skipped when it dwarfs the destination (cloning from near to far in strong perspective): // decoding it would cost more than the few per-pixel reads it saves. @@ -385,9 +473,9 @@ 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 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 { + if d >= 1.0 || d.is_nan() { continue; } let hard = hardness.clamp(0.0, 0.99); @@ -396,7 +484,7 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: // Source pixel at the same metric offset. let sm = [m[0] + offset[0], m[1] + offset[1]]; let (sx, sy) = hs.apply(sm[0] / lus, sm[1] / lvs); - sample(&src_copy, &win, sx - 0.5, sy - 0.5, &mut corners, &mut s); + sample(src_copy, &win, sx - 0.5, sy - 0.5, &mut corners, &mut s); for c in 0..n { px[c] += (s[c] - px[c]) * k; } @@ -405,9 +493,10 @@ 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) } /// Pixels of `area` read from the source surface (`read_region` layout). @@ -421,6 +510,10 @@ struct Window { /// read from `s`. fn sample(s: &Surface, win: &Window, x: f64, y: f64, corners: &mut Vec, out: &mut [f32]) { let n = out.len(); + if !x.is_finite() || !y.is_finite() { + out.fill(0.0); + return; + } 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 (x1, y1) = (x0.saturating_add(1), y0.saturating_add(1)); @@ -537,6 +630,42 @@ 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:?}"); + } + #[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, None); + } + assert_eq!(whole.read_region(Rect::new(0, 0, 100, 100)), pieces.read_region(Rect::new(0, 0, 100, 100))); + } + } + + /// A clip cuts every dab before it reads or writes: dabs far outside it allocate no tiles. + #[test] + fn clipped_clone_allocates_no_tiles_outside_the_clip() { + 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); + let clip = Rect::new(0, 0, 100, 100); + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(clip, &[0.5, 0.5, 0.5, 1.0]); + let tiles = s.tiles().count(); + let points = [[50.0, 50.0], [-40.0, 50.0], [5000.0, 5000.0], [-90_000.0, 70_000.0]]; + let (dabs, area) = clone_dabs(&sc, &mut s, [40.0, 50.0], points[0], &points, 20.0, 0.8, 1.0, Some(clip)); + assert_eq!(dabs, 1); + assert!(clip.contains_rect(&area), "{area:?}"); + assert_eq!(s.tiles().count(), tiles); } #[test] diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 5421640a84..13ab9c527b 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -21,7 +21,7 @@ serde_json = { workspace = true } thiserror = { workspace = true } rmcp = { version = "3.5", default-features = false, features = ["server", "macros", "transport-io"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] } -base64 = "0.22" +base64 = "0.23" schemars = "1" getrandom = { workspace = true } cap-std = "4" diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index f5ccef00c7..61a6454e79 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -329,7 +329,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, softLight, softDark), 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 @@ -350,8 +350,14 @@ pub struct UiSetParams { /// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a /// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off": /// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not - /// over this channel). updatesOpen (bool, the Help › Check for Updates window). Other fields - /// are an error. + /// over this channel). updatesOpen (bool, the Help › Check for Updates window). + /// histogramPanel (an object patching view ("compact", "expanded" or "allChannels"), channel + /// ("composite", "red", "green", "blue", "cyan", "magenta", "yellow", "black", "a", "b", + /// "luminosity" or "colors"), showChannelsInColor, showStatistics and source ("entireImage", + /// "selectedLayer" or "adjustmentComposite") for the Histogram panel), histogramRange ([from, + /// to] levels 0..255 whose Count and Percentile the panel shows, or null) and histogramRefresh + /// (true: an Uncached Refresh); `ui.inspect` reports them and the statistics under + /// `histogram`. Other fields are an error. pub fields: Value, } diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index e8566b439e..12e7329180 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -806,6 +806,21 @@ mod tests { assert!(authorize_desktop_engine_step("file.saveACopy", &serde_json::json!({})).is_err()); } + // Regression test for #3763: `edit.installedProfiles` scans the OS and user ICC folders but + // takes no path parameter, so automation let it through until it was listed as ambient. + #[test] + fn installed_profiles_listing_is_refused_for_automation() { + for params in [serde_json::json!({}), serde_json::json!({"class": "output", "colorSpace": "cmyk"})] { + assert!(authorize_engine_command("edit.installedProfiles", ¶ms).is_err(), "authorize_engine_command allowed it with {params}"); + assert!(authorize_desktop_engine_command("edit.installedProfiles", ¶ms).is_err(), "authorize_desktop_engine_command allowed it with {params}"); + assert!(authorize_engine_step("edit.installedProfiles", ¶ms).is_err(), "authorize_engine_step allowed it with {params}"); + } + // The sandboxed session refuses it before the engine scans any folder. + let mut headless = crate::Headless::new(); + let refused = headless.command_run("edit.installedProfiles", serde_json::json!({})).unwrap_err(); + assert!(refused.to_string().contains("ambient filesystem paths"), "{refused}"); + } + #[test] fn apply_data_set_is_judged_by_the_values_it_applies() { let mut headless = crate::Headless::new(); diff --git a/crates/automation/tests/it/smart_filter_authorization.rs b/crates/automation/tests/it/smart_filter_authorization.rs index 594832c41d..0a39df85f5 100644 --- a/crates/automation/tests/it/smart_filter_authorization.rs +++ b/crates/automation/tests/it/smart_filter_authorization.rs @@ -1,94 +1,15 @@ //! Stored smart-filter parameters must not grant automation ambient filesystem access. -use std::path::{Path, PathBuf}; -use std::sync::{ - Arc, - atomic::{AtomicU64, Ordering}, -}; +use std::path::Path; +use std::sync::Arc; -use photocraft_automation::{AuthorizedWorkspace, Headless, workspace::authorize_desktop_engine_step}; -use photocraft_doc::{Document, LayerContent, LayerId}; +#[path = "../support/smart_filter_fixture.rs"] +mod fixture; + +use fixture::{Fixture, assert_denied, cube, path_params}; +use photocraft_automation::{Headless, workspace::authorize_desktop_engine_step}; +use photocraft_doc::LayerContent; use photocraft_engine::{Session, actions_cmds::Action, jobs::Started, smart_cmds}; -use serde_json::{Value, json}; - -struct Fixture(PathBuf); - -impl Fixture { - fn new() -> Self { - static NEXT: AtomicU64 = AtomicU64::new(0); - let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); - let base = std::env::temp_dir().join(format!("pc-filter-auth-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); - std::fs::create_dir(&base).unwrap(); - std::fs::create_dir(base.join("workspace")).unwrap(); - std::fs::create_dir(base.join("outside")).unwrap(); - let f = Self(base); - std::fs::write(f.root().join("stripes.png"), png(|x| if (x / 2) % 2 == 0 { 0 } else { 255 })).unwrap(); - std::fs::write(f.outside("black.png"), png(|_| 0)).unwrap(); - std::fs::write(f.outside("white.png"), png(|_| 255)).unwrap(); - std::fs::write(f.outside("black.cube"), cube(0)).unwrap(); - std::fs::write(f.outside("white.cube"), cube(1)).unwrap(); - f - } - - fn root(&self) -> PathBuf { - self.0.join("workspace") - } - fn outside(&self, name: &str) -> PathBuf { - self.0.join("outside").join(name) - } - fn headless(&self) -> Headless { - Headless::with_workspace(AuthorizedWorkspace::new(Some(&self.root()), Some(&self.root())).unwrap()) - } - fn setup(&self, lookup: bool) -> (Headless, LayerId) { - let mut h = self.headless(); - h.handle("doc.new", json!({"width": 32, "height": 32, "background": "#808080"})).unwrap(); - h.handle("doc.new", json!({"width": 32, "height": 32})).unwrap(); - let layer = LayerId(h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap()); - let (id, params) = if lookup { - ("image.adjustments.colorLookup", json!({"layer": layer.0, "lut": "none"})) - } else { - ("filter.distort.displace", json!({"layer": layer.0, "mapDocument": 0, "horizontal": 50, "vertical": 0})) - }; - h.session.execute(id, params).unwrap(); - (h, layer) - } - fn saved_filter(&self, lookup: bool) -> (Document, LayerId) { - let (h, layer) = self.setup(lookup); - let mut doc = (*h.session.active().unwrap().doc).clone(); - let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!("smart object") }; - sm.smart_filters[0].params = path_params(lookup, &self.outside(if lookup { "white.cube" } else { "white.png" })); - let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap(); - std::fs::write(self.root().join("stored.pcraft"), bytes).unwrap(); - (doc, layer) - } -} -impl Drop for Fixture { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -fn png(value: impl Fn(u32) -> u8) -> Vec { - let mut pixels = Vec::new(); - for _ in 0..32 { - for x in 0..32 { - pixels.extend([value(x); 3]); - } - } - let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, pixels).unwrap(); - photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap() -} -fn cube(value: u8) -> String { - format!("LUT_3D_SIZE 2\n{}", format!("{value} {value} {value}\n").repeat(8)) -} -fn path_params(lookup: bool, path: &Path) -> Value { - if lookup { json!({"file": path}) } else { json!({"mapPath": path, "mapDocument": null}) } -} -fn assert_denied(result: Result) { - match result { - Err(error) => assert!(error.to_string().contains("ambient filesystem"), "{error}"), - Ok(value) => panic!("ambient file access was accepted: {value:?}"), - } -} +use serde_json::json; #[test] fn parameter_edits_reject_outside_images_and_luts_without_changing_the_document() { @@ -243,21 +164,6 @@ fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_ } } -#[test] -fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() { - // The denial path must fail the render before any stage is cached process-wide. - for lookup in [false, true] { - let f = Fixture::new(); - let (doc, layer) = f.saved_filter(lookup); - let mut s = Session::new(); - s.add_document(doc, None); - s.authorize = Some(authorize_desktop_engine_step); - let bytes = smart_cmds::stack_cache_bytes(); - assert_denied(s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true}))); - assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage"); - } -} - /// Choosing another Displace map through `setParams` embeds it (#7423), so the new map is read /// before the edit: a path outside the workspace is refused before that read, and the filter /// keeps the map it had. diff --git a/crates/automation/tests/smart_filter_stack_cache.rs b/crates/automation/tests/smart_filter_stack_cache.rs new file mode 100644 index 0000000000..f7698a4db1 --- /dev/null +++ b/crates/automation/tests/smart_filter_stack_cache.rs @@ -0,0 +1,25 @@ +//! One integration-test process keeps the global smart filter stage cache isolated from the +//! parallel tests in `tests/it`, so a denied render can be shown to have cached nothing. +use photocraft_automation::workspace::authorize_desktop_engine_step; +use photocraft_engine::{Session, smart_cmds}; +use serde_json::json; + +#[path = "support/smart_filter_fixture.rs"] +mod fixture; + +use fixture::{Fixture, assert_denied}; + +#[test] +fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() { + // The denial path must fail the render before any stage is cached process-wide. + for lookup in [false, true] { + let f = Fixture::new(); + let (doc, layer) = f.saved_filter(lookup); + let mut s = Session::new(); + s.add_document(doc, None); + s.authorize = Some(authorize_desktop_engine_step); + let bytes = smart_cmds::stack_cache_bytes(); + assert_denied(s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true}))); + assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage"); + } +} diff --git a/crates/automation/tests/support/smart_filter_fixture.rs b/crates/automation/tests/support/smart_filter_fixture.rs new file mode 100644 index 0000000000..26d5cab27b --- /dev/null +++ b/crates/automation/tests/support/smart_filter_fixture.rs @@ -0,0 +1,89 @@ +//! The smart filter authorization tests' fixture: a workspace with a striped image to place and +//! maps and LUTs outside it (`#[path]`-included by `it/smart_filter_authorization.rs` and +//! `smart_filter_stack_cache.rs`). +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use photocraft_automation::{AuthorizedWorkspace, Headless}; +use photocraft_doc::{Document, LayerContent, LayerId}; +use serde_json::{Value, json}; + +pub struct Fixture(PathBuf); + +impl Fixture { + pub fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let base = std::env::temp_dir().join(format!("pc-filter-auth-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir(&base).unwrap(); + std::fs::create_dir(base.join("workspace")).unwrap(); + std::fs::create_dir(base.join("outside")).unwrap(); + let f = Self(base); + std::fs::write(f.root().join("stripes.png"), png(|x| if (x / 2) % 2 == 0 { 0 } else { 255 })).unwrap(); + std::fs::write(f.outside("black.png"), png(|_| 0)).unwrap(); + std::fs::write(f.outside("white.png"), png(|_| 255)).unwrap(); + std::fs::write(f.outside("black.cube"), cube(0)).unwrap(); + std::fs::write(f.outside("white.cube"), cube(1)).unwrap(); + f + } + + pub fn root(&self) -> PathBuf { + self.0.join("workspace") + } + pub fn outside(&self, name: &str) -> PathBuf { + self.0.join("outside").join(name) + } + pub fn headless(&self) -> Headless { + Headless::with_workspace(AuthorizedWorkspace::new(Some(&self.root()), Some(&self.root())).unwrap()) + } + pub fn setup(&self, lookup: bool) -> (Headless, LayerId) { + let mut h = self.headless(); + h.handle("doc.new", json!({"width": 32, "height": 32, "background": "#808080"})).unwrap(); + h.handle("doc.new", json!({"width": 32, "height": 32})).unwrap(); + let layer = LayerId(h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap()); + let (id, params) = if lookup { + ("image.adjustments.colorLookup", json!({"layer": layer.0, "lut": "none"})) + } else { + ("filter.distort.displace", json!({"layer": layer.0, "mapDocument": 0, "horizontal": 50, "vertical": 0})) + }; + h.session.execute(id, params).unwrap(); + (h, layer) + } + pub fn saved_filter(&self, lookup: bool) -> (Document, LayerId) { + let (h, layer) = self.setup(lookup); + let mut doc = (*h.session.active().unwrap().doc).clone(); + let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!("smart object") }; + sm.smart_filters[0].params = path_params(lookup, &self.outside(if lookup { "white.cube" } else { "white.png" })); + let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap(); + std::fs::write(self.root().join("stored.pcraft"), bytes).unwrap(); + (doc, layer) + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +pub fn png(value: impl Fn(u32) -> u8) -> Vec { + let mut pixels = Vec::new(); + for _ in 0..32 { + for x in 0..32 { + pixels.extend([value(x); 3]); + } + } + let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, pixels).unwrap(); + photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap() +} +pub fn cube(value: u8) -> String { + format!("LUT_3D_SIZE 2\n{}", format!("{value} {value} {value}\n").repeat(8)) +} +pub fn path_params(lookup: bool, path: &Path) -> Value { + if lookup { json!({"file": path}) } else { json!({"mapPath": path, "mapDocument": null}) } +} +pub fn assert_denied(result: Result) { + match result { + Err(error) => assert!(error.to_string().contains("ambient filesystem"), "{error}"), + Ok(value) => panic!("ambient file access was accepted: {value:?}"), + } +} diff --git a/crates/cms/src/lutfile.rs b/crates/cms/src/lutfile.rs index dbe6281d8f..6a85f53908 100644 --- a/crates/cms/src/lutfile.rs +++ b/crates/cms/src/lutfile.rs @@ -90,7 +90,7 @@ impl LutFile { /// Parses a LUT, picking the format from `file_name`'s extension (falls back to sniffing). pub fn parse(file_name: &str, bytes: &[u8]) -> Result { let lower = file_name.to_ascii_lowercase(); - let text = String::from_utf8_lossy(bytes); + let text = decode_text(bytes); if lower.ends_with(".3dl") { parse_3dl(&text) } else if lower.ends_with(".look") || text.trim_start().starts_with('<') { @@ -100,6 +100,19 @@ pub fn parse(file_name: &str, bytes: &[u8]) -> Result { } } +/// UTF-16 with a byte-order mark (Photoshop loads such `.look` files, #9118), else UTF-8. +fn decode_text(bytes: &[u8]) -> std::borrow::Cow<'_, str> { + let utf16 = |rest: &[u8], from: fn([u8; 2]) -> u16| { + let units: Vec = rest.as_chunks::<2>().0.iter().map(|&c| from(c)).collect(); + String::from_utf16_lossy(&units).into() + }; + match bytes { + [0xFF, 0xFE, rest @ ..] => utf16(rest, u16::from_le_bytes), + [0xFE, 0xFF, rest @ ..] => utf16(rest, u16::from_be_bytes), + _ => String::from_utf8_lossy(bytes), + } +} + /// Adobe / Resolve `.cube`. 1D LUTs (`LUT_1D_SIZE`) are expanded to a 33³ table. pub fn parse_cube(text: &str) -> Result { let mut title = String::new(); @@ -179,12 +192,15 @@ pub fn parse_cube(text: &str) -> Result { } /// Lustre `.3dl`: an optional shaper line of input values, then integer RGB rows with **blue** -/// varying fastest; the output bit depth is inferred from the largest value. +/// varying fastest; the output bit depth is inferred from the largest value, and is at least 12 +/// bits in a file without a header. pub fn parse_3dl(text: &str) -> Result { let mut rows: Vec<[f64; 3]> = Vec::new(); + let mut header = false; for line in text.lines() { let line = line.split('#').next().unwrap_or("").trim(); if line.is_empty() || line.starts_with(|c: char| c.is_ascii_alphabetic()) { + header |= !line.is_empty(); continue; } let v: Vec = line.split_whitespace().filter_map(|s| s.parse().ok()).collect(); @@ -198,7 +214,9 @@ pub fn parse_3dl(text: &str) -> Result { return err(format!("{} rows is not a cube", rows.len())); } let max = rows.iter().flatten().fold(0.0f64, |a, &b| a.max(b)); - let scale = if max <= 1.0 { 1.0 } else { [1023.0, 4095.0, 16383.0, 65535.0].into_iter().find(|&s| max <= s).unwrap_or(max) }; + // Photoshop 27.11 reads a headerless table peaking at 255 or 1023 on the 12-bit scale (#9121). + let depths: &[f64] = if header { &[1023.0, 4095.0, 16383.0, 65535.0] } else { &[4095.0, 16383.0, 65535.0] }; + let scale = if max <= 1.0 { 1.0 } else { depths.iter().copied().find(|&s| max <= s).unwrap_or(max) }; let mut data = vec![0.0f32; n * n * n * 3]; for (i, row) in rows.iter().enumerate() { let (r, g, b) = (i / (n * n), (i / n) % n, i % n); @@ -213,6 +231,15 @@ pub fn parse_3dl(text: &str) -> Result { /// SpeedGrade `.look`: `` and a hex `` string of little-endian float32 RGB triplets /// (red fastest). pub fn parse_look(text: &str) -> Result { + // Comments may hold tag-like text, so drop them before looking for tags (#9109). + let mut clean = String::with_capacity(text.len()); + let mut rest = text; + while let Some(i) = rest.find("").map_or("", |e| &rest[i + 4 + e + 3..]); + } + clean.push_str(rest); + let text = clean.as_str(); let tag = |name: &str| -> Option { let open = format!("<{name}>"); let start = text.find(&open)? + open.len(); @@ -440,6 +467,25 @@ DOMAIN_MAX 0.5 1 1 assert!(parse_cube(&text).is_err()); } + #[test] + fn three_dl_without_a_header_reads_low_nodes_as_12_bit() { + let table = |peak: u32| -> String { + let mut text = String::from("0 1023\n"); + for i in 0..8 { + let on = |bit: u32| if i & bit != 0 { peak } else { 0 }; + text.push_str(&format!("{} {} {}\n", on(4), on(2), on(1))); + } + text + }; + for peak in [255, 1023, 4095] { + let white = parse_3dl(&table(peak)).unwrap().data[21..24].to_vec(); + assert_eq!(white, [peak as f32 / 4095.0; 3], "peak {peak}"); + } + // A header keeps the inferred 10-bit depth. + let white = parse_3dl(&format!("3DMESH\nMesh 1 10\n{}", table(1023))).unwrap().data[21..24].to_vec(); + assert_eq!(white, [1.0; 3]); + } + #[test] fn three_dl_blue_fastest_and_bit_depth() { let mut text = String::from("0 1023\n"); @@ -463,6 +509,29 @@ DOMAIN_MAX 0.5 1 1 assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data); } + #[test] + fn look_reads_utf16_with_either_byte_order() { + let id = LutFile::identity(2); + let hex: String = id.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect(); + let xml = format!("\"2\"\"{hex}\""); + let le: Vec = [0xFF, 0xFE].into_iter().chain(xml.encode_utf16().flat_map(u16::to_le_bytes)).collect(); + let be: Vec = [0xFE, 0xFF].into_iter().chain(xml.encode_utf16().flat_map(u16::to_be_bytes)).collect(); + assert_eq!(parse("a.look", &le).unwrap().data, id.data); + assert_eq!(parse("a.look", &be).unwrap().data, id.data); + } + + #[test] + fn look_ignores_tags_inside_xml_comments() { + let hex = |l: &LutFile| -> String { l.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect() }; + let inverse = LutFile::from_fn("", 2, |[r, g, b]| [1.0 - r, 1.0 - g, 1.0 - b]); + let xml = format!( + "\"2\"\"{}\" + + + + + + @@ -80,6 +86,11 @@ + + + + + diff --git a/packaging/windows/sign.ps1 b/packaging/windows/sign.ps1 index 5dcefceb03..6064a9409b 100644 --- a/packaging/windows/sign.ps1 +++ b/packaging/windows/sign.ps1 @@ -23,6 +23,16 @@ param( ) $ErrorActionPreference = 'Stop' +# Azure Trusted Signing loads this NuGet package's dlib into signtool, beside the signing secrets, +# so the package is pinned to a reviewed version and SHA-256 and refused on any mismatch (#2936). +# Upgrading is a deliberate change to both values: download +# https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client/ and record +# (Get-FileHash -Algorithm SHA256 ).Hash. +$TrustedSigningClient = @{ + Version = '1.0.95' + Sha256 = '3BFCF1E0A3CB42AF1692F0A8ED45C15DE070C2DE86F28A59B2795D904D8A920F' +} + function Write-Warn([string] $Message) { if ($env:GITHUB_ACTIONS) { Write-Output "::warning::$Message" } else { Write-Warning $Message } } @@ -74,7 +84,12 @@ try { # The dlib authenticates with DefaultAzureCredential, which reads AZURE_TENANT_ID, # AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment. $pkg = Join-Path $tmp 'trusted-signing.zip' - Invoke-WebRequest -Uri 'https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client' -OutFile $pkg + $version = $TrustedSigningClient.Version + Invoke-WebRequest -Uri "https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client/$version" -OutFile $pkg + $sha256 = (Get-FileHash -Algorithm SHA256 -Path $pkg).Hash + if ($sha256 -ne $TrustedSigningClient.Sha256) { + throw "Microsoft.Trusted.Signing.Client $version has SHA-256 $sha256, expected $($TrustedSigningClient.Sha256): refusing to load it" + } Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force $dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll | Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1 diff --git a/perf/budgets.toml b/perf/budgets.toml index 6b264a936d..7a16f37e2f 100644 --- a/perf/budgets.toml +++ b/perf/budgets.toml @@ -75,6 +75,17 @@ 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 every desktop OS). 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" + # ---- #209: interactive edits on layered documents (20 MP, styled layers) --------------------- [[scenario]] @@ -359,9 +370,9 @@ issue = 211 bench = "perf_scenarios" row = "Content-Aware Scale 24 MP to 90 % width" budget_ms = 3000 -enforce = false # 954 s in the 2026-10-05 baseline, which predates the seam-search proxies: the -# carving core is 2.6 s at 24 MP RGBA (docs/seam-carving-performance.md). Flip once a -# budgeted-class run shows the whole row under 3 s. +enforce = false # 954 s in the 2026-10-05 baseline, which predates the seam-search proxies. 2026-10-13: the +# row measured 3.3 s -> 1.6 s on a 4-core VM (CPU) with the kept seam search (docs/seam-carving-performance.md). +# Flip once a budgeted-class run shows the whole row under 3 s. [[scenario]] id = "P29" @@ -612,3 +623,11 @@ bench = "bench_filters2" row = "Reduce Noise" budget_ms = 1000 enforce = false # 2.8 s measured 2026-10-22 + +[[scenario]] +id = "P68" +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" diff --git a/scorecard/bugs.toml b/scorecard/bugs.toml index 6b7e9a3ee2..f997437ae6 100644 --- a/scorecard/bugs.toml +++ b/scorecard/bugs.toml @@ -67,7 +67,7 @@ id = "BUG-204-3" target = "RAW prefs and ask_before_saving_layered_tiff read, or hidden" status = "done" issue = 204 -note = "ask_before_saving_layered_tiff drives the TIFF Options prompt (ui-egui tiff_options_ui.rs); raw_defaults.open_in_camera_raw drives the Camera Raw dialog (camera_raw_ui.rs); the other raw_defaults fields are in prefs::HIDDEN_UNTIL_IMPLEMENTED" +note = "ask_before_saving_layered_tiff drives the TIFF Options prompt (ui-egui tiff_options_ui.rs); raw_defaults.open_in_camera_raw drives the Camera Raw dialog (camera_raw_ui.rs); the other raw_defaults fields drive the raw open and Camera Raw (#5128)" [[item]] id = "BUG-204-4" diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index acdb75326a..0eeb4c1c54 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -22,9 +22,9 @@ note = "smart_map.rs: 49 filters plus Hue/Saturation, Perspective Warp, Filter G [[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" @@ -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" @@ -78,9 +78,9 @@ note = "io pdf.rs: Save As / Save a Copy / CLI write one flattened RGB/Gray/CMYK [[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" @@ -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). PSD: spot planes stored and read 0 = solid as Photoshop does, identifiers in 1053; PhotoCraft 0.1.0–0.16.0 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" diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 2ec39ab4d9..9311cc1869 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -52,7 +52,7 @@ id = "TOOL-213-7" target = "Live stroke preview for every brush-based retouch tool" status = "partial" issue = 213 -note = "Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Color Replacement, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040), Pattern Stamp (aligned and Impressionist) and Background Eraser (#3754); the release commits the live stroke's pixels (P32) or, for the retouch tools, the same pixels the live stroke showed (tested equal at 8 and 16 bits). Spot Healing Brush shows the area it heals as a dark overlay and heals on release, as Photoshop does. Not yet live: Mixer Brush (in PR #3627)" +note = "Live (canvas.rs strokes_live): Brush, Pencil, Mixer Brush, Eraser, Color Replacement, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040), Pattern Stamp (aligned and Impressionist) and Background Eraser (#3754); the release commits the live stroke's pixels (P32) or, for the retouch tools, the same pixels the live stroke showed (tested equal at 8 and 16 bits). Spot Healing Brush shows the area it heals as a dark overlay and heals on release, as Photoshop does. Mixer Brush uses its incremental transaction and commits the displayed result (#3627)" [[item]] id = "TOOL-213-8" @@ -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); 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" @@ -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" @@ -312,3 +312,10 @@ target = "Color Replacement tool on the toolbar" status = "done" issue = 1045 note = "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" + +[[item]] +id = "TOOL-213-20" +target = "Art History Brush: Y-group, styles, Area, Tolerance, paints from a history state" +status = "partial" +issue = 213 +note = "paint.artHistoryBrush + Tool::ArtHistoryBrush in the Y flyout; 8 of 10 styles (no Tight Curl Long / Loose Curl Long); Area scales spacing, not coverage; rotated-patch look; no Mode/Opacity on the options bar, no pen pressure" diff --git a/scorecard/type.toml b/scorecard/type.toml index 6ef3a90f89..c119ac984d 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; a Type tool click on the work path or the active shape layer's path starts path type at the point clicked (type_tool.rs path_target, shared with area type, #6230; type.attachToPath at; path-type pointer over a path). Missing: vertical type on a path, dragging the start/flip, 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; a Type tool click on the work path or the active shape layer's path starts path type at the point clicked (type_tool.rs path_target, shared with area type, #6230; type.attachToPath at; path-type pointer over a path); PSD path text opens on its path from its Txt2 frame and saves back into the file's Txt2 object (#7962). Missing: vertical type on a path, dragging the start/flip, 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" diff --git a/scorecard/ui.toml b/scorecard/ui.toml index a0302c4d48..cb73dd0c57 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -66,7 +66,7 @@ id = "UI-217-9" target = "UI scale presets 100/115/125/150/200 % through zoom_factor, remembered" status = "done" issue = 217 -note = "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" +note = "prefs::UiScale offers Auto plus 75 to 300 percent (17 presets: 75 to 125 in 5 % steps since #3222, then 150 to 300; 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" [[item]] id = "UI-217-10" @@ -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" diff --git a/xtask/src/pinned.rs b/xtask/src/pinned.rs index baf798f1de..ddca8a6163 100644 --- a/xtask/src/pinned.rs +++ b/xtask/src/pinned.rs @@ -9,6 +9,7 @@ //! `--update-manifest` rewrites the manifest from the download (after moving a pin). //! Local mode copies from a working clone instead of downloading (see `fetch_local`). +use std::ffi::OsStr; use std::path::{Path, PathBuf}; use std::process::Command; @@ -117,7 +118,7 @@ impl PinnedCorpus { } if ft.is_dir() { self.collect_inner(&p, base, out, visited, dirs); - } else if ft.is_file() && p.extension().and_then(|e| e.to_str()).is_some_and(|e| self.exts.iter().any(|x| e.eq_ignore_ascii_case(x))) { + } else if ft.is_file() && p.extension().and_then(OsStr::to_str).is_some_and(|e| self.exts.iter().any(|x| e.eq_ignore_ascii_case(x))) { out.push(p.strip_prefix(base).unwrap_or(&p).to_path_buf()); } } diff --git a/xtask/src/scorecard.rs b/xtask/src/scorecard.rs index e33b3eec48..ba7673a886 100644 --- a/xtask/src/scorecard.rs +++ b/xtask/src/scorecard.rs @@ -5,6 +5,7 @@ //! numbers against the budgets). A fresh `cargo xtask perf` result in `target/` is not used: //! publish it with `cargo xtask perf --update-baseline`. //! - `crates/io/tests/it/corpus.rs`: the corpus floors (`Source { .. }` constants). +//! - `xtask/*-corpus.sha256`: file counts for the pinned corpora. //! - `scorecard/*.toml`: per-area checklists (id, target, status, issue, note). //! - the prefs audit: `Preferences` fields that no code outside `prefs.rs` / `prefs_ui.rs` reads. //! - counted from the tree: never-crash attribute coverage, `docs/parity-checklist.md`'s live count. @@ -468,13 +469,28 @@ pub struct Inputs { pub panic_hunt: bool, } +/// A pinned corpus counts the files its sha256 manifest lists (one per non-blank line), not +/// the number its doc comment in `corpus.rs` gives; other corpora keep the doc comment's count. +fn pinned_file_counts(corpora: &mut [Corpus], root: &Path) -> Result<(), String> { + for corpus in corpora { + let Some(pinned) = crate::corpus_pins::ALL.iter().find(|p| corpus.dir == format!("corpus/{}", p.dest)) else { continue }; + let manifest = std::fs::read_to_string(root.join(pinned.manifest)).map_err(|e| format!("read {}: {e}", pinned.manifest))?; + corpus.files = Some( + u32::try_from(manifest.lines().filter(|line| !line.trim().is_empty()).count()) + .map_err(|_| format!("{}: corpus file count exceeds u32", pinned.manifest))?, + ); + } + Ok(()) +} + pub fn gather(root: &Path) -> Result { let budgets = perf::parse_budgets(&std::fs::read_to_string(root.join(perf::BUDGETS)).map_err(|e| format!("read {}: {e}", perf::BUDGETS))?)?; let baseline = match std::fs::read_to_string(root.join(perf::BASELINE)) { Ok(t) => Some(serde_json::from_str(&t).map_err(|e| format!("{}: {e}", perf::BASELINE))?), Err(_) => None, }; - let corpora = parse_corpus(&std::fs::read_to_string(root.join(CORPUS_RS)).map_err(|e| format!("read {CORPUS_RS}: {e}"))?); + let mut corpora = parse_corpus(&std::fs::read_to_string(root.join(CORPUS_RS)).map_err(|e| format!("read {CORPUS_RS}: {e}"))?); + pinned_file_counts(&mut corpora, root)?; Ok(Inputs { budgets, baseline, @@ -522,7 +538,7 @@ pub fn render(inp: &Inputs) -> String { let mut s = String::from( "# PhotoCraft scorecard\n\nGenerated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.json`, \ -`crates/io/tests/it/corpus.rs`, `scorecard/*.toml` and the source tree. Do not edit by hand: change the sources and \ +`crates/io/tests/it/corpus.rs`, `xtask/*-corpus.sha256`, `scorecard/*.toml` and the source tree. Do not edit by hand: change the sources and \ regenerate (CI runs `cargo xtask scorecard --check`). Checklist statuses are verified against the code; numbers \ here supersede estimates elsewhere.\n\n## Summary\n\n| Area | Done | Partial | Missing | Key numbers |\n|---|---:|---:|---:|---|\n", ); @@ -791,6 +807,58 @@ const PHOTOSHOP: Source = Source { label: "photoshop oracles", dir: "corpus/phot assert!(parse_corpus("const X: u32 = 1;").is_empty()); } + /// The manifest decides, even when the doc comment says otherwise (on the tree they agree, + /// so only a synthetic root tells the two apart). + #[test] + fn pinned_corpus_counts_come_from_the_manifest_not_the_doc_comment() { + let pin = &crate::corpus_pins::ALL[0]; + let root = std::env::temp_dir().join(format!("scorecard-pinned-counts-{}", std::process::id())); + let manifest = root.join(pin.manifest); + std::fs::create_dir_all(manifest.parent().unwrap()).unwrap(); + std::fs::write( + &manifest, + "aa a.psd + +bb b.psd +cc c.psd +", + ) + .unwrap(); + let mut corpora = vec![ + Corpus { label: "pinned".into(), dir: format!("corpus/{}", pin.dest), files: Some(999), ..Default::default() }, + Corpus { label: "other".into(), dir: "corpus/not-pinned".into(), files: Some(7), ..Default::default() }, + ]; + let r = pinned_file_counts(&mut corpora, &root); + let _ = std::fs::remove_dir_all(&root); + r.unwrap(); + assert_eq!(corpora[0].files, Some(3), "three manifest lines, not the doc comment's 999"); + assert_eq!(corpora[1].files, Some(7), "a corpus without a pin keeps its doc comment count"); + } + + #[test] + fn scorecard_uses_pinned_corpus_file_counts() { + let root = crate::root(); + let inp = gather(&root).unwrap(); + let rendered = render(&inp); + for corpus in &inp.corpora { + let pinned = crate::corpus_pins::ALL.iter().find(|p| corpus.dir == format!("corpus/{}", p.dest)).unwrap(); + let manifest = std::fs::read_to_string(root.join(pinned.manifest)).unwrap(); + let files = manifest.lines().filter(|line| !line.trim().is_empty()).count(); + assert!( + rendered.contains(&format!( + "{}: oracle ≥ {}/{files}, round trip ≥ {}/{files}", + corpus.label, + corpus.pass_floor.unwrap(), + corpus.roundtrip_floor.unwrap() + )), + "{} must use the {files} files in {} instead of a count from a doc comment", + corpus.label, + pinned.manifest + ); + assert!(rendered.contains(&format!("| {} | `{}` | {files} |", corpus.label, corpus.dir))); + } + } + #[test] fn checklists_validate() { let ok = "area = \"tools\"\ntitle = \"Tools\"\norder = 3\n[[item]]\nid = \"T-1\"\ntarget = \"Pencil\"\nstatus = \"partial\"\nissue = 213\n";