mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-10 20:32:31 +00:00
Merge branch 'main' into fix/4845-autohide-taskbar
This commit is contained in:
249 files changed
+14900
-1016
No files matched your search
@@ -193,6 +193,16 @@ jobs:
|
||||
cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}"
|
||||
shell: bash
|
||||
|
||||
# Every command against adversarial params must return Err, never panic or hang (AGENTS.md
|
||||
# Rule 9). It is `#[ignore]`d (slow), so Test skips it. Same packages, features and targets as
|
||||
# Test, so nothing rebuilds: `--test panic_hunt` would drop the other crates' dev-dependency
|
||||
# features and recompile the whole graph. The `ci` profile terminates it if it hangs.
|
||||
- name: Panic hunt
|
||||
if: runner.os == 'Linux'
|
||||
run: >-
|
||||
cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests
|
||||
--run-ignored only -E 'binary_id(photocraft-engine::panic_hunt)'
|
||||
|
||||
- name: Doctests
|
||||
if: matrix.part == 'all' || matrix.part == 'unit'
|
||||
run: cargo test --workspace --locked --features heif --doc
|
||||
|
||||
+4
-2
@@ -27,7 +27,7 @@ next to them.
|
||||
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
@@ -38,11 +38,12 @@ next to them.
|
||||
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush` plus a dashed selection arc around the bristles | Lucide Icons and Contributors; arc by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush`, scaled to 70%, inside a dashed upright rounded "C" selection | Lucide Icons and Contributors; selection by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
@@ -57,6 +58,7 @@ next to them.
|
||||
| `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | <https://ethanschoonover.com/solarized/> | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) |
|
||||
| `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, <https://invent.kde.org/plasma/breeze> (its colour schemes are LGPL-2.0-or-later) | Original work |
|
||||
| `crates/ui-egui/src/theme.rs` (Soft Dark and Soft Light themes) | Original colour values and radii for two low-contrast themes on PhotoCraft's Studio layout; no third-party palette, file or code is used | PhotoCraft contributors | Original | Original work |
|
||||
| `crates/algo/data/libfacedetection.bin` (face detection weights, read by `crates/algo/src/faces.rs`, whose network code is a port of the library's) | libfacedetection CNN (YuNet design) weights and inference code, weights converted to half precision | Shiqi Yu | [ShiqiYu/libfacedetection](https://github.com/ShiqiYu/libfacedetection) (`src/facedetectcnn-data.cpp`, `facedetectcnn.cpp`, `facedetectcnn-model.cpp`) | BSD-3-Clause, [`LICENSE-libfacedetection.txt`](crates/algo/data/LICENSE-libfacedetection.txt) |
|
||||
|
||||
### Optional build input: craft-fonts (not files in this repo)
|
||||
|
||||
|
||||
Generated
+43
-30
@@ -1222,6 +1222,11 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
@@ -4613,7 +4618,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"async-io",
|
||||
@@ -4646,6 +4651,7 @@ dependencies = [
|
||||
"rfd",
|
||||
"ron",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"winit",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
@@ -4657,7 +4663,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-affinity"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4667,7 +4673,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-algo"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4680,7 +4686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-automation"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"cap-std",
|
||||
@@ -4702,7 +4708,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cli"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
@@ -4716,7 +4722,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cms"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"moxcms",
|
||||
"rayon",
|
||||
@@ -4724,7 +4730,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-codecs"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"exr",
|
||||
"flate2",
|
||||
@@ -4745,7 +4751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-color"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"serde",
|
||||
@@ -4753,7 +4759,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-compose"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-color",
|
||||
@@ -4768,7 +4774,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-doc"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4779,7 +4785,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-engine"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"craft-print",
|
||||
@@ -4795,6 +4801,7 @@ dependencies = [
|
||||
"photocraft-format",
|
||||
"photocraft-geom",
|
||||
"photocraft-io",
|
||||
"photocraft-ipp",
|
||||
"photocraft-ops",
|
||||
"photocraft-paint",
|
||||
"photocraft-plugins",
|
||||
@@ -4814,7 +4821,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-format"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc32fast",
|
||||
@@ -4835,14 +4842,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-geom"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-gpu"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"half",
|
||||
"log",
|
||||
@@ -4859,16 +4866,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-heif"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"craft-docpath",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"photocraft-affinity",
|
||||
@@ -4893,16 +4901,20 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ipp"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ops"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"photocraft-doc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-paint"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4913,7 +4925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-plugins"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4928,7 +4940,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-psd"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"photocraft-psd",
|
||||
@@ -4939,7 +4951,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raster"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4949,7 +4961,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raw"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4964,7 +4976,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-tablet"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
@@ -4976,7 +4988,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"photocraft-color",
|
||||
@@ -4987,7 +4999,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-text"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"parley",
|
||||
@@ -5001,7 +5013,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ui-egui"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-print",
|
||||
@@ -5037,12 +5049,13 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"toml",
|
||||
"unicode-bidi",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-vector"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
@@ -5053,7 +5066,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5075,7 +5088,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web-boot"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"js-sys",
|
||||
@@ -8662,7 +8675,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+11
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -36,9 +36,11 @@ photocraft-engine = { path = "crates/engine" }
|
||||
photocraft-ui-egui = { path = "crates/ui-egui" }
|
||||
photocraft-testkit = { path = "crates/testkit" }
|
||||
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" }
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = "1"
|
||||
@@ -136,6 +138,14 @@ opt-level = "z"
|
||||
[profile.wasm-release.package.naga]
|
||||
opt-level = "z"
|
||||
|
||||
# The browser UI toolkit favors size to stay within the WebAssembly upload budget.
|
||||
[profile.wasm-release.package.egui]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.epaint]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.eframe]
|
||||
opt-level = "z"
|
||||
|
||||
# Pixel processing keeps its speed-oriented optimization level, with two exceptions.
|
||||
# Colour-management transforms are built once and cached (`transform::cached`), so "z" costs
|
||||
# little runtime. The browser app composites per frame on the GPU (`crates/gpu`, WebGPU/WebGL2),
|
||||
|
||||
@@ -16,6 +16,7 @@ Inter font (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/
|
||||
JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
|
||||
Lucide icons (assets/icons/*.svg, except slice-knife.svg) — ISC; icons derived from Feather also MIT. See assets/icons/LICENSE-lucide.txt.
|
||||
SCOWL English word list (assets/dict/en_US-scowl-50.txt.gz) — SCOWL license. See assets/dict/LICENSE-SCOWL.txt.
|
||||
libfacedetection face detection weights and network (crates/algo/data/libfacedetection.bin, crates/algo/src/faces/cnn.rs) — BSD-3-Clause, Copyright (c) 2015-2019, Shiqi Yu, all rights reserved. See crates/algo/data/LICENSE-libfacedetection.txt.
|
||||
|
||||
Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in
|
||||
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and
|
||||
|
||||
@@ -275,6 +275,8 @@ flatpak install --user photocraft-<version>-linux-x86_64.flatpak # or -linux-a
|
||||
flatpak run ai.storyteller.photocraft
|
||||
```
|
||||
|
||||
The Flatpak prints through the host's CUPS socket (File › Print talks IPP to it directly, no `lp` needed); without CUPS, or without a printer, File › Print opens the system print dialog through the print portal. See [printing](docs/printing.md).
|
||||
|
||||
The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it.
|
||||
|
||||
On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072).
|
||||
@@ -300,6 +302,19 @@ Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are
|
||||
> [!IMPORTANT]
|
||||
> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, 15 missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity-checklist.md`](docs/parity-checklist.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap](ROADMAP.md) and [parity assessment](docs/target-app-parity.md). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Community packages
|
||||
|
||||
These installers are maintained by community members, not by the PhotoCraft team.
|
||||
|
||||
On Gentoo, the [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay) packages the Linux release as `media-gfx/photocraft-bin`:
|
||||
|
||||
```sh
|
||||
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
|
||||
emaint sync -r snakebyte
|
||||
echo 'media-gfx/photocraft-bin ~amd64' >> /etc/portage/package.accept_keywords/photocraft
|
||||
emerge --ask media-gfx/photocraft-bin
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Developer, architecture, automation, format, and security documentation is maintained in the [PhotoCraft documentation book](book/).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "photocraft-cli"
|
||||
description = "Headless Photocraft: convert, inspect, run commands, batch, MCP server"
|
||||
description = "Headless PhotoCraft: convert, inspect, run commands, batch, MCP server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Headless Photocraft command line. `run` is the whole program, so it can be
|
||||
//! Headless PhotoCraft command line. `run` is the whole program, so it can be
|
||||
//! tested in-process as well as through the binary.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
@@ -12,7 +12,7 @@ use photocraft_io::ExportOptions;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub const USAGE: &str = "\
|
||||
photocraft-cli: headless Photocraft
|
||||
photocraft-cli: headless PhotoCraft
|
||||
|
||||
USAGE:
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "photocraft-web"
|
||||
description = "Photocraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||
description = "PhotoCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -34,7 +34,7 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Photocraft in the browser.
|
||||
//! PhotoCraft in the browser.
|
||||
//!
|
||||
//! Runs the same [`photocraft_ui_egui::PhotocraftApp`] as the desktop app through eframe's web
|
||||
//! runner (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release`
|
||||
@@ -6,10 +6,10 @@
|
||||
//!
|
||||
//! Differences from the desktop app:
|
||||
//! - no TCP control server (browsers can't listen on sockets);
|
||||
//! - File → Open uses the browser file picker; bytes arrive asynchronously through
|
||||
//! `Services::inbox`;
|
||||
//! - file pickers return browser file contents asynchronously through `Services::file_dialog`;
|
||||
//! this includes File → Open and commands such as Place Embedded;
|
||||
//! - saving/exporting triggers a browser download;
|
||||
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
|
||||
//! - dropped files are read asynchronously by `web::WebShell` and delivered through `Services::inbox`.
|
||||
//!
|
||||
//! URL query flags: `?cpu` forces the CPU canvas path (same as `PHOTOCRAFT_CPU_CANVAS=1`);
|
||||
//! `?webgl` forces the WebGL2 backend instead of WebGPU.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -79,11 +81,13 @@ pub fn start() {
|
||||
Box::new(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
|
||||
let inbox: Inbox = Arc::default();
|
||||
let place_inbox: Inbox = Arc::default();
|
||||
let served: Served = Arc::default();
|
||||
load_font_manifest(served.clone(), cc.egui_ctx.clone());
|
||||
let (session, warnings) = crate::preset_bridge::session(presets.clone());
|
||||
preset_warnings.extend(warnings);
|
||||
let mut app = PhotocraftApp::new(session, services(inbox.clone()));
|
||||
let drag = listen_drag(&pen_target);
|
||||
let mut app = PhotocraftApp::new(session, services(inbox.clone(), place_inbox.clone(), drag_pos_service(pen_target.clone(), drag)));
|
||||
if !preset_warnings.is_empty() {
|
||||
photocraft_ui_egui::notices::post(&mut app, i18n::t("Some brush presets could not be loaded"), preset_warnings, true, None);
|
||||
}
|
||||
@@ -97,7 +101,7 @@ pub fn start() {
|
||||
}
|
||||
let unsaved = Arc::new(AtomicBool::new(false));
|
||||
guard_unload(unsaved.clone());
|
||||
Ok(Box::new(WebShell { app, inbox, unsaved, served, database, presets }))
|
||||
Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -140,6 +144,38 @@ fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::sty
|
||||
}
|
||||
}
|
||||
|
||||
/// The pointer in a drag over the page, in CSS pixels relative to the viewport (`clientX`,
|
||||
/// `clientY`): the last `dragover` or `drop` event's.
|
||||
type DragPos = Rc<Cell<Option<(f32, f32)>>>;
|
||||
|
||||
/// Follows files dragged over the canvas. eframe's drop events carry no pointer position and the
|
||||
/// page gets no pointer events during a drag, so this is where a drop lands: on the document's
|
||||
/// canvas it places the files, elsewhere it opens them (#5749).
|
||||
fn listen_drag(target: &web_sys::HtmlCanvasElement) -> DragPos {
|
||||
use wasm_bindgen::closure::Closure;
|
||||
let pos = DragPos::default();
|
||||
for kind in ["dragover", "drop"] {
|
||||
let pos = pos.clone();
|
||||
// A DragEvent is a MouseEvent: only its client position is read.
|
||||
let cb = Closure::<dyn FnMut(web_sys::MouseEvent)>::new(move |e: web_sys::MouseEvent| pos.set(Some((e.client_x() as f32, e.client_y() as f32))));
|
||||
if target.add_event_listener_with_callback(kind, cb.as_ref().unchecked_ref()).is_ok() {
|
||||
cb.forget();
|
||||
}
|
||||
}
|
||||
pos
|
||||
}
|
||||
|
||||
/// `Services::cursor_pos` for the web: the last drag position in egui points, mapped as eframe
|
||||
/// maps pointer events (relative to the canvas, divided by the zoom factor).
|
||||
fn drag_pos_service(canvas: web_sys::HtmlCanvasElement, pos: DragPos) -> photocraft_ui_egui::CursorPosFn {
|
||||
Box::new(move |ctx: &egui::Context| {
|
||||
let (x, y) = pos.get()?;
|
||||
let rect = canvas.get_bounding_client_rect();
|
||||
let zoom = ctx.zoom_factor();
|
||||
(zoom.is_finite() && zoom > 0.0).then(|| egui::pos2((x - rect.left() as f32) / zoom, (y - rect.top() as f32) / zoom))
|
||||
})
|
||||
}
|
||||
|
||||
/// Closing or reloading the tab while a document has unsaved changes asks first, as closing the
|
||||
/// desktop window does: the browser shows its own "Leave site?" prompt (#1380). `unsaved` is
|
||||
/// refreshed every frame by [`WebShell`].
|
||||
@@ -219,10 +255,13 @@ async fn fetch_bytes(url: &str, integrity: Option<&str>) -> Result<Vec<u8>, Stri
|
||||
}
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously,
|
||||
/// so the app's own drop path can't handle them) and feed them through the inbox.
|
||||
/// so the app's own drop path can't handle them) and feed them through the inboxes.
|
||||
struct WebShell {
|
||||
app: PhotocraftApp,
|
||||
/// Dropped files to open as documents.
|
||||
inbox: Inbox,
|
||||
/// Dropped files to place on the active document's canvas.
|
||||
place_inbox: Inbox,
|
||||
/// Read by the `beforeunload` listener ([`guard_unload`]).
|
||||
unsaved: Arc<AtomicBool>,
|
||||
served: Served,
|
||||
@@ -285,17 +324,26 @@ impl WebShell {
|
||||
impl eframe::App for WebShell {
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
for f in dropped {
|
||||
let inbox = self.inbox.clone();
|
||||
if !dropped.is_empty() {
|
||||
let names: Vec<String> =
|
||||
dropped.iter().map(|f| f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into())).collect();
|
||||
// Decided now, where the drop landed: on the document's canvas the files are placed
|
||||
// as layers (as on the desktop), elsewhere opened.
|
||||
let places = self.app.dropped_on_canvas(ctx, &names);
|
||||
let files: Vec<_> = dropped.into_iter().zip(names).zip(places).collect();
|
||||
let (inbox, place_inbox) = (self.inbox.clone(), self.place_inbox.clone());
|
||||
let ctx = ctx.clone();
|
||||
// One after another, so they open or place in the order they were dropped.
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
for ((f, name), place) in files {
|
||||
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));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -333,7 +381,7 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
fn services(inbox: Inbox) -> Services {
|
||||
fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services {
|
||||
Services {
|
||||
screen_pick: screen_color_service(),
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
|
||||
@@ -384,6 +432,8 @@ fn services(inbox: Inbox) -> Services {
|
||||
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string())
|
||||
})),
|
||||
inbox: Some(inbox),
|
||||
place_inbox: Some(place_inbox),
|
||||
cursor_pos: Some(cursor_pos),
|
||||
// Preferences live in the browser's localStorage.
|
||||
load_prefs: Some(Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten())),
|
||||
save_prefs: Some(Box::new(|text: &str| local_storage().ok_or("no localStorage")?.set_item(PREFS_KEY, text).map_err(|e| format!("{e:?}")))),
|
||||
|
||||
@@ -4,7 +4,7 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
default-run = "photocraft"
|
||||
description = "Photocraft: an open-source, native image editor"
|
||||
description = "PhotoCraft: an open-source, native image editor"
|
||||
build = "build.rs"
|
||||
|
||||
[features]
|
||||
@@ -65,6 +65,8 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
|
||||
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
photocraft-cms = { workspace = true }
|
||||
# The print portal's PDF: a private (0600) temporary file, removed when the dialog is done.
|
||||
tempfile = "3"
|
||||
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] }
|
||||
# Clipboard images in types other than PNG on Wayland (`services.rs`, #2546): arboard asks for
|
||||
# image/png only. Already in Cargo.lock (arboard's wayland-data-control); pinned exactly.
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
//! folder can't be written (read-only medium, Program Files), the app warns and falls back.
|
||||
//! 3. The platform convention: macOS `~/Library/Application Support/Photocraft`, Windows
|
||||
//! `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`.
|
||||
//!
|
||||
//! One exception: on Windows the platform convention keeps crash-recovery autosaves out of the
|
||||
//! roaming profile ([`recovery_dir`]).
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -60,6 +63,379 @@ pub fn config_dir() -> Option<PathBuf> {
|
||||
current().dir.clone()
|
||||
}
|
||||
|
||||
/// The crash-recovery folder's name.
|
||||
const RECOVERY: &str = "Recovery";
|
||||
|
||||
/// Where crash-recovery autosaves live, with any left in the settings directory by an earlier
|
||||
/// version moved there first ([`adopt_legacy_recovery`]). Call it once, at startup.
|
||||
pub fn recovery_dir() -> Option<PathBuf> {
|
||||
let data = current();
|
||||
let dir = resolve_recovery(data, |k| std::env::var_os(k))?;
|
||||
Some(match data.dir.as_ref().map(|d| d.join(RECOVERY)) {
|
||||
Some(legacy) => adopt_legacy_recovery(&legacy, &dir),
|
||||
None => dir,
|
||||
})
|
||||
}
|
||||
|
||||
/// The recovery folder for `data` (#5648). Autosaves hold whole documents and are rewritten every
|
||||
/// few minutes, so on Windows the platform convention puts them in the machine-local
|
||||
/// `%LOCALAPPDATA%\Photocraft\Recovery`: the roaming `%APPDATA%` may be synced at sign-in and
|
||||
/// sign-out or capped by policy. They stay out of `%TEMP%`, which cleanup tools may empty before
|
||||
/// a crashed document is recovered. The override and portable folders keep everything in one
|
||||
/// place, and other platforms already keep these folders local.
|
||||
pub fn resolve_recovery(data: &DataDir, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
if cfg!(windows)
|
||||
&& data.mode == Mode::Platform
|
||||
&& let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty())
|
||||
{
|
||||
return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY));
|
||||
}
|
||||
data.dir.as_ref().map(|d| d.join(RECOVERY))
|
||||
}
|
||||
|
||||
/// The lock file, beside the recovery folder, that keeps two PhotoCraft processes starting at
|
||||
/// once (`--new-instance`, control launches) from moving the same autosaves.
|
||||
const MIGRATION_LOCK: &str = "recovery-migration.lock";
|
||||
|
||||
/// How long a launch waits for another one's move before going ahead without moving anything.
|
||||
const MIGRATION_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Suffix of a copy staged for publishing ([`stage_copy`]): disposable, the original still exists.
|
||||
const STAGED: &str = ".moving";
|
||||
|
||||
/// Suffix of an original set aside while it is copied to another volume ([`move_across`]): it
|
||||
/// may be the only copy, so it is put back, never thrown away, if the move doesn't finish.
|
||||
const HELD: &str = ".held";
|
||||
|
||||
/// Move autosaves from `legacy` (the recovery folder of versions before #5648) into `dir`, so a
|
||||
/// crash just before an update is still offered for recovery. Returns the folder this process
|
||||
/// uses for its recovery store. That is `legacy` only when `dir` doesn't exist and no process can
|
||||
/// be moving autosaves (the move failed, or its lock can't be made), so nothing deletes what is
|
||||
/// written there. A launch that finds another one moving uses `dir`, which the move never
|
||||
/// replaces or removes. See [`adopt_locked`] for how they move.
|
||||
pub fn adopt_legacy_recovery(legacy: &Path, dir: &Path) -> PathBuf {
|
||||
adopt_legacy_recovery_waiting(legacy, dir, MIGRATION_WAIT)
|
||||
}
|
||||
|
||||
/// [`adopt_legacy_recovery`], waiting up to `wait` for another process's move.
|
||||
fn adopt_legacy_recovery_waiting(legacy: &Path, dir: &Path, wait: std::time::Duration) -> PathBuf {
|
||||
let held_aside = || legacy.parent().is_some_and(|p| !side_entries(p, legacy, HELD).is_empty());
|
||||
if legacy == dir || (!legacy.is_dir() && !held_aside()) {
|
||||
return dir.to_path_buf();
|
||||
}
|
||||
// An empty old folder has nothing to move (`remove_dir` only removes an empty one).
|
||||
if !held_aside() && std::fs::remove_dir(legacy).is_ok() {
|
||||
return dir.to_path_buf();
|
||||
}
|
||||
let unmoved = || if dir.exists() || !legacy.is_dir() { dir.to_path_buf() } else { legacy.to_path_buf() };
|
||||
let Some(parent) = dir.parent() else { return unmoved() };
|
||||
let lock =
|
||||
std::fs::create_dir_all(parent).and_then(|()| std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(parent.join(MIGRATION_LOCK)));
|
||||
let Ok(lock) = lock else {
|
||||
// No lock file means no other process can be moving: the old folder is safe to use.
|
||||
log::warn!("couldn't open the recovery move lock in {}; autosaves stay where they are", parent.display());
|
||||
return unmoved();
|
||||
};
|
||||
let deadline = std::time::Instant::now() + wait;
|
||||
loop {
|
||||
match lock.try_lock() {
|
||||
Ok(()) => break,
|
||||
Err(std::fs::TryLockError::WouldBlock) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(50)),
|
||||
Err(std::fs::TryLockError::WouldBlock) => {
|
||||
// Another process is moving and may yet remove the old folder: never write there.
|
||||
log::info!("another PhotoCraft is moving crash-recovery autosaves; using {}", dir.display());
|
||||
return dir.to_path_buf();
|
||||
}
|
||||
Err(std::fs::TryLockError::Error(e)) => {
|
||||
// Locking fails for every process here alike, so none can be moving.
|
||||
log::warn!("couldn't lock {}: {e}; autosaves stay where they are", parent.join(MIGRATION_LOCK).display());
|
||||
return unmoved();
|
||||
}
|
||||
}
|
||||
}
|
||||
// The lock file stays (removing it would race a process about to lock it); the OS releases
|
||||
// the lock when `lock` is dropped or the process ends.
|
||||
let used = adopt_locked(legacy, dir);
|
||||
if used == legacy {
|
||||
// This process writes its autosaves to the old folder: it keeps the lock for as long as it
|
||||
// runs, so no other launch moves (and removes) the folder under it.
|
||||
KEPT_LOCKS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(lock);
|
||||
}
|
||||
used
|
||||
}
|
||||
|
||||
/// Migration locks of the old folders this process uses (see [`adopt_legacy_recovery`]), kept
|
||||
/// until it ends.
|
||||
static KEPT_LOCKS: std::sync::Mutex<Vec<std::fs::File>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// The move itself, under the migration lock. First, what a process that ended mid-move left is
|
||||
/// tidied: originals it set aside are put back, copies it staged are removed. An empty `legacy`
|
||||
/// is then just removed. Otherwise the whole folder moves if `dir` doesn't exist yet; when both
|
||||
/// exist, each autosave (`<key>.pcraft` bundle and `<key>.json` sidecar, see
|
||||
/// `photocraft_format::autosave`) moves as a whole, bundle first, unless `dir` already has either
|
||||
/// part, and one that stays is left in `legacy` untouched. Every move is a [`move_path`].
|
||||
fn adopt_locked(legacy: &Path, dir: &Path) -> PathBuf {
|
||||
if let Some(parent) = legacy.parent() {
|
||||
for held in side_entries(parent, legacy, HELD) {
|
||||
put_back(&held, legacy);
|
||||
}
|
||||
}
|
||||
// Autosave parts held aside inside the old folder by a per-autosave move.
|
||||
let held_parts: Vec<(PathBuf, String)> = std::fs::read_dir(legacy)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|e| e.file_name().to_str().and_then(|n| side_of(n, HELD)).map(|orig| (e.path(), orig.to_owned())))
|
||||
.collect();
|
||||
for (held, original) in held_parts {
|
||||
put_back(&held, &legacy.join(original));
|
||||
}
|
||||
for folder in [dir.parent(), Some(dir)].into_iter().flatten() {
|
||||
remove_stale_staging(folder);
|
||||
}
|
||||
// Another process may have finished the move while this one waited.
|
||||
if !legacy.is_dir() {
|
||||
return dir.to_path_buf();
|
||||
}
|
||||
if std::fs::remove_dir(legacy).is_ok() {
|
||||
return dir.to_path_buf();
|
||||
}
|
||||
if !dir.exists() {
|
||||
// The common case: the whole folder moves at once.
|
||||
return match move_path(legacy, dir) {
|
||||
Ok(()) => {
|
||||
log::info!("moved crash-recovery autosaves from {} to {}", legacy.display(), dir.display());
|
||||
dir.to_path_buf()
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("couldn't move crash-recovery autosaves from {} to {} ({e}); using the old folder", legacy.display(), dir.display());
|
||||
if legacy.is_dir() { legacy.to_path_buf() } else { dir.to_path_buf() }
|
||||
}
|
||||
};
|
||||
}
|
||||
move_entries(legacy, dir);
|
||||
// Only an emptied folder goes; anything that couldn't move stays for a later launch.
|
||||
let _ = std::fs::remove_dir(legacy);
|
||||
dir.to_path_buf()
|
||||
}
|
||||
|
||||
/// Move each autosave of `from` into `to` (both folders): bundle first, then sidecar, skipped if
|
||||
/// `to` has either part. Temp files of an interrupted write (`photocraft_format::atomic`) go
|
||||
/// along too, for the recovery store's startup sweep there.
|
||||
fn move_entries(from: &Path, to: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(from) else { return };
|
||||
let names: Vec<String> = entries.flatten().filter_map(|e| e.file_name().to_str().map(str::to_owned)).collect();
|
||||
let keys: std::collections::BTreeSet<&str> = names.iter().filter_map(|n| n.strip_suffix(".pcraft").or_else(|| n.strip_suffix(".json"))).collect();
|
||||
for key in keys {
|
||||
let parts = [format!("{key}.pcraft"), format!("{key}.json")];
|
||||
if parts.iter().any(|p| std::fs::symlink_metadata(to.join(p)).is_ok()) {
|
||||
continue;
|
||||
}
|
||||
// A bundle that moves without its sidecar is still listed (`list_recovery` reads a
|
||||
// committed bundle's manifest); a sidecar must never move without its bundle.
|
||||
for part in &parts {
|
||||
let src = from.join(part);
|
||||
if std::fs::symlink_metadata(&src).is_ok()
|
||||
&& let Err(e) = move_path(&src, &to.join(part))
|
||||
{
|
||||
log::warn!("couldn't move {} to {}: {e}", src.display(), to.display());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for name in names.iter().filter(|n| n.ends_with(".pcsave.tmp")) {
|
||||
let _ = move_path(&from.join(name), &to.join(name));
|
||||
}
|
||||
}
|
||||
|
||||
/// How deep [`copy_tree`] follows folders: an autosave bundle is a few levels deep.
|
||||
const MAX_COPY_DEPTH: u32 = 16;
|
||||
|
||||
fn already_exists(path: &Path) -> std::io::Error {
|
||||
std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", path.display()))
|
||||
}
|
||||
|
||||
/// A path beside `of`, named `.<name>.<pid>-<n><suffix>`, that no other call uses.
|
||||
fn side_path(of: &Path, suffix: &str) -> std::io::Result<PathBuf> {
|
||||
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let parent = of.parent().ok_or_else(|| std::io::Error::other(format!("{} has no parent folder", of.display())))?;
|
||||
let name = of.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
Ok(parent.join(format!(".{name}.{}-{}{suffix}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))))
|
||||
}
|
||||
|
||||
/// The name `side` was made from, if it is a [`side_path`] name with `suffix`.
|
||||
fn side_of<'a>(side: &'a str, suffix: &str) -> Option<&'a str> {
|
||||
let rest = side.strip_prefix('.')?.strip_suffix(suffix)?;
|
||||
let (name, ids) = rest.rsplit_once('.')?;
|
||||
let (pid, n) = ids.split_once('-')?;
|
||||
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
|
||||
(!name.is_empty() && digits(pid) && digits(n)).then_some(name)
|
||||
}
|
||||
|
||||
/// The side paths with `suffix` in `folder` made from `of`.
|
||||
fn side_entries(folder: &Path, of: &Path, suffix: &str) -> Vec<PathBuf> {
|
||||
let Some(name) = of.file_name().and_then(|n| n.to_str()) else { return Vec::new() };
|
||||
std::fs::read_dir(folder)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.file_name().to_str().and_then(|n| side_of(n, suffix)) == Some(name))
|
||||
.map(|e| e.path())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Move the file or folder `from` to `to` without ever replacing anything at `to`. On one volume
|
||||
/// a folder is renamed (Windows refuses an existing target; elsewhere only an empty folder could
|
||||
/// be replaced) and a file is hard-linked, which fails on an existing target, and then unlinked
|
||||
/// at `from`. Otherwise (another volume, e.g. a roaming profile redirected to a network share
|
||||
/// while `%LOCALAPPDATA%` stays local) it is copied ([`move_across`]).
|
||||
fn move_path(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
if std::fs::symlink_metadata(to).is_ok() {
|
||||
return Err(already_exists(to));
|
||||
}
|
||||
let meta = std::fs::symlink_metadata(from)?;
|
||||
let moved = if meta.is_dir() { std::fs::rename(from, to) } else { std::fs::hard_link(from, to).and_then(|()| std::fs::remove_file(from)) };
|
||||
match moved {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if matches!(e.kind(), std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::DirectoryNotEmpty) => Err(e),
|
||||
Err(_) => move_across(from, to),
|
||||
}
|
||||
}
|
||||
|
||||
/// Move `from` to `to` by copying. `from` is first renamed aside on its own volume
|
||||
/// (`.<name>.<pid>-<n>.held`, [`hold`]), so a process still writing to the old path (another
|
||||
/// launch that couldn't take the lock, an older PhotoCraft) starts a new `from` there instead of
|
||||
/// adding to what is copied, and nothing it writes is removed. The held original is copied to a
|
||||
/// staged path ([`stage_copy`]), published without replacing anything ([`publish`]), and only
|
||||
/// then removed. If the copy or publish fails, the held original is put back ([`put_back`]).
|
||||
fn move_across(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
let held = hold(from)?;
|
||||
match stage_copy(&held, to).and_then(|staged| publish(&staged, to)) {
|
||||
Ok(()) => {
|
||||
let removed = if std::fs::symlink_metadata(&held)?.is_dir() { std::fs::remove_dir_all(&held) } else { std::fs::remove_file(&held) };
|
||||
if let Err(e) = removed {
|
||||
log::warn!("copied {} to {} but couldn't remove {}: {e}", from.display(), to.display(), held.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
put_back(&held, from);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename `from` aside on its own volume (see [`move_across`]); returns where it is now.
|
||||
fn hold(from: &Path) -> std::io::Result<PathBuf> {
|
||||
let held = side_path(from, HELD)?;
|
||||
std::fs::rename(from, &held)?;
|
||||
Ok(held)
|
||||
}
|
||||
|
||||
/// Return the held original `held` to `original`. If `original` has been made again meanwhile (a
|
||||
/// writer that kept using the old path), the held folder's entries move into it without replacing
|
||||
/// anything ([`move_entries`]); whatever can't move stays held, logged, and is tried again under
|
||||
/// the next move's lock ([`adopt_locked`] puts back what a process that ended mid-move held).
|
||||
/// Nothing held is ever removed with data in it.
|
||||
fn put_back(held: &Path, original: &Path) {
|
||||
if std::fs::symlink_metadata(original).is_err() && std::fs::rename(held, original).is_ok() {
|
||||
return;
|
||||
}
|
||||
if held.is_dir() && original.is_dir() {
|
||||
move_entries(held, original);
|
||||
if std::fs::remove_dir(held).is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
log::warn!("{} is kept aside: {} exists", held.display(), original.display());
|
||||
}
|
||||
|
||||
/// Copy `from` to a staging path beside `to` that this call creates exclusively, so nothing
|
||||
/// another process made is ever written to or cleaned up. On failure the staged copy is removed.
|
||||
fn stage_copy(from: &Path, to: &Path) -> std::io::Result<PathBuf> {
|
||||
let staged = side_path(to, STAGED)?;
|
||||
let is_dir = std::fs::symlink_metadata(from)?.is_dir();
|
||||
let copied = if is_dir { copy_tree(from, &staged, 0) } else { copy_file(from, &staged) };
|
||||
if let Err(e) = copied {
|
||||
// `copy_tree` and `copy_file` create `staged` exclusively, and everything under a
|
||||
// fresh folder is new: `AlreadyExists` means `staged` was someone else's to keep.
|
||||
if e.kind() != std::io::ErrorKind::AlreadyExists {
|
||||
let _ = if is_dir { std::fs::remove_dir_all(&staged) } else { std::fs::remove_file(&staged) };
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
Ok(staged)
|
||||
}
|
||||
|
||||
/// Remove copies a process left staged in `folder` when it ended mid-move: disposable, since the
|
||||
/// original is still in place or held aside. Called only under the migration lock, which every
|
||||
/// staging happens under. Links are never followed.
|
||||
fn remove_stale_staging(folder: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(folder) else { return };
|
||||
for entry in entries.flatten() {
|
||||
if !entry.file_name().to_str().is_some_and(|n| side_of(n, STAGED).is_some()) {
|
||||
continue;
|
||||
}
|
||||
let removed = match entry.file_type() {
|
||||
Ok(t) if t.is_dir() => std::fs::remove_dir_all(entry.path()),
|
||||
Ok(t) if t.is_file() => std::fs::remove_file(entry.path()),
|
||||
_ => continue,
|
||||
};
|
||||
if let Err(e) = removed {
|
||||
log::warn!("couldn't remove the stale staged copy {}: {e}", entry.path().display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Give the staged copy its final name `to` without replacing anything there (see [`move_path`]
|
||||
/// for the folder and file cases). On failure the staged copy is removed and `to` is untouched.
|
||||
fn publish(staged: &Path, to: &Path) -> std::io::Result<()> {
|
||||
let is_dir = std::fs::symlink_metadata(staged)?.is_dir();
|
||||
let published = if std::fs::symlink_metadata(to).is_ok() {
|
||||
Err(already_exists(to))
|
||||
} else if is_dir {
|
||||
std::fs::rename(staged, to)
|
||||
} else {
|
||||
std::fs::hard_link(staged, to).and_then(|()| std::fs::remove_file(staged))
|
||||
};
|
||||
if published.is_err() {
|
||||
let _ = if is_dir { std::fs::remove_dir_all(staged) } else { std::fs::remove_file(staged) };
|
||||
}
|
||||
published
|
||||
}
|
||||
|
||||
/// Copy the folder `from` to the new folder `to` (created here; an existing `to` is an error):
|
||||
/// plain files and folders only (a link or other special file is refused), at most
|
||||
/// [`MAX_COPY_DEPTH`] levels.
|
||||
fn copy_tree(from: &Path, to: &Path, depth: u32) -> std::io::Result<()> {
|
||||
if depth > MAX_COPY_DEPTH {
|
||||
return Err(std::io::Error::other(format!("{} is nested too deep", from.display())));
|
||||
}
|
||||
std::fs::create_dir(to)?;
|
||||
for entry in std::fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
let target = to.join(entry.file_name());
|
||||
if entry.file_type()?.is_dir() {
|
||||
copy_tree(&entry.path(), &target, depth.saturating_add(1))?;
|
||||
} else {
|
||||
copy_file(&entry.path(), &target)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy one plain file (a link or other special file is refused) to the new file `to` (an
|
||||
/// existing `to` is an error), flushed to disk.
|
||||
fn copy_file(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
if !std::fs::symlink_metadata(from)?.file_type().is_file() {
|
||||
return Err(std::io::Error::other(format!("{} is not a plain file", from.display())));
|
||||
}
|
||||
let mut src = std::fs::File::open(from)?;
|
||||
let mut dst = std::fs::OpenOptions::new().write(true).create_new(true).open(to)?;
|
||||
std::io::copy(&mut src, &mut dst)?;
|
||||
dst.sync_all()
|
||||
}
|
||||
|
||||
/// Resolve the data directory from the environment (`env`) and the executable's folder.
|
||||
pub fn resolve(env: impl Fn(&str) -> Option<OsString>, exe_dir: Option<&Path>) -> DataDir {
|
||||
if let Some(d) = env("PHOTOCRAFT_CONFIG_DIR").filter(|d| !d.is_empty()) {
|
||||
@@ -223,6 +599,300 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// #5648: on Windows, autosaves go to the machine-local `%LOCALAPPDATA%`, not the roaming
|
||||
/// profile; an override or portable folder keeps them inside itself.
|
||||
#[test]
|
||||
fn autosaves_stay_out_of_the_roaming_profile() {
|
||||
let root = temp("recovery-local");
|
||||
let settings = root.join("appdata").join("Photocraft");
|
||||
let local = root.join("local");
|
||||
let env = env_of(&[("LOCALAPPDATA", &local)]);
|
||||
let platform = DataDir { dir: Some(settings.clone()), mode: Mode::Platform, warning: None };
|
||||
let expected = if cfg!(windows) { local.join("Photocraft").join("Recovery") } else { settings.join("Recovery") };
|
||||
assert_eq!(resolve_recovery(&platform, &env), Some(expected));
|
||||
for mode in [Mode::Override, Mode::Portable] {
|
||||
let d = DataDir { dir: Some(settings.clone()), mode, warning: None };
|
||||
assert_eq!(resolve_recovery(&d, &env), Some(settings.join("Recovery")), "{mode:?}");
|
||||
}
|
||||
// No (or an empty) LOCALAPPDATA: beside the settings, as before.
|
||||
let empty = PathBuf::new();
|
||||
assert_eq!(resolve_recovery(&platform, env_of(&[("LOCALAPPDATA", &empty)])), Some(settings.join("Recovery")));
|
||||
assert_eq!(resolve_recovery(&platform, |_| None), Some(settings.join("Recovery")));
|
||||
let nowhere = DataDir { dir: None, mode: Mode::Platform, warning: None };
|
||||
assert_eq!(resolve_recovery(&nowhere, |_| None), None);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// An autosave bundle and its sidecar, as `format::Autosaver` writes them.
|
||||
fn autosave(dir: &Path, key: &str) {
|
||||
std::fs::create_dir_all(dir.join(format!("{key}.pcraft"))).unwrap();
|
||||
std::fs::write(dir.join(format!("{key}.pcraft")).join("manifest.json"), key).unwrap();
|
||||
std::fs::write(dir.join(format!("{key}.json")), key).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosaves_from_the_old_folder_move_to_the_new_one() {
|
||||
let root = temp("recovery-move");
|
||||
let legacy = root.join("roaming").join("Recovery");
|
||||
let dir = root.join("local").join("Photocraft").join("Recovery");
|
||||
autosave(&legacy, "a");
|
||||
assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir);
|
||||
assert!(!legacy.exists(), "the old folder is gone");
|
||||
assert_eq!(std::fs::read_to_string(dir.join("a.pcraft").join("manifest.json")).unwrap(), "a");
|
||||
assert!(dir.join("a.json").is_file());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosaves_merge_into_an_existing_folder_without_overwriting() {
|
||||
let root = temp("recovery-merge");
|
||||
let legacy = root.join("roaming").join("Recovery");
|
||||
let dir = root.join("local").join("Recovery");
|
||||
autosave(&legacy, "old");
|
||||
autosave(&legacy, "both");
|
||||
// A temp file of an interrupted write moves along, for the recovery store's sweep.
|
||||
let temp_file = ".old.json.4294967294-0.pcsave.tmp";
|
||||
std::fs::write(legacy.join(temp_file), b"partial").unwrap();
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("both.json"), "newer").unwrap();
|
||||
assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir);
|
||||
assert!(dir.join(temp_file).is_file() && !legacy.join(temp_file).exists());
|
||||
assert!(dir.join("old.pcraft").is_dir() && dir.join("old.json").is_file(), "a whole autosave moves");
|
||||
assert_eq!(std::fs::read_to_string(dir.join("both.json")).unwrap(), "newer", "never overwritten");
|
||||
assert!(!dir.join("both.pcraft").exists(), "a clashing autosave isn't split");
|
||||
assert!(legacy.join("both.pcraft").is_dir() && legacy.join("both.json").is_file(), "it stays behind whole");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosaves_that_cannot_move_keep_being_used_where_they_are() {
|
||||
let root = temp("recovery-stuck");
|
||||
let legacy = root.join("roaming").join("Recovery");
|
||||
autosave(&legacy, "a");
|
||||
// A file where the new folder's parent should be: nothing can be created there.
|
||||
std::fs::write(root.join("blocked"), b"in the way").unwrap();
|
||||
let dir = root.join("blocked").join("Recovery");
|
||||
assert_eq!(adopt_legacy_recovery(&legacy, &dir), legacy);
|
||||
assert!(legacy.join("a.json").is_file(), "nothing lost");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_old_folder_is_removed_even_where_it_cannot_move() {
|
||||
let root = temp("recovery-empty");
|
||||
let legacy = root.join("roaming").join("Recovery");
|
||||
std::fs::create_dir_all(&legacy).unwrap();
|
||||
std::fs::write(root.join("blocked"), b"in the way").unwrap();
|
||||
let dir = root.join("blocked").join("Recovery");
|
||||
assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir, "nothing to keep listed");
|
||||
assert!(!legacy.exists(), "the empty old folder is gone");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Where a rename can't cross volumes (a roaming profile on a network share), an autosave
|
||||
/// is copied whole, nested folders included, and only then removed.
|
||||
#[test]
|
||||
fn a_move_across_volumes_copies_then_removes() {
|
||||
let root = temp("recovery-copy");
|
||||
let legacy = root.join("roaming").join("Recovery");
|
||||
autosave(&legacy, "a");
|
||||
std::fs::create_dir_all(legacy.join("a.pcraft").join("tiles")).unwrap();
|
||||
std::fs::write(legacy.join("a.pcraft").join("tiles").join("t0.zst"), b"tile").unwrap();
|
||||
let dir = root.join("local").join("Recovery");
|
||||
std::fs::create_dir_all(root.join("local")).unwrap();
|
||||
move_across(&legacy, &dir).unwrap();
|
||||
assert!(!legacy.exists(), "the original is removed after the copy");
|
||||
assert_eq!(side_leftovers(&root.join("roaming")), 0, "nothing is left held aside");
|
||||
assert_eq!(std::fs::read_to_string(dir.join("a.pcraft").join("manifest.json")).unwrap(), "a");
|
||||
assert_eq!(std::fs::read(dir.join("a.pcraft").join("tiles").join("t0.zst")).unwrap(), b"tile");
|
||||
assert_eq!(std::fs::read_to_string(dir.join("a.json")).unwrap(), "a");
|
||||
// One file on its own, as the per-autosave moves do.
|
||||
move_across(&dir.join("a.json"), &root.join("a.json")).unwrap();
|
||||
assert!(!dir.join("a.json").exists() && root.join("a.json").is_file());
|
||||
assert_eq!(side_leftovers(&dir) + side_leftovers(&root), 0);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_copy_puts_the_original_back_and_leaves_no_partial_copy() {
|
||||
let root = temp("recovery-copy-fail");
|
||||
let legacy = root.join("Recovery");
|
||||
autosave(&legacy, "a");
|
||||
// Deeper than any bundle: the copy gives up part way.
|
||||
let mut deep = legacy.join("a.pcraft");
|
||||
for _ in 0..=MAX_COPY_DEPTH {
|
||||
deep = deep.join("d");
|
||||
}
|
||||
std::fs::create_dir_all(&deep).unwrap();
|
||||
let dir = root.join("Local");
|
||||
assert!(move_across(&legacy, &dir).is_err());
|
||||
assert!(!dir.exists(), "nothing is published");
|
||||
assert_eq!(side_leftovers(&root), 0, "no staged copy, nothing left held aside");
|
||||
assert!(legacy.join("a.json").is_file() && deep.is_dir(), "the original is back in place");
|
||||
// A target that already exists is never copied into or removed.
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("keep"), b"mine").unwrap();
|
||||
assert!(move_across(&legacy.join("a.json"), &dir).is_err());
|
||||
assert_eq!(std::fs::read(dir.join("keep")).unwrap(), b"mine");
|
||||
assert!(legacy.join("a.json").is_file());
|
||||
assert_eq!(side_leftovers(&root) + side_leftovers(&legacy), 0);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Staged (`.moving`) and held (`.held`) side paths left in `dir`.
|
||||
fn side_leftovers(dir: &Path) -> usize {
|
||||
std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.filter(|e| e.file_name().to_str().is_some_and(|n| side_of(n, STAGED).is_some() || side_of(n, HELD).is_some()))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// The target appears after the copy is staged (another process got there first): publishing
|
||||
/// fails, that process's target keeps its contents, the staged copy is removed and the
|
||||
/// original is left as it was. Folder and file alike.
|
||||
#[test]
|
||||
fn a_target_that_appears_while_copying_is_never_replaced_or_removed() {
|
||||
let root = temp("recovery-race");
|
||||
let legacy = root.join("roaming").join("Recovery");
|
||||
autosave(&legacy, "a");
|
||||
let local = root.join("local");
|
||||
std::fs::create_dir_all(&local).unwrap();
|
||||
// A whole folder.
|
||||
let dir = local.join("Recovery");
|
||||
let staged = stage_copy(&legacy, &dir).unwrap();
|
||||
autosave(&dir, "theirs");
|
||||
let e = publish(&staged, &dir).unwrap_err();
|
||||
assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(std::fs::read_to_string(dir.join("theirs.json")).unwrap(), "theirs", "their folder is intact");
|
||||
assert!(!dir.join("a.json").exists(), "nothing was copied into it");
|
||||
assert!(!staged.exists() && side_leftovers(&local) == 0, "only the staged copy is removed");
|
||||
assert!(legacy.join("a.json").is_file(), "the original is untouched");
|
||||
// One file.
|
||||
let to = dir.join("a.json");
|
||||
let staged = stage_copy(&legacy.join("a.json"), &to).unwrap();
|
||||
std::fs::write(&to, "theirs").unwrap();
|
||||
assert_eq!(publish(&staged, &to).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(std::fs::read_to_string(&to).unwrap(), "theirs");
|
||||
assert!(!staged.exists() && legacy.join("a.json").is_file());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A process holds the lock and is copying the old folder (held aside, copy staged) when a
|
||||
/// second launch gives up waiting. The second launch must get the new folder, never the old
|
||||
/// one, and what it writes there survives the first one finishing: the publish refuses the
|
||||
/// now-existing folder and the original is put back, so both autosaves stay (#6412 review).
|
||||
#[test]
|
||||
fn a_launch_that_times_out_never_writes_where_a_move_removes() {
|
||||
let root = temp("recovery-timeout");
|
||||
let legacy = root.join("roaming").join("Recovery");
|
||||
autosave(&legacy, "old");
|
||||
let local = root.join("local");
|
||||
std::fs::create_dir_all(&local).unwrap();
|
||||
let dir = local.join("Recovery");
|
||||
// First process: takes the lock, sets the original aside and stages its copy, then pauses.
|
||||
let first = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(local.join(MIGRATION_LOCK)).unwrap();
|
||||
first.lock().unwrap();
|
||||
let held = hold(&legacy).unwrap();
|
||||
let staged = stage_copy(&held, &dir).unwrap();
|
||||
// Second launch: times out and writes a newer autosave where it was told to.
|
||||
let chosen = adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO);
|
||||
assert_eq!(chosen, dir, "a launch that couldn't get the lock never uses the old folder");
|
||||
autosave(&chosen, "new");
|
||||
// First process resumes: publish, and on failure put the original back (as `move_across`).
|
||||
assert_eq!(publish(&staged, &dir).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists);
|
||||
put_back(&held, &legacy);
|
||||
drop(first);
|
||||
assert_eq!(std::fs::read_to_string(dir.join("new.json")).unwrap(), "new", "the newer autosave survives");
|
||||
assert_eq!(std::fs::read_to_string(legacy.join("old.json")).unwrap(), "old", "the old one is back in place");
|
||||
assert_eq!(side_leftovers(&local) + side_leftovers(&root.join("roaming")), 0);
|
||||
// The next launch merges them.
|
||||
assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
|
||||
assert!(dir.join("old.json").is_file() && dir.join("new.json").is_file() && !legacy.exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A writer that still uses the old path while it is being copied (an older PhotoCraft)
|
||||
/// starts a new folder there; the move removes only the original it held aside.
|
||||
#[test]
|
||||
fn a_write_to_the_old_path_during_a_copy_is_not_removed() {
|
||||
let root = temp("recovery-old-writer");
|
||||
let legacy = root.join("roaming").join("Recovery");
|
||||
autosave(&legacy, "old");
|
||||
let local = root.join("local");
|
||||
std::fs::create_dir_all(&local).unwrap();
|
||||
let dir = local.join("Recovery");
|
||||
let held = hold(&legacy).unwrap();
|
||||
let staged = stage_copy(&held, &dir).unwrap();
|
||||
autosave(&legacy, "new");
|
||||
publish(&staged, &dir).unwrap();
|
||||
std::fs::remove_dir_all(&held).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(dir.join("old.json")).unwrap(), "old");
|
||||
assert_eq!(std::fs::read_to_string(legacy.join("new.json")).unwrap(), "new", "the new write is untouched");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// What a process killed mid-move left is tidied at the next move: a held original is put
|
||||
/// back (it may be the only copy) and moved, a staged copy is removed; other dot-files and
|
||||
/// look-alike names are not touched.
|
||||
#[test]
|
||||
fn a_killed_move_is_tidied_next_time() {
|
||||
let root = temp("recovery-stale");
|
||||
let roaming = root.join("roaming");
|
||||
let legacy = roaming.join("Recovery");
|
||||
autosave(&legacy, "a");
|
||||
let held = hold(&legacy).unwrap();
|
||||
assert!(!legacy.exists() && held.is_dir());
|
||||
let local = root.join("local");
|
||||
let dir = local.join("Recovery");
|
||||
let stale = local.join(".Recovery.4294967294-3.moving");
|
||||
autosave(&stale, "half");
|
||||
let kept = [local.join(".Recovery.moving"), local.join(".keep.1-x.moving"), local.join("notes.txt")];
|
||||
for k in &kept {
|
||||
std::fs::write(k, b"x").unwrap();
|
||||
}
|
||||
assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
|
||||
assert_eq!(std::fs::read_to_string(dir.join("a.json")).unwrap(), "a", "the held original was put back and moved");
|
||||
assert!(!stale.exists(), "the stale staged copy is gone");
|
||||
assert_eq!(side_leftovers(&roaming), 0);
|
||||
assert!(kept.iter().all(|k| k.is_file()), "only side names are touched");
|
||||
assert_eq!(side_of(".a.json.12-0.moving", STAGED), Some("a.json"));
|
||||
assert!(side_of("a.json.12-0.moving", STAGED).is_none() && side_of(".a.json.-0.moving", STAGED).is_none());
|
||||
assert!(side_of(".a.json.12-0.held", STAGED).is_none() && side_of(".a.json.12-0.held", HELD) == Some("a.json"));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// While another process holds the move lock, a launch moves nothing and uses the new folder;
|
||||
/// once the lock is released, the move happens.
|
||||
#[test]
|
||||
fn a_move_waits_for_another_process_holding_the_lock() {
|
||||
let root = temp("recovery-lock");
|
||||
let legacy = root.join("roaming").join("Recovery");
|
||||
autosave(&legacy, "a");
|
||||
let dir = root.join("local").join("Recovery");
|
||||
std::fs::create_dir_all(root.join("local")).unwrap();
|
||||
let held = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(root.join("local").join(MIGRATION_LOCK)).unwrap();
|
||||
held.lock().unwrap();
|
||||
assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
|
||||
assert!(legacy.join("a.json").is_file() && !dir.exists(), "nothing moved under another's lock");
|
||||
drop(held);
|
||||
assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
|
||||
assert!(dir.join("a.json").is_file() && !legacy.exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_old_folder_or_the_same_folder_changes_nothing() {
|
||||
let root = temp("recovery-noop");
|
||||
let dir = root.join("Recovery");
|
||||
assert_eq!(adopt_legacy_recovery(&root.join("missing"), &dir), dir);
|
||||
assert!(!dir.exists(), "nothing is created up front");
|
||||
autosave(&dir, "a");
|
||||
assert_eq!(adopt_legacy_recovery(&dir, &dir), dir);
|
||||
assert!(dir.join("a.json").is_file());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_exe_dir_or_env_never_panics() {
|
||||
let d = resolve(|_| None, None);
|
||||
|
||||
@@ -70,25 +70,61 @@ pub fn start() {
|
||||
|
||||
/// Whether the portal's `FileChooser` interface is on the session bus: `Some(false)` only when the
|
||||
/// bus or the interface is definitely absent, `None` for anything inconclusive (a slow reply).
|
||||
///
|
||||
/// Asks the portal object to introspect itself rather than reading the interface's `version`
|
||||
/// property: xdg-desktop-portal exports `FileChooser` only when a backend offers it, and without
|
||||
/// one a property read fails with `InvalidArgs` ("No such interface"), an error the old check
|
||||
/// didn't count as absent (#5739). The interface list answers the question directly.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn file_chooser_portal() -> Option<bool> {
|
||||
let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build()) else {
|
||||
return Some(false);
|
||||
};
|
||||
let proxy = zbus::blocking::proxy::Builder::<zbus::blocking::Proxy<'_>>::new(&conn)
|
||||
.destination("org.freedesktop.portal.Desktop")
|
||||
.and_then(|b| b.path("/org/freedesktop/portal/desktop"))
|
||||
.and_then(|b| b.interface("org.freedesktop.portal.FileChooser"))
|
||||
.and_then(|b| b.cache_properties(zbus::proxy::CacheProperties::No).build())
|
||||
.ok()?;
|
||||
match proxy.get_property::<u32>("version") {
|
||||
Ok(_) => Some(true),
|
||||
Err(zbus::Error::MethodError(name, _, _)) => {
|
||||
let name = name.as_str();
|
||||
let absent = ["ServiceUnknown", "UnknownObject", "UnknownInterface", "UnknownProperty"].iter().any(|n| name.ends_with(n));
|
||||
absent.then_some(false)
|
||||
}
|
||||
Err(_) => None,
|
||||
let reply = conn.call_method(
|
||||
Some("org.freedesktop.portal.Desktop"),
|
||||
"/org/freedesktop/portal/desktop",
|
||||
Some("org.freedesktop.DBus.Introspectable"),
|
||||
"Introspect",
|
||||
&(),
|
||||
);
|
||||
match reply {
|
||||
Ok(message) => match message.body().deserialize::<String>() {
|
||||
Ok(xml) => Some(interface_listed(&xml)),
|
||||
Err(_) => None,
|
||||
},
|
||||
Err(e) => classify_error(&e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether introspection XML lists the portal's `FileChooser` interface (not a longer name such as
|
||||
/// `FileChooser2`).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn interface_listed(xml: &str) -> bool {
|
||||
["\"", "'"].iter().any(|quote| xml.contains(&format!("<interface name={quote}org.freedesktop.portal.FileChooser{quote}")))
|
||||
}
|
||||
|
||||
/// What a failed call says about the interface: `Some(false)` when the D-Bus error means the
|
||||
/// service, object or interface isn't there, `None` for anything else (a timeout, a dropped
|
||||
/// connection, an unknown error), which counts as present.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn classify_error(e: &zbus::Error) -> Option<bool> {
|
||||
use zbus::fdo::Error as Fdo;
|
||||
// Names a bus daemon or portal answers with when the thing asked about is absent.
|
||||
const ABSENT: [&str; 7] = ["ServiceUnknown", "NameHasNoOwner", "UnknownObject", "UnknownInterface", "UnknownMethod", "UnknownProperty", "InvalidArgs"];
|
||||
match e {
|
||||
zbus::Error::FDO(fdo) => matches!(
|
||||
**fdo,
|
||||
Fdo::ServiceUnknown(_)
|
||||
| Fdo::NameHasNoOwner(_)
|
||||
| Fdo::UnknownObject(_)
|
||||
| Fdo::UnknownInterface(_)
|
||||
| Fdo::UnknownMethod(_)
|
||||
| Fdo::UnknownProperty(_)
|
||||
| Fdo::InvalidArgs(_)
|
||||
)
|
||||
.then_some(false),
|
||||
zbus::Error::MethodError(name, _, _) => ABSENT.iter().any(|n| name.as_str().ends_with(n)).then_some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,4 +162,69 @@ mod tests {
|
||||
assert!(!on_path("kdialog", &path));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod portal {
|
||||
use super::super::*;
|
||||
|
||||
const WITH_FILE_CHOOSER: &str = r#"<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" "http://www.freedesktop.org/standards/dbus/introspect.dtd">
|
||||
<node>
|
||||
<interface name="org.freedesktop.DBus.Peer"/>
|
||||
<interface name="org.freedesktop.portal.Settings"><property name="version" type="u" access="read"/></interface>
|
||||
<interface name="org.freedesktop.portal.FileChooser">
|
||||
<method name="OpenFile"/>
|
||||
</interface>
|
||||
</node>"#;
|
||||
const WITHOUT_FILE_CHOOSER: &str = r#"<node>
|
||||
<interface name="org.freedesktop.DBus.Peer"/>
|
||||
<interface name="org.freedesktop.portal.Settings"/>
|
||||
</node>"#;
|
||||
|
||||
fn method_error(name: &'static str) -> zbus::Error {
|
||||
let call = zbus::message::Message::method_call("/a", "M").unwrap().build(&()).unwrap();
|
||||
let reply = zbus::message::Message::error(&call.header(), name).unwrap().build(&"detail").unwrap();
|
||||
zbus::Error::MethodError(name.try_into().unwrap(), Some("detail".into()), reply)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_interface_list_says_whether_a_file_chooser_is_offered() {
|
||||
assert!(interface_listed(WITH_FILE_CHOOSER));
|
||||
assert!(interface_listed("<interface name='org.freedesktop.portal.FileChooser'>"), "single quotes");
|
||||
assert!(!interface_listed(WITHOUT_FILE_CHOOSER), "portal without a backend");
|
||||
assert!(!interface_listed(r#"<interface name="org.freedesktop.portal.FileChooser2">"#), "longer name");
|
||||
assert!(!interface_listed(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dbus_errors_for_a_missing_interface_mean_absent() {
|
||||
// zbus's fdo proxies hand D-Bus errors back as `Error::FDO`, a plain method call as
|
||||
// `MethodError`; both forms count. The portal answers `InvalidArgs` ("No such interface")
|
||||
// when no backend offers the interface (#5739).
|
||||
for e in [
|
||||
zbus::fdo::Error::InvalidArgs("No such interface".into()),
|
||||
zbus::fdo::Error::UnknownInterface("x".into()),
|
||||
zbus::fdo::Error::ServiceUnknown("x".into()),
|
||||
zbus::fdo::Error::NameHasNoOwner("x".into()),
|
||||
zbus::fdo::Error::UnknownObject("x".into()),
|
||||
] {
|
||||
let desc = format!("{e:?}");
|
||||
assert_eq!(classify_error(&zbus::Error::FDO(Box::new(e))), Some(false), "{desc}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_error_names_still_count_as_absent() {
|
||||
for name in ["org.freedesktop.DBus.Error.ServiceUnknown", "org.freedesktop.DBus.Error.InvalidArgs", "org.freedesktop.DBus.Error.UnknownMethod"] {
|
||||
assert_eq!(classify_error(&method_error(name)), Some(false), "{name}");
|
||||
}
|
||||
assert_eq!(classify_error(&method_error("org.freedesktop.DBus.Error.AccessDenied")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inconclusive_errors_are_not_absent() {
|
||||
assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::NoReply("slow".into())))), None);
|
||||
assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::Failed("x".into())))), None);
|
||||
assert_eq!(classify_error(&zbus::Error::InputOutput(std::sync::Arc::new(std::io::Error::other("gone")))), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
-12
@@ -4,7 +4,9 @@
|
||||
//! lost device, the crash guard's panic report) vanished. A launch from a desktop menu has no
|
||||
//! terminal, so the file is what a bug report can attach: `<settings dir>/logs/photocraft.log`
|
||||
//! (see `app_dirs`). Each start moves the previous log to `photocraft.1.log` (and that one to
|
||||
//! `.2`), so the log of a run that crashed survives the next launch.
|
||||
//! `.2`), so the log of a run that crashed survives the next launch. A start while another
|
||||
//! instance is still running leaves that instance's log alone and writes `photocraft-<pid>.log`
|
||||
//! (see [`rotate`]).
|
||||
//!
|
||||
//! Levels: `info` for PhotoCraft's own crates, `warn` for everything else (wgpu and naga are
|
||||
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
|
||||
@@ -15,7 +17,7 @@
|
||||
//! written once the file is attached. Writing never panics: a file that can't be created or
|
||||
//! written leaves standard error as the only sink.
|
||||
|
||||
use std::fs::File;
|
||||
use std::fs::{File, OpenOptions, TryLockError};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
@@ -116,10 +118,76 @@ fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("photocraft.{n}.log")) }
|
||||
}
|
||||
|
||||
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
|
||||
/// and create a fresh, empty log file in `dir` (created if missing).
|
||||
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
/// Held by the instance that writes [`LOG_FILE`], from its start until it exits. A separate lock
|
||||
/// file rather than a lock on the log itself: Windows locks are mandatory, and a locked log could
|
||||
/// not be opened for a bug report while the app runs.
|
||||
pub const OWNER_LOCK: &str = "photocraft.lock";
|
||||
/// Held while one start rotates and prunes the logs, so two starts at once can't interleave.
|
||||
pub const ROTATE_LOCK: &str = "rotate.lock";
|
||||
|
||||
/// The log of an instance started while another one was writing [`LOG_FILE`].
|
||||
fn instance_log(dir: &Path, pid: u32) -> PathBuf {
|
||||
dir.join(format!("photocraft-{pid}.log"))
|
||||
}
|
||||
|
||||
/// The lock file that marks [`instance_log`] as in use.
|
||||
fn instance_lock(dir: &Path, pid: u32) -> PathBuf {
|
||||
dir.join(format!("photocraft-{pid}.lock"))
|
||||
}
|
||||
|
||||
/// The process id in an [`instance_log`] file name.
|
||||
fn instance_pid(name: &str) -> Option<u32> {
|
||||
let digits = name.strip_prefix("photocraft-")?.strip_suffix(".log")?;
|
||||
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
digits.parse().ok()
|
||||
}
|
||||
|
||||
fn open_lock(path: &Path) -> std::io::Result<File> {
|
||||
OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path)
|
||||
}
|
||||
|
||||
/// Take `file`'s lock without waiting: `Ok(true)` holds it, `Ok(false)` when another process holds it.
|
||||
fn try_hold(file: &File) -> std::io::Result<bool> {
|
||||
match file.try_lock() {
|
||||
Ok(()) => Ok(true),
|
||||
Err(TryLockError::WouldBlock) => Ok(false),
|
||||
Err(TryLockError::Error(e)) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a fresh, empty log file in `dir` (created if missing).
|
||||
///
|
||||
/// Normally the previous logs shift up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest
|
||||
/// dropped). While another instance is still writing [`LOG_FILE`] (it holds [`OWNER_LOCK`]), the
|
||||
/// chain is left alone and this start writes `photocraft-<pid>.log` instead: rotating would move
|
||||
/// that instance's live log away and, a few launches later, delete it (#6432). Such side-by-side
|
||||
/// logs are pruned to the newest [`KEEP`] once their instances have exited. On a file system
|
||||
/// without locks every start rotates, as before.
|
||||
pub fn rotate(dir: &Path) -> std::io::Result<LogFile> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
// Waits only for another start's few file operations; without locking support, go on.
|
||||
let guard = open_lock(&dir.join(ROTATE_LOCK)).ok().filter(|g| g.lock().is_ok());
|
||||
let log = rotate_locked(dir);
|
||||
drop(guard);
|
||||
log
|
||||
}
|
||||
|
||||
fn rotate_locked(dir: &Path) -> std::io::Result<LogFile> {
|
||||
prune_instance_logs(dir);
|
||||
let owner = open_lock(&dir.join(OWNER_LOCK))?;
|
||||
let state = try_hold(&owner);
|
||||
if let Ok(false) = state {
|
||||
let pid = std::process::id();
|
||||
let lock = open_lock(&instance_lock(dir, pid))?;
|
||||
let held = try_hold(&lock).unwrap_or(false);
|
||||
let path = instance_log(dir, pid);
|
||||
let file = File::create(&path)?;
|
||||
return Ok(LogFile { path, file, owner: held.then_some(lock) });
|
||||
}
|
||||
// This start holds the owner lock (or the file system has no locks).
|
||||
let held = state.unwrap_or(false);
|
||||
for n in (1..=KEEP).rev() {
|
||||
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
|
||||
@@ -128,7 +196,40 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
}
|
||||
let path = numbered(dir, 0);
|
||||
let file = File::create(&path)?;
|
||||
Ok((path, file))
|
||||
Ok(LogFile { path, file, owner: held.then_some(owner) })
|
||||
}
|
||||
|
||||
/// Remove the side-by-side logs of instances that have exited, all but the newest [`KEEP`].
|
||||
/// A log whose lock file is held belongs to a running instance and is never touched. Failures
|
||||
/// are ignored: pruning only saves disk space.
|
||||
fn prune_instance_logs(dir: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let mut finished: Vec<(SystemTime, u32)> = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let Some(pid) = entry.file_name().to_str().and_then(instance_pid) else { continue };
|
||||
// A missing lock file means nobody holds it; an unknown lock state counts as running.
|
||||
let running = match OpenOptions::new().read(true).write(true).open(instance_lock(dir, pid)) {
|
||||
Ok(lock) => !try_hold(&lock).unwrap_or(false),
|
||||
Err(_) => false,
|
||||
};
|
||||
if !running {
|
||||
let modified = entry.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
finished.push((modified, pid));
|
||||
}
|
||||
}
|
||||
finished.sort_unstable_by(|a, b| b.cmp(a));
|
||||
for &(_, pid) in finished.iter().skip(KEEP) {
|
||||
let _ = std::fs::remove_file(instance_log(dir, pid));
|
||||
let _ = std::fs::remove_file(instance_lock(dir, pid));
|
||||
}
|
||||
}
|
||||
|
||||
/// A log file opened by [`rotate`].
|
||||
pub struct LogFile {
|
||||
pub path: PathBuf,
|
||||
pub file: File,
|
||||
/// The held lock that marks `path` as in use; `None` where locking isn't supported.
|
||||
owner: Option<File>,
|
||||
}
|
||||
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
@@ -205,17 +306,20 @@ pub struct AppLogger {
|
||||
filter: Filter,
|
||||
stderr: bool,
|
||||
sink: Mutex<Sink>,
|
||||
/// The lock that marks the attached log file as in use (see [`rotate`]); held until exit.
|
||||
owner: Mutex<Option<File>>,
|
||||
}
|
||||
|
||||
impl AppLogger {
|
||||
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
|
||||
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
|
||||
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), owner: Mutex::new(None) }
|
||||
}
|
||||
|
||||
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
|
||||
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
|
||||
let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let LogFile { path, file, owner } = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
|
||||
*self.owner.lock().unwrap_or_else(PoisonError::into_inner) = owner;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
@@ -318,7 +422,7 @@ mod tests {
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
let LogFile { path, file: _file, owner: _owner } = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(std::fs::read_to_string(&path).expect("read"), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
@@ -330,6 +434,64 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #6432: a second launch moved the running instance's live log to `.1.log`, a third to
|
||||
/// `.2.log`, and a fourth deleted it.
|
||||
#[test]
|
||||
fn a_second_start_leaves_the_running_instance_log_alone() {
|
||||
let dir = temp_dir("second");
|
||||
let mut first = rotate(&dir).expect("first start");
|
||||
assert_eq!(first.path, dir.join(LOG_FILE));
|
||||
first.file.write_all(b"first, before\n").expect("write");
|
||||
for _ in 0..KEEP + 1 {
|
||||
let second = rotate(&dir).expect("second start");
|
||||
assert_eq!(second.path, instance_log(&dir, std::process::id()));
|
||||
assert_eq!(read(&second.path), "");
|
||||
}
|
||||
first.file.write_all(b"first, after\n").expect("write");
|
||||
assert_eq!(read(&dir.join(LOG_FILE)), "first, before\nfirst, after\n");
|
||||
assert!(!dir.join("photocraft.1.log").exists(), "the running instance's log was rotated");
|
||||
// Once the first instance is gone, the next start rotates as before.
|
||||
drop(first);
|
||||
let third = rotate(&dir).expect("third start");
|
||||
assert_eq!(third.path, dir.join(LOG_FILE));
|
||||
assert_eq!(read(&dir.join("photocraft.1.log")), "first, before\nfirst, after\n");
|
||||
drop(third);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_logs_are_pruned_only_when_no_one_holds_them() {
|
||||
let dir = temp_dir("prune");
|
||||
let first = rotate(&dir).expect("first start");
|
||||
// Earlier side-by-side runs that have exited, oldest first; the oldest has no lock file
|
||||
// (as if its lock file had been removed), the others an unlocked one.
|
||||
let now = SystemTime::now();
|
||||
for (age, pid) in [(400u64, 1u32), (300, 2), (200, 3), (100, 4)] {
|
||||
let path = instance_log(&dir, pid);
|
||||
std::fs::write(&path, format!("run {pid}")).expect("write");
|
||||
File::options().write(true).open(&path).expect("open").set_modified(now - Duration::from_secs(age)).expect("mtime");
|
||||
if pid != 1 {
|
||||
std::fs::write(instance_lock(&dir, pid), "").expect("lock file");
|
||||
}
|
||||
}
|
||||
// One older still running: its log is never removed, however old.
|
||||
let live = instance_log(&dir, 5);
|
||||
std::fs::write(&live, "live").expect("write");
|
||||
File::options().write(true).open(&live).expect("open").set_modified(now - Duration::from_secs(900)).expect("mtime");
|
||||
let held = File::create(instance_lock(&dir, 5)).expect("lock file");
|
||||
held.try_lock().expect("hold");
|
||||
|
||||
let second = rotate(&dir).expect("second start");
|
||||
assert_eq!(second.path, instance_log(&dir, std::process::id()));
|
||||
// The newest KEEP finished ones stay, the rest go with their lock files.
|
||||
assert!(instance_log(&dir, 4).exists() && instance_log(&dir, 3).exists());
|
||||
assert!(!instance_log(&dir, 2).exists() && !instance_log(&dir, 1).exists());
|
||||
assert!(!instance_lock(&dir, 2).exists());
|
||||
assert_eq!(read(&live), "live");
|
||||
drop((first, second, held));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
|
||||
let dir = temp_dir("blocked");
|
||||
@@ -355,7 +517,7 @@ mod tests {
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let LogFile { path, file, .. } = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
@@ -368,7 +530,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let LogFile { path, file, .. } = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
for i in 0..MAX_PENDING + 5 {
|
||||
sink.write(&format!("line {i}\n"));
|
||||
@@ -384,7 +546,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let LogFile { path, file, .. } = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
|
||||
+97
-20
@@ -1,9 +1,14 @@
|
||||
//! Photocraft desktop app.
|
||||
//! PhotoCraft desktop app.
|
||||
//!
|
||||
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
|
||||
//! --control-token-file <path>] [--automation-read-root <dir>]
|
||||
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [--present-mode <mode>]
|
||||
//! [--frame-latency <n>] [--stroke-trace <stderr|file.csv>] [--new-instance] [files…]`
|
||||
//! [--frame-latency <n>] [--stroke-trace <stderr|file.csv>] [--new-instance] [--] [files…]`
|
||||
//! (`photocraft --help` prints [`USAGE`]; `--version` prints the version).
|
||||
//!
|
||||
//! Any other argument starting with `-` is a usage error (exit status 2, issue #6431), so a typo
|
||||
//! such as `--safegpu` doesn't open the window and then fail to open the flag as a file. A file
|
||||
//! whose name starts with `-` goes after `--`.
|
||||
//!
|
||||
//! On Windows and Linux a launch hands its files to the PhotoCraft that is already running (new
|
||||
//! tabs in its window) and exits; `--new-instance` starts a separate copy instead, as does any
|
||||
@@ -58,6 +63,9 @@ mod photoshop_settings;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod portal_save;
|
||||
mod presentation;
|
||||
// The print portal needs the session bus (zbus, Linux only); its pure helpers are tested everywhere.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
mod print_portal;
|
||||
mod screen_color;
|
||||
mod services;
|
||||
// macOS gets one instance from LaunchServices (open-documents Apple events).
|
||||
@@ -149,21 +157,63 @@ fn parse_control_port(value: &str, source: &str) -> Result<u16, String> {
|
||||
value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)"))
|
||||
}
|
||||
|
||||
/// Process exit status for the collected `--control` / `PHOTOCRAFT_CONTROL_PORT` errors: `None`
|
||||
/// when there are none, else 2 (a command-line usage error), never 0.
|
||||
fn control_args_exit_code(errors: &[String]) -> Option<i32> {
|
||||
/// Process exit status for the collected usage errors (a bad `--control` /
|
||||
/// `PHOTOCRAFT_CONTROL_PORT`, an unknown option): `None` when there are none, else 2 (a
|
||||
/// command-line usage error), never 0.
|
||||
fn usage_exit_code(errors: &[String]) -> Option<i32> {
|
||||
if errors.is_empty() { None } else { Some(2) }
|
||||
}
|
||||
|
||||
/// `photocraft --help`. Keep in step with the options `main` accepts and the module docs.
|
||||
const USAGE: &str = "\
|
||||
Usage: photocraft [options] [--] [files…]
|
||||
|
||||
Opens PhotoCraft with the given files.
|
||||
|
||||
Options:
|
||||
-h, --help Print this help and exit
|
||||
--version Print the version, commit and build date and exit
|
||||
--safe-gpu Start with the CPU renderer for this launch (after a graphics driver crash)
|
||||
--in-window-menus macOS: keep the menus inside the window instead of the menu bar
|
||||
--new-instance Windows and Linux: start a separate PhotoCraft instead of opening
|
||||
the files in the one already running
|
||||
--present-mode <mode> How frames reach the display: fifo, autovsync or novsync
|
||||
--frame-latency <n> Frames queued for the display: 1 to 3
|
||||
--stroke-trace <stderr|file.csv>
|
||||
Time each brush stroke's frames
|
||||
--control <port> Start the localhost JSON-lines control server on this port
|
||||
--control-token <64-hex> Control server token (default: a new random one, printed)
|
||||
--control-token-file <path> Read the control server token from this file, or create it
|
||||
with a new one
|
||||
--automation-read-root <dir> Directory the control server may read files from
|
||||
--automation-write-root <dir>
|
||||
Directory the control server may write files to
|
||||
-- Treat every later argument as a file, even one starting with `-`
|
||||
|
||||
A bad --present-mode, --frame-latency or --stroke-trace value is reported and ignored.
|
||||
Environment: PHOTOCRAFT_CONTROL_PORT, PHOTOCRAFT_CONTROL_TOKEN, PHOTOCRAFT_CONTROL_TOKEN_FILE,
|
||||
PHOTOCRAFT_AUTOMATION_READ_ROOT, PHOTOCRAFT_AUTOMATION_WRITE_ROOT, PHOTOCRAFT_PRESENT_MODE,
|
||||
PHOTOCRAFT_FRAME_LATENCY, PHOTOCRAFT_STROKE_TRACE, PHOTOCRAFT_IN_WINDOW_MENUS, PHOTOCRAFT_NEW_INSTANCE,
|
||||
PHOTOCRAFT_CONFIG_DIR.
|
||||
";
|
||||
|
||||
/// Print to standard output for `--help` / `--version`. Unlike `println!`, never panics when
|
||||
/// stdout is closed or a pipe (`photocraft --help | head -1`).
|
||||
fn print_stdout(text: &str) {
|
||||
use std::io::Write as _;
|
||||
let mut out = std::io::stdout().lock();
|
||||
let _ = out.write_all(text.as_bytes()).and_then(|()| out.flush());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod control_port_tests {
|
||||
use super::{control_args_exit_code, parse_control_port};
|
||||
use super::{parse_control_port, usage_exit_code};
|
||||
|
||||
#[test]
|
||||
fn control_port_errors_exit_non_zero() {
|
||||
assert_eq!(control_args_exit_code(&[]), None);
|
||||
assert_eq!(usage_exit_code(&[]), None);
|
||||
let errors = vec![parse_control_port("nope", "--control").unwrap_err()];
|
||||
assert_eq!(control_args_exit_code(&errors), Some(2));
|
||||
assert_eq!(usage_exit_code(&errors), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -191,11 +241,11 @@ fn main() -> eframe::Result {
|
||||
// The system font scan can take seconds (minutes on some Macs): run it off the UI thread.
|
||||
photocraft_ui_egui::type_tool::warm_fonts();
|
||||
let mut control_port: Option<u16> = None;
|
||||
let mut control_arg_errors: Vec<String> = Vec::new();
|
||||
let mut usage_errors: Vec<String> = Vec::new();
|
||||
if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) {
|
||||
match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") {
|
||||
Ok(port) => control_port = Some(port),
|
||||
Err(error) => control_arg_errors.push(error),
|
||||
Err(error) => usage_errors.push(error),
|
||||
}
|
||||
}
|
||||
let mut control_token = None;
|
||||
@@ -214,18 +264,32 @@ fn main() -> eframe::Result {
|
||||
let (mut present_mode_arg, mut frame_latency_arg, mut stroke_trace_arg): (Option<String>, Option<String>, Option<String>) = (None, None, None);
|
||||
let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
// After `--`, every argument is a file, even one that starts with `-`.
|
||||
let mut only_files = false;
|
||||
while let Some(a) = args.next() {
|
||||
if only_files {
|
||||
match a.into_string() {
|
||||
Ok(s) => files.push(s),
|
||||
Err(raw) => unreadable_paths.push(raw.to_string_lossy().into_owned()),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match a.to_str() {
|
||||
Some("--") => only_files = true,
|
||||
Some("--help" | "-h") => {
|
||||
print_stdout(USAGE);
|
||||
return Ok(());
|
||||
}
|
||||
Some("--control") => match args.next() {
|
||||
Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
|
||||
Ok(port) => control_port = Some(port),
|
||||
Err(error) => control_arg_errors.push(error),
|
||||
Err(error) => usage_errors.push(error),
|
||||
},
|
||||
None => control_arg_errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
|
||||
None => usage_errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
|
||||
},
|
||||
Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) {
|
||||
Some(Ok(token)) => control_token = Some(token),
|
||||
Some(Err(raw)) => control_arg_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
|
||||
Some(Err(raw)) => usage_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
|
||||
None => control_token = None,
|
||||
},
|
||||
Some("--control-token-file") => control_token_file = args.next().map(std::path::PathBuf::from),
|
||||
@@ -264,23 +328,28 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
Some("--new-instance") => new_instance = true,
|
||||
Some("--version") => {
|
||||
println!("photocraft {}", photocraft_engine::build_info::long_version());
|
||||
print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
|
||||
return Ok(());
|
||||
}
|
||||
// Old macOS passes a process serial number when launched from Finder.
|
||||
Some(s) if s.starts_with("-psn_") => {}
|
||||
// A typo such as `--safegpu`, or `--help` spelt another way, must not open the window
|
||||
// and then fail to open the flag as a file (#6431).
|
||||
Some(s) if s.starts_with('-') => usage_errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")),
|
||||
Some(s) => files.push(s.to_owned()),
|
||||
None => unreadable_paths.push(a.to_string_lossy().into_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed control port must not silently drop the control server (issue #701): name the
|
||||
// bad value and fail the launch, like `photocraft-cli serve --port` does for the same typo.
|
||||
// Exit status 2 is the usual command-line usage error, so a launcher sees the failure.
|
||||
if let Some(code) = control_args_exit_code(&control_arg_errors) {
|
||||
for error in &control_arg_errors {
|
||||
// A malformed control port must not silently drop the control server (issue #701), and an
|
||||
// unknown option must not open the window (#6431): name the bad argument and fail the launch,
|
||||
// like `photocraft-cli` does for the same typos. Exit status 2 is the usual command-line usage
|
||||
// error, so a launcher sees the failure.
|
||||
if let Some(code) = usage_exit_code(&usage_errors) {
|
||||
for error in &usage_errors {
|
||||
eprintln!("photocraft: {error}");
|
||||
}
|
||||
eprintln!("Run `photocraft --help` for usage.");
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
@@ -499,7 +568,15 @@ fn main() -> eframe::Result {
|
||||
services::xwayland_command(var("FLATPAK_ID").as_deref(), var("APPIMAGE").as_deref(), std::env::var_os("DISPLAY").is_some());
|
||||
}
|
||||
}
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
#[cfg(target_os = "linux")]
|
||||
let session = {
|
||||
let mut session = Session::new();
|
||||
session.print_spooler = print_portal::spooler();
|
||||
session
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let session = Session::new();
|
||||
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");
|
||||
app.custom_titlebar = custom_titlebar;
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
//! Printing through the XDG Print portal (`org.freedesktop.portal.Print`): the system print
|
||||
//! dialog, which a sandboxed app can reach without a CUPS socket. The engine prints over IPP
|
||||
//! when it can reach a print server (`photocraft-ipp`); this is its fallback when it cannot, and
|
||||
//! the only route with `PHOTOCRAFT_PRINT_PORTAL=1` (`0` disables it). `PreparePrint` shows the
|
||||
//! dialog seeded with the job's copies and paper; `Print` hands it the PDF. Both answer through a
|
||||
//! `Request` object's `Response` signal, which this module waits for on its own thread so the
|
||||
//! window keeps painting while the dialog is open; the outcome goes to the log.
|
||||
//! https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Print.html
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use photocraft_engine::print_cmds::{PrintJob, Spooler};
|
||||
|
||||
/// The spooler to install on the session, unless `PHOTOCRAFT_PRINT_PORTAL=0`. The portal itself
|
||||
/// is looked up per job, so a missing one costs nothing at start-up.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn spooler() -> Option<Spooler> {
|
||||
let setting = std::env::var("PHOTOCRAFT_PRINT_PORTAL").ok();
|
||||
if setting.as_deref() == Some("0") {
|
||||
return None;
|
||||
}
|
||||
Some(Spooler { name: "portal", preferred: setting.as_deref() == Some("1"), run: std::sync::Arc::new(print) })
|
||||
}
|
||||
|
||||
/// Opens the system print dialog for the job and returns as soon as it is up.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn print(job: &PrintJob) -> Result<String, String> {
|
||||
let conn = zbus::blocking::connection::Builder::session()
|
||||
.and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build())
|
||||
.map_err(|e| format!("no session bus for the print portal ({e}); print to PDF instead"))?;
|
||||
let proxy = portal(&conn)?;
|
||||
let version: u32 = proxy.get_property("version").map_err(|e| format!("no print portal on this desktop ({e}); print to PDF instead"))?;
|
||||
// The document goes to a private temporary file (0600, created atomically by `tempfile`),
|
||||
// not a guessable name in a shared /tmp. The guard owns the file: it lives on the portal
|
||||
// thread until the dialog is done, and a failure to start that thread drops it here.
|
||||
let mut file = tempfile::Builder::new().prefix("photocraft-print-").suffix(".pdf").tempfile().map_err(|e| format!("cannot create the print file: {e}"))?;
|
||||
std::io::Write::write_all(&mut file, &job.pdf).map_err(|e| format!("cannot write the print file: {e}"))?;
|
||||
let (title, settings, page_setup) = (job.title.clone(), settings(job), page_setup(job));
|
||||
std::thread::Builder::new()
|
||||
.name("print-portal".into())
|
||||
.spawn(move || {
|
||||
let outcome = dialog(&conn, &title, &settings, &page_setup, file.path());
|
||||
drop(file);
|
||||
match outcome {
|
||||
Ok(what) => log::info!("print portal (v{version}): {what}"),
|
||||
Err(e) => log::warn!("print portal (v{version}): {e}"),
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("cannot start the print thread: {e}"))?;
|
||||
Ok("the system print dialog".into())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal(conn: &zbus::blocking::Connection) -> Result<zbus::blocking::Proxy<'static>, String> {
|
||||
zbus::blocking::Proxy::new(conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Print")
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `PreparePrint` then `Print`, each waited for on its request's `Response` signal.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn dialog(
|
||||
conn: &zbus::blocking::Connection,
|
||||
title: &str,
|
||||
settings: &[(&'static str, Seed)],
|
||||
page_setup: &[(&'static str, Seed)],
|
||||
pdf: &std::path::Path,
|
||||
) -> Result<String, String> {
|
||||
use std::collections::HashMap;
|
||||
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
|
||||
type Dict<'a> = HashMap<&'a str, Value<'a>>;
|
||||
fn as_dict(pairs: &[(&'static str, Seed)]) -> Dict<'static> {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let value = match v {
|
||||
Seed::Text(t) => Value::from(t.clone()),
|
||||
Seed::Mm(mm) => Value::from(*mm),
|
||||
};
|
||||
(*k, value)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
let proxy = portal(conn)?;
|
||||
let sender = conn.unique_name().map(|n| n.as_str().to_string()).ok_or("the bus connection has no name")?;
|
||||
let wait = |token: &str| -> Result<zbus::blocking::proxy::SignalIterator<'static>, String> {
|
||||
let path = request_path(&sender, token);
|
||||
let request = zbus::blocking::Proxy::new(conn, "org.freedesktop.portal.Desktop", path, "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
|
||||
request.receive_signal("Response").map_err(|e| e.to_string())
|
||||
};
|
||||
let response = |mut signals: zbus::blocking::proxy::SignalIterator<'static>| -> Result<HashMap<String, OwnedValue>, String> {
|
||||
let message = signals.next().ok_or("the print portal went away")?;
|
||||
let (code, results): (u32, HashMap<String, OwnedValue>) = message.body().deserialize().map_err(|e| format!("odd portal reply: {e}"))?;
|
||||
match code {
|
||||
0 => Ok(results),
|
||||
1 => Err("cancelled in the print dialog".into()),
|
||||
_ => Err("the print dialog failed".into()),
|
||||
}
|
||||
};
|
||||
|
||||
let token = format!("photocraft_prepare_{}", nonce());
|
||||
let signals = wait(&token)?;
|
||||
let mut options: Dict<'_> = HashMap::new();
|
||||
options.insert("handle_token", Value::from(token.as_str()));
|
||||
let _: OwnedObjectPath =
|
||||
proxy.call("PreparePrint", &("", title, as_dict(settings), as_dict(page_setup), options)).map_err(|e| format!("PreparePrint failed: {e}"))?;
|
||||
let results = response(signals)?;
|
||||
let prepared = results.get("token").and_then(|v| u32::try_from(v.clone()).ok()).ok_or("the print dialog gave no token")?;
|
||||
|
||||
let file = std::fs::File::open(pdf).map_err(|e| format!("cannot reopen the print file: {e}"))?;
|
||||
let token = format!("photocraft_print_{}", nonce());
|
||||
let signals = wait(&token)?;
|
||||
let mut options: Dict<'_> = HashMap::new();
|
||||
options.insert("handle_token", Value::from(token.as_str()));
|
||||
options.insert("token", Value::from(prepared));
|
||||
let fd = zbus::zvariant::Fd::from(std::os::fd::AsFd::as_fd(&file));
|
||||
let _: OwnedObjectPath = proxy.call("Print", &("", title, fd, options)).map_err(|e| format!("Print failed: {e}"))?;
|
||||
response(signals)?;
|
||||
Ok("printed through the system print dialog".into())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn nonce() -> u64 {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0);
|
||||
t ^ N.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The request object the portal answers on: the sender's unique name without its colon and
|
||||
/// with dots as underscores, then the handle token.
|
||||
fn request_path(unique_name: &str, token: &str) -> String {
|
||||
format!("/org/freedesktop/portal/desktop/request/{}/{token}", unique_name.trim_start_matches(':').replace('.', "_"))
|
||||
}
|
||||
|
||||
/// A value seeded into the dialog: GtkPrintSettings are strings; a GtkPageSetup's sizes are
|
||||
/// doubles in millimetres (`gtk_paper_size_new_from_gvariant` ignores anything else).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
enum Seed {
|
||||
Text(String),
|
||||
Mm(f64),
|
||||
}
|
||||
|
||||
fn text(s: &str) -> Seed {
|
||||
Seed::Text(s.to_string())
|
||||
}
|
||||
|
||||
/// The standard paper sizes, as (PWG name, PPD name, width mm, height mm), portrait. GTK's
|
||||
/// print settings take the PWG name; its page setup finds the size by the PPD name.
|
||||
const PAPERS: [(&str, &str, f64, f64); 8] = [
|
||||
("na_letter", "Letter", 215.9, 279.4),
|
||||
("na_legal", "Legal", 215.9, 355.6),
|
||||
("na_ledger", "Ledger", 279.4, 431.8),
|
||||
("iso_a3", "A3", 297.0, 420.0),
|
||||
("iso_a4", "A4", 210.0, 297.0),
|
||||
("iso_a5", "A5", 148.0, 210.0),
|
||||
("na_index-4x6", "4x6", 101.6, 152.4),
|
||||
("na_5x7", "5x7", 127.0, 177.8),
|
||||
];
|
||||
|
||||
const MM_PER_PT: f64 = 25.4 / 72.0;
|
||||
|
||||
type Paper = (&'static str, &'static str, f64, f64);
|
||||
|
||||
/// The job's paper: its row in `PAPERS` when it is a standard size, and the portrait size in
|
||||
/// millimetres.
|
||||
fn paper(paper_pt: (f64, f64)) -> (Option<&'static Paper>, f64, f64) {
|
||||
let (w, h) = (paper_pt.0.min(paper_pt.1) * MM_PER_PT, paper_pt.0.max(paper_pt.1) * MM_PER_PT);
|
||||
let row = PAPERS.iter().find(|(_, _, pw, ph)| (pw - w).abs() < 1.0 && (ph - h).abs() < 1.0);
|
||||
(row, w, h)
|
||||
}
|
||||
|
||||
fn orientation(landscape: bool) -> Seed {
|
||||
text(if landscape { "landscape" } else { "portrait" })
|
||||
}
|
||||
|
||||
/// `PreparePrint`'s settings (GtkPrintSettings keys, string values).
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn settings_for(copies: u32, paper_pt: (f64, f64), landscape: bool) -> Vec<(&'static str, Seed)> {
|
||||
let (row, w, h) = paper(paper_pt);
|
||||
let mut v = vec![("n-copies", text(&copies.to_string())), ("orientation", orientation(landscape))];
|
||||
match row {
|
||||
Some((pwg, _, _, _)) => v.push(("paper-format", text(pwg))),
|
||||
None => v.extend([("paper-width", text(&format!("{w:.2}"))), ("paper-height", text(&format!("{h:.2}")))]),
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// `PreparePrint`'s page setup (GtkPageSetup keys): the size in millimetres, found by its PPD
|
||||
/// name when it has one, else a custom size named by its dimensions.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn page_setup_for(paper_pt: (f64, f64), landscape: bool) -> Vec<(&'static str, Seed)> {
|
||||
let (row, w, h) = paper(paper_pt);
|
||||
let mut v = vec![("Orientation", orientation(landscape)), ("Width", Seed::Mm(w)), ("Height", Seed::Mm(h))];
|
||||
match row {
|
||||
Some((_, ppd, _, _)) => v.push(("PPDName", text(ppd))),
|
||||
None => v.push(("Name", text(&format!("custom_{w:.0}x{h:.0}mm")))),
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn settings(job: &PrintJob) -> Vec<(&'static str, Seed)> {
|
||||
settings_for(job.copies, job.paper, job.landscape)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn page_setup(job: &PrintJob) -> Vec<(&'static str, Seed)> {
|
||||
page_setup_for(job.paper, job.landscape)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_path_follows_the_portal_rule() {
|
||||
assert_eq!(request_path(":1.42", "photocraft_prepare_7"), "/org/freedesktop/portal/desktop/request/1_42/photocraft_prepare_7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_papers_get_their_pwg_and_ppd_names_and_custom_ones_their_size() {
|
||||
// A4 landscape as the engine lays it out: width > height, still A4.
|
||||
let s = settings_for(2, (841.89, 595.28), true);
|
||||
assert_eq!(s, vec![("n-copies", text("2")), ("orientation", text("landscape")), ("paper-format", text("iso_a4"))]);
|
||||
let p = page_setup_for((841.89, 595.28), true);
|
||||
assert_eq!(p[0], ("Orientation", text("landscape")));
|
||||
let Seed::Mm(w) = p[1].1 else { panic!("width is millimetres") };
|
||||
let Seed::Mm(h) = p[2].1 else { panic!("height is millimetres") };
|
||||
assert!((w - 210.0).abs() < 0.01 && (h - 297.0).abs() < 0.01, "{w} x {h}");
|
||||
assert_eq!(p[3], ("PPDName", text("A4")));
|
||||
assert_eq!(settings_for(1, (612.0, 792.0), false)[2], ("paper-format", text("na_letter")));
|
||||
assert_eq!(page_setup_for((612.0, 792.0), false)[3], ("PPDName", text("Letter")));
|
||||
assert_eq!(settings_for(1, (288.0, 432.0), false)[2], ("paper-format", text("na_index-4x6")));
|
||||
// 100 x 200 mm is nobody's standard.
|
||||
let custom = settings_for(1, (283.46, 566.93), false);
|
||||
assert_eq!(&custom[2..], &[("paper-width", text("100.00")), ("paper-height", text("200.00"))]);
|
||||
assert_eq!(page_setup_for((283.46, 566.93), false)[3], ("Name", text("custom_100x200mm")));
|
||||
}
|
||||
}
|
||||
@@ -264,8 +264,8 @@ fn block_on<T>(future: impl Future<Output = T>) -> T {
|
||||
}
|
||||
|
||||
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else `<exe dir>/PhotoCraftData` in
|
||||
/// portable mode, else the platform convention. Everything the app persists lives under it; see
|
||||
/// [`crate::app_dirs`].
|
||||
/// portable mode, else the platform convention. Everything the app persists lives under it
|
||||
/// except, on Windows, crash-recovery autosaves; see [`crate::app_dirs`].
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
crate::app_dirs::config_dir()
|
||||
}
|
||||
@@ -280,8 +280,10 @@ pub fn presets_dir() -> Option<PathBuf> {
|
||||
config_dir().map(|d| d.join("Presets"))
|
||||
}
|
||||
|
||||
/// Crash-recovery autosaves: under the settings directory, except on Windows, where they stay
|
||||
/// out of the roaming profile (see [`crate::app_dirs::recovery_dir`]).
|
||||
fn recovery_dir() -> Option<PathBuf> {
|
||||
config_dir().map(|d| d.join("Recovery"))
|
||||
crate::app_dirs::recovery_dir()
|
||||
}
|
||||
|
||||
/// Write `bytes` crash-safely (temp file beside the target, fsync, rename, directory fsync; see
|
||||
@@ -504,6 +506,11 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
save_file: Some(Arc::new(save_file)),
|
||||
file_dialog: Some(Box::new(show_file_dialog)),
|
||||
write: Some(Box::new(|path: &str, bytes: &[u8]| write_atomic(Path::new(path), bytes))),
|
||||
file_stamp: Some(Box::new(|path: &str| {
|
||||
// Follows a symlink, as Save writes through it to the file it points at.
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
Some(photocraft_ui_egui::FileStamp { len: meta.len(), modified: meta.modified().ok() })
|
||||
})),
|
||||
automation_read,
|
||||
automation_write,
|
||||
automation_open_pcraft,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//! `photocraft --help` and unknown options must not open the window (#6431): before the fix,
|
||||
//! any argument the loop didn't know was taken as a file, so `--help` or a typo such as
|
||||
//! `--safegpu` launched the GUI and logged "Couldn't open --help".
|
||||
//!
|
||||
//! These run the real binary, and every case must exit before the window opens: Windows and
|
||||
//! macOS runners have a desktop, so a launch that gets that far hangs until the test times out.
|
||||
//! Cases whose arguments are valid add a bad `--control` value, which fails the launch with the
|
||||
//! other usage errors, so stderr shows whether those arguments were taken as unknown options.
|
||||
|
||||
/// Run `photocraft` with `args` in an isolated config dir; return the exit
|
||||
/// status, stdout and stderr.
|
||||
fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String, String) {
|
||||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-usage-{}-{n}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"))
|
||||
.args(args)
|
||||
.env("PHOTOCRAFT_CONFIG_DIR", &dir)
|
||||
.env_remove("PHOTOCRAFT_CONTROL_PORT")
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
.output()
|
||||
.expect("failed to run photocraft binary");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
(output.status, String::from_utf8_lossy(&output.stdout).into_owned(), String::from_utf8_lossy(&output.stderr).into_owned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_prints_usage_and_exits_0() {
|
||||
for flag in ["--help", "-h"] {
|
||||
let (status, stdout, stderr) = run_photocraft(&[flag]);
|
||||
assert_eq!(status.code(), Some(0), "{flag} must exit 0, stderr: {stderr}");
|
||||
assert!(stdout.starts_with("Usage: photocraft"), "{flag} stdout: {stdout}");
|
||||
for option in ["--safe-gpu", "--new-instance", "--control <port>", "--version", "--"] {
|
||||
assert!(stdout.contains(option), "{flag} usage must list {option}: {stdout}");
|
||||
}
|
||||
assert!(!stderr.contains("Couldn't open"), "{flag} was taken as a file: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_wins_over_later_arguments() {
|
||||
let (status, stdout, _) = run_photocraft(&["--help", "picture.psd", "--safegpu"]);
|
||||
assert_eq!(status.code(), Some(0));
|
||||
assert!(stdout.starts_with("Usage: photocraft"), "stdout: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_options_exit_2_naming_them() {
|
||||
for flag in ["--safegpu", "-x", "--help=yes"] {
|
||||
let (status, stdout, stderr) = run_photocraft(&[flag]);
|
||||
assert_eq!(status.code(), Some(2), "{flag} must be a usage error, stderr: {stderr}");
|
||||
assert!(stderr.contains(&format!("unknown option `{flag}`")), "stderr: {stderr}");
|
||||
assert!(stderr.contains("photocraft --help"), "stderr should point at --help: {stderr}");
|
||||
assert!(stdout.is_empty(), "stdout: {stdout}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_option_errors_are_reported_with_control_port_errors() {
|
||||
let (status, _, stderr) = run_photocraft(&["--control", "nope", "--safegpu"]);
|
||||
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
|
||||
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
|
||||
assert!(stderr.contains("unknown option `--safegpu`"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arguments_after_double_dash_are_files() {
|
||||
// `--` ends the options: `--help` and `-x` after it are file names, so neither prints usage
|
||||
// nor is an unknown option; only the bad port is reported.
|
||||
let (status, stdout, stderr) = run_photocraft(&["--control", "nope", "--", "--help", "-x"]);
|
||||
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
|
||||
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
|
||||
assert!(!stdout.contains("Usage:"), "stdout: {stdout}");
|
||||
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_process_serial_number_is_still_ignored() {
|
||||
// Old macOS passes `-psn_<n>` when launching from Finder; it is neither a file nor an error.
|
||||
let (status, _, stderr) = run_photocraft(&["--control", "nope", "-psn_0_12345"]);
|
||||
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
|
||||
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
|
||||
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_instance_is_a_known_option() {
|
||||
let (status, _, stderr) = run_photocraft(&["--control", "nope", "--new-instance"]);
|
||||
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
|
||||
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
|
||||
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<ellipse cx="12" cy="12" rx="10" ry="7" stroke-dasharray="3.5 3.23" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 281 B |
@@ -9,8 +9,8 @@
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m11 10 3 3" />
|
||||
<path d="M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z" />
|
||||
<path d="M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031" />
|
||||
<path d="M6.5 12a5.5 5.5 0 1 0 5.5 5.5" stroke-dasharray="2 3" />
|
||||
<path d="M14 3H7a4 4 0 0 0-4 4v10a4 4 0 0 0 4 4h7" stroke-dasharray="2 3" />
|
||||
<path d="m14 10 2.1 2.1" />
|
||||
<path d="M10.85 17.7A2.45 2.45 0 1 0 8.4 15.25a1.834 1.834 0 0 1-.496 1.254A.7 .7 0 0 0 8.4 17.7z" />
|
||||
<path d="M13.278 14.922 21.265 6.937a.7 .7 0 0 0-2.101-2.101L11.177 12.822" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 466 B After Width: | Height: | Size: 502 B |
@@ -162,3 +162,10 @@ sync_github_name = true
|
||||
[people.sidney]
|
||||
real_name = "Sidney Markowitz"
|
||||
sync_github_name = true
|
||||
|
||||
[people.1Git2Clone]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
@@ -94,11 +94,8 @@ pub fn preview(bytes: &[u8]) -> Result<Preview<'_>, Error> {
|
||||
}
|
||||
image_data = true;
|
||||
}
|
||||
b"IEND" => {
|
||||
if !image_data {
|
||||
return Err(Error::Malformed("PNG has no image data"));
|
||||
}
|
||||
}
|
||||
b"IEND" if !image_data => return Err(Error::Malformed("PNG has no image data")),
|
||||
b"IEND" => {}
|
||||
_ if kind.first().is_some_and(u8::is_ascii_uppercase) => {
|
||||
return Err(Error::Unsupported("unknown critical PNG chunk"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
By downloading, copying, installing or using the software you agree to this license.
|
||||
If you do not agree to this license, do not download, install,
|
||||
copy or use the software.
|
||||
|
||||
|
||||
License Agreement For libfacedetection
|
||||
(3-clause BSD License)
|
||||
|
||||
Copyright (c) 2015-2019, Shiqi Yu, all rights reserved.
|
||||
shiqi.yu@gmail.com
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the names of the copyright holders nor the names of the contributors
|
||||
may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
This software is provided by the copyright holders and contributors "as is" and
|
||||
any express or implied warranties, including, but not limited to, the implied
|
||||
warranties of merchantability and fitness for a particular purpose are disclaimed.
|
||||
In no event shall copyright holders or contributors be liable for any direct,
|
||||
indirect, incidental, special, exemplary, or consequential damages
|
||||
(including, but not limited to, procurement of substitute goods or services;
|
||||
loss of use, data, or profits; or business interruption) however caused
|
||||
and on any theory of liability, whether in contract, strict liability,
|
||||
or tort (including negligence or otherwise) arising in any way out of
|
||||
the use of this software, even if advised of the possibility of such damage.
|
||||
Binary file not shown.
@@ -79,23 +79,48 @@ fn mirror(i: isize, n: usize) -> usize {
|
||||
|
||||
const TAPS: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
|
||||
|
||||
/// Planes smaller than this run their rows on one thread: the filter builds a whole pyramid per
|
||||
/// reference (up to 96), and splitting its small levels into one-row tasks cost more in rayon's
|
||||
/// scheduling than the rows themselves (a fifth of a Shadows render on 36 MP, measured).
|
||||
const PAR_MIN: usize = 1 << 15;
|
||||
|
||||
/// [`par_rows`] for a plane, on one thread when it is small (see [`PAR_MIN`]).
|
||||
fn rows(buf: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32]) + Sync + Send) {
|
||||
if buf.len() < PAR_MIN {
|
||||
buf.chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| f(y, row));
|
||||
} else {
|
||||
par_rows(buf, w, 1, f);
|
||||
}
|
||||
}
|
||||
|
||||
/// The binomial 5-tap blur, sampled every `step` pixels from 0 (1 = full resolution).
|
||||
fn blur_step(p: &Plane, step: usize) -> Plane {
|
||||
let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step));
|
||||
// Columns first at the kept rows, then rows at the kept columns.
|
||||
// Columns first at the kept rows, then rows at the kept columns. Each sum adds the same terms
|
||||
// in the same order as a `mirror` per tap would, so the result is bit for bit the same; the
|
||||
// source rows are found once per row and the columns mirrored only near the edges.
|
||||
let mut tmp = vec![0.0f32; p.w * h2];
|
||||
par_rows(&mut tmp, p.w, 1, |y2, row| {
|
||||
rows(&mut tmp, p.w, |y2, row| {
|
||||
let y = (y2 * step) as isize;
|
||||
let src: [&[f32]; 5] = std::array::from_fn(|k| {
|
||||
let r = mirror(y + k as isize - 2, p.h);
|
||||
&p.v[r * p.w..(r + 1) * p.w]
|
||||
});
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
*o = (0..5).map(|k| TAPS[k] * p.v[mirror(y + k as isize - 2, p.h) * p.w + x]).sum();
|
||||
*o = (0..5).map(|k| TAPS[k] * src[k][x]).sum();
|
||||
}
|
||||
});
|
||||
let mut out = vec![0.0f32; w2 * h2];
|
||||
par_rows(&mut out, w2, 1, |y2, row| {
|
||||
rows(&mut out, w2, |y2, row| {
|
||||
let src = &tmp[y2 * p.w..(y2 + 1) * p.w];
|
||||
for (x2, o) in row.iter_mut().enumerate() {
|
||||
let x = (x2 * step) as isize;
|
||||
*o = (0..5).map(|k| TAPS[k] * src[mirror(x + k as isize - 2, p.w)]).sum();
|
||||
let x = x2 * step;
|
||||
*o = if x >= 2 && x + 2 < p.w {
|
||||
let s = &src[x - 2..x + 3];
|
||||
(0..5).map(|k| TAPS[k] * s[k]).sum()
|
||||
} else {
|
||||
(0..5).map(|k| TAPS[k] * src[mirror(x as isize + k as isize - 2, p.w)]).sum()
|
||||
};
|
||||
}
|
||||
});
|
||||
Plane { w: w2, h: h2, v: out }
|
||||
@@ -107,14 +132,45 @@ fn down(p: &Plane) -> Plane {
|
||||
}
|
||||
|
||||
/// `p` brought up to `w × h`: zeros between samples, then the blur times four.
|
||||
///
|
||||
/// Three samples in four are zero, so their taps are skipped instead of blurring a zero-filled
|
||||
/// plane: the nonzero terms are added in the same order (skipping `+0.0` terms changes at most the
|
||||
/// sign of a zero, and the filter only adds and exponentiates these values).
|
||||
fn up(p: &Plane, w: usize, h: usize) -> Plane {
|
||||
let mut z = Plane { w, h, v: vec![0.0; w * h] };
|
||||
for y in 0..p.h.min(h.div_ceil(2)) {
|
||||
for x in 0..p.w.min(w.div_ceil(2)) {
|
||||
z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0;
|
||||
}
|
||||
let (lh, lw) = (p.h.min(h.div_ceil(2)), p.w.min(w.div_ceil(2)));
|
||||
// The source sample behind index `i` of a zero-stuffed axis of length `n` holding `lim` samples.
|
||||
let src = |i: isize, n: usize, lim: usize| {
|
||||
let m = mirror(i, n);
|
||||
(m.is_multiple_of(2) && m / 2 < lim).then_some(m / 2)
|
||||
};
|
||||
// Columns first, only at the even columns (the odd ones are all zero).
|
||||
let mut tmp = vec![0.0f32; lw * h];
|
||||
if lw > 0 {
|
||||
rows(&mut tmp, lw, |y, row| {
|
||||
let taps: [Option<&[f32]>; 5] = std::array::from_fn(|k| src(y as isize + k as isize - 2, h, lh).map(|r| &p.v[r * p.w..r * p.w + lw]));
|
||||
for (j, o) in row.iter_mut().enumerate() {
|
||||
*o = (0..5).filter_map(|k| taps[k].map(|s| TAPS[k] * (s[j] * 4.0))).sum();
|
||||
}
|
||||
});
|
||||
}
|
||||
blur_step(&z, 1)
|
||||
let mut out = vec![0.0f32; w * h];
|
||||
rows(&mut out, w, |y, row| {
|
||||
let t = &tmp[y * lw..(y + 1) * lw];
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
*o = if x >= 2 && x + 2 < w {
|
||||
// Away from the edges no tap mirrors.
|
||||
(0..5)
|
||||
.filter_map(|k| {
|
||||
let i = x + k - 2;
|
||||
(i.is_multiple_of(2) && i / 2 < lw).then(|| TAPS[k] * t[i / 2])
|
||||
})
|
||||
.sum()
|
||||
} else {
|
||||
(0..5).filter_map(|k| src(x as isize + k as isize - 2, w, lw).map(|j| TAPS[k] * t[j])).sum()
|
||||
};
|
||||
}
|
||||
});
|
||||
Plane { w, h, v: out }
|
||||
}
|
||||
|
||||
/// Levels down to about 2 pixels on the short side.
|
||||
@@ -154,7 +210,7 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
|
||||
for k in 0..refs {
|
||||
let r = lo + step * k as f32;
|
||||
let mut m = Plane { w: u.w, h: u.h, v: u.v.clone() };
|
||||
par_rows(&mut m.v, u.w, 1, |_, row| {
|
||||
rows(&mut m.v, u.w, |_, row| {
|
||||
for v in row.iter_mut() {
|
||||
*v = remap(*v, r);
|
||||
}
|
||||
@@ -163,7 +219,7 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
|
||||
for l in 0..n - 1 {
|
||||
let coarse = up(&gr[l + 1], gr[l].w, gr[l].h);
|
||||
let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w);
|
||||
par_rows(&mut lap[l], lw, 1, |y, row| {
|
||||
rows(&mut lap[l], lw, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let i = y * lw + x;
|
||||
let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0);
|
||||
@@ -337,6 +393,62 @@ mod tests {
|
||||
|
||||
const SRGB: Encoding = Encoding(0.0);
|
||||
|
||||
/// The plain blur: a `mirror` per tap over the whole plane, the reference for [`blur_step`].
|
||||
fn blur_step_reference(p: &Plane, step: usize) -> Plane {
|
||||
let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step));
|
||||
let mut tmp = vec![0.0f32; p.w * h2];
|
||||
for y2 in 0..h2 {
|
||||
for x in 0..p.w {
|
||||
tmp[y2 * p.w + x] = (0..5).map(|k| TAPS[k] * p.v[mirror((y2 * step) as isize + k as isize - 2, p.h) * p.w + x]).sum();
|
||||
}
|
||||
}
|
||||
let mut out = vec![0.0f32; w2 * h2];
|
||||
for y2 in 0..h2 {
|
||||
for x2 in 0..w2 {
|
||||
out[y2 * w2 + x2] = (0..5).map(|k| TAPS[k] * tmp[y2 * p.w + mirror((x2 * step) as isize + k as isize - 2, p.w)]).sum();
|
||||
}
|
||||
}
|
||||
Plane { w: w2, h: h2, v: out }
|
||||
}
|
||||
|
||||
/// The plain upsampling: a zero-stuffed plane blurred whole, the reference for [`up`].
|
||||
fn up_reference(p: &Plane, w: usize, h: usize) -> Plane {
|
||||
let mut z = Plane { w, h, v: vec![0.0; w * h] };
|
||||
for y in 0..p.h.min(h.div_ceil(2)) {
|
||||
for x in 0..p.w.min(w.div_ceil(2)) {
|
||||
z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0;
|
||||
}
|
||||
}
|
||||
blur_step_reference(&z, 1)
|
||||
}
|
||||
|
||||
fn noise_plane(w: usize, h: usize, seed: u32) -> Plane {
|
||||
let mut s = seed.wrapping_mul(2_654_435_761).max(1);
|
||||
let v = (0..w * h)
|
||||
.map(|_| {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
(s % 20_000) as f32 / 1000.0 - 16.0
|
||||
})
|
||||
.collect();
|
||||
Plane { w, h, v }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fast_pyramid_matches_the_plain_one_exactly() {
|
||||
// Odd, even, tiny and large-enough-to-run-in-parallel sizes, as the pyramid's levels are.
|
||||
for (i, (w, h)) in [(1, 1), (1, 7), (2, 2), (3, 5), (5, 3), (8, 6), (37, 23), (64, 64), (301, 211)].into_iter().enumerate() {
|
||||
let p = noise_plane(w, h, i as u32 + 1);
|
||||
for step in [1, 2] {
|
||||
assert_eq!(blur_step(&p, step).v, blur_step_reference(&p, step).v, "blur {w}×{h} step {step}");
|
||||
}
|
||||
for (uw, uh) in [(w * 2, h * 2), (w * 2 - 1, h * 2 - 1), (w * 2, h * 2 - 1), (w.max(2) * 2 - 3, h * 2)] {
|
||||
assert_eq!(up(&p, uw, uh).v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> {
|
||||
(0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect()
|
||||
}
|
||||
|
||||
@@ -566,6 +566,133 @@ pub fn level_transform(ch: usize, from: &[f32], to: &[f32], a: Adapt) -> Vec<(f3
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One stroke of the Content-Aware Fill workspace's Sampling Brush: a hard round brush of
|
||||
/// diameter `size` along `points` (document px), adding to the sampling area or taking from it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SamplingStroke {
|
||||
pub add: bool,
|
||||
pub size: f64,
|
||||
pub points: Vec<[f64; 2]>,
|
||||
}
|
||||
|
||||
impl SamplingStroke {
|
||||
/// Bounds of the pixels the stroke covers.
|
||||
pub fn bounds(&self) -> Rect {
|
||||
let r = (self.size / 2.0).max(0.5);
|
||||
self.points.iter().fold(Rect::EMPTY, |acc, p| {
|
||||
let b = Rect::new(
|
||||
(p[0] - r).floor() as i32,
|
||||
(p[1] - r).floor() as i32,
|
||||
((p[0] + r).ceil() as i32).saturating_add(1),
|
||||
((p[1] + r).ceil() as i32).saturating_add(1),
|
||||
);
|
||||
if acc.is_empty() { b } else { acc.union(&b) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Squared distance from `p` to the segment `a`–`b`.
|
||||
fn seg_dist2(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let l2 = dx * dx + dy * dy;
|
||||
let t = if l2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let (qx, qy) = (a[0] + t * dx - p[0], a[1] + t * dy - p[1]);
|
||||
qx * qx + qy * qy
|
||||
}
|
||||
|
||||
/// Paint `strokes` in order into `mask` (row-major over `area`): a pixel whose centre lies within
|
||||
/// half the brush size of a stroke's path is set (adding) or cleared (subtracting). A mask that
|
||||
/// does not cover `area` exactly is left as it is.
|
||||
pub fn paint_strokes(mask: &mut [bool], area: Rect, strokes: &[SamplingStroke]) {
|
||||
let w = area.width() as usize;
|
||||
if mask.len() != w * area.height() as usize {
|
||||
return;
|
||||
}
|
||||
for s in strokes {
|
||||
let r = (s.size / 2.0).max(0.5);
|
||||
let r2 = r.powi(2);
|
||||
let segs: Vec<([f64; 2], [f64; 2])> = match s.points.as_slice() {
|
||||
[] => continue,
|
||||
[p] => vec![(*p, *p)],
|
||||
pts => pts.windows(2).map(|p| (p[0], p[1])).collect(),
|
||||
};
|
||||
// A stroke covers the union of its segments' pixels: each segment only looks at its own
|
||||
// bounds.
|
||||
for (a, b) in segs {
|
||||
let bounds = Rect::new(
|
||||
(a[0].min(b[0]) - r).floor() as i32,
|
||||
(a[1].min(b[1]) - r).floor() as i32,
|
||||
((a[0].max(b[0]) + r).ceil() as i32).saturating_add(1),
|
||||
((a[1].max(b[1]) + r).ceil() as i32).saturating_add(1),
|
||||
)
|
||||
.intersect(&area);
|
||||
for y in bounds.y0..bounds.y1 {
|
||||
for x in bounds.x0..bounds.x1 {
|
||||
let c = [f64::from(x) + 0.5, f64::from(y) + 0.5];
|
||||
if seg_dist2(c, a, b) <= r2
|
||||
&& let Some(m) = mask.get_mut((y - area.y0) as usize * w + (x - area.x0) as usize)
|
||||
{
|
||||
*m = s.add;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixels of `area` (row-major) whose centres lie inside the polygon `pts` (even-odd rule):
|
||||
/// what a lasso outlines.
|
||||
pub fn polygon_mask(area: Rect, pts: &[[f64; 2]]) -> Vec<bool> {
|
||||
let (w, h) = (area.width() as usize, area.height() as usize);
|
||||
let mut out = vec![false; w * h];
|
||||
if pts.len() < 3 {
|
||||
return out;
|
||||
}
|
||||
let mut xs: Vec<f64> = Vec::new();
|
||||
for row in 0..h {
|
||||
let cy = f64::from(area.y0) + row as f64 + 0.5;
|
||||
xs.clear();
|
||||
for i in 0..pts.len() {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
|
||||
if (a[1] <= cy) != (b[1] <= cy) {
|
||||
xs.push(a[0] + (cy - a[1]) / (b[1] - a[1]) * (b[0] - a[0]));
|
||||
}
|
||||
}
|
||||
xs.sort_by(f64::total_cmp);
|
||||
for pair in xs.as_chunks::<2>().0 {
|
||||
// Pixel centres x + 0.5 within [pair0, pair1).
|
||||
let x0 = ((pair[0] - 0.5).ceil() as i64 - i64::from(area.x0)).clamp(0, w as i64) as usize;
|
||||
let x1 = ((pair[1] - 0.5).ceil() as i64 - i64::from(area.x0)).clamp(0, w as i64) as usize;
|
||||
out[row * w + x0..row * w + x1.max(x0)].iter_mut().for_each(|v| *v = true);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Grow (`by` > 0) or shrink (`by` < 0) a mask by `|by|` pixels (Euclidean): the lasso's Expand
|
||||
/// and Contract.
|
||||
pub fn grow_mask(mask: &[bool], w: usize, h: usize, by: i32) -> Vec<bool> {
|
||||
if by == 0 || w == 0 || h == 0 {
|
||||
return mask.to_vec();
|
||||
}
|
||||
let r = f32::from(u16::try_from(by.unsigned_abs()).unwrap_or(u16::MAX));
|
||||
if by > 0 {
|
||||
let d = crate::selection::edt(mask, w, h);
|
||||
d.iter().map(|v| *v <= r).collect()
|
||||
} else {
|
||||
let outside: Vec<bool> = mask.iter().map(|v| !v).collect();
|
||||
let d = crate::selection::edt(&outside, w, h);
|
||||
d.iter().map(|v| *v > r).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The Sampling Brush's starting size for a selection with bounds `hole`: a tenth of the sampling
|
||||
/// window's side, as Photoshop opens the workspace (20 px for a 50 px selection, 40 for 100).
|
||||
pub fn default_brush_size(hole: Rect) -> f64 {
|
||||
let (w, h) = (f64::from(hole.width().max(50)), f64::from(hole.height().max(50)));
|
||||
(0.4 * (w * h).sqrt()).round()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -577,6 +704,43 @@ mod tests {
|
||||
(img, hole)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strokes_paint_their_path_and_leave_a_mismatched_mask_alone() {
|
||||
let area = Rect::new(10, 20, 50, 40);
|
||||
let (w, h) = (40, 20);
|
||||
let strokes = [
|
||||
SamplingStroke { add: true, size: 6.0, points: vec![[12.0, 25.0], [40.0, 25.0], [45.0, 35.0]] },
|
||||
SamplingStroke { add: false, size: 3.0, points: vec![[30.0, 25.0]] },
|
||||
SamplingStroke { add: true, size: 2.0, points: Vec::new() },
|
||||
];
|
||||
let mut mask = vec![false; w * h];
|
||||
paint_strokes(&mut mask, area, &strokes);
|
||||
// The same pixels as testing every pixel against every segment of each stroke in turn.
|
||||
let mut want = vec![false; w * h];
|
||||
for s in &strokes {
|
||||
let r2 = (s.size / 2.0).max(0.5).powi(2);
|
||||
let segs: Vec<_> = if s.points.len() == 1 { vec![(s.points[0], s.points[0])] } else { s.points.windows(2).map(|p| (p[0], p[1])).collect() };
|
||||
for (i, m) in want.iter_mut().enumerate() {
|
||||
let c = [f64::from(area.x0) + (i % w) as f64 + 0.5, f64::from(area.y0) + (i / w) as f64 + 0.5];
|
||||
if segs.iter().any(|(a, b)| seg_dist2(c, *a, *b) <= r2) {
|
||||
*m = s.add;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(mask, want);
|
||||
assert!(mask[5 * w + 10] && !mask[5 * w + 20], "added along the path, cleared under the subtracting dab");
|
||||
// A mask shorter or longer than the area is not touched (and nothing panics).
|
||||
for len in [0, 7, w * h - 1, w * h + 3] {
|
||||
let mut short = vec![false; len];
|
||||
paint_strokes(&mut short, area, &strokes);
|
||||
assert!(short.iter().all(|v| !v), "len {len}");
|
||||
}
|
||||
// Hostile coordinates stay inside the area.
|
||||
let mut m = vec![false; w * h];
|
||||
let wild = [SamplingStroke { add: true, size: 5000.0, points: vec![[f64::MAX, -1e300], [f64::NAN, 1e7]] }];
|
||||
paint_strokes(&mut m, area, &wild);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_fill_stops_and_progress_reaches_one() {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
+36
-19
@@ -28,32 +28,47 @@ pub(crate) fn centre(b: Rect) -> (f32, f32, f32) {
|
||||
(b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w.min(h) / 2.0)
|
||||
}
|
||||
|
||||
/// The bounds' centre and half width and height: Twirl, Pinch and Spherize act inside the ellipse
|
||||
/// inscribed in the bounds, as Photoshop's do, so on a wide or tall image the effect reaches all
|
||||
/// four edges instead of stopping at a circle fitted to the shorter side (#5643). A point maps to
|
||||
/// unit-circle coordinates `(u, v)` and back.
|
||||
fn ellipse(b: Rect) -> (f32, f32, f32, f32) {
|
||||
let (w, h) = (b.width() as f32, b.height() as f32);
|
||||
(b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w / 2.0, h / 2.0)
|
||||
}
|
||||
|
||||
pub(crate) fn twirl(src: &Image, out: Rect, ctx: &Ctx, angle: f32) -> Vec<f32> {
|
||||
let (cx, cy, rr) = centre(ctx.bounds);
|
||||
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
|
||||
let a = angle.to_radians();
|
||||
remap(src, out, ctx, Edge::Repeat, |x, y| {
|
||||
let (dx, dy) = (x - cx, y - cy);
|
||||
let r = (dx * dx + dy * dy).sqrt();
|
||||
if r >= rr || rr <= 0.0 {
|
||||
if hx <= 0.0 || hy <= 0.0 {
|
||||
return (x, y);
|
||||
}
|
||||
let t = a * (1.0 - r / rr);
|
||||
let (u, v) = ((x - cx) / hx, (y - cy) / hy);
|
||||
let r = (u * u + v * v).sqrt();
|
||||
if r >= 1.0 {
|
||||
return (x, y);
|
||||
}
|
||||
let t = a * (1.0 - r);
|
||||
let (s, c) = t.sin_cos();
|
||||
(cx + dx * c - dy * s, cy + dx * s + dy * c)
|
||||
(cx + (u * c - v * s) * hx, cy + (u * s + v * c) * hy)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn pinch(src: &Image, out: Rect, ctx: &Ctx, amount: f32) -> Vec<f32> {
|
||||
let (cx, cy, rr) = centre(ctx.bounds);
|
||||
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
|
||||
let a = (amount / 100.0).clamp(-1.0, 1.0);
|
||||
remap(src, out, ctx, Edge::Repeat, |x, y| {
|
||||
let (dx, dy) = (x - cx, y - cy);
|
||||
let r = (dx * dx + dy * dy).sqrt();
|
||||
if r >= rr || r <= 0.0 {
|
||||
if hx <= 0.0 || hy <= 0.0 {
|
||||
return (x, y);
|
||||
}
|
||||
let rs = rr * (r / rr).powf(1.0 - a * 0.5);
|
||||
(cx + dx * rs / r, cy + dy * rs / r)
|
||||
let (u, v) = ((x - cx) / hx, (y - cy) / hy);
|
||||
let r = (u * u + v * v).sqrt();
|
||||
if r >= 1.0 || r <= 0.0 {
|
||||
return (x, y);
|
||||
}
|
||||
let k = r.powf(1.0 - a * 0.5) / r;
|
||||
(cx + u * k * hx, cy + v * k * hy)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -64,20 +79,22 @@ fn sphere_1d(rn: f32, a: f32) -> f32 {
|
||||
}
|
||||
|
||||
pub(crate) fn spherize(src: &Image, out: Rect, ctx: &Ctx, amount: f32, mode: SpherizeMode) -> Vec<f32> {
|
||||
let b = ctx.bounds;
|
||||
let (cx, cy, rr) = centre(b);
|
||||
let (hx, hy) = (b.width() as f32 / 2.0, b.height() as f32 / 2.0);
|
||||
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
|
||||
let a = (amount / 100.0).clamp(-1.0, 1.0);
|
||||
remap(src, out, ctx, Edge::Repeat, |x, y| {
|
||||
let (dx, dy) = (x - cx, y - cy);
|
||||
match mode {
|
||||
SpherizeMode::Normal => {
|
||||
let r = (dx * dx + dy * dy).sqrt();
|
||||
if r >= rr || r <= 0.0 {
|
||||
if hx <= 0.0 || hy <= 0.0 {
|
||||
return (x, y);
|
||||
}
|
||||
let rs = rr * sphere_1d(r / rr, a);
|
||||
(cx + dx * rs / r, cy + dy * rs / r)
|
||||
let (u, v) = (dx / hx, dy / hy);
|
||||
let r = (u * u + v * v).sqrt();
|
||||
if r >= 1.0 || r <= 0.0 {
|
||||
return (x, y);
|
||||
}
|
||||
let k = sphere_1d(r, a) / r;
|
||||
(cx + u * k * hx, cy + v * k * hy)
|
||||
}
|
||||
SpherizeMode::HorizontalOnly => {
|
||||
if dx.abs() >= hx {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Face detection: a port of libfacedetection's CNN (Shiqi Yu, BSD-3; `data/LICENSE-libfacedetection.txt`).
|
||||
//!
|
||||
//! The network is the YuNet design: a backbone of 1×1 pointwise and 3×3 depthwise convolutions with
|
||||
//! 2×2 max pooling, a feature pyramid at strides 8, 16 and 32, and per-cell heads for the face score,
|
||||
//! the box and five landmarks (eyes, nose tip, mouth corners). The weights (`data/libfacedetection.bin`,
|
||||
//! converted to half precision from the library's `facedetectcnn-data.cpp`) are about 100 KB, and
|
||||
//! inference is plain Rust: no runtime, so it works offline.
|
||||
//!
|
||||
//! [`detect`] takes straight RGBA in `0..=1` and returns faces in pixel coordinates of that image.
|
||||
//!
|
||||
//! The browser build (wasm32) leaves the network and its weights out, as `photocraft-raw` does with
|
||||
//! its JPEG XL decoder: they would push the wasm over the per-file cap of static hosts
|
||||
//! (packaging/web/package.sh). There [`AVAILABLE`] is false and [`detect`] finds nothing.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod cnn;
|
||||
|
||||
/// Whether this build can detect faces (not the browser build: see the module docs).
|
||||
pub const AVAILABLE: bool = cfg!(not(target_arch = "wasm32"));
|
||||
|
||||
/// Longest side the network looks at; larger images are scaled down first (faces smaller than
|
||||
/// about 10 px at this scale are missed).
|
||||
pub const MAX_SIDE: usize = 1280;
|
||||
|
||||
/// One detected face, in pixels of the image passed to [`detect`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Face {
|
||||
/// Confidence `0..=1` (the library reports faces above 0.5 as reliable).
|
||||
pub score: f32,
|
||||
/// Left, top, width and height.
|
||||
pub rect: [f32; 4],
|
||||
/// Right eye, left eye (as seen in the image: the first is on the image's left), nose tip,
|
||||
/// right mouth corner, left mouth corner.
|
||||
pub landmarks: [[f32; 2]; 5],
|
||||
}
|
||||
|
||||
/// Faces in `px` (straight RGBA `0..=1`, `w × h`, row-major) scoring at least `min_score`, best
|
||||
/// first. Transparent pixels count as white. `None` when `px` doesn't match the size.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn detect(px: &[[f32; 4]], w: usize, h: usize, min_score: f32) -> Option<Vec<Face>> {
|
||||
if w == 0 || h == 0 || px.len() != w.checked_mul(h)? {
|
||||
return None;
|
||||
}
|
||||
// Scale down (box filter) so the longer side is at most MAX_SIDE.
|
||||
let step = w.max(h).div_ceil(MAX_SIDE).max(1);
|
||||
let (sw, sh) = (w.div_ceil(step), h.div_ceil(step));
|
||||
let mut bgr = vec![[0.0f32; 3]; sw * sh];
|
||||
for y in 0..sh {
|
||||
for x in 0..sw {
|
||||
let (mut acc, mut n) = ([0.0f32; 3], 0.0f32);
|
||||
for yy in y * step..((y + 1) * step).min(h) {
|
||||
for xx in x * step..((x + 1) * step).min(w) {
|
||||
let [r, g, b, a] = px[yy * w + xx];
|
||||
let a = a.clamp(0.0, 1.0);
|
||||
for (k, v) in [b, g, r].into_iter().enumerate() {
|
||||
acc[k] += v.clamp(0.0, 1.0) * a + (1.0 - a);
|
||||
}
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
bgr[y * sw + x] = acc.map(|v| (v / n.max(1.0) * 255.0).round());
|
||||
}
|
||||
}
|
||||
let s = step as f32;
|
||||
let faces = cnn::network(&bgr, sw, sh)?
|
||||
.into_iter()
|
||||
.filter(|f| f.score >= min_score)
|
||||
.map(|f| Face { score: f.score, rect: f.rect.map(|v| v * s), landmarks: f.landmarks.map(|[x, y]| [x * s, y * s]) })
|
||||
.collect();
|
||||
Some(faces)
|
||||
}
|
||||
|
||||
/// The browser build has no detector (see the module docs): always `None`.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn detect(_px: &[[f32; 4]], _w: usize, _h: usize, _min_score: f32) -> Option<Vec<Face>> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_faces_in_flat_or_tiny_images_and_bad_sizes_are_refused() {
|
||||
assert_eq!(detect(&vec![[0.5, 0.5, 0.5, 1.0]; 64 * 48], 64, 48, 0.5), Some(vec![]));
|
||||
assert_eq!(detect(&[[1.0, 1.0, 1.0, 1.0]], 1, 1, 0.5), Some(vec![]));
|
||||
assert_eq!(detect(&[[1.0; 4]; 3], 2, 2, 0.5), None);
|
||||
assert_eq!(detect(&[], 0, 0, 0.5), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
//! The network itself (native builds only: see the parent module's docs).
|
||||
|
||||
use super::Face;
|
||||
use crate::par;
|
||||
|
||||
/// The weights: `PCFACE1\0`, the layer count (u16), then per layer its input channels (u16), filters
|
||||
/// (u16) and kind (u8: 0 pointwise, 1 depthwise), the weights and the biases, all little-endian f16.
|
||||
static WEIGHTS: &[u8] = include_bytes!("../../data/libfacedetection.bin");
|
||||
|
||||
const MAGIC: &[u8] = b"PCFACE1\0";
|
||||
const LAYERS: usize = 53;
|
||||
/// Faces whose boxes overlap more than this (intersection over union) are one face.
|
||||
const NMS_OVERLAP: f32 = 0.45;
|
||||
/// Candidates kept before non-maximum suppression, as the library does.
|
||||
const TOP_K: usize = 1000;
|
||||
|
||||
struct Layer {
|
||||
channels: usize,
|
||||
filters: usize,
|
||||
depthwise: bool,
|
||||
weights: Vec<f32>,
|
||||
biases: Vec<f32>,
|
||||
}
|
||||
|
||||
/// A feature map: `rows × cols` cells of `channels` values each.
|
||||
#[derive(Clone)]
|
||||
struct Blob {
|
||||
rows: usize,
|
||||
cols: usize,
|
||||
channels: usize,
|
||||
data: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Blob {
|
||||
fn new(rows: usize, cols: usize, channels: usize) -> Self {
|
||||
Blob { rows, cols, channels, data: vec![0.0; rows * cols * channels] }
|
||||
}
|
||||
}
|
||||
|
||||
fn f16_to_f32(h: u16) -> f32 {
|
||||
let sign = if h & 0x8000 != 0 { -1.0 } else { 1.0 };
|
||||
let exp = i32::from((h >> 10) & 0x1f);
|
||||
let frac = f32::from(h & 0x3ff);
|
||||
match exp {
|
||||
0 => sign * frac * (2.0f32).powi(-24),
|
||||
31 => {
|
||||
if frac == 0.0 {
|
||||
sign * f32::INFINITY
|
||||
} else {
|
||||
f32::NAN
|
||||
}
|
||||
}
|
||||
_ => sign * (1.0 + frac / 1024.0) * (2.0f32).powi(exp - 15),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(bytes: &[u8]) -> Option<Vec<Layer>> {
|
||||
let rest = bytes.strip_prefix(MAGIC)?;
|
||||
let mut at = 0usize;
|
||||
let u16_at = |n: &mut usize| -> Option<u16> {
|
||||
let b = rest.get(*n..*n + 2)?;
|
||||
*n += 2;
|
||||
Some(u16::from_le_bytes([b[0], b[1]]))
|
||||
};
|
||||
let count = usize::from(u16_at(&mut at)?);
|
||||
if count != LAYERS {
|
||||
return None;
|
||||
}
|
||||
let mut layers = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let channels = usize::from(u16_at(&mut at)?);
|
||||
let filters = usize::from(u16_at(&mut at)?);
|
||||
let depthwise = *rest.get(at)? == 1;
|
||||
at += 1;
|
||||
let nw = if depthwise { 9 * channels } else { filters * channels };
|
||||
let mut read = |n: usize| -> Option<Vec<f32>> { (0..n).map(|_| u16_at(&mut at).map(f16_to_f32)).collect() };
|
||||
let weights = read(nw)?;
|
||||
let biases = read(filters)?;
|
||||
if depthwise && filters != channels {
|
||||
return None;
|
||||
}
|
||||
layers.push(Layer { channels, filters, depthwise, weights, biases });
|
||||
}
|
||||
(at == rest.len()).then_some(layers)
|
||||
}
|
||||
|
||||
/// A 1×1 or 3×3 depthwise convolution with bias, then ReLU when `relu`.
|
||||
fn conv(x: &Blob, l: &Layer, relu: bool) -> Option<Blob> {
|
||||
if x.channels != l.channels {
|
||||
return None;
|
||||
}
|
||||
let mut out = Blob::new(x.rows, x.cols, l.filters);
|
||||
let (rows, cols, cin, cout) = (x.rows, x.cols, x.channels, l.filters);
|
||||
par::chunks_mut(&mut out.data, cols * cout, rows * cols * cout > 1 << 16, |row, line| {
|
||||
for col in 0..cols {
|
||||
let o = &mut line[col * cout..(col + 1) * cout];
|
||||
if l.depthwise {
|
||||
o.copy_from_slice(&l.biases);
|
||||
for r in row.saturating_sub(1)..(row + 2).min(rows) {
|
||||
for c in col.saturating_sub(1)..(col + 2).min(cols) {
|
||||
let k = (r + 1 - row) * 3 + (c + 1 - col);
|
||||
let input = &x.data[(r * cols + c) * cin..(r * cols + c + 1) * cin];
|
||||
let w = &l.weights[k * cin..(k + 1) * cin];
|
||||
for ((o, i), w) in o.iter_mut().zip(input).zip(w) {
|
||||
*o += i * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let input = &x.data[(row * cols + col) * cin..(row * cols + col + 1) * cin];
|
||||
for (f, o) in o.iter_mut().enumerate() {
|
||||
let w = &l.weights[f * cin..(f + 1) * cin];
|
||||
*o = l.biases[f] + input.iter().zip(w).map(|(i, w)| i * w).sum::<f32>();
|
||||
}
|
||||
}
|
||||
if relu {
|
||||
for v in o.iter_mut() {
|
||||
*v = v.max(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Pointwise then depthwise (ReLU after the depthwise one when `relu`).
|
||||
fn conv_dp(x: &Blob, p: &Layer, d: &Layer, relu: bool) -> Option<Blob> {
|
||||
conv(&conv(x, p, false)?, d, relu)
|
||||
}
|
||||
|
||||
fn unit(x: &Blob, l: &[Layer]) -> Option<Blob> {
|
||||
let [p1, d1, p2, d2] = l else { return None };
|
||||
conv_dp(&conv_dp(x, p1, d1, true)?, p2, d2, true)
|
||||
}
|
||||
|
||||
fn max_pool(x: &Blob) -> Blob {
|
||||
let rows = x.rows.saturating_sub(3).div_ceil(2) + 1;
|
||||
let cols = x.cols.saturating_sub(3).div_ceil(2) + 1;
|
||||
let mut out = Blob::new(rows, cols, x.channels);
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
let o = (r * cols + c) * x.channels;
|
||||
for ch in 0..x.channels {
|
||||
let mut m = f32::NEG_INFINITY;
|
||||
for rr in r * 2..(r * 2 + 2).min(x.rows) {
|
||||
for cc in c * 2..(c * 2 + 2).min(x.cols) {
|
||||
m = m.max(x.data[(rr * x.cols + cc) * x.channels + ch]);
|
||||
}
|
||||
}
|
||||
out.data[o + ch] = m;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `small` upsampled ×2 (nearest) plus `big`.
|
||||
fn up_add(small: &Blob, big: &Blob) -> Option<Blob> {
|
||||
if small.rows * 2 != big.rows || small.cols * 2 != big.cols || small.channels != big.channels {
|
||||
return None;
|
||||
}
|
||||
let mut out = big.clone();
|
||||
let ch = big.channels;
|
||||
for r in 0..big.rows {
|
||||
for c in 0..big.cols {
|
||||
let s = ((r / 2) * small.cols + c / 2) * ch;
|
||||
let o = (r * big.cols + c) * ch;
|
||||
for k in 0..ch {
|
||||
out.data[o + k] += small.data[s + k];
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The network's input: each cell holds the 3×3 neighbourhood (stride 2) of BGR values `0..=255`,
|
||||
/// so the first, 3×3 stride-2 convolution is a pointwise one. The size is padded to 32.
|
||||
fn input(bgr: &[[f32; 3]], w: usize, h: usize) -> Blob {
|
||||
let rows = h.div_ceil(32) * 16;
|
||||
let cols = w.div_ceil(32) * 16;
|
||||
let mut b = Blob::new(rows, cols, 32);
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
let cell = (r * cols + c) * 32;
|
||||
for fy in 0..3 {
|
||||
let Some(y) = (r * 2 + fy).checked_sub(1).filter(|&y| y < h) else { continue };
|
||||
for fx in 0..3 {
|
||||
let Some(x) = (c * 2 + fx).checked_sub(1).filter(|&x| x < w) else { continue };
|
||||
let o = cell + (fy * 3 + fx) * 3;
|
||||
b.data[o..o + 3].copy_from_slice(&bgr[y * w + x]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// The heads of one pyramid level, decoded to candidate faces (in network pixels).
|
||||
fn candidates(feat: &Blob, l: &[Layer], heads: [usize; 4], stride: f32, out: &mut Vec<Face>) -> Option<()> {
|
||||
let head = |i: usize| conv_dp(feat, l.get(i)?, l.get(i + 1)?, false);
|
||||
let (cls, reg, obj, kps) = (head(heads[0])?, head(heads[1])?, head(heads[2])?, head(heads[3])?);
|
||||
if cls.channels != 1 || obj.channels != 1 || reg.channels != 4 || kps.channels != 10 {
|
||||
return None;
|
||||
}
|
||||
let sigmoid = |v: f32| 1.0 / (1.0 + (-v.clamp(-88.0, 88.0)).exp());
|
||||
for r in 0..feat.rows {
|
||||
for c in 0..feat.cols {
|
||||
let i = r * feat.cols + c;
|
||||
let score = (sigmoid(cls.data[i]) * sigmoid(obj.data[i])).sqrt();
|
||||
if score < 0.2 {
|
||||
continue;
|
||||
}
|
||||
let (px, py) = (c as f32 * stride, r as f32 * stride);
|
||||
let b = ®.data[i * 4..i * 4 + 4];
|
||||
let (cx, cy) = (b[0] * stride + px, b[1] * stride + py);
|
||||
let (bw, bh) = (b[2].exp() * stride, b[3].exp() * stride);
|
||||
let k = &kps.data[i * 10..i * 10 + 10];
|
||||
let mut landmarks = [[0.0; 2]; 5];
|
||||
for (n, lm) in landmarks.iter_mut().enumerate() {
|
||||
*lm = [k[2 * n] * stride + px, k[2 * n + 1] * stride + py];
|
||||
}
|
||||
out.push(Face { score, rect: [cx - bw / 2.0, cy - bh / 2.0, bw, bh], landmarks });
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn iou(a: &[f32; 4], b: &[f32; 4]) -> f32 {
|
||||
let w = ((a[0] + a[2]).min(b[0] + b[2]) - a[0].max(b[0])).max(0.0);
|
||||
let h = ((a[1] + a[3]).min(b[1] + b[3]) - a[1].max(b[1])).max(0.0);
|
||||
let i = w * h;
|
||||
if i <= 0.0 { 0.0 } else { i / (a[2] * a[3] + b[2] * b[3] - i) }
|
||||
}
|
||||
|
||||
/// The heads (cls, reg, obj, kps: their first layer) of the stride 8, 16 and 32 levels.
|
||||
const HEADS: [[usize; 4]; 3] = [[29, 35, 41, 47], [31, 37, 43, 49], [33, 39, 45, 51]];
|
||||
|
||||
/// The backbone and feature pyramid: the stride 8, 16 and 32 feature maps the heads read.
|
||||
fn features(bgr: &[[f32; 3]], w: usize, h: usize, l: &[Layer]) -> Option<[Blob; 3]> {
|
||||
let x = conv(&input(bgr, w, h), l.first()?, true)?;
|
||||
let x = max_pool(&conv_dp(&x, l.get(1)?, l.get(2)?, true)?);
|
||||
let x = unit(&unit(&x, l.get(3..7)?)?, l.get(7..11)?)?;
|
||||
let f8 = unit(&max_pool(&x), l.get(11..15)?)?;
|
||||
let f16 = unit(&max_pool(&f8), l.get(15..19)?)?;
|
||||
let f32_ = unit(&max_pool(&f16), l.get(19..23)?)?;
|
||||
let f32_ = conv_dp(&f32_, l.get(27)?, l.get(28)?, true)?;
|
||||
let f16 = conv_dp(&up_add(&f32_, &f16)?, l.get(25)?, l.get(26)?, true)?;
|
||||
let f8 = conv_dp(&up_add(&f16, &f8)?, l.get(23)?, l.get(24)?, true)?;
|
||||
Some([f8, f16, f32_])
|
||||
}
|
||||
|
||||
pub(super) fn network(bgr: &[[f32; 3]], w: usize, h: usize) -> Option<Vec<Face>> {
|
||||
let l = parse(WEIGHTS)?;
|
||||
let levels = features(bgr, w, h, &l)?;
|
||||
let mut faces = Vec::new();
|
||||
// Heads (cls, reg, obj, kps) per level, as in the library's model.
|
||||
for ((feat, heads), stride) in levels.iter().zip(HEADS).zip([8.0, 16.0, 32.0]) {
|
||||
candidates(feat, &l, heads, stride, &mut faces)?;
|
||||
}
|
||||
faces.sort_by(|a, b| b.score.total_cmp(&a.score));
|
||||
faces.truncate(TOP_K);
|
||||
let mut kept: Vec<Face> = Vec::new();
|
||||
for f in faces {
|
||||
if kept.iter().all(|k| iou(&k.rect, &f.rect) <= NMS_OVERLAP) {
|
||||
kept.push(f);
|
||||
}
|
||||
}
|
||||
Some(kept)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn weights_parse() {
|
||||
let l = parse(WEIGHTS).expect("bundled weights");
|
||||
assert_eq!(l.len(), LAYERS);
|
||||
assert_eq!((l[0].channels, l[0].filters, l[0].depthwise), (32, 16, false));
|
||||
assert_eq!((l[2].channels, l[2].filters, l[2].depthwise), (16, 16, true));
|
||||
assert!(l.iter().all(|l| l.weights.iter().chain(&l.biases).all(|v| v.is_finite())));
|
||||
// Damaged weights are refused, not read past their end.
|
||||
assert!(parse(&WEIGHTS[..WEIGHTS.len() - 1]).is_none());
|
||||
assert!(parse(b"nope").is_none());
|
||||
}
|
||||
|
||||
/// The sum of every head's raw output on a fixed test image matches libfacedetection's own C++
|
||||
/// (computed with its f32 weights; ours are f16, hence the tolerance).
|
||||
#[test]
|
||||
fn heads_match_the_reference_implementation() {
|
||||
let (w, h) = (96usize, 72usize);
|
||||
let bgr: Vec<[f32; 3]> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let (r, g, b) = ((x * 7 + y * 3) % 256, (x * x + y * 5) % 256, (x * y * 3 + 11) % 256);
|
||||
[b as f32, g as f32, r as f32]
|
||||
})
|
||||
.collect();
|
||||
let l = parse(WEIGHTS).unwrap();
|
||||
let levels = features(&bgr, w, h, &l).unwrap();
|
||||
let reference =
|
||||
[27.647039, 549.579923, -1645.299678, 914.784787, -6.149318, 35.705980, -454.742148, 163.847111, 1.267630, 28.415509, -85.662257, 62.294208];
|
||||
let mut got = Vec::new();
|
||||
for (feat, heads) in levels.iter().zip(HEADS) {
|
||||
for first in heads {
|
||||
let o = conv_dp(feat, &l[first], &l[first + 1], false).unwrap();
|
||||
got.push(o.data.iter().map(|&v| f64::from(v)).sum::<f64>());
|
||||
}
|
||||
}
|
||||
for (g, r) in got.iter().zip(reference) {
|
||||
assert!((g - r).abs() <= 0.01 * r.abs().max(10.0), "{got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f16_decoding() {
|
||||
assert_eq!(f16_to_f32(0x3c00), 1.0);
|
||||
assert_eq!(f16_to_f32(0xc000), -2.0);
|
||||
assert_eq!(f16_to_f32(0x0000), 0.0);
|
||||
assert_eq!(f16_to_f32(0x3555), 0.333_251_95);
|
||||
assert_eq!(f16_to_f32(0x0001), 2.0f32.powi(-24));
|
||||
}
|
||||
}
|
||||
+262
-54
@@ -60,25 +60,98 @@ impl Rng {
|
||||
}
|
||||
}
|
||||
|
||||
/// Columns per band of the vertical blur pass: 64 f32 = 256 bytes = four cache lines per row, so
|
||||
/// walking down a band touches whole lines, and the band's working set stays in cache.
|
||||
const BLUR_BAND: usize = 64;
|
||||
|
||||
/// Box blur of radius `r` (edges clamped): a running f32 sum along rows, then along columns.
|
||||
///
|
||||
/// Both passes are parallel and give bit for bit what the one-line-at-a-time version gave: every
|
||||
/// output sums its window in the same order. Rows are independent. For the column pass, the
|
||||
/// image is cut into bands of columns; each column keeps its own f32 running sum, started from
|
||||
/// its first window summed in order and updated in the same order, so only the loop nest changes
|
||||
/// (the inner loop runs across a band's columns and vectorises), never any column's arithmetic.
|
||||
fn blur(w: usize, h: usize, v: &[f32], r: usize) -> Vec<f32> {
|
||||
let pass = |src: &[f32], horizontal: bool| -> Vec<f32> {
|
||||
let mut dst = vec![0.0f32; src.len()];
|
||||
let (n, m) = if horizontal { (h, w) } else { (w, h) };
|
||||
for line in 0..n {
|
||||
let at = |k: i64| {
|
||||
let k = k.clamp(0, m as i64 - 1) as usize;
|
||||
if horizontal { src[line * w + k] } else { src[k * w + line] }
|
||||
};
|
||||
let mut acc: f32 = (-(r as i64)..=r as i64).map(at).sum();
|
||||
for k in 0..m {
|
||||
let i = if horizontal { line * w + k } else { k * w + line };
|
||||
dst[i] = acc / (2 * r + 1) as f32;
|
||||
acc += at(k as i64 + r as i64 + 1) - at(k as i64 - r as i64);
|
||||
if w == 0 || h == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let den = (2 * r + 1) as f32;
|
||||
let mut tmp = vec![0.0f32; w * h];
|
||||
par_rows(&mut tmp, w, |line, dst| {
|
||||
let src = &v[line * w..(line + 1) * w];
|
||||
let at = |k: i64| src[k.clamp(0, w as i64 - 1) as usize];
|
||||
let mut acc: f32 = (-(r as i64)..=r as i64).map(at).sum();
|
||||
for (k, o) in dst.iter_mut().enumerate() {
|
||||
*o = acc / den;
|
||||
acc += at(k as i64 + r as i64 + 1) - at(k as i64 - r as i64);
|
||||
}
|
||||
});
|
||||
let mut dst = vec![0.0f32; w * h];
|
||||
// One list of row segments per band, each segment a `&mut` slice of `dst`: no `unsafe`.
|
||||
let mut bands: Vec<Vec<&mut [f32]>> = (0..w.div_ceil(BLUR_BAND)).map(|_| Vec::with_capacity(h)).collect();
|
||||
for row in dst.chunks_exact_mut(w) {
|
||||
for (band, seg) in bands.iter_mut().zip(row.chunks_mut(BLUR_BAND)) {
|
||||
band.push(seg);
|
||||
}
|
||||
}
|
||||
let src = &tmp;
|
||||
let run = |bi: usize, segs: &mut [&mut [f32]], acc: &mut Vec<f32>| {
|
||||
let (x0, bw) = (bi * BLUR_BAND, segs.first().map_or(0, |s| s.len()));
|
||||
let row = |k: i64| {
|
||||
let o = (k.clamp(0, h as i64 - 1) as usize) * w + x0;
|
||||
&src[o..o + bw]
|
||||
};
|
||||
// The first window, summed in order from the same start as `Iterator::sum` uses.
|
||||
acc.clear();
|
||||
acc.resize(bw, std::iter::empty::<f32>().sum());
|
||||
for k in -(r as i64)..=r as i64 {
|
||||
for (t, &x) in acc.iter_mut().zip(row(k)) {
|
||||
*t += x;
|
||||
}
|
||||
}
|
||||
for (k, seg) in segs.iter_mut().enumerate() {
|
||||
let (add, sub) = (row(k as i64 + r as i64 + 1), row(k as i64 - r as i64));
|
||||
for (((o, t), &p), &m) in seg.iter_mut().zip(acc.iter_mut()).zip(add).zip(sub) {
|
||||
*o = *t / den;
|
||||
*t += p - m;
|
||||
}
|
||||
}
|
||||
dst
|
||||
};
|
||||
pass(&pass(v, true), false)
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
bands.into_par_iter().enumerate().for_each_init(Vec::new, |acc, (bi, mut segs)| run(bi, &mut segs, acc));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let mut acc = Vec::new();
|
||||
for (bi, mut segs) in bands.into_iter().enumerate() {
|
||||
run(bi, &mut segs, &mut acc);
|
||||
}
|
||||
}
|
||||
dst
|
||||
}
|
||||
|
||||
/// Runs `f(y, row)` over the `w`-wide rows of `buf`, in parallel on native targets.
|
||||
fn par_rows(buf: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32]) + Sync + Send) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
buf.par_chunks_mut(w).enumerate().for_each(|(y, row)| f(y, row));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
buf.chunks_mut(w).enumerate().for_each(|(y, row)| f(y, row));
|
||||
}
|
||||
|
||||
/// [`par_rows`] over three row-aligned buffers in lockstep.
|
||||
fn par_rows3(a: &mut [f32], b: &mut [f32], c: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32], &mut [f32], &mut [f32]) + Sync + Send) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
a.par_chunks_mut(w).zip(b.par_chunks_mut(w)).zip(c.par_chunks_mut(w)).enumerate().for_each(|(y, ((a, b), c))| f(y, a, b, c));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
a.chunks_mut(w).zip(b.chunks_mut(w)).zip(c.chunks_mut(w)).enumerate().for_each(|(y, ((a, b), c))| f(y, a, b, c));
|
||||
}
|
||||
|
||||
/// Harris corners: at most `max` corners at least `min_dist` pixels apart, strongest first,
|
||||
@@ -88,28 +161,40 @@ pub fn harris(w: usize, h: usize, img: &[f32], max: usize, min_dist: f32, valid:
|
||||
return Vec::new();
|
||||
}
|
||||
let (mut xx, mut yy, mut xy) = (vec![0.0f32; w * h], vec![0.0f32; w * h], vec![0.0f32; w * h]);
|
||||
for y in 1..h - 1 {
|
||||
// Rows are independent (each output pixel reads only `img`), so they run in parallel.
|
||||
par_rows3(&mut xx, &mut yy, &mut xy, w, |y, xxr, yyr, xyr| {
|
||||
if y == 0 || y + 1 == h {
|
||||
return;
|
||||
}
|
||||
for x in 1..w - 1 {
|
||||
let p = |dx: i64, dy: i64| img[((y as i64 + dy) as usize) * w + (x as i64 + dx) as usize];
|
||||
// Sobel.
|
||||
let gx = (p(1, -1) + 2.0 * p(1, 0) + p(1, 1)) - (p(-1, -1) + 2.0 * p(-1, 0) + p(-1, 1));
|
||||
let gy = (p(-1, 1) + 2.0 * p(0, 1) + p(1, 1)) - (p(-1, -1) + 2.0 * p(0, -1) + p(1, -1));
|
||||
let i = y * w + x;
|
||||
xx[i] = gx * gx;
|
||||
yy[i] = gy * gy;
|
||||
xy[i] = gx * gy;
|
||||
xxr[x] = gx * gx;
|
||||
yyr[x] = gy * gy;
|
||||
xyr[x] = gx * gy;
|
||||
}
|
||||
}
|
||||
});
|
||||
// Two box passes approximate the Gaussian window.
|
||||
let (xx, yy, xy) = (blur(w, h, &blur(w, h, &xx, 1), 1), blur(w, h, &blur(w, h, &yy, 1), 1), blur(w, h, &blur(w, h, &xy, 1), 1));
|
||||
let r: Vec<f32> = (0..w * h).map(|i| xx[i] * yy[i] - xy[i] * xy[i] - 0.04 * (xx[i] + yy[i]).powi(2)).collect();
|
||||
let mut r = vec![0.0f32; w * h];
|
||||
par_rows(&mut r, w, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let i = y * w + x;
|
||||
*o = xx[i] * yy[i] - xy[i] * xy[i] - 0.04 * (xx[i] + yy[i]).powi(2);
|
||||
}
|
||||
});
|
||||
let peak = r.iter().copied().fold(0.0f32, f32::max);
|
||||
if peak <= 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let m = PATCH_RADIUS + 1;
|
||||
let mut cand: Vec<Corner> = Vec::new();
|
||||
for y in m..h - m {
|
||||
// Rows are scanned in parallel and joined in row order; the sort below fully orders the
|
||||
// candidates anyway (score, then y, then x), so the order they arrive in cannot matter.
|
||||
let rows = par_map((h - m).saturating_sub(m), |i| {
|
||||
let y = m + i;
|
||||
let mut found: Vec<Corner> = Vec::new();
|
||||
for x in m..w - m {
|
||||
let v = r[y * w + x];
|
||||
if v < peak * 0.01 {
|
||||
@@ -125,9 +210,11 @@ pub fn harris(w: usize, h: usize, img: &[f32], max: usize, min_dist: f32, valid:
|
||||
continue;
|
||||
}
|
||||
}
|
||||
cand.push(Corner { x: x as f32, y: y as f32, score: v });
|
||||
found.push(Corner { x: x as f32, y: y as f32, score: v });
|
||||
}
|
||||
}
|
||||
found
|
||||
});
|
||||
let mut cand: Vec<Corner> = rows.into_iter().flatten().collect();
|
||||
cand.sort_by(|a, b| b.score.total_cmp(&a.score).then(a.y.total_cmp(&b.y)).then(a.x.total_cmp(&b.x)));
|
||||
// Minimum spacing via a coarse occupancy grid.
|
||||
let cell = min_dist.max(1.0);
|
||||
@@ -176,37 +263,37 @@ pub fn describe(w: usize, h: usize, img: &[f32], corners: &[Corner], upright: bo
|
||||
let (xi, yi) = ((x.round() as i64).clamp(0, w as i64 - 1) as usize, (y.round() as i64).clamp(0, h as i64 - 1) as usize);
|
||||
smooth[yi * w + xi]
|
||||
};
|
||||
corners
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let (s, co) = if upright {
|
||||
(0.0, 1.0)
|
||||
} else {
|
||||
let (mut m10, mut m01) = (0.0f32, 0.0f32);
|
||||
let r = PATCH_RADIUS as i64;
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
if dx * dx + dy * dy <= r * r {
|
||||
let v = at(c.x + dx as f32, c.y + dy as f32);
|
||||
m10 += dx as f32 * v;
|
||||
m01 += dy as f32 * v;
|
||||
}
|
||||
// Every descriptor depends only on its own corner and the smoothed image: computed in
|
||||
// parallel, kept in corner order.
|
||||
par_map(corners.len(), |ci| {
|
||||
let c = &corners[ci];
|
||||
let (s, co) = if upright {
|
||||
(0.0, 1.0)
|
||||
} else {
|
||||
let (mut m10, mut m01) = (0.0f32, 0.0f32);
|
||||
let r = PATCH_RADIUS as i64;
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
if dx * dx + dy * dy <= r * r {
|
||||
let v = at(c.x + dx as f32, c.y + dy as f32);
|
||||
m10 += dx as f32 * v;
|
||||
m01 += dy as f32 * v;
|
||||
}
|
||||
}
|
||||
m01.atan2(m10).sin_cos()
|
||||
};
|
||||
let mut d = [0u64; 4];
|
||||
for (i, p) in pairs().iter().enumerate() {
|
||||
let rot = |x: f32, y: f32| (c.x + x * co - y * s, c.y + x * s + y * co);
|
||||
let (ax, ay) = rot(p[0], p[1]);
|
||||
let (bx, by) = rot(p[2], p[3]);
|
||||
if at(ax, ay) < at(bx, by) {
|
||||
d[i / 64] |= 1 << (i % 64);
|
||||
}
|
||||
}
|
||||
d
|
||||
})
|
||||
.collect()
|
||||
m01.atan2(m10).sin_cos()
|
||||
};
|
||||
let mut d = [0u64; 4];
|
||||
for (i, p) in pairs().iter().enumerate() {
|
||||
let rot = |x: f32, y: f32| (c.x + x * co - y * s, c.y + x * s + y * co);
|
||||
let (ax, ay) = rot(p[0], p[1]);
|
||||
let (bx, by) = rot(p[2], p[3]);
|
||||
if at(ax, ay) < at(bx, by) {
|
||||
d[i / 64] |= 1 << (i % 64);
|
||||
}
|
||||
}
|
||||
d
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hamming(a: &Descriptor, b: &Descriptor) -> u32 {
|
||||
@@ -560,3 +647,124 @@ mod tests {
|
||||
assert_eq!(hamming(&[0, 0, 0, 0], &[1, 3, 0, u64::MAX]), 67);
|
||||
}
|
||||
}
|
||||
|
||||
/// The row / column-band parallel `blur`, `harris` and `describe` against the serial code they
|
||||
/// replaced. Every output must stay bit for bit the same.
|
||||
#[cfg(test)]
|
||||
mod parallel_tests {
|
||||
use super::*;
|
||||
|
||||
/// The serial blur exactly as it was before the parallelisation.
|
||||
fn ref_blur(w: usize, h: usize, v: &[f32], r: usize) -> Vec<f32> {
|
||||
let pass = |src: &[f32], horizontal: bool| -> Vec<f32> {
|
||||
let mut dst = vec![0.0f32; src.len()];
|
||||
let (n, m) = if horizontal { (h, w) } else { (w, h) };
|
||||
for line in 0..n {
|
||||
let at = |k: i64| {
|
||||
let k = k.clamp(0, m as i64 - 1) as usize;
|
||||
if horizontal { src[line * w + k] } else { src[k * w + line] }
|
||||
};
|
||||
let mut acc: f32 = (-(r as i64)..=r as i64).map(at).sum();
|
||||
for k in 0..m {
|
||||
let i = if horizontal { line * w + k } else { k * w + line };
|
||||
dst[i] = acc / (2 * r + 1) as f32;
|
||||
acc += at(k as i64 + r as i64 + 1) - at(k as i64 - r as i64);
|
||||
}
|
||||
}
|
||||
dst
|
||||
};
|
||||
pass(&pass(v, true), false)
|
||||
}
|
||||
|
||||
fn mix(mut z: u64) -> u64 {
|
||||
z = z.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// Integer-only data with negatives, signed zeros and large values, so a different order of
|
||||
/// additions cannot hide.
|
||||
fn noise(len: usize, seed: u64) -> Vec<f32> {
|
||||
(0..len as u64)
|
||||
.map(|i| {
|
||||
let z = mix(seed.wrapping_mul(0x1234_5678_9ABC).wrapping_add(i));
|
||||
let v = (z >> 40) as f32 / 16_777_216.0;
|
||||
match z & 31 {
|
||||
0 => -v,
|
||||
1 => -0.0,
|
||||
2 => v * 1000.0,
|
||||
_ => v,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Blocks of random brightness: lots of strong corners, no libm in the making. Only the
|
||||
/// recorded-hash test below uses it, and that one is Linux x86_64 only.
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
fn blocks(w: usize, h: usize, seed: u64) -> Vec<f32> {
|
||||
(0..w * h).map(|i| (mix(seed + ((i % w) / 8) as u64 * 4096 + ((i / w) / 8) as u64) >> 40) as f32 / 16_777_216.0).collect()
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
fn fnv(parts: &[u64]) -> u64 {
|
||||
let mut h = 0xcbf2_9ce4_8422_2325u64;
|
||||
for p in parts {
|
||||
h ^= p;
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blur_matches_the_serial_code() {
|
||||
// Widths around the 64-column band (1 band, band + 1, several bands, a ragged last one),
|
||||
// 1-wide and 1-high images, and radii 0, 1, 2, larger than the image.
|
||||
let widths = [1usize, 2, 3, 31, 63, 64, 65, 127, 128, 129, 200];
|
||||
let heights = [1usize, 2, 3, 5, 17, 40];
|
||||
for (a, &w) in widths.iter().enumerate() {
|
||||
for (b, &h) in heights.iter().enumerate() {
|
||||
let v = noise(w * h, (a * 10 + b) as u64);
|
||||
for r in [0usize, 1, 2, 3, 7, 50] {
|
||||
let (got, want) = (blur(w, h, &v, r), ref_blur(w, h, &v, r));
|
||||
let (g, e): (Vec<u32>, Vec<u32>) = (got.iter().map(|x| x.to_bits()).collect(), want.iter().map(|x| x.to_bits()).collect());
|
||||
assert_eq!(g, e, "blur {w}x{h} r={r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(blur(0, 0, &[], 1).is_empty());
|
||||
}
|
||||
|
||||
// `harris` and `describe` go through the platform's hypot / ln / cos / atan2 / sin_cos, so the
|
||||
// hashes recorded from the old code only hold where those are the same functions.
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
#[test]
|
||||
fn harris_and_describe_hashes_recorded_from_the_serial_code() {
|
||||
// Sizes: a whole number of 64-column bands and a ragged one; with and without a validity mask.
|
||||
for (w, h, max, md, want_corners, want_desc) in
|
||||
[(211usize, 157usize, 300usize, 6.0f32, 0xeccc6928d3ae3b8cu64, 0xd1e6caae4d68f253u64), (128, 97, 60, 3.0, 0xe3f6615117fa7da7, 0x818ae7e965ecf88f)]
|
||||
{
|
||||
let img = blocks(w, h, 7);
|
||||
let valid: Vec<bool> = (0..w * h).map(|i| !((i % w) > w / 2 && (i / w) < h / 3)).collect();
|
||||
let mut parts = Vec::new();
|
||||
for v in [None, Some(valid.as_slice())] {
|
||||
let c = harris(w, h, &img, max, md, v);
|
||||
for k in &c {
|
||||
parts.extend([k.x.to_bits() as u64, k.y.to_bits() as u64, k.score.to_bits() as u64]);
|
||||
}
|
||||
parts.push(c.len() as u64);
|
||||
}
|
||||
assert_eq!(fnv(&parts), want_corners, "harris {w}x{h}");
|
||||
let c = harris(w, h, &img, max, md, None);
|
||||
assert!(c.len() > 10, "{} corners", c.len());
|
||||
let mut parts = Vec::new();
|
||||
for upright in [false, true] {
|
||||
for d in describe(w, h, &img, &c, upright) {
|
||||
parts.extend(d);
|
||||
}
|
||||
}
|
||||
assert_eq!(fnv(&parts), want_desc, "describe {w}x{h}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ mod distort;
|
||||
mod distort2;
|
||||
pub mod erase;
|
||||
pub mod exif;
|
||||
pub mod faces;
|
||||
pub mod features;
|
||||
mod fxutil;
|
||||
mod gallery;
|
||||
|
||||
@@ -8,7 +8,8 @@ use crate::image::Image;
|
||||
use crate::{Ctx, HsbModel};
|
||||
|
||||
/// Custom: `Σ kernel·pixel / scale + offset/255` per colour channel (alpha
|
||||
/// kept). Kernels shorter than 25 entries are zero-padded.
|
||||
/// kept). Kernels shorter than 25 entries are zero-padded. CMYK samples are
|
||||
/// ink, so there it runs on `1 - ink` and a positive offset brightens.
|
||||
pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f32, offset: f32) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let cc = ncol(ctx, n);
|
||||
@@ -16,6 +17,7 @@ pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f
|
||||
(0..25).filter_map(|i| kernel.get(i).copied().filter(|v| *v != 0.0).map(|v| (i as i32 % 5 - 2, i as i32 / 5 - 2, v))).collect();
|
||||
let s = if scale == 0.0 { 1.0 } else { scale };
|
||||
let off = offset / 255.0;
|
||||
let sub = crate::fxutil::subtractive(ctx);
|
||||
let mut res = src.crop(out);
|
||||
if k.is_empty() {
|
||||
return res;
|
||||
@@ -23,8 +25,10 @@ pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f
|
||||
for (i, px) in res.chunks_exact_mut(n).enumerate() {
|
||||
let (x, y) = xy(out, i);
|
||||
for (c, v) in px.iter_mut().enumerate().take(cc) {
|
||||
let sum: f32 = k.iter().map(|&(dx, dy, w)| src.get(x + dx, y + dy, c) * w).sum();
|
||||
*v = sum / s + off;
|
||||
// CMYK: convolve brightness (1 - ink), then back to ink.
|
||||
let lum = |v: f32| if sub { 1.0 - v } else { v };
|
||||
let sum: f32 = k.iter().map(|&(dx, dy, w)| lum(src.get(x + dx, y + dy, c)) * w).sum();
|
||||
*v = lum(sum / s + off);
|
||||
}
|
||||
}
|
||||
res
|
||||
|
||||
+587
-90
@@ -35,7 +35,7 @@
|
||||
#![allow(clippy::needless_range_loop)] // index loops mirror the maths (pixels × channels)
|
||||
|
||||
use crate::features::{self, Descriptor, Model};
|
||||
use crate::photo_util::{Normal, Rng, par_map, solve_dense};
|
||||
use crate::photo_util::{Normal, Rng, par_map, par_rows, solve_dense};
|
||||
use crate::transform::Homography;
|
||||
|
||||
/// Photomerge layouts.
|
||||
@@ -996,58 +996,99 @@ pub fn blend_levels(w: usize, h: usize) -> usize {
|
||||
n
|
||||
}
|
||||
|
||||
/// `a[i] ⊕ b[i]` for every `i` (the shorter length wins, like `zip`), in parallel on native
|
||||
/// targets. Purely elementwise, so the result is the serial result bit for bit.
|
||||
fn par_zip(a: &[f32], b: &[f32], f: impl Fn(f32, f32) -> f32 + Sync + Send) -> Vec<f32> {
|
||||
const CHUNK: usize = 1 << 14;
|
||||
let mut out = vec![0.0f32; a.len().min(b.len())];
|
||||
let fill = |o: &mut [f32], a: &[f32], b: &[f32]| {
|
||||
for ((o, &a), &b) in o.iter_mut().zip(a).zip(b) {
|
||||
*o = f(a, b);
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
out.par_chunks_mut(CHUNK).zip(a.par_chunks(CHUNK)).zip(b.par_chunks(CHUNK)).for_each(|((o, a), b)| fill(o, a, b));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
out.chunks_mut(CHUNK).zip(a.chunks(CHUNK)).zip(b.chunks(CHUNK)).for_each(|((o, a), b)| fill(o, a, b));
|
||||
out
|
||||
}
|
||||
|
||||
/// `f(a[i])` for every `i`, in parallel on native targets (elementwise, so bit for bit serial).
|
||||
fn par_map1(a: &[f32], f: impl Fn(f32) -> f32 + Sync + Send) -> Vec<f32> {
|
||||
par_zip(a, a, |x, _| f(x))
|
||||
}
|
||||
|
||||
// `reduce`, `expand` and `push_pull` run one pyramid level for one image at a time, and the
|
||||
// images themselves only fill part of a many-core machine, so each of them also runs its rows
|
||||
// in parallel. Every output element is computed by the same expression, in the same order, as
|
||||
// the one-thread loops they replace (rows never read each other's output), so the result is
|
||||
// bit for bit unchanged.
|
||||
|
||||
fn reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> (usize, usize, Vec<f32>) {
|
||||
let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let k = [1.0f32, 4.0, 6.0, 4.0, 1.0];
|
||||
// Separable 5-tap binomial, then decimate.
|
||||
let mut tmp = vec![0.0f32; nw * h * ch];
|
||||
for y in 0..h {
|
||||
par_rows(&mut tmp, nw, ch, |y, row| {
|
||||
let line = &src[y * w * ch..(y + 1) * w * ch];
|
||||
for x in 0..nw {
|
||||
// The clamped source column of each tap is the same for every channel.
|
||||
let xs: [usize; 5] = std::array::from_fn(|t| (2 * x as i64 + t as i64 - 2).clamp(0, w as i64 - 1) as usize * ch);
|
||||
for c in 0..ch {
|
||||
let mut s = 0.0;
|
||||
for (t, kv) in k.iter().enumerate() {
|
||||
let xx = (2 * x as i64 + t as i64 - 2).clamp(0, w as i64 - 1) as usize;
|
||||
s += kv * src[(y * w + xx) * ch + c];
|
||||
for (kv, xx) in k.iter().zip(xs) {
|
||||
s += kv * line[xx + c];
|
||||
}
|
||||
tmp[(y * nw + x) * ch + c] = s / 16.0;
|
||||
row[x * ch + c] = s / 16.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut out = vec![0.0f32; nw * nh * ch];
|
||||
for y in 0..nh {
|
||||
for x in 0..nw {
|
||||
for c in 0..ch {
|
||||
let mut s = 0.0;
|
||||
for (t, kv) in k.iter().enumerate() {
|
||||
let yy = (2 * y as i64 + t as i64 - 2).clamp(0, h as i64 - 1) as usize;
|
||||
s += kv * tmp[(yy * nw + x) * ch + c];
|
||||
}
|
||||
out[(y * nw + x) * ch + c] = s / 16.0;
|
||||
par_rows(&mut out, nw, ch, |y, row| {
|
||||
// Each output row is five source rows added in tap order: the same sum per element as
|
||||
// before (`0 + k0·a + k1·b + …`), but over whole rows so it vectorises.
|
||||
for (t, kv) in k.iter().enumerate() {
|
||||
let yy = (2 * y as i64 + t as i64 - 2).clamp(0, h as i64 - 1) as usize;
|
||||
for (o, &v) in row.iter_mut().zip(&tmp[yy * nw * ch..(yy + 1) * nw * ch]) {
|
||||
*o += kv * v;
|
||||
}
|
||||
}
|
||||
}
|
||||
for o in row.iter_mut() {
|
||||
*o /= 16.0;
|
||||
}
|
||||
});
|
||||
(nw, nh, out)
|
||||
}
|
||||
|
||||
fn expand(sw: usize, sh: usize, ch: usize, src: &[f32], w: usize, h: usize) -> Vec<f32> {
|
||||
// Bilinear upsampling at half-pixel-aligned positions.
|
||||
let mut out = vec![0.0f32; w * h * ch];
|
||||
for y in 0..h {
|
||||
let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32);
|
||||
let (y0, ty) = (fy.floor() as usize, fy - fy.floor());
|
||||
let y1 = (y0 + 1).min(sh - 1);
|
||||
for x in 0..w {
|
||||
// Source columns and weights depend on x only: worked out once instead of for every row.
|
||||
// The clamped position is never negative, so truncating is `floor` (and spares the libm call).
|
||||
let cols: Vec<(usize, usize, f32)> = (0..w)
|
||||
.map(|x| {
|
||||
let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sw - 1) as f32);
|
||||
let (x0, tx) = (fx.floor() as usize, fx - fx.floor());
|
||||
let x1 = (x0 + 1).min(sw - 1);
|
||||
for c in 0..ch {
|
||||
let p = |xx: usize, yy: usize| src[(yy * sw + 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 * w + x) * ch + c] = top + (bot - top) * ty;
|
||||
let x0 = fx as usize;
|
||||
(x0 * ch, (x0 + 1).min(sw - 1) * ch, fx - x0 as f32)
|
||||
})
|
||||
.collect();
|
||||
par_rows(&mut out, w, ch, |y, row| {
|
||||
let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32);
|
||||
let y0 = fy as usize;
|
||||
let ty = fy - y0 as f32;
|
||||
let y1 = (y0 + 1).min(sh - 1);
|
||||
let (r0, r1) = (&src[y0 * sw * ch..(y0 + 1) * sw * ch], &src[y1 * sw * ch..(y1 + 1) * sw * ch]);
|
||||
for (px, &(x0, x1, tx)) in row.chunks_exact_mut(ch.max(1)).zip(&cols) {
|
||||
for (c, o) in px.iter_mut().enumerate() {
|
||||
let top = r0[x0 + c] + (r0[x1 + c] - r0[x0 + c]) * tx;
|
||||
let bot = r1[x0 + c] + (r1[x1 + c] - r1[x0 + c]) * tx;
|
||||
*o = top + (bot - top) * ty;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
@@ -1058,41 +1099,75 @@ fn push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32]) {
|
||||
return;
|
||||
}
|
||||
let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
|
||||
// Coarse sums. A coarse row is fed by two fine rows; walking them in raster order adds each
|
||||
// coarse cell's (up to) four fine pixels in the order the serial scatter-add did, from zero.
|
||||
let mut cpx = vec![0.0f32; nw * nh * ch];
|
||||
let mut cw = vec![0.0f32; nw * nh];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (i, j) = (y * w + x, (y / 2) * nw + x / 2);
|
||||
let a = wt[i];
|
||||
cw[j] += a;
|
||||
for c in 0..ch {
|
||||
cpx[j * ch + c] += a * px[i * ch + c];
|
||||
par_rows(&mut cw, nw, 1, |cy, row| {
|
||||
for y in 2 * cy..(2 * cy + 2).min(h) {
|
||||
for (x, &a) in wt[y * w..(y + 1) * w].iter().enumerate() {
|
||||
row[x / 2] += a;
|
||||
}
|
||||
}
|
||||
}
|
||||
for j in 0..nw * nh {
|
||||
if cw[j] > 0.0 {
|
||||
for c in 0..ch {
|
||||
cpx[j * ch + c] /= cw[j];
|
||||
});
|
||||
let px_in: &[f32] = px;
|
||||
par_rows(&mut cpx, nw, ch, |cy, row| {
|
||||
for y in 2 * cy..(2 * cy + 2).min(h) {
|
||||
for x in 0..w {
|
||||
let (i, j) = (y * w + x, x / 2);
|
||||
let a = wt[i];
|
||||
for c in 0..ch {
|
||||
row[j * ch + c] += a * px_in[i * ch + c];
|
||||
}
|
||||
}
|
||||
cw[j] = 1.0;
|
||||
}
|
||||
});
|
||||
// Normalise by the weight (this reads `cw` before it is reset to 0 / 1 below).
|
||||
par_rows(&mut cpx, nw, ch, |cy, row| {
|
||||
for cx in 0..nw {
|
||||
let wj = cw[cy * nw + cx];
|
||||
if wj > 0.0 {
|
||||
for c in 0..ch {
|
||||
row[cx * ch + c] /= wj;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
for v in cw.iter_mut() {
|
||||
if *v > 0.0 {
|
||||
*v = 1.0;
|
||||
}
|
||||
}
|
||||
if cw.contains(&0.0) && cw.iter().any(|v| *v > 0.0) {
|
||||
push_pull(nw, nh, ch, &mut cpx, &cw);
|
||||
}
|
||||
for y in 0..h {
|
||||
par_rows(px, w, ch, |y, row| {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
if wt[i] < 1.0 {
|
||||
let j = (y / 2) * nw + x / 2;
|
||||
let a = wt[i];
|
||||
for c in 0..ch {
|
||||
px[i * ch + c] = a * px[i * ch + c] + (1.0 - a) * cpx[j * ch + c];
|
||||
row[x * ch + c] = a * row[x * ch + c] + (1.0 - a) * cpx[j * ch + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One image's pyramid in [`multiband`]: the grid origin `(gx0, gy0)` at level 0, the Laplacian
|
||||
/// levels `(w, h, colours)` and the Gaussian weight levels.
|
||||
type PyramidBand = (i32, i32, Vec<(usize, usize, Vec<f32>)>, Vec<Vec<f32>>);
|
||||
|
||||
/// The part of `band` on panorama row `py` of level `l`: the panorama column its first pixel
|
||||
/// lands on, its width, its weight row and its colour row. `None` when it misses the row.
|
||||
fn band_row(band: &PyramidBand, l: usize, ch: usize, py: usize) -> Option<(usize, usize, &[f32], &[f32])> {
|
||||
let (ox, oy) = ((band.0 >> l) as usize, (band.1 >> l) as usize);
|
||||
let (lw, lh, colours) = &band.2[l];
|
||||
(py >= oy && py - oy < *lh).then(|| {
|
||||
let y = py - oy;
|
||||
(ox, *lw, &band.3[l][y * lw..(y + 1) * lw], &colours[y * lw * ch..(y + 1) * lw * ch])
|
||||
})
|
||||
}
|
||||
|
||||
/// Multi-band blend of `imgs` into a `w × h` panorama (origin 0,0) with one weight map per image
|
||||
@@ -1119,22 +1194,37 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
|
||||
let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32);
|
||||
let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32);
|
||||
let (gw, gh) = ((gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize);
|
||||
// Each grid pixel takes the image pixel that lands on it (every image pixel lands on at
|
||||
// most one), so rows of the grid are independent and fill in parallel.
|
||||
let src_of = |gx: usize, gy: usize| -> Option<usize> {
|
||||
let (x, y) = (gx as i64 + gx0 as i64 - im.x0 as i64, gy as i64 + gy0 as i64 - im.y0 as i64);
|
||||
(x >= 0 && y >= 0 && (x as usize) < im.w && (y as usize) < im.h).then(|| y as usize * im.w + x as usize)
|
||||
};
|
||||
let mut px = vec![0.0f32; gw * gh * ch];
|
||||
let mut cov = vec![0.0f32; gw * gh];
|
||||
let mut wt = vec![0.0f32; gw * gh];
|
||||
for y in 0..im.h {
|
||||
for x in 0..im.w {
|
||||
let (gx, gy) = (im.x0 + x as i32 - gx0, im.y0 + y as i32 - gy0);
|
||||
if gx < 0 || gy < 0 || gx as usize >= gw || gy as usize >= gh {
|
||||
continue;
|
||||
par_rows(&mut px, gw, ch, |gy, row| {
|
||||
for gx in 0..gw {
|
||||
if let Some(i) = src_of(gx, gy) {
|
||||
row[gx * ch..(gx + 1) * ch].copy_from_slice(&im.px[i * ch..(i + 1) * ch]);
|
||||
}
|
||||
let (i, g) = (y * im.w + x, gy as usize * gw + gx as usize);
|
||||
cov[g] = im.alpha[i];
|
||||
wt[g] = weights[k][i] * im.alpha[i].min(1.0);
|
||||
px[g * ch..(g + 1) * ch].copy_from_slice(&im.px[i * ch..(i + 1) * ch]);
|
||||
}
|
||||
}
|
||||
let bin: Vec<f32> = cov.iter().map(|a| if *a > 0.5 { 1.0 } else { 0.0 }).collect();
|
||||
});
|
||||
let mut cov = vec![0.0f32; gw * gh];
|
||||
par_rows(&mut cov, gw, 1, |gy, row| {
|
||||
for (gx, o) in row.iter_mut().enumerate() {
|
||||
if let Some(i) = src_of(gx, gy) {
|
||||
*o = im.alpha[i];
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut wt = vec![0.0f32; gw * gh];
|
||||
par_rows(&mut wt, gw, 1, |gy, row| {
|
||||
for (gx, o) in row.iter_mut().enumerate() {
|
||||
if let Some(i) = src_of(gx, gy) {
|
||||
*o = weights[k][i] * im.alpha[i].min(1.0);
|
||||
}
|
||||
}
|
||||
});
|
||||
let bin: Vec<f32> = par_map1(&cov, |a| if a > 0.5 { 1.0 } else { 0.0 });
|
||||
push_pull(gw, gh, ch, &mut px, &bin);
|
||||
// Laplacian of the colours, Gaussian of the weights.
|
||||
let mut lap: Vec<(usize, usize, Vec<f32>)> = Vec::new();
|
||||
@@ -1148,7 +1238,7 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
|
||||
}
|
||||
let (nw, nh, down) = reduce(cw, chh, ch, &cur);
|
||||
let up = expand(nw, nh, ch, &down, cw, chh);
|
||||
lap.push((cw, chh, cur.iter().zip(&up).map(|(a, b)| a - b).collect()));
|
||||
lap.push((cw, chh, par_zip(&cur, &up, |a, b| a - b)));
|
||||
gau.push(cwt.clone());
|
||||
let (_, _, wdown) = reduce(cw, chh, 1, &cwt);
|
||||
cw = nw;
|
||||
@@ -1158,41 +1248,57 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
|
||||
}
|
||||
(gx0, gy0, lap, gau)
|
||||
});
|
||||
for (gx0, gy0, lap, gau) in &bands {
|
||||
for (l, ((lw, lh, band), g)) in lap.iter().zip(gau).enumerate() {
|
||||
let (ox, oy) = ((gx0 >> l) as usize, (gy0 >> l) as usize);
|
||||
let (pw, ph) = sizes[l];
|
||||
for y in 0..*lh {
|
||||
let py = oy + y;
|
||||
if py >= ph {
|
||||
break;
|
||||
}
|
||||
for x in 0..*lw {
|
||||
// Accumulate the bands. Each pixel adds the bands in the same order as the serial loop (per
|
||||
// level, bands one after another), but rows of a level are independent, so they run in
|
||||
// parallel: every row walks the bands itself.
|
||||
for l in 0..levels {
|
||||
let pw = sizes[l].0;
|
||||
par_rows(&mut wsum[l], pw, 1, |py, row| {
|
||||
for b in &bands {
|
||||
let Some((ox, lw, g, _)) = band_row(b, l, ch, py) else { continue };
|
||||
for x in 0..lw {
|
||||
let px = ox + x;
|
||||
if px >= pw {
|
||||
break;
|
||||
}
|
||||
let (i, p) = (y * lw + x, py * pw + px);
|
||||
let k = g[i];
|
||||
let k = g[x];
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
row[px] += k;
|
||||
}
|
||||
}
|
||||
});
|
||||
par_rows(&mut acc[l], pw, ch, |py, row| {
|
||||
for b in &bands {
|
||||
let Some((ox, lw, g, band)) = band_row(b, l, ch, py) else { continue };
|
||||
for x in 0..lw {
|
||||
let px = ox + x;
|
||||
if px >= pw {
|
||||
break;
|
||||
}
|
||||
let k = g[x];
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
wsum[l][p] += k;
|
||||
for c in 0..ch {
|
||||
acc[l][p * ch + c] += k * band[i * ch + c];
|
||||
row[px * ch + c] += k * band[x * ch + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
for l in 0..levels {
|
||||
let (pw, ph) = sizes[l];
|
||||
for p in 0..pw * ph {
|
||||
let k = wsum[l][p];
|
||||
for c in 0..ch {
|
||||
acc[l][p * ch + c] = if k > 1e-8 { acc[l][p * ch + c] / k } else { 0.0 };
|
||||
let pw = sizes[l].0;
|
||||
let ws = &wsum[l];
|
||||
par_rows(&mut acc[l], pw, ch, |py, row| {
|
||||
for x in 0..pw {
|
||||
let k = ws[py * pw + x];
|
||||
for c in 0..ch {
|
||||
row[x * ch + c] = if k > 1e-8 { row[x * ch + c] / k } else { 0.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Collapse.
|
||||
// `levels ≥ 1`, so there is always a coarsest level.
|
||||
@@ -1201,22 +1307,27 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
|
||||
let (pw, ph) = sizes[l];
|
||||
let (sw, sh) = sizes[l + 1];
|
||||
let up = expand(sw, sh, ch, &cur, pw, ph);
|
||||
cur = acc[l].iter().zip(&up).map(|(a, b)| a + b).collect();
|
||||
cur = par_zip(&acc[l], &up, |a, b| a + b);
|
||||
}
|
||||
// Coverage: union of the images.
|
||||
// Coverage: union of the images. Rows are independent; each walks the images in order, so a
|
||||
// pixel sees the same sequence of `max` calls as in the serial loop.
|
||||
let mut cov = vec![0.0f32; w * h];
|
||||
for im in imgs {
|
||||
for y in 0..im.h {
|
||||
par_rows(&mut cov, w, 1, |py, row| {
|
||||
for im in imgs {
|
||||
let y = py as i64 - im.y0 as i64;
|
||||
if y < 0 || y as usize >= im.h {
|
||||
continue;
|
||||
}
|
||||
for x in 0..im.w {
|
||||
let (px, py) = (im.x0 + x as i32, im.y0 + y as i32);
|
||||
if px < 0 || py < 0 || px as usize >= w || py as usize >= h {
|
||||
let px = im.x0 + x as i32;
|
||||
if px < 0 || px as usize >= w {
|
||||
continue;
|
||||
}
|
||||
let p = py as usize * w + px as usize;
|
||||
cov[p] = cov[p].max(im.alpha[y * im.w + x]);
|
||||
let p = px as usize;
|
||||
row[p] = row[p].max(im.alpha[y as usize * im.w + x]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
(cur, cov)
|
||||
}
|
||||
|
||||
@@ -1431,3 +1542,389 @@ mod tests {
|
||||
assert!(cov.iter().all(|c| *c == 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// The row-parallel `reduce` / `expand` / `push_pull` / `multiband` against the serial code they
|
||||
/// replaced, kept below verbatim as the reference. Every output must stay bit for bit the same.
|
||||
#[cfg(test)]
|
||||
mod parallel_tests {
|
||||
use super::*;
|
||||
|
||||
fn mix(mut z: u64) -> u64 {
|
||||
z = z.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// Deterministic data from integer arithmetic only (no libm): mostly `0..1`, with negatives,
|
||||
/// signed zeros and large values so a different order of operations cannot hide.
|
||||
fn noise(len: usize, seed: u64) -> Vec<f32> {
|
||||
(0..len as u64)
|
||||
.map(|i| {
|
||||
let z = mix(seed.wrapping_mul(0x1234_5678_9ABC).wrapping_add(i));
|
||||
let v = (z >> 40) as f32 / 16_777_216.0;
|
||||
match z & 31 {
|
||||
0 => -v,
|
||||
1 => -0.0,
|
||||
2 => v * 1000.0,
|
||||
3 => 0.0,
|
||||
_ => v,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Weights with exact zeros, exact ones and partial values, in blobs and noise.
|
||||
fn weights(w: usize, h: usize, seed: u64) -> Vec<f32> {
|
||||
(0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let z = mix(seed + i as u64);
|
||||
if (x / 5 + y / 3) % 3 == 0 {
|
||||
0.0
|
||||
} else if z.is_multiple_of(7) {
|
||||
((z >> 20) % 100) as f32 / 100.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn bits(v: &[f32]) -> Vec<u32> {
|
||||
v.iter().map(|x| x.to_bits()).collect()
|
||||
}
|
||||
|
||||
fn fnv(parts: &[&[f32]]) -> u64 {
|
||||
let mut h = 0xcbf2_9ce4_8422_2325u64;
|
||||
for p in parts {
|
||||
for x in *p {
|
||||
h ^= x.to_bits() as u64;
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Sizes that cross the odd / even and tiny cases (1 wide, 1 high, 1×1, odd, power of two + 1).
|
||||
const SIZES: [(usize, usize); 12] = [(1, 1), (1, 9), (9, 1), (2, 2), (3, 5), (4, 4), (17, 13), (64, 40), (65, 41), (97, 61), (130, 7), (7, 130)];
|
||||
|
||||
fn ref_reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> (usize, usize, Vec<f32>) {
|
||||
let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let k = [1.0f32, 4.0, 6.0, 4.0, 1.0];
|
||||
// Separable 5-tap binomial, then decimate.
|
||||
let mut tmp = vec![0.0f32; nw * h * ch];
|
||||
for y in 0..h {
|
||||
for x in 0..nw {
|
||||
for c in 0..ch {
|
||||
let mut s = 0.0;
|
||||
for (t, kv) in k.iter().enumerate() {
|
||||
let xx = (2 * x as i64 + t as i64 - 2).clamp(0, w as i64 - 1) as usize;
|
||||
s += kv * src[(y * w + xx) * ch + c];
|
||||
}
|
||||
tmp[(y * nw + x) * ch + c] = s / 16.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![0.0f32; nw * nh * ch];
|
||||
for y in 0..nh {
|
||||
for x in 0..nw {
|
||||
for c in 0..ch {
|
||||
let mut s = 0.0;
|
||||
for (t, kv) in k.iter().enumerate() {
|
||||
let yy = (2 * y as i64 + t as i64 - 2).clamp(0, h as i64 - 1) as usize;
|
||||
s += kv * tmp[(yy * nw + x) * ch + c];
|
||||
}
|
||||
out[(y * nw + x) * ch + c] = s / 16.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
(nw, nh, out)
|
||||
}
|
||||
|
||||
fn ref_expand(sw: usize, sh: usize, ch: usize, src: &[f32], w: usize, h: usize) -> Vec<f32> {
|
||||
// Bilinear upsampling at half-pixel-aligned positions.
|
||||
let mut out = vec![0.0f32; w * h * ch];
|
||||
for y in 0..h {
|
||||
let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32);
|
||||
let (y0, ty) = (fy.floor() as usize, fy - fy.floor());
|
||||
let y1 = (y0 + 1).min(sh - 1);
|
||||
for x in 0..w {
|
||||
let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sw - 1) as f32);
|
||||
let (x0, tx) = (fx.floor() as usize, fx - fx.floor());
|
||||
let x1 = (x0 + 1).min(sw - 1);
|
||||
for c in 0..ch {
|
||||
let p = |xx: usize, yy: usize| src[(yy * sw + 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 * w + x) * ch + c] = top + (bot - top) * ty;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn ref_push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32]) {
|
||||
if w <= 1 && h <= 1 {
|
||||
return;
|
||||
}
|
||||
let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let mut cpx = vec![0.0f32; nw * nh * ch];
|
||||
let mut cw = vec![0.0f32; nw * nh];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (i, j) = (y * w + x, (y / 2) * nw + x / 2);
|
||||
let a = wt[i];
|
||||
cw[j] += a;
|
||||
for c in 0..ch {
|
||||
cpx[j * ch + c] += a * px[i * ch + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
for j in 0..nw * nh {
|
||||
if cw[j] > 0.0 {
|
||||
for c in 0..ch {
|
||||
cpx[j * ch + c] /= cw[j];
|
||||
}
|
||||
cw[j] = 1.0;
|
||||
}
|
||||
}
|
||||
if cw.contains(&0.0) && cw.iter().any(|v| *v > 0.0) {
|
||||
ref_push_pull(nw, nh, ch, &mut cpx, &cw);
|
||||
}
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
if wt[i] < 1.0 {
|
||||
let j = (y / 2) * nw + x / 2;
|
||||
let a = wt[i];
|
||||
for c in 0..ch {
|
||||
px[i * ch + c] = a * px[i * ch + c] + (1.0 - a) * cpx[j * ch + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ref_multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], levels: usize) -> (Vec<f32>, Vec<f32>) {
|
||||
let ch = imgs.first().map_or(1, |i| i.ch);
|
||||
let levels = levels.max(1);
|
||||
let align = 1i32 << (levels - 1);
|
||||
// Level sizes of the panorama.
|
||||
let mut sizes = vec![(w, h)];
|
||||
for _ in 1..levels {
|
||||
let Some(&(pw, ph)) = sizes.last() else { break };
|
||||
sizes.push((pw.div_ceil(2), ph.div_ceil(2)));
|
||||
}
|
||||
let mut acc: Vec<Vec<f32>> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh * ch]).collect();
|
||||
let mut wsum: Vec<Vec<f32>> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh]).collect();
|
||||
let margin = align * 4;
|
||||
// Each image on its ROI grown by a margin and aligned to the coarsest level's grid.
|
||||
let bands = par_map(imgs.len(), |k| {
|
||||
let im = &imgs[k];
|
||||
let gx0 = ((im.x0 - margin).max(0) / align) * align;
|
||||
let gy0 = ((im.y0 - margin).max(0) / align) * align;
|
||||
let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32);
|
||||
let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32);
|
||||
let (gw, gh) = ((gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize);
|
||||
let mut px = vec![0.0f32; gw * gh * ch];
|
||||
let mut cov = vec![0.0f32; gw * gh];
|
||||
let mut wt = vec![0.0f32; gw * gh];
|
||||
for y in 0..im.h {
|
||||
for x in 0..im.w {
|
||||
let (gx, gy) = (im.x0 + x as i32 - gx0, im.y0 + y as i32 - gy0);
|
||||
if gx < 0 || gy < 0 || gx as usize >= gw || gy as usize >= gh {
|
||||
continue;
|
||||
}
|
||||
let (i, g) = (y * im.w + x, gy as usize * gw + gx as usize);
|
||||
cov[g] = im.alpha[i];
|
||||
wt[g] = weights[k][i] * im.alpha[i].min(1.0);
|
||||
px[g * ch..(g + 1) * ch].copy_from_slice(&im.px[i * ch..(i + 1) * ch]);
|
||||
}
|
||||
}
|
||||
let bin: Vec<f32> = cov.iter().map(|a| if *a > 0.5 { 1.0 } else { 0.0 }).collect();
|
||||
ref_push_pull(gw, gh, ch, &mut px, &bin);
|
||||
// Laplacian of the colours, Gaussian of the weights.
|
||||
let mut lap: Vec<(usize, usize, Vec<f32>)> = Vec::new();
|
||||
let mut gau: Vec<Vec<f32>> = Vec::new();
|
||||
let (mut cw, mut chh, mut cur, mut cwt) = (gw, gh, px, wt);
|
||||
for l in 0..levels {
|
||||
if l + 1 == levels {
|
||||
lap.push((cw, chh, cur.clone()));
|
||||
gau.push(cwt.clone());
|
||||
break;
|
||||
}
|
||||
let (nw, nh, down) = ref_reduce(cw, chh, ch, &cur);
|
||||
let up = ref_expand(nw, nh, ch, &down, cw, chh);
|
||||
lap.push((cw, chh, cur.iter().zip(&up).map(|(a, b)| a - b).collect()));
|
||||
gau.push(cwt.clone());
|
||||
let (_, _, wdown) = ref_reduce(cw, chh, 1, &cwt);
|
||||
cw = nw;
|
||||
chh = nh;
|
||||
cur = down;
|
||||
cwt = wdown;
|
||||
}
|
||||
(gx0, gy0, lap, gau)
|
||||
});
|
||||
for (gx0, gy0, lap, gau) in &bands {
|
||||
for (l, ((lw, lh, band), g)) in lap.iter().zip(gau).enumerate() {
|
||||
let (ox, oy) = ((gx0 >> l) as usize, (gy0 >> l) as usize);
|
||||
let (pw, ph) = sizes[l];
|
||||
for y in 0..*lh {
|
||||
let py = oy + y;
|
||||
if py >= ph {
|
||||
break;
|
||||
}
|
||||
for x in 0..*lw {
|
||||
let px = ox + x;
|
||||
if px >= pw {
|
||||
break;
|
||||
}
|
||||
let (i, p) = (y * lw + x, py * pw + px);
|
||||
let k = g[i];
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
wsum[l][p] += k;
|
||||
for c in 0..ch {
|
||||
acc[l][p * ch + c] += k * band[i * ch + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for l in 0..levels {
|
||||
let (pw, ph) = sizes[l];
|
||||
for p in 0..pw * ph {
|
||||
let k = wsum[l][p];
|
||||
for c in 0..ch {
|
||||
acc[l][p * ch + c] = if k > 1e-8 { acc[l][p * ch + c] / k } else { 0.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
// Collapse.
|
||||
// `levels ≥ 1`, so there is always a coarsest level.
|
||||
let Some(mut cur) = acc.pop() else { return (vec![0.0; w * h * ch], vec![0.0; w * h]) };
|
||||
for l in (0..levels - 1).rev() {
|
||||
let (pw, ph) = sizes[l];
|
||||
let (sw, sh) = sizes[l + 1];
|
||||
let up = ref_expand(sw, sh, ch, &cur, pw, ph);
|
||||
cur = acc[l].iter().zip(&up).map(|(a, b)| a + b).collect();
|
||||
}
|
||||
// Coverage: union of the images.
|
||||
let mut cov = vec![0.0f32; w * h];
|
||||
for im in imgs {
|
||||
for y in 0..im.h {
|
||||
for x in 0..im.w {
|
||||
let (px, py) = (im.x0 + x as i32, im.y0 + y as i32);
|
||||
if px < 0 || py < 0 || px as usize >= w || py as usize >= h {
|
||||
continue;
|
||||
}
|
||||
let p = py as usize * w + px as usize;
|
||||
cov[p] = cov[p].max(im.alpha[y * im.w + x]);
|
||||
}
|
||||
}
|
||||
}
|
||||
(cur, cov)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduce_and_expand_match_the_serial_code() {
|
||||
for (n, &(w, h)) in SIZES.iter().enumerate() {
|
||||
for ch in [1usize, 3, 4] {
|
||||
let src = noise(w * h * ch, n as u64 * 7 + ch as u64);
|
||||
let (nw, nh, got) = reduce(w, h, ch, &src);
|
||||
let (rw, rh, want) = ref_reduce(w, h, ch, &src);
|
||||
assert_eq!((nw, nh), (rw, rh));
|
||||
assert_eq!(bits(&got), bits(&want), "reduce {w}x{h}x{ch}");
|
||||
// Expand back to the original size and to sizes that are not twice the source's.
|
||||
for (ew, eh) in [(w, h), (2 * nw, 2 * nh), (2 * nw - 1, 2 * nh + 1), (w + 3, h + 5)] {
|
||||
let got = expand(nw, nh, ch, &want, ew, eh);
|
||||
let want_up = ref_expand(nw, nh, ch, &want, ew, eh);
|
||||
assert_eq!(bits(&got), bits(&want_up), "expand {nw}x{nh}x{ch} to {ew}x{eh}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_pull_matches_the_serial_code() {
|
||||
for (n, &(w, h)) in SIZES.iter().enumerate() {
|
||||
for ch in [1usize, 3] {
|
||||
for variant in 0..3u64 {
|
||||
let wt = match variant {
|
||||
0 => weights(w, h, n as u64),
|
||||
1 => vec![1.0; w * h],
|
||||
// A single covered pixel: every level of the recursion has to fill from it.
|
||||
_ => (0..w * h).map(|i| if i == (w * h) / 3 { 1.0 } else { 0.0 }).collect(),
|
||||
};
|
||||
let px = noise(w * h * ch, 100 + n as u64);
|
||||
let (mut got, mut want) = (px.clone(), px);
|
||||
push_pull(w, h, ch, &mut got, &wt);
|
||||
ref_push_pull(w, h, ch, &mut want, &wt);
|
||||
assert_eq!(bits(&got), bits(&want), "push_pull {w}x{h}x{ch} variant {variant}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ROIs of assorted sizes and offsets (some sticking out of the canvas), with holes in the alpha.
|
||||
fn rois(w: usize, h: usize, ch: usize, seed: u64) -> (Vec<RoiImage>, Vec<Vec<f32>>) {
|
||||
let specs =
|
||||
[(0i32, 0i32, w * 2 / 3, h), (w as i32 / 3, -(h as i32) / 5, w * 2 / 3 + 1, h * 3 / 4 + 1), (-(w as i32) / 4, h as i32 / 2, w / 2 + 1, h / 2 + 3)];
|
||||
let imgs: Vec<RoiImage> = specs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, &(x0, y0, iw, ih))| {
|
||||
let (iw, ih) = (iw.max(1), ih.max(1));
|
||||
let mut alpha = vec![1.0f32; iw * ih];
|
||||
for (i, a) in alpha.iter_mut().enumerate() {
|
||||
let z = mix(seed + k as u64 * 1000 + i as u64);
|
||||
if (i % iw) / 4 % 5 == 2 && (i / iw) % 6 < 3 {
|
||||
*a = 0.0;
|
||||
} else if z.is_multiple_of(11) {
|
||||
*a = 0.3;
|
||||
}
|
||||
}
|
||||
RoiImage { x0, y0, w: iw, h: ih, ch, px: noise(iw * ih * ch, seed + k as u64), alpha }
|
||||
})
|
||||
.collect();
|
||||
let wts = imgs.iter().enumerate().map(|(k, r)| weights(r.w, r.h, seed + 50 + k as u64)).collect();
|
||||
(imgs, wts)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiband_matches_the_serial_code() {
|
||||
for (n, &(w, h)) in [(1usize, 1usize), (5, 3), (40, 30), (97, 61), (130, 70), (64, 64), (201, 129)].iter().enumerate() {
|
||||
for ch in [1usize, 3] {
|
||||
let (imgs, wts) = rois(w, h, ch, n as u64 * 31 + ch as u64);
|
||||
for levels in [1usize, 2, 3, 5, 7] {
|
||||
let got = multiband(w, h, &imgs, &wts, levels);
|
||||
let want = ref_multiband(w, h, &imgs, &wts, levels);
|
||||
assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels}");
|
||||
assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// No images at all, and zero levels.
|
||||
assert_eq!(multiband(9, 7, &[], &[], 3).0.len(), ref_multiband(9, 7, &[], &[], 3).0.len());
|
||||
}
|
||||
|
||||
/// Hashes recorded from the code before it was made parallel (plain f32 arithmetic only, so
|
||||
/// they hold on every platform).
|
||||
#[test]
|
||||
fn multiband_hashes_recorded_from_the_serial_code() {
|
||||
let (imgs, wts) = rois(97, 61, 3, 5);
|
||||
let (px, cov) = multiband(97, 61, &imgs, &wts, 5);
|
||||
assert_eq!(fnv(&[&px, &cov]), 0xf89a5726082ebb6c, "97x61 rgb, 5 levels");
|
||||
let (imgs, wts) = rois(130, 70, 1, 9);
|
||||
let (px, cov) = multiband(130, 70, &imgs, &wts, 7);
|
||||
assert_eq!(fnv(&[&px, &cov]), 0xb7d7ddc59d201730, "130x70 grey, 7 levels");
|
||||
let wt = weights(65, 41, 3);
|
||||
let mut px = noise(65 * 41 * 3, 4);
|
||||
push_pull(65, 41, 3, &mut px, &wt);
|
||||
assert_eq!(fnv(&[&px]), 0x50ea36a84ca9a68f, "push_pull 65x41 rgb");
|
||||
let (nw, nh, r) = reduce(65, 41, 3, &px);
|
||||
assert_eq!(fnv(&[&r, &expand(nw, nh, 3, &r, 65, 41)]), 0x51823acfb4206dd8, "reduce + expand 65x41 rgb");
|
||||
}
|
||||
}
|
||||
@@ -146,8 +146,9 @@ pub(crate) fn lens_flare(src: &Image, out: Rect, ctx: &Ctx, brightness: f32, cen
|
||||
let mut c = rgba(ctx, px);
|
||||
for k in 0..3 {
|
||||
let a = (f[k] * bright).max(0.0);
|
||||
// Screen keeps highlights below white for display-referred values.
|
||||
c[k] = 1.0 - (1.0 - c[k]) * (1.0 - a.min(1.0));
|
||||
// Screen (c + a·(1 − c)) keeps display-referred values below white; a 32-bit
|
||||
// value already above white is left as is rather than pulled down towards it.
|
||||
c[k] += a.min(1.0) * (1.0 - c[k]).max(0.0);
|
||||
}
|
||||
set_rgba(ctx, px, c);
|
||||
}
|
||||
|
||||
+391
-50
@@ -23,85 +23,184 @@ pub enum Stat {
|
||||
Variance,
|
||||
}
|
||||
|
||||
fn stat(v: &mut [f32], s: Stat) -> f32 {
|
||||
/// Reusable per-chunk scratch for [`Stat::Entropy`]: an 8-bit histogram plus the list of bins the
|
||||
/// current pixel touched. Allocated once per parallel chunk instead of one `HashMap` per channel
|
||||
/// per pixel (the old cost: ~4.6 s for 3 × 24 MP).
|
||||
struct EntropyScratch {
|
||||
counts: [u32; 256],
|
||||
touched: Vec<u8>,
|
||||
}
|
||||
|
||||
impl EntropyScratch {
|
||||
fn new() -> Self {
|
||||
Self { counts: [0; 256], touched: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Shannon entropy (bits) of the 8-bit values in `v`, over log2(N). `v.len() >= 2`.
|
||||
fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 {
|
||||
let n = v.len() as f32;
|
||||
for x in v {
|
||||
let bin = (x.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let c = &mut scratch.counts[usize::from(bin)];
|
||||
if *c == 0 {
|
||||
scratch.touched.push(bin);
|
||||
}
|
||||
*c += 1;
|
||||
}
|
||||
// Sum the bins in ascending order. The old HashMap summed in its (randomly seeded) iteration
|
||||
// order, and float addition is not associative, so the old result could differ in the last
|
||||
// bit from run to run; a fixed order makes it deterministic. Sorting only the few touched
|
||||
// bins keeps this O(samples), not O(256), per pixel.
|
||||
scratch.touched.sort_unstable();
|
||||
let h: f32 = scratch
|
||||
.touched
|
||||
.iter()
|
||||
.map(|&bin| {
|
||||
let p = scratch.counts[usize::from(bin)] as f32 / n;
|
||||
-p * p.log2()
|
||||
})
|
||||
.sum();
|
||||
// Reset only the bins used, so the 256-entry array is never cleared wholesale.
|
||||
for &bin in &scratch.touched {
|
||||
scratch.counts[usize::from(bin)] = 0;
|
||||
}
|
||||
scratch.touched.clear();
|
||||
h / n.log2()
|
||||
}
|
||||
|
||||
/// `x` to the power `k` by repeated squaring. Every step is one IEEE 754 multiplication, so the
|
||||
/// result has the same bits on every target. On Windows (MSVC), `f32::powi` with an exponent the
|
||||
/// compiler cannot see as a constant calls the C runtime's `powf`, which rounds differently.
|
||||
fn ipow(x: f32, k: u32) -> f32 {
|
||||
let (mut base, mut k, mut acc) = (x, k, 1.0f32);
|
||||
loop {
|
||||
if k & 1 != 0 {
|
||||
acc *= base;
|
||||
}
|
||||
k >>= 1;
|
||||
if k == 0 {
|
||||
return acc;
|
||||
}
|
||||
base *= base;
|
||||
}
|
||||
}
|
||||
|
||||
fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 {
|
||||
let n = v.len() as f32;
|
||||
if v.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
// Modes that need neither the mean nor the moments return before computing them (the old code
|
||||
// computed the mean for every mode and threw it away; skipping it changes no result).
|
||||
match s {
|
||||
Stat::Summation => return v.iter().sum::<f32>(),
|
||||
Stat::Minimum => return v.iter().copied().fold(f32::MAX, f32::min),
|
||||
Stat::Maximum => return v.iter().copied().fold(f32::MIN, f32::max),
|
||||
Stat::Range => return v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min),
|
||||
Stat::Median => {
|
||||
// `total_cmp` is a total order whose ties are bit-identical values, so an unstable
|
||||
// sort gives exactly the stable sort's result.
|
||||
v.sort_unstable_by(f32::total_cmp);
|
||||
let k = v.len();
|
||||
return if k % 2 == 1 { v[k / 2] } else { (v[k / 2 - 1] + v[k / 2]) / 2.0 };
|
||||
}
|
||||
Stat::Entropy => {
|
||||
if v.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
return entropy(v, scratch);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mean = v.iter().sum::<f32>() / n;
|
||||
let m = |k: i32| v.iter().map(|x| (x - mean).powi(k)).sum::<f32>() / n;
|
||||
let m = |k: u32| v.iter().map(|x| ipow(x - mean, k)).sum::<f32>() / n;
|
||||
match s {
|
||||
Stat::Mean => mean,
|
||||
Stat::Summation => v.iter().sum::<f32>(),
|
||||
Stat::Minimum => v.iter().copied().fold(f32::MAX, f32::min),
|
||||
Stat::Maximum => v.iter().copied().fold(f32::MIN, f32::max),
|
||||
Stat::Range => v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min),
|
||||
Stat::Median => {
|
||||
v.sort_by(f32::total_cmp);
|
||||
let k = v.len();
|
||||
if k % 2 == 1 { v[k / 2] } else { (v[k / 2 - 1] + v[k / 2]) / 2.0 }
|
||||
}
|
||||
Stat::Variance => m(2),
|
||||
Stat::StandardDeviation => m(2).sqrt(),
|
||||
Stat::Skewness => {
|
||||
let sd = m(2).sqrt();
|
||||
// Map −2..2 to 0..1 (0.5 = symmetric).
|
||||
if sd < 1e-6 { 0.5 } else { (m(3) / sd.powi(3) / 4.0 + 0.5).clamp(0.0, 1.0) }
|
||||
if sd < 1e-6 { 0.5 } else { (m(3) / ipow(sd, 3) / 4.0 + 0.5).clamp(0.0, 1.0) }
|
||||
}
|
||||
Stat::Kurtosis => {
|
||||
let var = m(2);
|
||||
// 0..10 → 0..1.
|
||||
if var < 1e-12 { 0.0 } else { (m(4) / (var * var) / 10.0).clamp(0.0, 1.0) }
|
||||
}
|
||||
Stat::Entropy => {
|
||||
if v.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut counts: std::collections::HashMap<u8, u32> = std::collections::HashMap::new();
|
||||
for x in v.iter() {
|
||||
*counts.entry((x.clamp(0.0, 1.0) * 255.0).round() as u8).or_default() += 1;
|
||||
}
|
||||
let h: f32 = counts
|
||||
.values()
|
||||
.map(|&c| {
|
||||
let p = c as f32 / n;
|
||||
-p * p.log2()
|
||||
})
|
||||
.sum();
|
||||
h / n.log2()
|
||||
}
|
||||
// Handled above; a no-op keeps the match exhaustive without a panic path.
|
||||
Stat::Summation | Stat::Minimum | Stat::Maximum | Stat::Range | Stat::Median | Stat::Entropy => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Output pixels per parallel task: big enough to amortise the per-task scratch buffers and
|
||||
/// rayon's scheduling, small enough (6000 tasks for 24 MP) to balance across many cores.
|
||||
const CHUNK: usize = 4096;
|
||||
|
||||
/// Combines `layers` (each `len` pixels) with statistic `s`. Colour results retain their float
|
||||
/// range; quantisation/clipping belongs to the destination surface, not the statistic.
|
||||
pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> {
|
||||
// Pick the statistic once, outside the pixel loop: each arm passes `stat` a constant mode, so
|
||||
// the compiler builds one loop per statistic with the `match` folded away. Matching on `s`
|
||||
// three times per pixel inside one shared loop made one thread 5–15% slower than the old
|
||||
// serial loop (Mean, Variance, StdDev, Skewness, Kurtosis); this alone cuts Mean's
|
||||
// instructions by a fifth.
|
||||
match s {
|
||||
Stat::Entropy => combine_with(layers, |v, sc| stat(v, Stat::Entropy, sc)),
|
||||
Stat::Kurtosis => combine_with(layers, |v, sc| stat(v, Stat::Kurtosis, sc)),
|
||||
Stat::Maximum => combine_with(layers, |v, sc| stat(v, Stat::Maximum, sc)),
|
||||
Stat::Mean => combine_with(layers, |v, sc| stat(v, Stat::Mean, sc)),
|
||||
Stat::Median => combine_with(layers, |v, sc| stat(v, Stat::Median, sc)),
|
||||
Stat::Minimum => combine_with(layers, |v, sc| stat(v, Stat::Minimum, sc)),
|
||||
Stat::Range => combine_with(layers, |v, sc| stat(v, Stat::Range, sc)),
|
||||
Stat::Skewness => combine_with(layers, |v, sc| stat(v, Stat::Skewness, sc)),
|
||||
Stat::StandardDeviation => combine_with(layers, |v, sc| stat(v, Stat::StandardDeviation, sc)),
|
||||
Stat::Summation => combine_with(layers, |v, sc| stat(v, Stat::Summation, sc)),
|
||||
Stat::Variance => combine_with(layers, |v, sc| stat(v, Stat::Variance, sc)),
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_with(layers: &[Vec<[f32; 4]>], f: impl Fn(&mut [f32], &mut EntropyScratch) -> f32 + Sync + Send) -> Vec<[f32; 4]> {
|
||||
let len = layers.iter().map(Vec::len).max().unwrap_or(0);
|
||||
let mut out = vec![[0.0f32; 4]; len];
|
||||
let mut samples: [Vec<f32>; 3] = Default::default();
|
||||
for (i, o) in out.iter_mut().enumerate() {
|
||||
for v in samples.iter_mut() {
|
||||
v.clear();
|
||||
}
|
||||
let mut alpha = 0.0f32;
|
||||
for l in layers {
|
||||
if let Some(p) = l.get(i)
|
||||
&& p[3] > 0.0
|
||||
{
|
||||
for c in 0..3 {
|
||||
samples[c].push(p[c]);
|
||||
// Every output pixel depends only on the input pixels at the same index, so chunks of the
|
||||
// output are independent: `par_rows` over a `CHUNK`-wide "row" with one channel (it runs
|
||||
// serially on wasm). The per-pixel arithmetic is unchanged, hence the result is bit-identical
|
||||
// to the serial loop for every mode (Entropy: see [`entropy`]).
|
||||
crate::photo_util::par_rows(&mut out, CHUNK, 1, |chunk, outs| {
|
||||
// Scratch is per chunk, not per pixel: the old loop's three `Vec`s were already reused
|
||||
// across pixels, so keep that, and give each task its own set.
|
||||
// Sized to the layer count up front and filled by index (`k` samples per pixel): a
|
||||
// `push` per sample re-checked the capacity and stored the length every time; indexing
|
||||
// cuts the loop's instructions by another third.
|
||||
let mut samples: [Vec<f32>; 3] = [0, 1, 2].map(|_| vec![0.0; layers.len()]);
|
||||
let mut scratch = EntropyScratch::new();
|
||||
let base = chunk * CHUNK;
|
||||
for (j, o) in outs.iter_mut().enumerate() {
|
||||
let i = base + j;
|
||||
let mut k = 0;
|
||||
let mut alpha = 0.0f32;
|
||||
for l in layers {
|
||||
if let Some(p) = l.get(i)
|
||||
&& p[3] > 0.0
|
||||
{
|
||||
for c in 0..3 {
|
||||
samples[c][k] = p[c];
|
||||
}
|
||||
k += 1;
|
||||
alpha = alpha.max(p[3]);
|
||||
}
|
||||
alpha = alpha.max(p[3]);
|
||||
}
|
||||
if alpha <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
for c in 0..3 {
|
||||
o[c] = f(&mut samples[c][..k], &mut scratch);
|
||||
}
|
||||
o[3] = alpha;
|
||||
}
|
||||
if alpha <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
for c in 0..3 {
|
||||
o[c] = stat(&mut samples[c], s);
|
||||
}
|
||||
o[3] = alpha;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
@@ -168,4 +267,246 @@ mod tests {
|
||||
let layers = vec![px(0.5), px(0.5), px(1.0)];
|
||||
assert_eq!(combine(&layers, Stat::Median)[0][0], 0.5);
|
||||
}
|
||||
|
||||
/// Five layers over six pixels (the last layer is short). Covers opaque, partly transparent,
|
||||
/// fully transparent, HDR / negative, single-sample and tied-value pixels.
|
||||
fn fixture() -> Vec<Vec<[f32; 4]>> {
|
||||
vec![
|
||||
vec![[0.10, 0.20, 0.30, 1.0], [0.5, 0.5, 0.5, 1.0], [0.9, 0.9, 0.9, 0.0], [-2.0, 4.0, 0.0, 1.0], [0.7, 0.2, 0.1, 0.5], [0.25, 0.25, 0.25, 1.0]],
|
||||
vec![[0.90, 0.60, 0.30, 1.0], [0.0, 0.0, 0.0, 0.0], [0.8, 0.8, 0.8, 0.0], [3.0, -1.0, 0.5, 0.75], [0.0, 0.0, 0.0, 0.0], [0.25, 0.75, 0.25, 1.0]],
|
||||
vec![[0.20, 0.90, 0.30, 0.5], [0.1, 0.2, 0.3, 1.0], [0.7, 0.7, 0.7, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 0.0, 0.0], [0.5, 0.75, 0.25, 0.25]],
|
||||
vec![[0.30, 0.05, 0.30, 0.25], [0.3, 0.4, 0.5, 0.5], [0.6, 0.6, 0.6, 0.0], [1.5, 1.5, 1.5, 1.0], [0.0, 0.0, 0.0, 0.0], [0.25, 0.75, 0.5, 1.0]],
|
||||
vec![[0.10, 0.70, 0.99, 1.0], [0.9, 0.1, 0.2, 1.0], [0.5, 0.5, 0.5, 0.0], [0.0, 0.0, 0.0, 1.0]],
|
||||
]
|
||||
}
|
||||
|
||||
fn bits(px: &[[f32; 4]]) -> Vec<[u32; 4]> {
|
||||
px.iter().map(|p| p.map(f32::to_bits)).collect()
|
||||
}
|
||||
|
||||
/// Every mode except Entropy is bit-for-bit what the old serial loop produced. The expected
|
||||
/// bit patterns were recorded by running the unmodified `combine` on [`fixture`].
|
||||
#[test]
|
||||
fn statistics_are_bit_identical_to_the_serial_implementation() {
|
||||
let layers = fixture();
|
||||
let expected: Vec<(Stat, Vec<[u32; 4]>)> = vec![
|
||||
(
|
||||
Stat::Kurtosis,
|
||||
[
|
||||
[0x3e96956b, 0x3e182e3f, 0x3ea66664, 0x3f800000],
|
||||
[0x3e3d004c, 0x3e0b4396, 0x3e030e1f, 0x3f800000],
|
||||
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
|
||||
[0x3e594eb2, 0x3e6ef420, 0x3e7ffffe, 0x3f800000],
|
||||
[0x00000000, 0x00000000, 0x00000000, 0x3f000000],
|
||||
[0x3e6eeeee, 0x3e6eeeee, 0x3e6eeeee, 0x3f800000],
|
||||
]
|
||||
.to_vec(),
|
||||
),
|
||||
(
|
||||
Stat::Maximum,
|
||||
[
|
||||
[0x3f666666, 0x3f666666, 0x3f7d70a4, 0x3f800000],
|
||||
[0x3f666666, 0x3f000000, 0x3f000000, 0x3f800000],
|
||||
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
|
||||
[0x40400000, 0x40800000, 0x3fc00000, 0x3f800000],
|
||||
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
|
||||
[0x3f000000, 0x3f400000, 0x3f000000, 0x3f800000],
|
||||
]
|
||||
.to_vec(),
|
||||
),
|
||||
(
|
||||
Stat::Mean,
|
||||
[
|
||||
[0x3ea3d70a, 0x3efae148, 0x3ee0418a, 0x3f800000],
|
||||
[0x3ee66666, 0x3e99999a, 0x3ec00000, 0x3f800000],
|
||||
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
|
||||
[0x3f19999a, 0x3f800000, 0x3f000000, 0x3f800000],
|
||||
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
|
||||
[0x3ea00000, 0x3f200000, 0x3ea00000, 0x3f800000],
|
||||
]
|
||||
.to_vec(),
|
||||
),
|
||||
(
|
||||
Stat::Median,
|
||||
[
|
||||
[0x3e4ccccd, 0x3f19999a, 0x3e99999a, 0x3f800000],
|
||||
[0x3ecccccd, 0x3e99999a, 0x3ecccccd, 0x3f800000],
|
||||
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
|
||||
[0x3f000000, 0x3f000000, 0x3f000000, 0x3f800000],
|
||||
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
|
||||
[0x3e800000, 0x3f400000, 0x3e800000, 0x3f800000],
|
||||
]
|
||||
.to_vec(),
|
||||
),
|
||||
(
|
||||
Stat::Minimum,
|
||||
[
|
||||
[0x3dcccccd, 0x3d4ccccd, 0x3e99999a, 0x3f800000],
|
||||
[0x3dcccccd, 0x3dcccccd, 0x3e4ccccd, 0x3f800000],
|
||||
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
|
||||
[0xc0000000, 0xbf800000, 0x00000000, 0x3f800000],
|
||||
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
|
||||
[0x3e800000, 0x3e800000, 0x3e800000, 0x3f800000],
|
||||
]
|
||||
.to_vec(),
|
||||
),
|
||||
(
|
||||
Stat::Range,
|
||||
[
|
||||
[0x3f4ccccc, 0x3f599999, 0x3f30a3d7, 0x3f800000],
|
||||
[0x3f4ccccc, 0x3ecccccd, 0x3e99999a, 0x3f800000],
|
||||
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
|
||||
[0x40a00000, 0x40a00000, 0x3fc00000, 0x3f800000],
|
||||
[0x00000000, 0x00000000, 0x00000000, 0x3f000000],
|
||||
[0x3e800000, 0x3f000000, 0x3e800000, 0x3f800000],
|
||||
]
|
||||
.to_vec(),
|
||||
),
|
||||
(
|
||||
Stat::Skewness,
|
||||
[
|
||||
[0x3f522026, 0x3ee7cf76, 0x3f600000, 0x3f800000],
|
||||
[0x3f1bd152, 0x3efffffe, 0x3ee4a11b, 0x3f800000],
|
||||
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
|
||||
[0x3eedb65a, 0x3f2ea754, 0x3f3a6c7a, 0x3f800000],
|
||||
[0x3f000000, 0x3f000000, 0x3f000000, 0x3f000000],
|
||||
[0x3f49e69e, 0x3e58658a, 0x3f49e69e, 0x3f800000],
|
||||
]
|
||||
.to_vec(),
|
||||
),
|
||||
(
|
||||
Stat::StandardDeviation,
|
||||
[
|
||||
[0x3e99421e, 0x3ea23b6b, 0x3e8d4fdf, 0x3f800000],
|
||||
[0x3e97739e, 0x3e21e89b, 0x3e050581, 0x3f800000],
|
||||
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
|
||||
[0x3fd3e0b1, 0x3fd9f9e5, 0x3f0c378c, 0x3f800000],
|
||||
[0x00000000, 0x00000000, 0x00000000, 0x3f000000],
|
||||
[0x3dddb3d7, 0x3e5db3d7, 0x3dddb3d7, 0x3f800000],
|
||||
]
|
||||
.to_vec(),
|
||||
),
|
||||
(
|
||||
Stat::Summation,
|
||||
[
|
||||
[0x3fcccccd, 0x401ccccd, 0x400c28f6, 0x3f800000],
|
||||
[0x3fe66666, 0x3f99999a, 0x3fc00000, 0x3f800000],
|
||||
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
|
||||
[0x40400000, 0x40a00000, 0x40200000, 0x3f800000],
|
||||
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
|
||||
[0x3fa00000, 0x40200000, 0x3fa00000, 0x3f800000],
|
||||
]
|
||||
.to_vec(),
|
||||
),
|
||||
(
|
||||
Stat::Variance,
|
||||
[
|
||||
[0x3db78033, 0x3dcd9e83, 0x3d9c022a, 0x3f800000],
|
||||
[0x3db33332, 0x3ccccccd, 0x3c8a3d70, 0x3f800000],
|
||||
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
|
||||
[0x402f5c29, 0x4039999a, 0x3e99999a, 0x3f800000],
|
||||
[0x00000000, 0x00000000, 0x00000000, 0x3f000000],
|
||||
[0x3c400000, 0x3d400000, 0x3c400000, 0x3f800000],
|
||||
]
|
||||
.to_vec(),
|
||||
),
|
||||
];
|
||||
for (mode, want) in expected {
|
||||
assert_eq!(bits(&combine(&layers, mode)), want, "{mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Entropy against values worked out by hand (bits of the 8-bit histogram, over log2(N)).
|
||||
#[test]
|
||||
fn entropy_matches_hand_computed_values() {
|
||||
let out = combine(&fixture(), Stat::Entropy);
|
||||
let h = |ps: &[f64]| -> f64 {
|
||||
let n: f64 = ps.iter().sum();
|
||||
ps.iter().map(|c| -(c / n) * (c / n).log2()).sum::<f64>() / n.log2()
|
||||
};
|
||||
// Pixel 1: four distinct values -> 2 bits / log2(4) = exactly 1.
|
||||
assert_eq!(out[1][0], 1.0);
|
||||
// Pixel 2: every sample transparent -> untouched.
|
||||
assert_eq!(out[2], [0.0; 4]);
|
||||
// Pixel 3, red: -2 and 0 land in bin 0, 3 and 1.5 clamp into bin 255, 0.5 is alone.
|
||||
assert!((f64::from(out[3][0]) - h(&[2.0, 2.0, 1.0])).abs() < 1e-6, "{}", out[3][0]);
|
||||
// Pixel 4: a single contributing sample has no entropy.
|
||||
assert_eq!(out[4], [0.0, 0.0, 0.0, 0.5]);
|
||||
// Pixel 5: three samples share a bin, one is alone, in every channel.
|
||||
for (c, v) in out[5].iter().take(3).enumerate() {
|
||||
assert!((f64::from(*v) - h(&[3.0, 1.0])).abs() < 1e-6, "channel {c}: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The old HashMap summed in a random order, so Entropy could differ by an ulp between runs.
|
||||
/// Now the bins are summed in ascending order: repeated runs (and any thread count) agree.
|
||||
#[test]
|
||||
fn entropy_is_deterministic() {
|
||||
let layers: Vec<Vec<[f32; 4]>> = (0..7)
|
||||
.map(|k| {
|
||||
(0..3 * CHUNK + 17).map(|i| [((i * 7 + k * 13) % 11) as f32 / 10.0, ((i + k) % 5) as f32 / 4.0, ((i * k) % 9) as f32 / 8.0, 1.0]).collect()
|
||||
})
|
||||
.collect();
|
||||
let first = bits(&combine(&layers, Stat::Entropy));
|
||||
for _ in 0..8 {
|
||||
assert_eq!(bits(&combine(&layers, Stat::Entropy)), first);
|
||||
}
|
||||
}
|
||||
|
||||
/// Chunking is invisible: a multi-chunk image (with a ragged last chunk and layers of unequal
|
||||
/// length) gives, pixel by pixel, what combining that pixel on its own gives.
|
||||
#[test]
|
||||
fn chunked_result_equals_per_pixel_result() {
|
||||
let len = 2 * CHUNK + 5;
|
||||
let layer = |k: usize, n: usize| -> Vec<[f32; 4]> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
[
|
||||
((i * 31 + k * 7) % 101) as f32 / 100.0,
|
||||
((i * 17 + k) % 53) as f32 / 52.0 - 0.2,
|
||||
((i + 3 * k) % 29) as f32 / 28.0,
|
||||
if (i + k).is_multiple_of(4) { 0.0 } else { 0.5 + ((i + k) % 3) as f32 / 4.0 },
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let layers = vec![layer(1, len), layer(2, CHUNK + 3), layer(3, len - 1), layer(4, 0), layer(5, len)];
|
||||
for mode in [
|
||||
Stat::Entropy,
|
||||
Stat::Kurtosis,
|
||||
Stat::Maximum,
|
||||
Stat::Mean,
|
||||
Stat::Median,
|
||||
Stat::Minimum,
|
||||
Stat::Range,
|
||||
Stat::Skewness,
|
||||
Stat::StandardDeviation,
|
||||
Stat::Summation,
|
||||
Stat::Variance,
|
||||
] {
|
||||
let whole = combine(&layers, mode);
|
||||
assert_eq!(whole.len(), len);
|
||||
for i in (0..len).step_by(97).chain([CHUNK - 1, CHUNK, CHUNK + 1, 2 * CHUNK - 1, 2 * CHUNK, len - 1]) {
|
||||
let one: Vec<Vec<[f32; 4]>> = layers.iter().map(|l| l.get(i).map(|p| vec![*p]).unwrap_or_default()).collect();
|
||||
assert_eq!(whole[i].map(f32::to_bits), combine(&one, mode)[0].map(f32::to_bits), "{mode:?} pixel {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_layers_gives_an_empty_result() {
|
||||
assert!(combine(&[], Stat::Median).is_empty());
|
||||
}
|
||||
|
||||
/// [`ipow`] multiplies in the order `x·x²` for the cube and `x²·x²` for the fourth power.
|
||||
#[test]
|
||||
fn ipow_squares_and_multiplies() {
|
||||
for x in [0.58f32, -0.12, -2.6, 2.4, -0.6, 1.0e-3, 0.0] {
|
||||
assert_eq!(ipow(x, 0).to_bits(), 1.0f32.to_bits());
|
||||
assert_eq!(ipow(x, 1).to_bits(), x.to_bits());
|
||||
assert_eq!(ipow(x, 2).to_bits(), (x * x).to_bits());
|
||||
assert_eq!(ipow(x, 3).to_bits(), (x * (x * x)).to_bits());
|
||||
assert_eq!(ipow(x, 4).to_bits(), ((x * x) * (x * x)).to_bits());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -414,6 +414,24 @@ fn twirl_keeps_centre_and_outside() {
|
||||
assert_ne!(out.pixel(20, 12), s.pixel(20, 12));
|
||||
}
|
||||
|
||||
/// #5643: on a wide image Twirl, Pinch and Spherize fill the ellipse inscribed in the bounds, as
|
||||
/// Photoshop's do, not just a circle fitted to the height: a point near the left and right edges
|
||||
/// on the middle row changes, the corners don't, and a square still uses its inscribed circle.
|
||||
#[test]
|
||||
fn round_distortions_fill_the_inscribed_ellipse() {
|
||||
let b = Rect::new(0, 0, 80, 40);
|
||||
let s = pattern(SampleType::F32, b);
|
||||
for p in [FilterParams::Twirl { angle: 180.0 }, FilterParams::Pinch { amount: 80.0 }, FilterParams::Spherize { amount: 80.0, mode: SpherizeMode::Normal }] {
|
||||
let out = apply(&s, &p, b, b, None);
|
||||
// 30 px left of centre on the middle row: outside the old 20 px circle, inside the ellipse.
|
||||
assert_ne!(out.pixel(10, 21), s.pixel(10, 21), "{p:?}");
|
||||
assert_ne!(out.pixel(69, 18), s.pixel(69, 18), "{p:?}");
|
||||
for (x, y) in [(0, 0), (79, 0), (0, 39), (79, 39)] {
|
||||
assert_eq!(out.pixel(x, y), s.pixel(x, y), "{p:?}: corner ({x}, {y}) is outside the ellipse");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_blur_spreads_along_angle_only() {
|
||||
let mut s = flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]);
|
||||
|
||||
@@ -435,6 +435,35 @@ fn lens_flare_is_brightest_at_its_centre() {
|
||||
assert!(out.pixel(38, 2)[0] >= 0.2 - 1e-6, "screen never darkens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lens_flare_never_darkens_32_bit_values_above_one() {
|
||||
// #5785: the screen formula turned 2.0 into 1.5 (and 1.0 at the core).
|
||||
let p = FilterParams::LensFlare { brightness: 150.0, center_x: 0.25, center_y: 0.5, lens: LensType::Zoom };
|
||||
let out = run(&flat(SampleType::F32, R, [2.0, 2.0, 2.0, 1.0]), &p);
|
||||
for px in out.read_region(R).as_chunks::<4>().0 {
|
||||
assert!(px[..3].iter().all(|&v| v >= 2.0), "{px:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lens_flare_8_bit_result_is_the_screen_blend() {
|
||||
// The flare amount per pixel, read from a black 32-bit image (screen over 0 is the amount).
|
||||
let p = FilterParams::LensFlare { brightness: 150.0, center_x: 0.25, center_y: 0.5, lens: LensType::Zoom };
|
||||
let amount = run(&flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]), &p).read_region(R);
|
||||
let src = pattern(SampleType::U8, R);
|
||||
let base = src.read_region(R);
|
||||
let expected: Vec<f32> = base
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.zip(amount.as_chunks::<4>().0)
|
||||
.flat_map(|(c, a)| [0, 1, 2].map(|k| 1.0 - (1.0 - c[k]) * (1.0 - a[k].min(1.0))).into_iter().chain([c[3]]))
|
||||
.collect();
|
||||
let mut want = Surface::new(fmt(SampleType::U8));
|
||||
want.write_region(R, &expected);
|
||||
assert_eq!(run(&src, &p).read_region(R), want.read_region(R));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lighting_point_light_falls_off() {
|
||||
let s = flat(SampleType::F32, R, [0.8, 0.8, 0.8, 1.0]);
|
||||
@@ -608,6 +637,28 @@ fn custom_kernel_shifts_and_offsets() {
|
||||
assert!((off.pixel(5, 5)[0] - 0.4).abs() < 1e-5);
|
||||
}
|
||||
|
||||
/// CMYK samples are ink: Custom's positive offset brightens (removes ink), as in Photoshop
|
||||
/// (identity, scale 1, offset +50: white stays white, C=100% becomes C≈80%). Alpha is untouched.
|
||||
/// Photoshop's CMYK depths; float is unclamped on both polarities (RGB white + offset exceeds 1).
|
||||
#[test]
|
||||
fn custom_offset_brightens_cmyk() {
|
||||
let mut id = vec![0.0; 25];
|
||||
id[12] = 1.0;
|
||||
let p = FilterParams::Custom { kernel: id, scale: 1.0, offset: 50.0 };
|
||||
for depth in [SampleType::U8, SampleType::U16] {
|
||||
let f = PixelFormat::new(ColorMode::Cmyk, depth, true);
|
||||
let mut s = Surface::new(f);
|
||||
s.fill_rect(R, &[0.0, 0.0, 0.0, 0.0, 0.6]);
|
||||
let w = run(&s, &p).pixel(5, 5);
|
||||
assert!(w[..4].iter().all(|v| v.abs() < 1e-6) && (w[4] - 0.6).abs() < 0.01, "{depth:?} white: {w:?}");
|
||||
let mut s = Surface::new(f);
|
||||
s.fill_rect(R, &[1.0, 0.5, 0.0, 0.0, 1.0]);
|
||||
let c = run(&s, &p).pixel(5, 5);
|
||||
let want = [1.0 - 50.0 / 255.0, 0.5 - 50.0 / 255.0, 0.0, 0.0, 1.0];
|
||||
assert!(c.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{depth:?} C100: {c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hsb_filter_encodes_and_decodes() {
|
||||
let s = flat(SampleType::F32, R, [1.0, 0.0, 0.0, 1.0]);
|
||||
|
||||
@@ -231,14 +231,37 @@ pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option<Homography> {
|
||||
|
||||
/// The destination rectangle [`warp_surface`] fills for `src_rect` under `h`: the warped corners
|
||||
/// plus a pixel for filter support, each side clamped to ±2²⁰. `None` when a corner maps to
|
||||
/// infinity (the result is then empty). Lets a caller price a warp before allocating it.
|
||||
/// infinity (the result is then empty). Use [`warp_output_size`] to price the full result before
|
||||
/// admitting a transform: clipping these bounds must not hide an excessive scale.
|
||||
pub fn warp_output_bounds(src_rect: Rect, h: &Homography) -> Option<Rect> {
|
||||
corner_bounds(&warp_corners(src_rect, h)?)
|
||||
}
|
||||
|
||||
/// Unclipped output dimensions, including the same filter support as [`warp_output_bounds`].
|
||||
/// Position does not affect the price, so translating a small layer far away stays cheap.
|
||||
/// Unrepresentable or non-finite dimensions saturate at `u64::MAX`, allowing a caller to refuse
|
||||
/// the warp with a saturating pixel-budget comparison. Empty and singular inputs have no output.
|
||||
pub fn warp_output_size(src_rect: Rect, h: &Homography) -> Option<(u64, u64)> {
|
||||
let corners = warp_corners(src_rect, h)?;
|
||||
if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite()) {
|
||||
return Some((u64::MAX, u64::MAX));
|
||||
}
|
||||
let x0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor();
|
||||
let y0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor();
|
||||
let x1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil();
|
||||
let y1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil();
|
||||
// Float-to-integer casts saturate, including when the finite endpoints' difference overflows.
|
||||
Some((((x1 - x0) as u64).saturating_add(2), ((y1 - y0) as u64).saturating_add(2)))
|
||||
}
|
||||
|
||||
fn warp_corners(src_rect: Rect, h: &Homography) -> Option<[(f64, f64); 4]> {
|
||||
if src_rect.is_empty() || h.inverse().is_none() {
|
||||
return None;
|
||||
}
|
||||
let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)]
|
||||
.map(|(x, y)| h.apply(x as f64, y as f64));
|
||||
corner_bounds(&corners)
|
||||
Some(
|
||||
[(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)]
|
||||
.map(|(x, y)| h.apply(x as f64, y as f64)),
|
||||
)
|
||||
}
|
||||
|
||||
fn corner_bounds(corners: &[(f64, f64); 4]) -> Option<Rect> {
|
||||
@@ -510,6 +533,31 @@ fn warp(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn output_size_prices_the_unclipped_extent_independently_of_position() {
|
||||
let src = Rect::new(0, 0, 24, 16);
|
||||
for dx in [0.0, 900_000.0, 10_000_000.0] {
|
||||
let h = Homography([1.0, 0.0, dx, 0.0, 1.0, -dx, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(warp_output_size(src, &h), Some((26, 18)));
|
||||
}
|
||||
for sx in [1e7, -1e7] {
|
||||
let h = Homography([sx, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(warp_output_size(src, &h), Some((240_000_002, 18)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_size_saturates_overflows_and_preserves_filter_support() {
|
||||
let src = Rect::new(0, 0, 24, 16);
|
||||
let fractional = Homography([0.5, 0.0, 0.25, 0.0, 1.0, 0.75, 0.0, 0.0, 1.0]);
|
||||
let bounds = warp_output_bounds(src, &fractional).unwrap();
|
||||
assert_eq!(warp_output_size(src, &fractional), Some((u64::from(bounds.width()), u64::from(bounds.height()))));
|
||||
let huge = Homography([1e308, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(warp_output_size(src, &huge), Some((u64::MAX, u64::MAX)));
|
||||
assert_eq!(warp_output_size(Rect::EMPTY, &Homography::IDENTITY), None);
|
||||
assert_eq!(warp_output_size(src, &Homography([0.0; 9])), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "photocraft-automation"
|
||||
description = "MCP server (rmcp) over the Photocraft engine, plus a bridge to a running GUI"
|
||||
description = "MCP server (rmcp) over the PhotoCraft engine, plus a bridge to a running GUI"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
@@ -135,7 +135,27 @@ pub fn repeat_auth_reply(line: &str) -> Option<Value> {
|
||||
}
|
||||
|
||||
fn read_token_file(path: &Path) -> Result<String, AutomationError> {
|
||||
let token = std::fs::read_to_string(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
// Validate what we actually opened, not just the name: an existing control-token file must be
|
||||
// a regular file that no other local principal can read. A freshly created one is 0600
|
||||
// (`create_token_file`); an operator-supplied one is checked here so a group- or world-readable
|
||||
// token is refused rather than trusted as the control credential (#4966).
|
||||
let mut file = std::fs::File::open(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
let meta = file.metadata().map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
if !meta.is_file() {
|
||||
return Err(AutomationError::Io(format!("{}: control token file is not a regular file", path.display())));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if meta.mode() & 0o077 != 0 {
|
||||
return Err(AutomationError::Io(format!(
|
||||
"{}: control token file is readable by group or others (mode {:o}); restrict it to its owner (e.g. chmod 600)",
|
||||
path.display(),
|
||||
meta.mode() & 0o777
|
||||
)));
|
||||
}
|
||||
}
|
||||
let token = std::io::read_to_string(&mut file).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
let token = token.trim().to_owned();
|
||||
validate_token(&token)?;
|
||||
Ok(token)
|
||||
@@ -433,6 +453,27 @@ mod tests {
|
||||
sender.join().unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_existing_token_file_must_be_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-token-perm-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("token.txt");
|
||||
let token = "0123456789abcdef".repeat(4); // 64 hex chars, passes validate_token
|
||||
std::fs::write(&path, &token).unwrap();
|
||||
|
||||
// A group- or world-readable token is refused: the control credential must not be shared (#4966).
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
assert!(read_token_file(&path).is_err(), "a 0644 token file must be refused");
|
||||
|
||||
// Owner-only is opened, read and validated.
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
assert_eq!(read_token_file(&path).unwrap(), token, "a 0600 token file must be read");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_limiter_releases_capacity() {
|
||||
let limiter = ConnectionLimiter::new(1);
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
use photocraft_automation::Headless;
|
||||
use serde_json::json;
|
||||
|
||||
/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0).
|
||||
/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0). Krita writes a raw tile
|
||||
/// (flag 0) as plain interleaved pixels; only LZF tiles are split into byte planes.
|
||||
fn tiles(bgra: [u8; 4]) -> Vec<u8> {
|
||||
let n = 64 * 64;
|
||||
let mut out = b"VERSION 2\nTILEWIDTH 64\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 1\n".to_vec();
|
||||
out.extend(format!("0,0,LZF,{}\n", 1 + n * 4).into_bytes());
|
||||
out.push(0);
|
||||
for b in bgra {
|
||||
out.extend(std::iter::repeat_n(b, n));
|
||||
for _ in 0..n {
|
||||
out.extend(bgra);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Photocraft colour management: a pure-Rust ICC v2/v4 colour management module.
|
||||
//! PhotoCraft colour management: a pure-Rust ICC v2/v4 colour management module.
|
||||
//!
|
||||
//! * [`Profile`] parses ICC profiles (matrix/TRC RGB, gray TRC, `mft1`/`mft2`/`mAB `/`mBA `
|
||||
//! LUT profiles incl. CMYK↔Lab, `curv`/`para` curves) and writes ICC v4 profiles.
|
||||
|
||||
@@ -245,7 +245,7 @@ fn smooth(x: f32) -> f32 {
|
||||
x * x * (3.0 - 2.0 * x)
|
||||
}
|
||||
|
||||
/// Generated looks shipped with Photocraft (procedural, CC0): `(id, label)`.
|
||||
/// Generated looks shipped with PhotoCraft (procedural, CC0): `(id, label)`.
|
||||
pub const BUILTIN: [(&str, &str); 8] = [
|
||||
("warm", "Warm Filter"),
|
||||
("cool", "Cool Filter"),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39 …) are proprietary, and the
|
||||
//! freely downloadable characterisation-based profiles come with redistribution conditions, so
|
||||
//! Photocraft ships a CMYK profile it generates itself from a documented printer model:
|
||||
//! PhotoCraft ships a CMYK profile it generates itself from a documented printer model:
|
||||
//!
|
||||
//! * **Forward model** (CMYK → XYZ): Yule–Nielsen modified Neugebauer (n = 2) over the 16
|
||||
//! ink overprints, with Demichel coverage weights and a parabolic tone value increase (dot
|
||||
|
||||
@@ -38,7 +38,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
|
||||
| WebP | yes | yes | U8 | RGB, RGBA | yes | yes | yes | yes | no | no | yes (VP8, when `webp_lossless` is off) | `image-webp` / built-in VP8 |
|
||||
| GIF | yes | yes | U8 | RGBA | 1-bit | no | no | no | no | no | yes (256-colour palette) | `image` |
|
||||
| BMP | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` |
|
||||
| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` |
|
||||
| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` (read) / built in (RLE write, one scanline per packet) |
|
||||
| ICO | yes | yes | U8 | RGBA (at most 256×256) | yes | no | no | no | no | no | no | `image` |
|
||||
| DDS | yes | **no** | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | n/a | built in |
|
||||
| Netpbm (PBM/PGM/PPM/PAM/PFM) | yes | yes | U8, U16, F32 (float: PFM gray/RGB only) | all six (PAM `CMYK`/`CMYK_ALPHA`) | yes (PAM) | no | no | no | no | no | no | built in |
|
||||
|
||||
@@ -15,7 +15,7 @@ use half::f16;
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, DecodeWarning, ExrChannelInfo, ExrPartInfo, Image, SampleType, base_channel_name};
|
||||
use crate::image::{ChannelLayout, DecodeWarning, ExrChannelInfo, ExrPartInfo, Image, SampleType, base_channel_name, exr_color_channels};
|
||||
use crate::options::{EncodeOptions, ExrCompression, Limits};
|
||||
|
||||
const F: Format = Format::OpenExr;
|
||||
@@ -123,20 +123,9 @@ pub(super) fn read_layer(bytes: &[u8], index: usize) -> Result<Layer<AnyChannels
|
||||
fn layer_to_image(layer: &Layer<AnyChannels<FlatSamples>>) -> Result<Image, CodecError> {
|
||||
let (w, h) = (layer.size.0, layer.size.1);
|
||||
let channels = &layer.channel_data.list;
|
||||
let find = |n: &str| channels.iter().find(|c| base_channel_name(&c.name.to_string()) == n);
|
||||
let (color, layout): (Vec<_>, ChannelLayout) = if let (Some(r), Some(g), Some(b)) = (find("R"), find("G"), find("B")) {
|
||||
match find("A") {
|
||||
Some(a) => (vec![r, g, b, a], ChannelLayout::Rgba),
|
||||
None => (vec![r, g, b], ChannelLayout::Rgb),
|
||||
}
|
||||
} else if let Some(y) = find("Y").or_else(|| (channels.len() == 1).then(|| &channels[0])) {
|
||||
match find("A") {
|
||||
Some(a) => (vec![y, a], ChannelLayout::GrayA),
|
||||
None => (vec![y], ChannelLayout::Gray),
|
||||
}
|
||||
} else {
|
||||
return Err(CodecError::unsupported(F, "no R/G/B or Y channels"));
|
||||
};
|
||||
let names: Vec<_> = channels.iter().map(|c| c.name.to_string()).collect();
|
||||
let (layout, selected) = exr_color_channels(names.iter().map(String::as_str)).ok_or_else(|| CodecError::unsupported(F, "no R/G/B or Y channels"))?;
|
||||
let color: Vec<_> = selected.iter().map(|i| channels.get(*i).ok_or_else(|| err("selected channel is missing"))).collect::<Result<_, _>>()?;
|
||||
let all_f16 = color.iter().all(|c| matches!(c.sample_data, FlatSamples::F16(_)));
|
||||
let sample = if all_f16 { SampleType::F16 } else { SampleType::F32 };
|
||||
let npx = w * h;
|
||||
|
||||
@@ -10,6 +10,7 @@ pub(crate) mod jpeg;
|
||||
pub(crate) mod pcx;
|
||||
pub(crate) mod png;
|
||||
pub(crate) mod pnm;
|
||||
pub(crate) mod tga;
|
||||
pub(crate) mod tiff;
|
||||
pub(crate) mod tiff_exif;
|
||||
pub(crate) mod tiff_ifd;
|
||||
|
||||
@@ -66,6 +66,21 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// PAM ends its header with an LF-terminated line, unlike P4–P6's single separator.
|
||||
fn pam_endhdr(&mut self) -> Result<(), CodecError> {
|
||||
while let Some(&c) = self.b.get(self.pos) {
|
||||
self.pos += 1;
|
||||
if c == b'\n' {
|
||||
// Stop at the first LF: whitespace and '#' after it are raster bytes.
|
||||
return Ok(());
|
||||
}
|
||||
if !c.is_ascii_whitespace() {
|
||||
return Err(err("unexpected data after PAM ENDHDR"));
|
||||
}
|
||||
}
|
||||
Err(err("unterminated PAM ENDHDR line"))
|
||||
}
|
||||
|
||||
fn rest(&self) -> &'a [u8] {
|
||||
&self.b[self.pos.min(self.b.len())..]
|
||||
}
|
||||
@@ -269,7 +284,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result<Image, CodecError>
|
||||
_ => return Err(err("unknown PAM header field")),
|
||||
}
|
||||
}
|
||||
r.single_ws()?;
|
||||
r.pam_endhdr()?;
|
||||
let (w, h, depth, maxval) = match (w, h, depth, maxval) {
|
||||
(Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d),
|
||||
_ => return Err(err("incomplete PAM header")),
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Targa writer. The `image` crate still reads Targa ([`super::via_image`]); writing is ours
|
||||
//! because its run-length packets ran on from the end of one row into the next. TGA 2.0 says a
|
||||
//! packet must not cross a scanline, and readers that decode a row at a time (Pillow, texture
|
||||
//! converters, game engines) misread such files (#6382).
|
||||
//!
|
||||
//! Like Photoshop's Targa: RLE (types 10 and 11), rows stored bottom-up (the bottom-left origin
|
||||
//! every reader assumes, also those that ignore the origin bits), alpha bits in the descriptor,
|
||||
//! and the TGA 2.0 footer.
|
||||
|
||||
use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::image::{ChannelLayout, Image, SampleType};
|
||||
|
||||
/// Most pixels one packet holds (a 7-bit count, plus one).
|
||||
const MAX_PACKET: usize = 128;
|
||||
|
||||
/// The TGA 2.0 footer: no extension or developer area, then the signature.
|
||||
const FOOTER: &[u8; 26] = b"\0\0\0\0\0\0\0\0TRUEVISION-XFILE.\0";
|
||||
|
||||
/// Encodes an 8-bit Gray, Gray+alpha, RGB or RGBA image as a run-length encoded Targa.
|
||||
pub(crate) fn encode(img: &Image) -> Result<Vec<u8>, CodecError> {
|
||||
let (w, h) = img.dimensions();
|
||||
if img.sample_type() != SampleType::U8 {
|
||||
return Err(CodecError::encode(Format::Tga, format!("{:?} samples (Targa stores 8 bits)", img.sample_type())));
|
||||
}
|
||||
let (bpp, image_type, alpha_bits) = match img.layout() {
|
||||
ChannelLayout::Gray => (1, 11, 0),
|
||||
ChannelLayout::GrayA => (2, 11, 8),
|
||||
ChannelLayout::Rgb => (3, 10, 0),
|
||||
ChannelLayout::Rgba => (4, 10, 8),
|
||||
l => return Err(CodecError::encode(Format::Tga, format!("no Targa layout for {l:?}"))),
|
||||
};
|
||||
let (Ok(w16), Ok(h16)) = (u16::try_from(w), u16::try_from(h)) else {
|
||||
return Err(CodecError::encode(Format::Tga, format!("dimensions exceed 65535x65535: {w}x{h}")));
|
||||
};
|
||||
let row = w as usize * bpp;
|
||||
let data = img.data();
|
||||
if data.len() != row * h as usize {
|
||||
return Err(CodecError::InvalidImage("buffer size mismatch".into()));
|
||||
}
|
||||
let mut out = Vec::with_capacity(18 + data.len() + data.len() / MAX_PACKET + FOOTER.len());
|
||||
out.extend_from_slice(&[0, 0, image_type, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
out.extend_from_slice(&w16.to_le_bytes());
|
||||
out.extend_from_slice(&h16.to_le_bytes());
|
||||
// Bits per pixel; the descriptor holds the alpha bits, origin bits 0 (bottom-left).
|
||||
out.extend_from_slice(&[(bpp * 8) as u8, alpha_bits]);
|
||||
let mut px = vec![0u8; row];
|
||||
for y in (0..h as usize).rev() {
|
||||
px.copy_from_slice(&data[y * row..(y + 1) * row]);
|
||||
// Targa stores colour as BGR.
|
||||
if bpp >= 3 {
|
||||
for p in px.chunks_exact_mut(bpp) {
|
||||
p.swap(0, 2);
|
||||
}
|
||||
}
|
||||
rle_row(&px, bpp, &mut out);
|
||||
}
|
||||
out.extend_from_slice(FOOTER);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One scanline as run-length packets that end with it: a run of two or more equal pixels is a
|
||||
/// run packet, anything else goes out in raw packets.
|
||||
fn rle_row(row: &[u8], bpp: usize, out: &mut Vec<u8>) {
|
||||
let px: Vec<&[u8]> = row.chunks_exact(bpp).collect();
|
||||
let n = px.len();
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
let mut run = 1;
|
||||
while i + run < n && run < MAX_PACKET && px[i + run] == px[i] {
|
||||
run += 1;
|
||||
}
|
||||
if run >= 2 {
|
||||
out.push(0x80 | (run - 1) as u8);
|
||||
out.extend_from_slice(px[i]);
|
||||
i += run;
|
||||
continue;
|
||||
}
|
||||
// Raw until two equal pixels start a run, the packet is full or the row ends. The first
|
||||
// pixel always goes in: it differs from the one after it, or it is the row's last.
|
||||
let start = i;
|
||||
while i < n && i - start < MAX_PACKET && !(i > start && i + 1 < n && px[i] == px[i + 1]) {
|
||||
i += 1;
|
||||
}
|
||||
out.push((i - start - 1) as u8);
|
||||
for p in &px[start..i] {
|
||||
out.extend_from_slice(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Formats delegated to the `image` crate (pure-Rust features only):
|
||||
//! GIF, BMP, TGA, ICO, QOI, Radiance HDR, and AVIF encode (feature `avif`).
|
||||
//! GIF, BMP, TGA, ICO, QOI, Radiance HDR, and AVIF encode (feature `avif`). Targa is written by
|
||||
//! [`super::tga`] and only read here.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
@@ -163,6 +164,10 @@ pub(crate) fn encode(f: Format, src: &Image, plan: Plan, opts: &EncodeOptions) -
|
||||
return Err(CodecError::encode(f, format!("dimensions exceed {mw}x{mh}")));
|
||||
}
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
// Targa is written by our encoder: the image crate's RLE packets cross scanlines (#6382).
|
||||
if f == Format::Tga {
|
||||
return super::tga::encode(&img);
|
||||
}
|
||||
let dynimg = to_dynamic(&img)?;
|
||||
let mut cursor = Cursor::new(Vec::new());
|
||||
match f {
|
||||
|
||||
@@ -321,20 +321,75 @@ pub struct ExrPartInfo {
|
||||
|
||||
impl ExrPartInfo {
|
||||
/// How much this part looks like a colour image the plain [`Image`] decode can show:
|
||||
/// 4 RGBA, 3 RGB, 2 YA, 1 any-single-channel, 0 anything else (e.g. `Z` beside `A`).
|
||||
/// 4 RGBA, 3 RGB, 2 YA, 1 Y or any-single-channel, 0 anything else (e.g. `Z` beside `A`).
|
||||
/// The colour and alpha channels must share a complete layer prefix. The highest-ranked
|
||||
/// group wins; equal groups prefer unprefixed channels, then the first prefix alphabetically.
|
||||
/// The auto-pick takes the highest rank; ties go to the earlier part.
|
||||
pub fn color_rank(&self) -> u8 {
|
||||
let has = |n: &str| self.channels.iter().any(|c| base_channel_name(&c.name) == n);
|
||||
if has("R") && has("G") && has("B") {
|
||||
return if has("A") { 4 } else { 3 };
|
||||
}
|
||||
if has("Y") {
|
||||
return if has("A") { 2 } else { 1 };
|
||||
}
|
||||
u8::from(self.channels.len() == 1)
|
||||
exr_color_channels(self.channels.iter().map(|c| c.name.as_str())).map_or(0, |(layout, _)| layout.channels() as u8)
|
||||
}
|
||||
}
|
||||
|
||||
/// One selection policy for header ranking/probing and flat decoding: never synthesize
|
||||
/// RGB(A) or YA from separate layers. OpenEXR layers use the full prefix before the last
|
||||
/// dot, including nested layers; a leading or trailing dot alone does not name a layer.
|
||||
pub(crate) fn exr_color_channels<'a>(names: impl IntoIterator<Item = &'a str>) -> Option<(ChannelLayout, Vec<usize>)> {
|
||||
#[derive(Default)]
|
||||
struct Group {
|
||||
r: Option<usize>,
|
||||
g: Option<usize>,
|
||||
b: Option<usize>,
|
||||
a: Option<usize>,
|
||||
y: Option<usize>,
|
||||
}
|
||||
|
||||
// Sorted prefixes make rank ties deterministic independently of channel order, with
|
||||
// the empty (unprefixed) group first. Unsupported channels cannot complete a group.
|
||||
let mut groups = std::collections::BTreeMap::<&str, Group>::new();
|
||||
let mut count = 0usize;
|
||||
for (index, name) in names.into_iter().enumerate() {
|
||||
count = count.saturating_add(1);
|
||||
let (prefix, base) = name.rsplit_once('.').filter(|(prefix, base)| !prefix.is_empty() && !base.is_empty()).unwrap_or(("", name));
|
||||
if !matches!(base, "R" | "G" | "B" | "A" | "Y") {
|
||||
continue;
|
||||
}
|
||||
let group = groups.entry(prefix).or_default();
|
||||
let component = match base {
|
||||
"R" => &mut group.r,
|
||||
"G" => &mut group.g,
|
||||
"B" => &mut group.b,
|
||||
"A" => &mut group.a,
|
||||
"Y" => &mut group.y,
|
||||
_ => continue,
|
||||
};
|
||||
component.get_or_insert(index);
|
||||
}
|
||||
|
||||
let mut best = None;
|
||||
let mut best_rank = 0;
|
||||
for group in groups.values() {
|
||||
let candidate = if let (Some(r), Some(g), Some(b)) = (group.r, group.g, group.b) {
|
||||
match group.a {
|
||||
Some(a) => (ChannelLayout::Rgba, vec![r, g, b, a]),
|
||||
None => (ChannelLayout::Rgb, vec![r, g, b]),
|
||||
}
|
||||
} else if let Some(y) = group.y {
|
||||
match group.a {
|
||||
Some(a) => (ChannelLayout::GrayA, vec![y, a]),
|
||||
None => (ChannelLayout::Gray, vec![y]),
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let rank = candidate.0.channels();
|
||||
if rank > best_rank {
|
||||
best = Some(candidate);
|
||||
best_rank = rank;
|
||||
}
|
||||
}
|
||||
best.or_else(|| (count == 1).then(|| (ChannelLayout::Gray, vec![0])))
|
||||
}
|
||||
|
||||
/// Strip an optional `layer.` prefix from a channel name (`diffuse.R` → `R`).
|
||||
pub(crate) fn base_channel_name(name: &str) -> &str {
|
||||
name.rsplit('.').next().unwrap_or(name)
|
||||
|
||||
@@ -8,7 +8,9 @@ use exr::prelude::{
|
||||
WritableImage,
|
||||
};
|
||||
use photocraft_codecs::f16;
|
||||
use photocraft_codecs::{ChannelLayout, DecodeOptions, DecodeWarning, Format, Limits, SampleType, decode, decode_as_with, decode_exr_part, exr_info};
|
||||
use photocraft_codecs::{
|
||||
ChannelLayout, DecodeOptions, DecodeWarning, Format, Limits, SampleType, decode, decode_as_with, decode_exr_part, exr_info, probe_size,
|
||||
};
|
||||
|
||||
fn layer_f32(name: &str, comp: Compression, chans: &[(&str, Vec<f32>)]) -> Layer<AnyChannels<FlatSamples>> {
|
||||
let n = chans.iter().find(|(_, v)| !v.is_empty()).map_or(0, |(_, v)| v.len());
|
||||
@@ -226,3 +228,107 @@ fn decoding_one_part_does_not_decode_the_others() {
|
||||
assert_eq!((img.width(), img.height()), (4, 4));
|
||||
assert!(img.to_normalized().iter().all(|v| near(*v, 0.75)), "wrong pixels");
|
||||
}
|
||||
|
||||
fn channel_group_file(channels: &[(&str, f32)]) -> Vec<u8> {
|
||||
let channels: Vec<_> = channels.iter().map(|(name, value)| (*name, vec![*value; 4])).collect();
|
||||
write_image(vec![layer_f32("groups", Compression::ZIP1, &channels)])
|
||||
}
|
||||
|
||||
fn assert_channel_group(bytes: &[u8], layout: ChannelLayout, expected: &[f32]) {
|
||||
assert_eq!(exr_info(bytes, &Limits::default()).unwrap()[0].color_rank(), expected.len() as u8);
|
||||
assert_eq!(probe_size(bytes).unwrap(), (2, 2));
|
||||
for img in [decode(bytes).unwrap(), decode_exr_part(bytes, 0, &DecodeOptions::default()).unwrap()] {
|
||||
assert_eq!(img.layout(), layout);
|
||||
assert_eq!(img.to_normalized(), expected.repeat(4));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_decode_keeps_rgb_channels_in_one_group() {
|
||||
// A partial group sorts before the nested beauty layer, but cannot donate its R.
|
||||
let bytes = channel_group_file(&[("aaa.R", 9.0), ("beauty.diffuse.R", 0.25), ("beauty.diffuse.G", 0.5), ("beauty.diffuse.B", 2.0)]);
|
||||
assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_decode_does_not_borrow_another_groups_alpha() {
|
||||
for alpha in ["A", "aaa.A", "beauty.other.A"] {
|
||||
let bytes = channel_group_file(&[(alpha, 0.125), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0)]);
|
||||
assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]);
|
||||
let bytes = channel_group_file(&[(alpha, 0.125), ("beauty.Y", 0.5)]);
|
||||
assert_channel_group(&bytes, ChannelLayout::Gray, &[0.5]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_decode_rejects_rgb_split_between_groups() {
|
||||
for channels in [
|
||||
[("aaa.R", 0.25), ("bbb.G", 0.5), ("ccc.B", 2.0)],
|
||||
[("beauty.R", 0.25), ("beauty.diffuse.G", 0.5), ("beauty.diffuse.B", 2.0)],
|
||||
[(".R", 0.25), ("G", 0.5), ("B", 2.0)],
|
||||
] {
|
||||
let bytes = channel_group_file(&channels);
|
||||
assert_eq!(exr_info(&bytes, &Limits::default()).unwrap()[0].color_rank(), 0);
|
||||
assert!(probe_size(&bytes).is_err());
|
||||
assert!(decode(&bytes).is_err());
|
||||
assert!(decode_exr_part(&bytes, 0, &DecodeOptions::default()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_pick_and_probe_skip_incoherent_rgb_parts() {
|
||||
let bytes = write_image(vec![
|
||||
layer_f32("split", Compression::ZIP1, &[("aaa.R", vec![9.0; 4]), ("bbb.G", vec![8.0; 4]), ("ccc.B", vec![7.0; 4])]),
|
||||
layer_f32("luminance", Compression::ZIP1, &[("Y", vec![0.5; 16]), ("A", vec![0.75; 16])]),
|
||||
]);
|
||||
let parts = exr_info(&bytes, &Limits::default()).unwrap();
|
||||
assert_eq!(parts.iter().map(|part| part.color_rank()).collect::<Vec<_>>(), [0, 2]);
|
||||
assert_eq!(probe_size(&bytes).unwrap(), (4, 4));
|
||||
let img = decode(&bytes).unwrap();
|
||||
assert_eq!((img.width(), img.height(), img.layout()), (4, 4, ChannelLayout::GrayA));
|
||||
assert_eq!(img.to_f32_samples().unwrap(), [0.5, 0.75].repeat(16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_decode_prefers_the_highest_ranked_complete_group() {
|
||||
let bytes =
|
||||
channel_group_file(&[("aaa.R", 9.0), ("aaa.G", 8.0), ("aaa.B", 7.0), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0), ("beauty.A", 0.75)]);
|
||||
assert_channel_group(&bytes, ChannelLayout::Rgba, &[0.25, 0.5, 2.0, 0.75]);
|
||||
let bytes = channel_group_file(&[("aaa.Y", 9.0), ("beauty.Y", 0.5), ("beauty.A", 0.75)]);
|
||||
assert_channel_group(&bytes, ChannelLayout::GrayA, &[0.5, 0.75]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_decode_breaks_group_ties_by_prefix() {
|
||||
let bytes = channel_group_file(&[("z.R", 9.0), ("z.G", 8.0), ("z.B", 7.0), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0)]);
|
||||
assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]);
|
||||
// Even a prefix that sorts before the unprefixed channel names loses a rank tie.
|
||||
let bytes = channel_group_file(&[("0.R", 9.0), ("0.G", 8.0), ("0.B", 7.0), ("R", 0.25), ("G", 0.5), ("B", 2.0)]);
|
||||
assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_decode_preserves_luminance_and_lone_channel_fallbacks() {
|
||||
for prefix in ["", "beauty."] {
|
||||
let y = format!("{prefix}Y");
|
||||
let a = format!("{prefix}A");
|
||||
assert_channel_group(&channel_group_file(&[(&y, 2.0), (&a, 0.75)]), ChannelLayout::GrayA, &[2.0, 0.75]);
|
||||
assert_channel_group(&channel_group_file(&[(&y, 2.0)]), ChannelLayout::Gray, &[2.0]);
|
||||
}
|
||||
for name in ["depth.Z", "A", "beauty.A"] {
|
||||
// A lone alpha is one grayscale channel, not the same samples duplicated as YA.
|
||||
assert_channel_group(&channel_group_file(&[(name, 9.0)]), ChannelLayout::Gray, &[9.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_float_group_does_not_use_unrelated_float_channels() {
|
||||
let mut layer = layer_f32("mixed", Compression::ZIP1, &[("aaa.R", vec![9.0; 4]), ("aaa.A", vec![0.125; 4])]);
|
||||
for (name, value) in [("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0), ("beauty.A", 0.75)] {
|
||||
layer.channel_data.list.push(AnyChannel::new(name, FlatSamples::F16(vec![f16::from_f32(value); 4])));
|
||||
}
|
||||
layer.channel_data = AnyChannels::sort(layer.channel_data.list);
|
||||
let bytes = write_image(vec![layer]);
|
||||
assert_channel_group(&bytes, ChannelLayout::Rgba, &[0.25, 0.5, 2.0, 0.75]);
|
||||
assert_eq!(decode(&bytes).unwrap().sample_type(), SampleType::F16);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use photocraft_codecs::{ChannelLayout, CodecError, Format, SampleType, decode, decode_as};
|
||||
|
||||
const PAM_GRAY: &[u8] = b"P7\nWIDTH 1\nHEIGHT 1\nDEPTH 1\nMAXVAL 255\nENDHDR";
|
||||
const ENDINGS: &[&[u8]] = &[b"\n", b" \n", b"\t \n", b"\r\n", b" \t\r\n"];
|
||||
|
||||
#[test]
|
||||
fn pam_consumes_the_complete_endhdr_line() {
|
||||
for ending in ENDINGS {
|
||||
let mut bytes = PAM_GRAY.to_vec();
|
||||
bytes.extend_from_slice(ending);
|
||||
bytes.push(0x80);
|
||||
let img = decode(&bytes).unwrap();
|
||||
assert_eq!(img.dimensions(), (1, 1));
|
||||
assert_eq!(img.layout(), ChannelLayout::Gray);
|
||||
assert_eq!(img.data(), &[0x80], "ENDHDR ending {ending:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pam_preserves_every_possible_first_raster_byte() {
|
||||
// In particular, LF, CR, space, tab and '#' must not be skipped as header syntax.
|
||||
for ending in ENDINGS {
|
||||
for first in 0..=255u8 {
|
||||
let mut bytes = PAM_GRAY.to_vec();
|
||||
bytes.extend_from_slice(ending);
|
||||
bytes.push(first);
|
||||
assert_eq!(decode(&bytes).unwrap().data(), &[first], "ENDHDR ending {ending:?}, raster byte {first}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pam_crlf_header_preserves_u16_samples_and_alpha() {
|
||||
for ending in ENDINGS {
|
||||
let mut bytes = b"P7\r\nWIDTH 1\r\nHEIGHT 1\r\nDEPTH 2\r\nMAXVAL 65535\r\nTUPLTYPE GRAYSCALE_ALPHA\r\nENDHDR".to_vec();
|
||||
bytes.extend_from_slice(ending);
|
||||
bytes.extend_from_slice(b" \n#\r");
|
||||
let img = decode(&bytes).unwrap();
|
||||
assert_eq!(img.layout(), ChannelLayout::GrayA);
|
||||
assert_eq!(img.sample_type(), SampleType::U16);
|
||||
assert_eq!(img.to_u16_samples().unwrap(), vec![0x200a, 0x230d], "ENDHDR ending {ending:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pam_rejects_unterminated_or_invalid_endhdr_lines() {
|
||||
for suffix in [b"".as_slice(), b" ", b"\r", b" \t\r", b" \x80", b" \x80\n\x7f", b"#comment\n\x7f"] {
|
||||
let mut bytes = PAM_GRAY.to_vec();
|
||||
bytes.extend_from_slice(suffix);
|
||||
assert!(matches!(decode_as(Format::Pnm, &bytes), Err(CodecError::Malformed { .. })), "ENDHDR suffix {suffix:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pam_truncation_never_uses_header_whitespace_as_pixels() {
|
||||
for ending in ENDINGS {
|
||||
let mut bytes = PAM_GRAY.to_vec();
|
||||
bytes.extend_from_slice(ending);
|
||||
bytes.push(0x80);
|
||||
for cut in 0..bytes.len() {
|
||||
assert!(decode_as(Format::Pnm, &bytes[..cut]).is_err(), "ENDHDR ending {ending:?}, truncated at {cut}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_pnm_keeps_its_single_whitespace_separator() {
|
||||
for separator in *b" \t\r\n" {
|
||||
for first in *b" \t\r\n#" {
|
||||
for (header, channels) in [(b"P5\n1 1\n255".as_slice(), 1), (b"P6\n1 1\n255", 3)] {
|
||||
let mut bytes = header.to_vec();
|
||||
bytes.push(separator);
|
||||
bytes.extend(std::iter::repeat_n(first, channels));
|
||||
assert_eq!(decode(&bytes).unwrap().data(), vec![first; channels]);
|
||||
}
|
||||
let mut bytes = b"P4\n8 1".to_vec();
|
||||
bytes.extend_from_slice(&[separator, first]);
|
||||
let expected: Vec<u8> = (0..8).map(|bit| if first & (0x80 >> bit) == 0 { 255 } else { 0 }).collect();
|
||||
assert_eq!(decode(&bytes).unwrap().data(), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Targa writing (#6382): run-length packets end with their scanline, rows are stored bottom-up,
|
||||
//! and the file reads back exactly.
|
||||
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, decode_as, encode};
|
||||
|
||||
/// The packets of a run-length encoded Targa body, as (pixels, is a run), from just after the
|
||||
/// 18-byte header (no image ID or colour map) up to `pixels` pixels.
|
||||
fn packets(file: &[u8], bpp: usize, pixels: usize) -> Vec<(usize, bool)> {
|
||||
let mut at = 18;
|
||||
let mut seen = 0;
|
||||
let mut out = Vec::new();
|
||||
while seen < pixels {
|
||||
let head = file[at];
|
||||
let count = usize::from(head & 0x7f) + 1;
|
||||
let run = head & 0x80 != 0;
|
||||
at += 1 + if run { bpp } else { count * bpp };
|
||||
seen += count;
|
||||
out.push((count, run));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A `w`×`h` image whose rows mix long runs (wider than a packet), short ones and noise, and
|
||||
/// whose last pixel equals the next row's first, so a packet would run on if it could.
|
||||
fn sample(w: u32, h: u32, layout: ChannelLayout) -> Image {
|
||||
let n = layout.channels();
|
||||
let mut data = Vec::new();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let v = match x {
|
||||
_ if x < 150 => 40,
|
||||
_ if x % 3 == 0 => (x * 7 + y * 13) as u8,
|
||||
_ => 200,
|
||||
};
|
||||
let px = [v, v.wrapping_add(y as u8), v ^ 0x5a, 255 - v];
|
||||
data.extend_from_slice(&px[..n]);
|
||||
}
|
||||
}
|
||||
Image::from_u8(w, h, layout, data).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rle_packets_end_with_their_scanline() {
|
||||
for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] {
|
||||
let (w, h) = (300u32, 4u32);
|
||||
let file = encode(&sample(w, h, layout), Format::Tga, &EncodeOptions::default()).unwrap();
|
||||
let bpp = layout.channels();
|
||||
let mut col = 0;
|
||||
for (count, _) in packets(&file, bpp, (w * h) as usize) {
|
||||
assert!(count <= 128, "{layout:?}: a packet holds at most 128 pixels");
|
||||
assert!(col + count <= w as usize, "{layout:?}: a packet runs from column {col} past the row's end");
|
||||
col = (col + count) % w as usize;
|
||||
}
|
||||
// A solid image is one run per row (three packets of 128, 128 and 44), not one long run.
|
||||
let solid = Image::from_u8(w, h, layout, vec![90; (w * h) as usize * bpp]).unwrap();
|
||||
let file = encode(&solid, Format::Tga, &EncodeOptions::default()).unwrap();
|
||||
let p = packets(&file, bpp, (w * h) as usize);
|
||||
assert_eq!(p.len(), 3 * h as usize, "{layout:?}");
|
||||
assert!(p.iter().all(|&(_, run)| run));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_is_bottom_up_rle_with_its_alpha_bits_and_the_tga2_footer() {
|
||||
let mut data = vec![0u8; 2 * 2 * 4];
|
||||
// Top row red, bottom row blue, opaque.
|
||||
for (i, px) in data.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
*px = if i < 2 { [255, 0, 0, 255] } else { [0, 0, 255, 255] };
|
||||
}
|
||||
let file = encode(&Image::from_u8(2, 2, ChannelLayout::Rgba, data).unwrap(), Format::Tga, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(file[2], 10, "run-length true colour");
|
||||
assert_eq!((file[16], file[17]), (32, 8), "32 bits per pixel, 8 alpha bits, bottom-left origin");
|
||||
// The first stored row is the bottom one: a run of two blue pixels, in BGRA.
|
||||
assert_eq!(&file[18..23], &[0x81, 255, 0, 0, 255]);
|
||||
assert!(file.ends_with(b"TRUEVISION-XFILE.\0"));
|
||||
let gray = encode(&Image::from_u8(2, 1, ChannelLayout::Gray, vec![1, 2]).unwrap(), Format::Tga, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!((gray[2], gray[16], gray[17]), (11, 8, 0), "run-length grayscale, no alpha bits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_layout_reads_back_exactly() {
|
||||
for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] {
|
||||
for (w, h) in [(1, 1), (300, 4), (129, 3), (5, 130)] {
|
||||
let img = sample(w, h, layout);
|
||||
let file = encode(&img, Format::Tga, &EncodeOptions::default()).unwrap();
|
||||
let back = decode_as(Format::Tga, &file).unwrap();
|
||||
assert_eq!((back.dimensions(), back.layout()), ((w, h), layout), "{layout:?} {w}x{h}");
|
||||
assert_eq!(back.data(), img.data(), "{layout:?} {w}x{h}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,14 +30,12 @@ impl MeasurementScale {
|
||||
}
|
||||
/// Photoshop's "Scale" column text, e.g. `1 pixels = 1.0000 pixels`.
|
||||
pub fn describe(&self) -> String {
|
||||
format!("{} pixels = {:.4} {}", trim(self.pixel_length), self.logical_length, self.units)
|
||||
// f64 Display already drops a whole number's `.0` (`5`, `0.5`) and, unlike an i64 cast,
|
||||
// prints lengths past i64::MAX in full.
|
||||
format!("{} pixels = {:.4} {}", self.pixel_length, self.logical_length, self.units)
|
||||
}
|
||||
}
|
||||
|
||||
fn trim(v: f64) -> String {
|
||||
if v.fract() == 0.0 { format!("{}", v as i64) } else { format!("{v}") }
|
||||
}
|
||||
|
||||
/// One Count tool group: its numbered markers (document pixel coordinates, in click order).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -133,6 +131,17 @@ mod tests {
|
||||
assert_eq!(MeasurementScale { pixel_length: 0.0, ..Default::default() }.factor(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_text_prints_large_and_fractional_pixel_lengths() {
|
||||
let big = MeasurementScale { pixel_length: 1e20, logical_length: 1.0, units: "mm".into() };
|
||||
assert_eq!(big.describe(), "100000000000000000000 pixels = 1.0000 mm");
|
||||
assert_eq!(MeasurementScale::default().describe(), "1 pixels = 1.0000 pixels");
|
||||
let s = |p: f64| MeasurementScale { pixel_length: p, ..Default::default() }.describe();
|
||||
assert_eq!(s(5.0), "5 pixels = 1.0000 pixels");
|
||||
assert_eq!(s(0.5), "0.5 pixels = 1.0000 pixels");
|
||||
assert_eq!(s(12.25), "12.25 pixels = 1.0000 pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_defaults_fill_missing_fields() {
|
||||
let m: Measurement = serde_json::from_str("{}").unwrap();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The Photocraft document model: pure data, no rendering, no UI.
|
||||
//! The PhotoCraft document model: pure data, no rendering, no UI.
|
||||
//!
|
||||
//! Layer order: inside every group (and the root), `children[0]` is the **bottom** layer, matching
|
||||
//! compositing order and the PSD file order. UIs display the list reversed.
|
||||
|
||||
+17
-1
@@ -74,7 +74,7 @@ impl Duotone {
|
||||
let density = 1.0 - gray.clamp(0.0, 1.0);
|
||||
let mut out = [1.0f32; 3];
|
||||
for ink in &self.inks {
|
||||
let d = curve_eval(&ink.curve, density);
|
||||
let d = ink.density(density);
|
||||
for (o, c) in out.iter_mut().zip(ink.color) {
|
||||
// Each ink filters light: full density transmits the ink colour.
|
||||
*o *= 1.0 - d * (1.0 - c);
|
||||
@@ -209,6 +209,22 @@ mod tests {
|
||||
assert!(c[0] > c[2], "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duotone_render_clamps_ink_density_like_multichannel() {
|
||||
// Curves above 100 % (accepted by `image.mode.duotone`) must not push the print out of
|
||||
// gamut: red + blue at full density is black, not green.
|
||||
let over = |name: &str, color| {
|
||||
let mut ink = DuotoneInk::new(name, color);
|
||||
ink.curve = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 2.0 }];
|
||||
ink
|
||||
};
|
||||
let d = Duotone { inks: vec![over("Red", [1.0, 0.0, 0.0]), over("Blue", [0.0, 0.0, 1.0])], psd_raw: None };
|
||||
assert_eq!(d.render(0.0), [0.0, 0.0, 0.0]);
|
||||
for g in [0.0, 0.25, 0.5, 1.0] {
|
||||
assert!(d.render(g).iter().all(|c| (0.0..=1.0).contains(c)), "{g}: {:?}", d.render(g));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_mode_ids_roundtrip() {
|
||||
for m in StackMode::ALL {
|
||||
|
||||
@@ -43,6 +43,10 @@ roxmltree = "0.20"
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
# Printing on Unix: an IPP client for CUPS (pure Rust); the web build cannot print.
|
||||
[target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))'.dependencies]
|
||||
photocraft-ipp = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
photocraft-affinity = { workspace = true, features = ["synth"] }
|
||||
# Synthetic camera raw files for the open tests.
|
||||
|
||||
@@ -296,6 +296,18 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||||
/// Image › Adjustments run as a smart filter (`image.adjustments.<kind>` recorded on a smart
|
||||
/// object): the adjustment applied to a copy of `surf`. `None` for kinds or params it can't run.
|
||||
pub(crate) fn adjust_as_filter(kind: &str, p: &Value, surf: &Surface) -> Option<Surface> {
|
||||
// A Color Lookup filter re-reads its LUT `file`, a path stored in the document: followed only
|
||||
// as `doc_path` allows, else the filter can't run (like a missing file).
|
||||
let resolved;
|
||||
let p = match p.get("file").and_then(Value::as_str).filter(|f| kind == "colorLookup" && !f.is_empty()) {
|
||||
Some(file) => {
|
||||
let mut q = p.clone();
|
||||
q["file"] = json!(crate::doc_path::resolve(file).ok()?.to_str()?);
|
||||
resolved = q;
|
||||
&resolved
|
||||
}
|
||||
None => p,
|
||||
};
|
||||
let mut out = surf.clone();
|
||||
if kind == "shadowsHighlights" {
|
||||
let sh = shadows_highlights_params(p);
|
||||
|
||||
@@ -1121,8 +1121,12 @@ fn place_scale_marker(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
// Bottom-left with a margin; the text sits under (or over) the bar.
|
||||
let margin = (w.min(h) * 0.03).round().max(2.0);
|
||||
let bar_h = (font_size / 4.0).round().clamp(2.0, h / 4.0);
|
||||
let text_h = if show_text { font_size * 1.25 } else { 0.0 };
|
||||
// `fontSize` is in points; `type.create` renders it at resolution/72 px per point, so lay out
|
||||
// the bar and label in those pixels (at 300 ppi, 12 pt is 50 px) (#5754).
|
||||
let dpi = f64::from(d.resolution_dpi);
|
||||
let font_px = font_size * if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 };
|
||||
let bar_h = (font_px / 4.0).round().clamp(2.0, h / 4.0);
|
||||
let text_h = if show_text { font_px * 1.25 } else { 0.0 };
|
||||
// Keep the bar on the canvas when the text under it doesn't fit; the label is clipped instead (#932).
|
||||
let bar_y = if text_top { h - margin - bar_h } else { h - margin - bar_h - text_h }.max(0.0);
|
||||
// Likewise pull a bar nearly as wide as the image in from the right edge (#932).
|
||||
@@ -1143,7 +1147,7 @@ fn place_scale_marker(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
let mut text_layer = Value::Null;
|
||||
if show_text {
|
||||
let baseline = if text_top { f64::from(bar.y0) - font_size * 0.3 } else { f64::from(bar.y1) + font_size };
|
||||
let baseline = if text_top { f64::from(bar.y0) - font_px * 0.3 } else { f64::from(bar.y1) + font_px };
|
||||
let mut tp = json!({"x": bar.x0, "y": baseline, "text": label, "size": font_size, "color": [rgb[0], rgb[1], rgb[2], 1.0], "name": label});
|
||||
if let Some(f) = &font {
|
||||
tp["font"] = json!(f);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use photocraft_doc::ColorMode;
|
||||
use photocraft_doc::{ColorMode, LayerContent, LayerId};
|
||||
|
||||
fn session(w: u32, h: u32, depth: u32) -> Session {
|
||||
let mut s = Session::new();
|
||||
@@ -355,6 +355,27 @@ fn place_scale_marker_builds_one_group_in_one_step() {
|
||||
assert!(d(&s).layers.iter().all(|l| l.name != "Measurement Scale Marker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_scale_marker_lays_out_its_label_in_points_at_high_resolution() {
|
||||
// `fontSize` is in points and `type.create` renders it at resolution/72 px per point, but the
|
||||
// layout treated it as pixels: at 300 ppi a 12 pt label (50 px) overlapped the bar.
|
||||
for res in [72.0, 300.0] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 1200, "height": 900, "resolution": res, "background": "white"})).unwrap();
|
||||
let r = s.execute("image.analysis.placeScaleMarker", json!({"fontSize": 12, "textPosition": "bottom"})).unwrap();
|
||||
let bar_bottom = r["barRect"][3].as_f64().unwrap();
|
||||
let doc = d(&s);
|
||||
let Some(LayerContent::Text(t)) = doc.layer(LayerId(r["text"].as_u64().unwrap())).map(|l| &l.content) else { panic!("a type layer: {r}") };
|
||||
assert_eq!(t.size_pt, 12.0, "{res} ppi: type.create still gets points");
|
||||
let layout = photocraft_text::shared().lock().unwrap().layout(t, doc.resolution_dpi);
|
||||
let b = layout.bounds().expect("the label has a line");
|
||||
let ty = t.transform.m[5];
|
||||
let (top, bottom) = (f64::from(b[1]) + ty, f64::from(b[3]) + ty);
|
||||
assert!(top >= bar_bottom - 0.5, "{res} ppi: label top {top} overlaps the bar ending at {bar_bottom}: {r}");
|
||||
assert!(bottom <= 900.5, "{res} ppi: label bottom {bottom} stays on the canvas: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_scale_marker_on_a_short_document_errors_without_panicking() {
|
||||
// A document under 8 px tall inverted the bar-height clamp and panicked (#932). Call `run`
|
||||
|
||||
@@ -528,7 +528,7 @@ fn chosen_artboards(doc: &Document, p: &Value) -> Vec<LayerId> {
|
||||
}
|
||||
}
|
||||
|
||||
/// File › Export › Artboards to Files: `<prefix>_<artboard name>.<format>` per board.
|
||||
/// File › Export › Artboards to Files: `<prefix>_<index:04>_<artboard name>.<format>` per board.
|
||||
fn artboards_to_files(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.export.artboardsToFiles";
|
||||
let dir = str_param(p, "dir", cmd).or_else(|_| str_param(p, "output", cmd))?.to_string();
|
||||
@@ -538,8 +538,9 @@ fn artboards_to_files(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name));
|
||||
let doc = d.doc.clone();
|
||||
let mut files = Vec::new();
|
||||
for one in chosen_documents(&doc, p)? {
|
||||
let name = if prefix.is_empty() { sanitize(&one.name) } else { format!("{}_{}", sanitize(&prefix), sanitize(&one.name)) };
|
||||
for (i, one) in chosen_documents(&doc, p)?.into_iter().enumerate() {
|
||||
// Names may be identical, or collide after sanitizing or filesystem case folding.
|
||||
let name = if prefix.is_empty() { format!("{i:04}_{}", sanitize(&one.name)) } else { format!("{}_{i:04}_{}", sanitize(&prefix), sanitize(&one.name)) };
|
||||
let path = join(&dir, &format!("{name}.{format}"));
|
||||
save_doc(&one, &path, save)?;
|
||||
files.push(path);
|
||||
|
||||
@@ -146,8 +146,8 @@ fn export_to_files_and_pdf() {
|
||||
let r = s.execute("file.export.artboardsToFiles", json!({"dir": d, "prefix": "site"})).unwrap();
|
||||
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(files.len(), 2);
|
||||
assert!(files[0].ends_with("site_Empty_One.png"), "{files:?}");
|
||||
assert!(files[1].ends_with("site_Red Board.png"), "{files:?}");
|
||||
assert!(files[0].ends_with("site_0000_Empty_One.png"), "{files:?}");
|
||||
assert!(files[1].ends_with("site_0001_Red Board.png"), "{files:?}");
|
||||
let img = photocraft_io::import("x.png", &std::fs::read(&files[1]).unwrap()).unwrap().document;
|
||||
assert_eq!((img.size.width, img.size.height), (10, 10));
|
||||
let img2 = photocraft_io::import("x.png", &std::fs::read(&files[0]).unwrap()).unwrap().document;
|
||||
|
||||
@@ -1986,6 +1986,10 @@ fn fill_from(d: &Descriptor) -> Mapped {
|
||||
match get_enum(d, "Usng").as_deref() {
|
||||
Some("FrgC") | None => {}
|
||||
Some("Pttn") => p["contents"] = json!("pattern"),
|
||||
Some("Clr") => match get_obj(d, "Clr ").and_then(color_hex) {
|
||||
Some(c) => p["color"] = json!(c),
|
||||
None => return Mapped::Unsupported { harmless: false },
|
||||
},
|
||||
Some(_) => return Mapped::Unsupported { harmless: false },
|
||||
}
|
||||
if let Some(t) = get_num(d, "Tlrn") {
|
||||
@@ -2032,7 +2036,16 @@ fn fill_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
if id == "paint.bucket" {
|
||||
let mut d = desc("null").with("From", D::Descriptor(desc("Pnt ").with("Hrzn", px(jnum(p, "x")?)).with("Vrtc", px(jnum(p, "y")?))));
|
||||
d = d.with("Tlrn", long(jnum(p, "tolerance").unwrap_or(32.0))).with("AntA", D::Boolean(jbool(p, "antiAlias").unwrap_or(true)));
|
||||
d = d.with("Usng", enm("FlCn", if jstr(p, "contents") == Some("pattern") { "Pttn" } else { "FrgC" }));
|
||||
// `source` is the command's older name for `contents`.
|
||||
d = if p.get("contents").or_else(|| p.get("source")).and_then(Value::as_str) == Some("pattern") {
|
||||
d.with("Usng", enm("FlCn", "Pttn"))
|
||||
} else {
|
||||
// An explicit colour overrides the foreground (#7199): it travels as Fill's `Clr `.
|
||||
match bucket_color(p.get("color"))? {
|
||||
Some(c) => d.with("Usng", enm("FlCn", "Clr ")).with("Clr ", D::Descriptor(c)),
|
||||
None => d.with("Usng", enm("FlCn", "FrgC")),
|
||||
}
|
||||
};
|
||||
if let Some(o) = jnum(p, "opacity") {
|
||||
d = d.with("Opct", pct(o));
|
||||
}
|
||||
@@ -2054,6 +2067,29 @@ fn fill_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// The Paint Bucket's `color` as `paint.bucket` reads it (`[r, g, b(, a)]` in 0..=1 or
|
||||
/// `#rrggbb(aa)`): `Some(None)` when it fills with the foreground (no colour, or a string the
|
||||
/// command doesn't read), `None` when the colour can't be written (not opaque, or outside 0..=1).
|
||||
fn bucket_color(v: Option<&Value>) -> Option<Option<Descriptor>> {
|
||||
let rgba = match v {
|
||||
Some(Value::Array(a)) if a.len() >= 3 => {
|
||||
let c: Vec<f64> = a.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect();
|
||||
[*c.first()?, *c.get(1)?, *c.get(2)?, c.get(3).copied().unwrap_or(1.0)]
|
||||
}
|
||||
Some(Value::String(h)) => {
|
||||
let h = h.trim_start_matches('#');
|
||||
let c = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| f64::from(v) / 255.0);
|
||||
let (Some(r), Some(g), Some(b)) = (c(0), c(2), c(4)) else { return Some(None) };
|
||||
[r, g, b, c(6).unwrap_or(1.0)]
|
||||
}
|
||||
_ => return Some(None),
|
||||
};
|
||||
if rgba[3] != 1.0 || !rgba.iter().all(|v| (0.0..=1.0).contains(v)) {
|
||||
return None;
|
||||
}
|
||||
Some(Some(desc("RGBC").with("Rd ", D::Double(rgba[0] * 255.0)).with("Grn ", D::Double(rgba[1] * 255.0)).with("Bl ", D::Double(rgba[2] * 255.0))))
|
||||
}
|
||||
|
||||
const STROKE: &[F] = &[
|
||||
F("Wdth", "width", Kind::Long),
|
||||
F("Lctn", "location", Kind::Enum("StrL", STROKE_LOCATION)),
|
||||
|
||||
@@ -432,6 +432,55 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `paint.bucket` exported to .atn and imported back → its params, or None when it doesn't export.
|
||||
fn bucket_round_trip(params: Value) -> Option<Value> {
|
||||
let item = export_item("paint.bucket", ¶ms, "S")?;
|
||||
let e = event_for(&item.event)?;
|
||||
match (e.from)(item.descriptor.as_ref()?) {
|
||||
Mapped::Steps(steps) => steps.first().map(|(id, p)| {
|
||||
assert_eq!(id, "paint.bucket");
|
||||
p.clone()
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A Paint Bucket step with an explicit colour keeps it through .atn (#7199): replayed on a fresh
|
||||
/// document, whose foreground is black, it still fills red. A colour .atn can't hold makes the
|
||||
/// step not export rather than fill with the foreground.
|
||||
#[test]
|
||||
fn bucket_keeps_an_explicit_color_through_atn() {
|
||||
let fill = |params: &Value| {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8, "background": "#ffffff"})).unwrap();
|
||||
s.execute("paint.bucket", params.clone()).unwrap();
|
||||
let d = s.active().unwrap();
|
||||
d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().pixel(2, 2)
|
||||
};
|
||||
let red = json!({"x": 2, "y": 2, "antiAlias": false, "color": "#ff0000"});
|
||||
let back = bucket_round_trip(red.clone()).unwrap();
|
||||
assert_eq!(back["color"], "#ff0000", "{back}");
|
||||
assert_eq!(fill(&back), fill(&red));
|
||||
assert_eq!(fill(&back), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
// The colour as an array of channels.
|
||||
assert_eq!(bucket_round_trip(json!({"x": 1, "y": 1, "color": [0.0, 0.5, 1.0]})).unwrap()["color"], "#0080ff");
|
||||
// No colour, or one the command can't read: the foreground, as before.
|
||||
for p in [json!({"x": 1, "y": 1}), json!({"x": 1, "y": 1, "color": "red"})] {
|
||||
let back = bucket_round_trip(p.clone()).unwrap();
|
||||
assert!(back.get("color").is_none() && back.get("contents").is_none(), "{p} → {back}");
|
||||
}
|
||||
// A pattern fill ignores the colour, also when named with the older `source`.
|
||||
for key in ["contents", "source"] {
|
||||
let back = bucket_round_trip(json!({"x": 1, "y": 1, key: "pattern", "color": "#ff0000"})).unwrap();
|
||||
assert_eq!(back["contents"], "pattern", "{key}: {back}");
|
||||
assert!(back.get("color").is_none(), "{key}: {back}");
|
||||
}
|
||||
// Translucent or out-of-range colours have no .atn form.
|
||||
for c in [json!("#ff000080"), json!([1.0, 0.0, 0.0, 0.5]), json!([2.0, 0.0, 0.0])] {
|
||||
assert!(bucket_round_trip(json!({"x": 1, "y": 1, "color": c})).is_none(), "{c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_shapes_become_photoshop_steps_not_errors() {
|
||||
// Image size in two different percentages, a Paint Bucket click in percent of the canvas, a
|
||||
|
||||
@@ -793,13 +793,19 @@ fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.tools.presets.len() == before {
|
||||
return Err(bad(cmd, format!("no brush preset named `{name}`")));
|
||||
}
|
||||
if s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(&name)) {
|
||||
forget_deleted_preset(s, &name);
|
||||
Ok(json!({ "count": s.tools.presets.len() }))
|
||||
}
|
||||
|
||||
/// After preset `name` was deleted (here or in Edit › Presets › Preset Manager): the current
|
||||
/// brush no longer comes from a preset, and the library is saved.
|
||||
pub(crate) fn forget_deleted_preset(s: &mut Session, name: &str) {
|
||||
if s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(name)) {
|
||||
s.tools.current_preset = None;
|
||||
// The remembered brush names the preset it was picked from: the name went away.
|
||||
s.tools.brush_changed();
|
||||
}
|
||||
s.brush_presets_changed();
|
||||
Ok(json!({ "count": s.tools.presets.len() }))
|
||||
}
|
||||
|
||||
/// Overwrite an existing preset with the current brush (or the current brush plus a `brush`
|
||||
@@ -863,9 +869,16 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Paint amount = darkness × alpha × selection (black = full paint, like Photoshop).
|
||||
let (w, h) = (area.width() as usize, area.height() as usize);
|
||||
let mut v = vec![0.0f32; w * h];
|
||||
// The sampled tip is one f32 buffer over the whole selection; a large selection must fail
|
||||
// gracefully instead of aborting the process on allocation, so reserve it fallibly. The
|
||||
// multiply is checked too: on a 32-bit target (wasm32) it can overflow where the rest of
|
||||
// this command is already fallible.
|
||||
let len = w.checked_mul(h).ok_or_else(|| crate::allocation::error("defining a brush from the selection", "requested size overflowed"))?;
|
||||
let mut v = crate::allocation::filled(len, 0.0, "defining a brush from the selection")?;
|
||||
let sc = sel.channels();
|
||||
let sv = sel.read_region(area);
|
||||
// The selection mask is a second buffer of the same size; it must fail gracefully too
|
||||
// (review of #5469), so read it through the fallible allocator.
|
||||
let sv = crate::allocation::read_region(sel, area, "defining a brush from the selection")?;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let c = surf.rgba(area.x0 + x as i32, area.y0 + y as i32);
|
||||
|
||||
@@ -442,6 +442,26 @@ fn define_brush_from_selection() {
|
||||
assert!(rgba(&s, 30, 32)[3] > 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn define_brush_from_selection_survives_allocation_failure() {
|
||||
// The sampled tip is one f32 buffer over the whole selection, and the selection mask is a
|
||||
// second of the same size. Both are reserved through the fallible allocator, so an allocation
|
||||
// failure must be a graceful error (brush, presets, history and document untouched), never a
|
||||
// panic or an OOM abort.
|
||||
let mut s = session(40, 40);
|
||||
s.execute("select.rect", json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap();
|
||||
let brush_before = s.tools.brush.clone();
|
||||
let presets_before = s.tools.presets.clone();
|
||||
let history_before = s.active().unwrap().history.past_len();
|
||||
crate::allocation::fail_next_for_test();
|
||||
let result = s.execute("brush.defineFromSelection", json!({"name": "Blob"}));
|
||||
let _ = crate::allocation::capacity::<u8>(0, "reset failpoint");
|
||||
assert!(result.is_err(), "an allocation failure must be reported, not panic");
|
||||
assert_eq!(s.tools.brush, brush_before, "the current brush is unchanged");
|
||||
assert_eq!(s.tools.presets, presets_before, "the preset list is unchanged");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), history_before, "no history step was recorded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn works_on_cmyk_and_16_bit() {
|
||||
for (mode, depth) in [("cmyk", 8), ("rgb", 16), ("grayscale", 32)] {
|
||||
|
||||
@@ -109,21 +109,29 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.rename";
|
||||
let i = find(s, p, "name", cmd)?;
|
||||
let new = text(p, "newName", cmd)?;
|
||||
if index_of(&s.tools.presets, &new).is_some_and(|j| j != i) {
|
||||
rename_at(s, i, &new, cmd)?;
|
||||
Ok(json!({ "name": new }))
|
||||
}
|
||||
|
||||
/// Rename preset `i` (shared by Brush Settings and Edit › Presets › Preset Manager): refuses a
|
||||
/// name another preset has (case-insensitively), makes the preset the user's and keeps the
|
||||
/// current preset pointing at it.
|
||||
pub(crate) fn rename_at(s: &mut Session, i: usize, new: &str, cmd: &str) -> Result<()> {
|
||||
if index_of(&s.tools.presets, new).is_some_and(|j| j != i) {
|
||||
return Err(bad(cmd, format!("a brush preset named `{new}` already exists")));
|
||||
}
|
||||
let pr = s.tools.presets.get_mut(i).ok_or_else(|| bad(cmd, "no such preset"))?;
|
||||
if pr.name != new {
|
||||
let was_current = s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(&pr.name));
|
||||
pr.name = new.clone();
|
||||
pr.name = new.to_string();
|
||||
pr.builtin = false;
|
||||
if was_current {
|
||||
s.tools.current_preset = Some(new.clone());
|
||||
s.tools.current_preset = Some(new.to_string());
|
||||
s.tools.brush_changed();
|
||||
}
|
||||
s.brush_presets_changed();
|
||||
}
|
||||
Ok(json!({ "name": new }))
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -64,10 +64,9 @@ fn validate(p: &Value) -> Result<()> {
|
||||
Some(Value::String(s)) => {
|
||||
crate::commands::parse_hex(s).ok_or_else(|| bad(format!("`color` must be \"#rrggbb\", not {s:?}")))?;
|
||||
}
|
||||
Some(Value::Array(a)) if (3..=4).contains(&a.len()) && a.iter().all(|v| v.as_f64().is_some_and(|x| x.is_finite() && (0.0..=1.0).contains(&x))) => {}
|
||||
Some(Value::Array(a)) if (3..=4).contains(&a.len()) => {
|
||||
if !a.iter().all(|v| v.as_f64().is_some_and(|x| x.is_finite() && (0.0..=1.0).contains(&x))) {
|
||||
return Err(bad("`color` as an array must hold 3 or 4 numbers in 0..1"));
|
||||
}
|
||||
return Err(bad("`color` as an array must hold 3 or 4 numbers in 0..1"));
|
||||
}
|
||||
Some(_) => return Err(bad("`color` must be \"#rrggbb\" or [r, g, b] in 0..1")),
|
||||
}
|
||||
|
||||
@@ -117,7 +117,10 @@ fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
fn turn(s: &mut Session, label: &str, t: crate::canvas_geom::Turn) -> Result<Value> {
|
||||
if let Some(d) = s.active() {
|
||||
let a = t.affine(f64::from(d.doc.size.width), f64::from(d.doc.size.height));
|
||||
s.set_sampler_map(a);
|
||||
// Sampler points are pixel indices, not pixel edges: map the pixel's centre, so the point
|
||||
// lands on the same pixel `Turn::pixel_map` moves the pixels to (`w - 1 - x`).
|
||||
let half = photocraft_geom::Affine::translate(0.5, 0.5);
|
||||
s.set_sampler_map(half.then(&a).then(&photocraft_geom::Affine::translate(-0.5, -0.5)));
|
||||
}
|
||||
s.edit(label, |doc, _| {
|
||||
crate::canvas_geom::turn_canvas(doc, t);
|
||||
@@ -295,7 +298,8 @@ fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result<Value> {
|
||||
|
||||
/// The layer a command's `"layer"` param names: an id, or a name (the topmost layer of that
|
||||
/// name, as Photoshop's "select layer by name" action step finds it, so a loaded action replays
|
||||
/// in another document). `None` when the param is absent.
|
||||
/// in another document; a name that matches no layer exactly matches regardless of case, so
|
||||
/// `"background"` finds the Background). `None` when the param is absent.
|
||||
pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result<Option<LayerId>> {
|
||||
match p.get("layer") {
|
||||
Some(Value::Number(n)) => {
|
||||
@@ -303,19 +307,41 @@ pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result<Option<LayerId>>
|
||||
}
|
||||
Some(Value::String(name)) => {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
d.doc
|
||||
.walk()
|
||||
.into_iter()
|
||||
.rev()
|
||||
let layers = d.doc.walk();
|
||||
let mut top_down = layers.iter().rev();
|
||||
top_down
|
||||
.clone()
|
||||
.find(|(_, _, l)| l.name == *name)
|
||||
.or_else(|| top_down.find(|(_, _, l)| l.name.to_lowercase() == name.to_lowercase()))
|
||||
.map(|(_, _, l)| Some(l.id))
|
||||
.ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\"")))
|
||||
.ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\"; {}", layer_listing(&d.doc))))
|
||||
}
|
||||
Some(Value::Null) | None => Ok(None),
|
||||
Some(other) => Err(EngineError::Other(format!("`layer` must be an id or a name, not {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The layers of `doc` as a caller can name them, for an error about a layer that isn't there:
|
||||
/// `the document's layers are 2 "Background", 3 "Layer 1"` (top-down, at most 20).
|
||||
pub(crate) fn layer_listing(doc: &Document) -> String {
|
||||
const MAX: usize = 20;
|
||||
let layers = doc.walk();
|
||||
if layers.is_empty() {
|
||||
return "the document has no layers".into();
|
||||
}
|
||||
// Names are unbounded input: each one is cut at NAME characters so the error stays short.
|
||||
const NAME: usize = 64;
|
||||
let name = |n: &str| match n.char_indices().nth(NAME) {
|
||||
Some((cut, _)) => format!("{}…", &n[..cut]),
|
||||
None => n.to_string(),
|
||||
};
|
||||
let mut list: Vec<String> = layers.iter().rev().take(MAX).map(|(_, _, l)| format!("{} {:?}", l.id.0, name(&l.name))).collect();
|
||||
if layers.len() > MAX {
|
||||
list.push(format!("… {} more (see `document.inspect`)", layers.len() - MAX));
|
||||
}
|
||||
format!("the document's layers are {} (a `layer` param takes an id or a name)", list.join(", "))
|
||||
}
|
||||
|
||||
/// The layer a command targets: [`explicit_layer`], else the active layer.
|
||||
pub(crate) fn layer_param(s: &Session, p: &Value) -> Result<LayerId> {
|
||||
match explicit_layer(s, p)? {
|
||||
@@ -1422,6 +1448,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
v.extend(crate::edit_menu_cmds::specs());
|
||||
v.extend(crate::fill_key_cmds::specs());
|
||||
v.extend(crate::sample_cmds::specs());
|
||||
v.extend(crate::face_cmds::specs());
|
||||
v.extend(crate::sampler_cmds::specs());
|
||||
v.extend(crate::brush_key_cmds::specs());
|
||||
v.extend(crate::stamp_cmds::specs());
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Paths stored in a document reach the file system only through [`resolve`] (built on
|
||||
//! [`photocraft_io::doc_path::resolve`]): linked smart objects, the Displace map and Color Lookup
|
||||
//! file a smart filter re-reads, data-set images and video footage. A refused path is handled like
|
||||
//! a missing file: the layer keeps its last rendering, and commands that need the file say why.
|
||||
//!
|
||||
//! Smart-object rendering has no `Session` argument, so the path of the document being worked on
|
||||
//! travels in a thread-local scope, entered where commands run ([`crate::Session::edit`], command
|
||||
//! dispatch, background jobs). Outside a scope there is no document: relative paths are refused
|
||||
//! and only local absolute paths are followed.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use photocraft_io::doc_path::NotFollowed;
|
||||
|
||||
thread_local! {
|
||||
static DOCUMENT: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Restores the previous document path when dropped.
|
||||
pub(crate) struct DocumentScope(Option<PathBuf>);
|
||||
|
||||
impl Drop for DocumentScope {
|
||||
fn drop(&mut self) {
|
||||
let previous = self.0.take();
|
||||
DOCUMENT.with(|d| *d.borrow_mut() = previous);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve stored paths against `document` (the path of the document being worked on, `None`
|
||||
/// for one without a file) until the returned scope is dropped.
|
||||
pub(crate) fn scope(document: Option<&Path>) -> DocumentScope {
|
||||
let next = document.filter(|d| !d.as_os_str().is_empty()).map(Path::to_path_buf);
|
||||
DocumentScope(DOCUMENT.with(|d| d.replace(next)))
|
||||
}
|
||||
|
||||
/// The document path of the current scope (to carry it to another thread).
|
||||
pub(crate) fn current() -> Option<PathBuf> {
|
||||
DOCUMENT.with(|d| d.borrow().clone())
|
||||
}
|
||||
|
||||
/// The file to open for `stored`, a path stored in the document of the current scope.
|
||||
pub fn resolve(stored: &str) -> Result<PathBuf, NotFollowed> {
|
||||
resolve_in(stored, current().as_deref())
|
||||
}
|
||||
|
||||
/// The file to open for `stored`, a path stored in the document saved at `document`.
|
||||
pub fn resolve_in(stored: &str, document: Option<&Path>) -> Result<PathBuf, NotFollowed> {
|
||||
photocraft_io::doc_path::resolve(stored, document)
|
||||
}
|
||||
|
||||
/// The message for a stored path that is not followed.
|
||||
pub(crate) fn refusal(what: &str, stored: &str, why: NotFollowed) -> crate::EngineError {
|
||||
crate::EngineError::Other(format!("{what} {stored} is not opened: {why}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Each kind of place that follows a path stored in a document goes through [`super::resolve`]: a
|
||||
//! link to another computer is never followed, and a relative link next to the document is.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use photocraft_doc::{LayerContent, LayerId, SmartSource};
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A link to a share on a TEST-NET-1 address, which never answers.
|
||||
const REMOTE: &str = r"\\192.0.2.1\share\x.png";
|
||||
|
||||
/// An empty folder of its own (tests run in parallel).
|
||||
fn dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("photocraft-doc-path-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A 32×32 PNG whose pixel at column `x` is `rgb(x)`.
|
||||
fn png(path: &Path, rgb: impl Fn(u32) -> [u8; 3]) {
|
||||
let mut px = Vec::new();
|
||||
for _ in 0..32 {
|
||||
for x in 0..32 {
|
||||
px.extend(rgb(x));
|
||||
}
|
||||
}
|
||||
let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, px).unwrap();
|
||||
std::fs::write(path, photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
/// On Unix `//` followed by an absolute path names that same local file, while the resolver sees a
|
||||
/// path on another computer: a place that skipped the resolver would read the file.
|
||||
#[cfg(unix)]
|
||||
fn network_alias(p: &Path) -> String {
|
||||
format!("/{}", p.display())
|
||||
}
|
||||
|
||||
/// A 32×32 white document saved (as far as its links know) at `d/doc.pcraft`, with `file` placed
|
||||
/// linked → (session, layer id).
|
||||
fn document(d: &Path, file: &Path) -> (Session, LayerId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
s.active_mut().unwrap().path = Some(d.join("doc.pcraft").to_string_lossy().into_owned());
|
||||
s.execute("file.placeLinked", json!({"path": file.to_string_lossy()})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
/// Point smart object `id` at `path` without re-rendering it.
|
||||
fn relink(s: &mut Session, id: LayerId, path: &str) {
|
||||
s.edit("Relink", |doc, _| {
|
||||
if let Some(LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) {
|
||||
sm.source = SmartSource::Linked { path: path.into() };
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn centre(s: &Session) -> [f32; 3] {
|
||||
let p = photocraft_compose::render(&s.active().unwrap().doc, Rect::new(16, 16, 17, 17)).px[0];
|
||||
[p[0], p[1], p[2]]
|
||||
}
|
||||
|
||||
const RED: [f32; 3] = [1.0, 0.0, 0.0];
|
||||
const BLUE: [f32; 3] = [0.0, 0.0, 1.0];
|
||||
|
||||
fn update(s: &mut Session) -> Result<serde_json::Value, String> {
|
||||
s.execute("layer.smartObjects.updateModifiedContent", json!({})).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_smart_objects_follow_only_local_files() {
|
||||
let d = dir("links");
|
||||
png(&d.join("red.png"), |_| [255, 0, 0]);
|
||||
png(&d.join("blue.png"), |_| [0, 0, 255]);
|
||||
let (mut s, id) = document(&d, &d.join("red.png"));
|
||||
assert_eq!(centre(&s), RED);
|
||||
|
||||
relink(&mut s, id, REMOTE);
|
||||
s.start_link_scan();
|
||||
assert!(s.link_scan.is_none(), "no look at a file on another computer");
|
||||
assert_eq!(s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(), json!({"updated": []}));
|
||||
let err = update(&mut s).unwrap_err();
|
||||
assert!(err.contains("is not opened: the path is on another computer"), "{err}");
|
||||
assert_eq!(centre(&s), RED, "the layer keeps its rendering");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
relink(&mut s, id, &network_alias(&d.join("blue.png")));
|
||||
assert!(update(&mut s).is_err());
|
||||
assert_eq!(centre(&s), RED, "the file was not read");
|
||||
}
|
||||
|
||||
// A relative link is taken from the document's folder.
|
||||
relink(&mut s, id, "blue.png");
|
||||
update(&mut s).unwrap();
|
||||
assert_eq!(centre(&s), BLUE);
|
||||
s.start_link_scan();
|
||||
assert!(s.link_scan.is_some(), "a followed link is looked at");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_copies_only_followed_links() {
|
||||
let d = dir("package");
|
||||
png(&d.join("red.png"), |_| [255, 0, 0]);
|
||||
let (mut s, id) = document(&d, &d.join("red.png"));
|
||||
relink(&mut s, id, "red.png");
|
||||
#[allow(unused_mut)]
|
||||
let mut remote = vec![REMOTE.to_string()];
|
||||
#[cfg(unix)]
|
||||
remote.push(network_alias(&d.join("red.png")));
|
||||
for path in &remote {
|
||||
s.execute("file.placeLinked", json!({"path": d.join("red.png").to_string_lossy()})).unwrap();
|
||||
let other = s.active().unwrap().active_layer.unwrap();
|
||||
relink(&mut s, other, path);
|
||||
}
|
||||
let r = s.execute("file.package", json!({"dir": d.join("pkg").to_string_lossy()})).unwrap();
|
||||
let mut missing: Vec<String> = serde_json::from_value(r["missing"].clone()).unwrap();
|
||||
missing.sort();
|
||||
remote.sort();
|
||||
assert_eq!(missing, remote);
|
||||
let links = r["links"].as_array().unwrap();
|
||||
assert_eq!(links.len(), 1);
|
||||
assert!(links[0].as_str().unwrap().ends_with("red.png"), "{links:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_saves_embed_only_followed_links() {
|
||||
let d = dir("psd");
|
||||
png(&d.join("red.png"), |_| [255, 0, 0]);
|
||||
let (mut s, id) = document(&d, &d.join("red.png"));
|
||||
relink(&mut s, id, "red.png");
|
||||
s.execute("file.placeLinked", json!({"path": d.join("red.png").to_string_lossy()})).unwrap();
|
||||
let other = s.active().unwrap().active_layer.unwrap();
|
||||
relink(&mut s, other, REMOTE);
|
||||
let warnings = |r: serde_json::Value| r["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect::<Vec<_>>();
|
||||
let saved = warnings(s.execute("file.saveACopy", json!({"path": d.join("copy.psd").to_string_lossy()})).unwrap());
|
||||
assert!(saved.iter().any(|w| w.contains("red.png was embedded")), "{saved:?}");
|
||||
assert!(saved.iter().any(|w| w.contains("is not opened: the path is on another computer")), "{saved:?}");
|
||||
// Package as PSD writes the copy of the document, whose remote link stays as it was.
|
||||
let packaged = warnings(s.execute("file.package", json!({"dir": d.join("pkg").to_string_lossy(), "format": "psd"})).unwrap());
|
||||
assert!(packaged.iter().any(|w| w.contains("is not opened: the path is on another computer")), "{packaged:?}");
|
||||
}
|
||||
|
||||
/// The pixels of the active document after its smart object `id` re-renders with its first smart
|
||||
/// filter's params set to `params`.
|
||||
fn replayed(s: &mut Session, id: LayerId, params: serde_json::Value) -> Vec<[f32; 4]> {
|
||||
s.edit("Params", |doc, _| {
|
||||
if let Some(LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) {
|
||||
sm.smart_filters[0].params = params;
|
||||
}
|
||||
crate::smart_cmds::refresh(doc, id).map(|_| ())
|
||||
})
|
||||
.unwrap();
|
||||
let doc = &s.active().unwrap().doc;
|
||||
photocraft_compose::render(doc, doc.bounds()).px
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_filters_reread_only_followed_files() {
|
||||
let d = dir("filters");
|
||||
png(&d.join("ramp.png"), |x| [(x * 8) as u8, 0, 0]);
|
||||
png(&d.join("map.png"), |_| [255, 255, 255]);
|
||||
// An inverting look: corner (r, g, b) of the cube maps to (1-r, 1-g, 1-b), red fastest.
|
||||
let corners = (0..8).map(|i| format!("{} {} {}\n", 1 - (i & 1), 1 - ((i >> 1) & 1), 1 - ((i >> 2) & 1))).collect::<String>();
|
||||
std::fs::write(d.join("invert.cube"), format!("LUT_3D_SIZE 2\n{corners}")).unwrap();
|
||||
let path = |name: &str| d.join(name).to_string_lossy().into_owned();
|
||||
|
||||
// Displace re-reads its `mapPath`.
|
||||
let (mut s, id) = document(&d, &d.join("ramp.png"));
|
||||
s.execute("filter.distort.displace", json!({"layer": id.0, "mapPath": path("map.png"), "horizontal": 50, "vertical": 0})).unwrap();
|
||||
let displaced = replayed(&mut s, id, json!({"mapPath": path("map.png"), "horizontal": 50, "vertical": 0}));
|
||||
let unfiltered = replayed(&mut s, id, json!({"mapPath": REMOTE, "horizontal": 50, "vertical": 0}));
|
||||
assert_ne!(displaced, unfiltered, "the map moves the pixels");
|
||||
assert_eq!(replayed(&mut s, id, json!({"mapPath": "map.png", "horizontal": 50, "vertical": 0})), displaced, "a relative map next to the document");
|
||||
#[cfg(unix)]
|
||||
assert_eq!(replayed(&mut s, id, json!({"mapPath": network_alias(&d.join("map.png")), "horizontal": 50, "vertical": 0})), unfiltered);
|
||||
|
||||
// Color Lookup re-reads its LUT `file`.
|
||||
let (mut s, id) = document(&d, &d.join("ramp.png"));
|
||||
s.execute("image.adjustments.colorLookup", json!({"layer": id.0, "lut": "none"})).unwrap();
|
||||
let inverted = replayed(&mut s, id, json!({"file": path("invert.cube")}));
|
||||
let unfiltered = replayed(&mut s, id, json!({"file": REMOTE}));
|
||||
assert_ne!(inverted, unfiltered, "the look changes the pixels");
|
||||
assert_eq!(replayed(&mut s, id, json!({"file": "invert.cube"})), inverted, "a relative look next to the document");
|
||||
#[cfg(unix)]
|
||||
assert_eq!(replayed(&mut s, id, json!({"file": network_alias(&d.join("invert.cube"))})), unfiltered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_sets_and_footage_read_only_followed_files() {
|
||||
let d = dir("datasets");
|
||||
png(&d.join("blue.png"), |_| [0, 0, 255]);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 32, "height": 32, "background": "#ff0000"})).unwrap();
|
||||
s.active_mut().unwrap().path = Some(d.join("doc.pcraft").to_string_lossy().into_owned());
|
||||
let layer = s.active().unwrap().active_layer.unwrap();
|
||||
s.execute("image.variables.define", json!({"defs": [{"name": "img", "layer": layer.0, "type": "pixelReplacement", "method": "conform"}]})).unwrap();
|
||||
let set = |name: &str, path: &str| json!({"name": name, "values": [{"variable": "img", "kind": "pixels", "value": path}]});
|
||||
s.execute("image.variables.dataSets", json!({"dataSets": [set("remote", REMOTE), set("relative", "blue.png")]})).unwrap();
|
||||
let err = s.execute("image.applyDataSet", json!({"name": "remote"})).unwrap_err().to_string();
|
||||
assert!(err.contains("is not opened: the path is on another computer"), "{err}");
|
||||
assert_eq!(centre(&s), RED);
|
||||
s.execute("image.applyDataSet", json!({"name": "relative"})).unwrap();
|
||||
assert_eq!(centre(&s), BLUE);
|
||||
|
||||
// Reload Frame re-reads the video layer's footage.
|
||||
s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": d.join("blue.png").to_string_lossy()})).unwrap();
|
||||
let footage = |s: &mut Session, path: &str| {
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
s.edit("Footage", |doc, _| {
|
||||
if let Some(v) = doc.layer_mut(id).and_then(|l| l.video.as_mut()) {
|
||||
v.source = photocraft_doc::VideoSource::File { path: path.into() };
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("layer.videoLayers.reloadFrame", json!({})).map_err(|e| e.to_string())
|
||||
};
|
||||
let err = footage(&mut s, REMOTE).unwrap_err();
|
||||
assert!(err.contains("is not opened: the path is on another computer"), "{err}");
|
||||
footage(&mut s, "blue.png").unwrap();
|
||||
}
|
||||
@@ -110,6 +110,12 @@ fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
fn pixel_layer(s: &Session) -> std::result::Result<LayerId, String> {
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
let id = d.active_layer.ok_or("no active layer")?;
|
||||
pixel_layer_id(s, id)
|
||||
}
|
||||
|
||||
/// `id` if it is an unlocked pixel layer of the active document.
|
||||
fn pixel_layer_id(s: &Session, id: LayerId) -> std::result::Result<LayerId, String> {
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
let l = d.doc.layer(id).ok_or("no active layer")?;
|
||||
if !matches!(l.content, LayerContent::Raster(_)) {
|
||||
return Err(format!("active layer is {} {} layer, not a pixel layer", l.content.article(), l.content.kind_name()));
|
||||
@@ -358,38 +364,169 @@ fn delete_and_fill(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
content_aware_fill_as(s, &json!({"sampling": "rectangular"}), "edit.deleteAndFillSelection", "Delete and Fill Selection")
|
||||
}
|
||||
|
||||
fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &'static str) -> Result<Value> {
|
||||
use photocraft_algo::content_aware::{FillOptions, auto_sampling_with, color_level, fill_owned, rotation_level};
|
||||
let id = pixel_layer(s).map_err(EngineError::Other)?;
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let doc = st.doc.clone();
|
||||
let sel = doc.selection.clone().ok_or_else(|| bad(cmd, "no selection"))?;
|
||||
let hb = sel.content_bounds().intersect(&doc.bounds());
|
||||
if hb.is_empty() {
|
||||
/// Everything one Content-Aware Fill needs, worked out from the document and the command's
|
||||
/// params. The Content-Aware Fill workspace runs exactly this for its preview, so what it shows
|
||||
/// is what OK and Apply write.
|
||||
pub struct CafPlan {
|
||||
pub layer: LayerId,
|
||||
/// The rectangle worked on: the sampling window, and any sampling-brush strokes or custom
|
||||
/// area beyond it.
|
||||
pub rect: Rect,
|
||||
/// Photoshop's sampling window around the fill area (`content_aware::sampling_window`).
|
||||
pub window: Rect,
|
||||
pub fmt: PixelFormat,
|
||||
/// What is sampled over `rect`: the target's pixels, or with Sample All Layers the visible
|
||||
/// composite in the target's encoding.
|
||||
pub img: Vec<f32>,
|
||||
/// The target's own pixels over `rect` when they differ from `img` (Sample All Layers); see
|
||||
/// [`CafPlan::layer_pixels`].
|
||||
pub layer_px: Option<Vec<f32>>,
|
||||
/// The fill area (selection coverage, after the workspace's lasso edits) over `rect`.
|
||||
pub cover: Vec<f32>,
|
||||
pub hole: Vec<bool>,
|
||||
/// What may be copied from: the sampling option's area before the brush strokes, and after.
|
||||
pub base: Vec<bool>,
|
||||
pub source: Vec<bool>,
|
||||
pub opts: photocraft_algo::content_aware::FillOptions,
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
impl CafPlan {
|
||||
/// The target layer's pixels over `rect`.
|
||||
pub fn layer_pixels(&self) -> &[f32] {
|
||||
self.layer_px.as_deref().unwrap_or(&self.img)
|
||||
}
|
||||
}
|
||||
|
||||
/// The workspace's lasso edits of the fill area: `{"op": "new|add|subtract|intersect", "points":
|
||||
/// [[x, y], ...]}` or `{"op": "expand|contract", "by": px}`, applied in order.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum FillEdit {
|
||||
Lasso { op: SelOp, points: Vec<[f64; 2]> },
|
||||
Grow(i32),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SelOp {
|
||||
New,
|
||||
Add,
|
||||
Subtract,
|
||||
Intersect,
|
||||
}
|
||||
|
||||
/// Most `fillEdits` and `samplingStrokes` one fill takes, and most points in all of them
|
||||
/// together: the strokes and lassos are painted pixel by pixel, segment by segment.
|
||||
pub const MAX_CAF_EDITS: usize = 1_000;
|
||||
pub const MAX_CAF_POINTS: usize = 200_000;
|
||||
|
||||
fn points_param(v: &Value, cmd: &str, what: &str, budget: &mut usize) -> Result<Vec<[f64; 2]>> {
|
||||
let a = v.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("{what} needs `points` [[x, y], ...]")))?;
|
||||
*budget = budget.checked_sub(a.len()).ok_or_else(|| bad(cmd, format!("at most {MAX_CAF_POINTS} points in all fill edits and sampling strokes")))?;
|
||||
a.iter()
|
||||
.map(|p| {
|
||||
let x = p.get(0).and_then(Value::as_f64).filter(|v| v.is_finite() && v.abs() < 1e7);
|
||||
let y = p.get(1).and_then(Value::as_f64).filter(|v| v.is_finite() && v.abs() < 1e7);
|
||||
match (x, y) {
|
||||
(Some(x), Some(y)) => Ok([x, y]),
|
||||
_ => Err(bad(cmd, format!("{what} points must be pairs of finite numbers"))),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `p[key]` as an array of at most [`MAX_CAF_EDITS`] entries (empty when absent).
|
||||
fn edits_param<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<&'a [Value]> {
|
||||
let Some(a) = p.get(key) else { return Ok(&[]) };
|
||||
let a = a.as_array().ok_or_else(|| bad(cmd, format!("`{key}` must be an array")))?;
|
||||
if a.len() > MAX_CAF_EDITS {
|
||||
return Err(bad(cmd, format!("`{key}` takes at most {MAX_CAF_EDITS} entries")));
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
fn fill_edits(p: &Value, cmd: &str, budget: &mut usize) -> Result<Vec<FillEdit>> {
|
||||
edits_param(p, "fillEdits", cmd)?
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let op = e.get("op").and_then(Value::as_str).unwrap_or("");
|
||||
Ok(match op {
|
||||
"expand" | "contract" => {
|
||||
let by = e
|
||||
.get("by")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|v| v.is_finite() && (0.0..=1000.0).contains(v))
|
||||
.ok_or_else(|| bad(cmd, "`by` must be 0..1000 px"))?;
|
||||
FillEdit::Grow(if op == "expand" { by.round() as i32 } else { -(by.round() as i32) })
|
||||
}
|
||||
"new" | "add" | "subtract" | "intersect" => FillEdit::Lasso {
|
||||
op: match op {
|
||||
"new" => SelOp::New,
|
||||
"add" => SelOp::Add,
|
||||
"subtract" => SelOp::Subtract,
|
||||
_ => SelOp::Intersect,
|
||||
},
|
||||
points: points_param(e, cmd, "a lasso edit", budget)?,
|
||||
},
|
||||
o => return Err(bad(cmd, format!("unknown fill edit `{o}` (new|add|subtract|intersect|expand|contract)"))),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sampling_strokes(p: &Value, cmd: &str, budget: &mut usize) -> Result<Vec<photocraft_algo::content_aware::SamplingStroke>> {
|
||||
edits_param(p, "samplingStrokes", cmd)?
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let add = match e.get("mode").and_then(Value::as_str).unwrap_or("add") {
|
||||
"add" => true,
|
||||
"subtract" => false,
|
||||
o => return Err(bad(cmd, format!("unknown stroke mode `{o}` (add|subtract)"))),
|
||||
};
|
||||
let size = e
|
||||
.get("size")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|v| v.is_finite() && (1.0..=5000.0).contains(v))
|
||||
.ok_or_else(|| bad(cmd, "a stroke's `size` must be 1..5000 px"))?;
|
||||
Ok(photocraft_algo::content_aware::SamplingStroke { add, size, points: points_param(e, cmd, "a sampling stroke", budget)? })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A Content-Aware Fill's params, checked against the document before any pixel is read.
|
||||
struct CafParams<'a> {
|
||||
sel: &'a Surface,
|
||||
/// The selection's bounds on the canvas.
|
||||
sb: Rect,
|
||||
edits: Vec<FillEdit>,
|
||||
strokes: Vec<photocraft_algo::content_aware::SamplingStroke>,
|
||||
sampling: String,
|
||||
custom_mask: Option<&'a Surface>,
|
||||
custom_rect: Option<Rect>,
|
||||
opts: photocraft_algo::content_aware::FillOptions,
|
||||
output: String,
|
||||
surf: &'a Surface,
|
||||
}
|
||||
|
||||
fn caf_params<'a>(doc: &'a Document, id: LayerId, p: &Value, cmd: &str) -> Result<CafParams<'a>> {
|
||||
use photocraft_algo::content_aware::{FillOptions, color_level, rotation_level};
|
||||
let sel = doc.selection.as_ref().ok_or_else(|| bad(cmd, "no selection"))?;
|
||||
let sb = sel.content_bounds().intersect(&doc.bounds());
|
||||
if sb.is_empty() {
|
||||
return Err(bad(cmd, "the selection is outside the canvas"));
|
||||
}
|
||||
let mut budget = MAX_CAF_POINTS;
|
||||
let edits = fill_edits(p, cmd, &mut budget)?;
|
||||
let strokes = sampling_strokes(p, cmd, &mut budget)?;
|
||||
let sampling = str_or(p, "sampling", "auto").to_string();
|
||||
let custom_mask: Option<Surface> = p.get("channel").and_then(|v| channel_mask(&doc, v)).cloned();
|
||||
let custom_mask: Option<&Surface> = p.get("channel").and_then(|v| channel_mask(doc, v));
|
||||
let custom_rect = if p.get("area").is_some() { Some(rect_param(p, "area", cmd)?) } else { None };
|
||||
// Photoshop's window (`content_aware::sampling_window`) for Auto and Rectangular; `margin`
|
||||
// asks for a rectangle that far around the selection instead.
|
||||
let window = match sampling.as_str() {
|
||||
"auto" => crate::fill_cmds::sampling_window(hb, doc.bounds()),
|
||||
"rectangular" => match int(p, "margin") {
|
||||
Some(m) => hb.inflate(m.clamp(0, 100_000) as i32),
|
||||
None => crate::fill_cmds::sampling_window(hb, doc.bounds()),
|
||||
},
|
||||
"custom" => {
|
||||
let r = match (custom_rect, custom_mask.as_ref()) {
|
||||
(Some(r), _) => r,
|
||||
(None, Some(m)) => m.content_bounds(),
|
||||
(None, None) => return Err(bad(cmd, "custom sampling needs `area` [x,y,w,h] or `channel` (alpha channel index or name)")),
|
||||
};
|
||||
r.union(&hb).inflate(4)
|
||||
}
|
||||
match sampling.as_str() {
|
||||
"auto" | "rectangular" => {}
|
||||
// The workspace's Custom: only what the Sampling Brush adds.
|
||||
"custom" if custom_rect.is_some() || custom_mask.is_some() || strokes.iter().any(|s| s.add) => {}
|
||||
"custom" => return Err(bad(cmd, "custom sampling needs `area` [x,y,w,h], `channel` (alpha channel index or name) or `samplingStrokes`")),
|
||||
other => return Err(bad(cmd, format!("unknown sampling `{other}` (auto|rectangular|custom)"))),
|
||||
}
|
||||
.intersect(&doc.bounds());
|
||||
let opts = FillOptions {
|
||||
color_adaptation: color_level(str_or(p, "colorAdaptation", "default")),
|
||||
rotations: rotation_level(str_or(p, "rotationAdaptation", "none")),
|
||||
@@ -402,6 +539,77 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &
|
||||
return Err(bad(cmd, "`output` must be current|new|duplicate"));
|
||||
}
|
||||
let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?;
|
||||
Ok(CafParams { sel, sb, edits, strokes, sampling, custom_mask, custom_rect, opts, output, surf })
|
||||
}
|
||||
|
||||
/// Work out a Content-Aware Fill (see [`CafPlan`]) on layer `id` of `doc`.
|
||||
pub fn content_aware_plan(doc: &Document, id: LayerId, p: &Value, cmd: &str) -> Result<CafPlan> {
|
||||
content_aware_plan_with(doc, id, p, cmd, &photocraft_raster::Interrupt::NONE)
|
||||
}
|
||||
|
||||
/// [`content_aware_plan`], asking `ctl` row by row whether to stop ([`EngineError::Cancelled`]).
|
||||
pub fn content_aware_plan_with(doc: &Document, id: LayerId, p: &Value, cmd: &str, ctl: &photocraft_raster::Interrupt) -> Result<CafPlan> {
|
||||
use photocraft_algo::content_aware::{auto_sampling_with, grow_mask, paint_strokes, polygon_mask};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::fill_cmds::{read_window, read_window_mask};
|
||||
let CafParams { sel, sb, edits, strokes, sampling, custom_mask, custom_rect, opts, output, surf } = caf_params(doc, id, p, cmd)?;
|
||||
let canvas = doc.bounds();
|
||||
let cancel = || ctl.cancelled();
|
||||
let stop = || if ctl.cancelled() { Err(EngineError::Cancelled) } else { Ok(()) };
|
||||
// The fill area: the selection, then the lasso edits, over a rectangle that holds them all.
|
||||
let grow: i32 = edits.iter().map(|e| if let FillEdit::Grow(b) = e { b.max(&0) } else { &0 }).sum();
|
||||
let reach = edits.iter().fold(sb, |r, e| match e {
|
||||
FillEdit::Lasso { points, .. } => {
|
||||
points.iter().fold(r, |r, q| r.union(&Rect::new(q[0].floor() as i32, q[1].floor() as i32, q[0].floor() as i32 + 1, q[1].floor() as i32 + 1)))
|
||||
}
|
||||
FillEdit::Grow(_) => r,
|
||||
});
|
||||
let er = reach.inflate(grow.saturating_add(1)).intersect(&canvas);
|
||||
let (ew, eh) = crate::fill_cmds::window_size(er)?;
|
||||
let ecover = read_window_mask(sel, er, &cancel).ok_or(EngineError::Cancelled)?;
|
||||
let mut ehole: Vec<bool> = ecover.par_iter().map(|c| *c > 0.0).collect();
|
||||
for e in &edits {
|
||||
stop()?;
|
||||
match e {
|
||||
FillEdit::Lasso { op, points } => {
|
||||
let m = polygon_mask(er, points);
|
||||
for (h, v) in ehole.iter_mut().zip(&m) {
|
||||
*h = match op {
|
||||
SelOp::New => *v,
|
||||
SelOp::Add => *h || *v,
|
||||
SelOp::Subtract => *h && !*v,
|
||||
SelOp::Intersect => *h && *v,
|
||||
};
|
||||
}
|
||||
}
|
||||
FillEdit::Grow(by) => ehole = grow_mask(&ehole, ew, eh, *by),
|
||||
}
|
||||
}
|
||||
let (bx0, by0, bx1, by1) = mask_bounds(ew, eh, &ehole);
|
||||
if bx1 <= bx0 {
|
||||
return Err(bad(cmd, "the fill area is empty"));
|
||||
}
|
||||
let hb = Rect::new(er.x0 + bx0 as i32, er.y0 + by0 as i32, er.x0 + bx1 as i32, er.y0 + by1 as i32);
|
||||
// Photoshop's window (`content_aware::sampling_window`) for Auto and Rectangular; `margin`
|
||||
// asks for a rectangle that far around the fill area instead.
|
||||
let window = match sampling.as_str() {
|
||||
"rectangular" => match int(p, "margin") {
|
||||
Some(m) => hb.inflate(m.clamp(0, 100_000) as i32),
|
||||
None => crate::fill_cmds::sampling_window(hb, canvas),
|
||||
},
|
||||
"custom" => match (custom_rect, custom_mask) {
|
||||
(Some(r), _) => r,
|
||||
(None, Some(m)) => m.content_bounds(),
|
||||
(None, None) => hb,
|
||||
}
|
||||
.union(&hb)
|
||||
.inflate(4),
|
||||
_ => crate::fill_cmds::sampling_window(hb, canvas),
|
||||
}
|
||||
.intersect(&canvas);
|
||||
// Strokes that add to the sampling area may reach past the window.
|
||||
let rect = strokes.iter().filter(|s| s.add).fold(window, |r, s| r.union(&s.bounds())).intersect(&canvas);
|
||||
let fmt = surf.format();
|
||||
// Photoshop's Sample All Layers checkbox is off unless the caller asks. When the active
|
||||
// layer has no pixels in the window there is nothing to sample, so that case uses the
|
||||
@@ -412,56 +620,127 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &
|
||||
};
|
||||
let n = fmt.channels();
|
||||
// Refuse a window too large to read before allocating for it, whatever the sampling (#963).
|
||||
let (w, h) = crate::fill_cmds::window_size(window)?;
|
||||
// A background job when started with `Session::start` (#210): reading the window and the
|
||||
// PatchMatch fill run on a worker against the document snapshot, cancellable per row band.
|
||||
let (w, h) = crate::fill_cmds::window_size(rect)?;
|
||||
let pixels = read_window(surf, rect, &cancel).ok_or(EngineError::Cancelled)?;
|
||||
let (img, layer_px) = if sample_all {
|
||||
stop()?;
|
||||
(crate::retouch_cmds::composite_region(doc, Some(id), crate::retouch_cmds::SampleLayers::All, rect, fmt).data, Some(pixels))
|
||||
} else {
|
||||
(pixels, None)
|
||||
};
|
||||
stop()?;
|
||||
let mut cover = vec![0.0f32; w * h];
|
||||
let mut hole = vec![false; w * h];
|
||||
cover.par_chunks_mut(w.max(1)).zip(hole.par_chunks_mut(w.max(1))).enumerate().try_for_each(|(y, (crow, hrow))| {
|
||||
stop()?;
|
||||
let dy = rect.y0 + y as i32;
|
||||
if dy < er.y0 || dy >= er.y1 {
|
||||
return Ok(());
|
||||
}
|
||||
for (x, (c, hl)) in crow.iter_mut().zip(hrow.iter_mut()).enumerate() {
|
||||
let dx = rect.x0 + x as i32;
|
||||
if dx >= er.x0 && dx < er.x1 {
|
||||
let j = (dy - er.y0) as usize * ew + (dx - er.x0) as usize;
|
||||
let (eh, ec) = (ehole.get(j).copied().unwrap_or(false), ecover.get(j).copied().unwrap_or(0.0));
|
||||
*hl = eh;
|
||||
// Pixels the lasso added are filled outright; selected ones by their coverage.
|
||||
*c = if eh { if ec > 0.0 { ec } else { 1.0 } } else { 0.0 };
|
||||
}
|
||||
}
|
||||
Ok::<(), EngineError>(())
|
||||
})?;
|
||||
drop((ecover, ehole));
|
||||
// The sampling option's area: Auto keeps what looks like the surroundings within the window,
|
||||
// Rectangular the whole window, Custom the given area (or nothing, for the brush to add to).
|
||||
let mut base = vec![false; w * h];
|
||||
match sampling.as_str() {
|
||||
"auto" | "rectangular" => {
|
||||
let (ww, wh) = (window.width() as usize, window.height() as usize);
|
||||
let (ox, oy) = ((window.x0 - rect.x0) as usize, (window.y0 - rect.y0) as usize);
|
||||
let area: Vec<bool> = match sampling.as_str() {
|
||||
"auto" if window == rect => auto_sampling_with(w, h, n, &img, &hole, ctl).map_err(|_| EngineError::Cancelled)?,
|
||||
"auto" => {
|
||||
let mut wimg = vec![0.0f32; ww * wh * n];
|
||||
let mut whole = vec![false; ww * wh];
|
||||
for y in 0..wh {
|
||||
let s = ((oy + y) * w + ox) * n;
|
||||
wimg[y * ww * n..(y + 1) * ww * n].copy_from_slice(&img[s..s + ww * n]);
|
||||
whole[y * ww..(y + 1) * ww].copy_from_slice(&hole[(oy + y) * w + ox..(oy + y) * w + ox + ww]);
|
||||
}
|
||||
auto_sampling_with(ww, wh, n, &wimg, &whole, ctl).map_err(|_| EngineError::Cancelled)?
|
||||
}
|
||||
_ => vec![true; ww * wh],
|
||||
};
|
||||
for y in 0..wh {
|
||||
base[(oy + y) * w + ox..(oy + y) * w + ox + ww].copy_from_slice(&area[y * ww..(y + 1) * ww]);
|
||||
}
|
||||
}
|
||||
_ => match (custom_rect, custom_mask) {
|
||||
(Some(r), _) => base.par_iter_mut().enumerate().for_each(|(i, b)| *b = r.contains(rect.x0 + (i % w) as i32, rect.y0 + (i / w) as i32)),
|
||||
(None, Some(m)) => {
|
||||
let v = read_window_mask(m, rect, &cancel).ok_or(EngineError::Cancelled)?;
|
||||
base.par_iter_mut().zip(&v).for_each(|(b, v)| *b = *v > 0.5);
|
||||
}
|
||||
(None, None) => {}
|
||||
},
|
||||
}
|
||||
stop()?;
|
||||
base.par_iter_mut().zip(&hole).for_each(|(b, h)| *b &= !*h);
|
||||
let mut source = base.clone();
|
||||
paint_strokes(&mut source, rect, &strokes);
|
||||
source.par_iter_mut().zip(&hole).for_each(|(s, h)| *s &= !*h);
|
||||
Ok(CafPlan { layer: id, rect, window, fmt, img, layer_px, cover, hole, base, source, opts, output })
|
||||
}
|
||||
|
||||
fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &'static str) -> Result<Value> {
|
||||
use photocraft_algo::content_aware::fill_owned;
|
||||
// The workspace names the layer it opened on: after an Apply to a new layer that one is
|
||||
// active, but the fill still samples the original.
|
||||
let id = match p.get("layer").and_then(Value::as_u64) {
|
||||
Some(l) => pixel_layer_id(s, LayerId(l)),
|
||||
None => pixel_layer(s),
|
||||
}
|
||||
.map_err(EngineError::Other)?;
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let doc = st.doc.clone();
|
||||
// Check the params before the job starts.
|
||||
caf_params(&doc, id, p, cmd)?;
|
||||
let p = p.clone();
|
||||
// A background job when started with `Session::start` (#210): working out the fill area and
|
||||
// the sampling area, reading the pixels and the PatchMatch fill run on a worker against the
|
||||
// document snapshot, cancellable per row band.
|
||||
crate::jobs::run(
|
||||
s,
|
||||
label,
|
||||
true,
|
||||
move |ctx| {
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::fill_cmds::{read_window, read_window_cover, read_window_mask};
|
||||
ctx.progress(0.0, label);
|
||||
let cancel = || ctx.cancelled();
|
||||
let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?;
|
||||
let img = if sample_all {
|
||||
if cancel() {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
let region = crate::retouch_cmds::composite_region(&doc, None, crate::retouch_cmds::SampleLayers::All, window, fmt);
|
||||
if cancel() {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
region.data
|
||||
} else {
|
||||
read_window(surf, window, &cancel).ok_or(EngineError::Cancelled)?
|
||||
};
|
||||
let hole = read_window_cover(&sel, window, &cancel).ok_or(EngineError::Cancelled)?;
|
||||
let mut source = match (sampling.as_str(), custom_rect, custom_mask.as_ref()) {
|
||||
("custom", Some(r), _) => (0..w * h).into_par_iter().map(|i| r.contains(window.x0 + (i % w) as i32, window.y0 + (i / w) as i32)).collect(),
|
||||
("custom", None, Some(m)) => read_window_mask(m, window, &cancel).ok_or(EngineError::Cancelled)?.par_iter().map(|v| *v > 0.5).collect(),
|
||||
_ => vec![true; w * h],
|
||||
};
|
||||
ctx.check()?;
|
||||
// Auto: only what looks like the selection's surroundings.
|
||||
if sampling == "auto" {
|
||||
source = auto_sampling_with(w, h, n, &img, &hole, &photocraft_raster::Interrupt::cancel_only(&cancel)).map_err(|_| EngineError::Cancelled)?;
|
||||
}
|
||||
ctx.check()?;
|
||||
// The fill changes only the selected pixels: keep the original and the coverage over
|
||||
// their bounds for the apply step, and fill the window's pixels themselves.
|
||||
let plan = content_aware_plan_with(&doc, id, &p, cmd, &photocraft_raster::Interrupt::cancel_only(&cancel))?;
|
||||
let CafPlan { layer, rect, fmt, img, layer_px, cover, hole, source, opts, output, .. } = plan;
|
||||
let (w, h) = (rect.width() as usize, rect.height() as usize);
|
||||
let n = fmt.channels();
|
||||
// The fill changes only the fill area: keep the layer's pixels and the coverage over
|
||||
// its bounds for the apply step, and fill the sampled pixels themselves.
|
||||
let (bx0, by0, bx1, by1) = mask_bounds(w, h, &hole);
|
||||
let original: Vec<f32> = (by0..by1).flat_map(|y| img[(y * w + bx0) * n..(y * w + bx1) * n].iter().copied()).collect();
|
||||
let at = |x: usize, y: usize| (window.x0 + x as i32, window.y0 + y as i32);
|
||||
let ((x0, y0), (x1, y1)) = (at(bx0, by0), at(bx1, by1));
|
||||
let cover = read_window_mask(&sel, Rect::new(x0, y0, x1, y1), &cancel).ok_or(EngineError::Cancelled)?;
|
||||
let own = layer_px.as_deref().unwrap_or(&img);
|
||||
let original: Vec<f32> = (by0..by1).flat_map(|y| own[(y * w + bx0) * n..(y * w + bx1) * n].iter().copied()).collect();
|
||||
let cover: Vec<f32> = (by0..by1).flat_map(|y| cover[y * w + bx0..y * w + bx1].iter().copied()).collect();
|
||||
let n_hole = cover.iter().filter(|c| **c > 0.0).count();
|
||||
let filled = ctx.stage(0.05, 1.0, label, |ctl| fill_owned(w, h, n, img, &hole, &source, &opts, ctl)).map_err(|_| EngineError::Cancelled)?;
|
||||
Ok(FilledWindow { filled, bounds: (bx0, by0, bx1, by1), original, cover, n_hole })
|
||||
ctx.check()?;
|
||||
let mut filled = ctx.stage(0.05, 1.0, label, |ctl| fill_owned(w, h, n, img, &hole, &source, &opts, ctl)).map_err(|_| EngineError::Cancelled)?;
|
||||
// Sampled from all layers: outside the fill area the window keeps the layer's pixels.
|
||||
if let Some(own) = layer_px {
|
||||
filled.par_chunks_mut(n.max(1)).zip(own.par_chunks(n.max(1))).zip(hole.par_iter()).for_each(|((f, o), h)| {
|
||||
if !*h {
|
||||
f.copy_from_slice(o);
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok((layer, rect, fmt, output, FilledWindow { filled, bounds: (bx0, by0, bx1, by1), original, cover, n_hole }))
|
||||
},
|
||||
move |s, done| apply_content_aware_fill(s, label, id, &output, fmt, window, done),
|
||||
move |s, (layer, rect, fmt, output, done)| apply_content_aware_fill(s, label, layer, &output, fmt, rect, done),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1027,18 +1306,18 @@ fn preset_manager(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new =
|
||||
p.get("newName").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "missing `newName`"))?.to_string();
|
||||
match kind.as_str() {
|
||||
"brushes" => {
|
||||
// A renamed built-in is the user's preset now (and persists as one).
|
||||
let b = &mut s.tools.presets[i];
|
||||
b.name = new;
|
||||
b.builtin = false;
|
||||
}
|
||||
// Same rules as Brush Settings: no duplicate names (case-insensitive), the current
|
||||
// preset follows, a renamed built-in is the user's preset now (and persists as one).
|
||||
"brushes" => crate::brush_preset_cmds::rename_at(s, i, &new, cmd)?,
|
||||
"patterns" => s.patterns.items[i].name = new,
|
||||
_ => s.edit_state.custom_shapes[i].name = new,
|
||||
}
|
||||
}
|
||||
"delete" => match kind.as_str() {
|
||||
"brushes" => drop(s.tools.presets.remove(i)),
|
||||
"brushes" => {
|
||||
let gone = s.tools.presets.remove(i);
|
||||
crate::brush_cmds::forget_deleted_preset(s, &gone.name);
|
||||
}
|
||||
"patterns" => drop(s.patterns.items.remove(i)),
|
||||
_ => drop(s.edit_state.custom_shapes.remove(i)),
|
||||
},
|
||||
@@ -1169,7 +1448,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Content-Aware Fill…",
|
||||
["Edit"],
|
||||
None,
|
||||
r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"sampleAllLayers":bool=false,"output":"current|new|duplicate","seed":u64=1}"##,
|
||||
r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"samplingStrokes":[{"mode":"add|subtract","size":px,"points":[[x,y],…]}]? (the workspace's Sampling Brush, after the sampling option's area; added strokes may lie outside the window),"fillEdits":[{"op":"new|add|subtract|intersect","points":[[x,y],…]}|{"op":"expand|contract","by":px}]? (the workspace's lasso edits of the fill area, in order),"sampleAllLayers":bool=false,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"output":"current|new|duplicate","layer":id?=active (the layer filled and sampled),"seed":u64=1}"##,
|
||||
can_caf,
|
||||
content_aware_fill
|
||||
),
|
||||
|
||||
@@ -266,6 +266,52 @@ fn content_aware_fill_samples_photoshops_window() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_aware_fill_takes_the_workspace_edits() {
|
||||
// The plan the workspace previews: lasso edits change the fill area (and the window follows),
|
||||
// sampling-brush strokes change what may be copied, also outside the window.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 600, "height": 400, "background": "white"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 275, "y": 175, "width": 50, "height": 50})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let (doc, id) = (st.doc.clone(), st.active_layer.unwrap());
|
||||
let plan = |p: Value| content_aware_plan(&doc, id, &p, "edit.contentAwareFill");
|
||||
let count = |m: &[bool]| m.iter().filter(|v| **v).count();
|
||||
let base = plan(json!({"sampling": "rectangular"})).unwrap();
|
||||
assert_eq!((base.window, count(&base.hole)), (Rect::new(200, 100, 400, 300), 2500));
|
||||
assert_eq!(count(&base.source), 200 * 200 - 2500);
|
||||
// A lasso square added beside the selection: a bigger fill area, a window around both.
|
||||
let p = plan(json!({"sampling": "rectangular", "fillEdits": [{"op": "add", "points": [[340, 175], [390, 175], [390, 225], [340, 225]]}]})).unwrap();
|
||||
assert_eq!(count(&p.hole), 5000);
|
||||
assert!(p.window.width() > 200 && p.window.contains_rect(&Rect::new(275, 175, 390, 225)), "{:?}", p.window);
|
||||
// Expand by 2 grows the 50 px square to about 54 px (rounded corners).
|
||||
let p = plan(json!({"sampling": "rectangular", "fillEdits": [{"op": "expand", "by": 2}]})).unwrap();
|
||||
assert!((54 * 54 - 20..=54 * 54).contains(&count(&p.hole)), "{}", count(&p.hole));
|
||||
// Subtracting it all leaves nothing to fill.
|
||||
assert!(plan(json!({"fillEdits": [{"op": "subtract", "points": [[0, 0], [600, 0], [600, 400], [0, 400]]}]})).is_err());
|
||||
// A subtracting stroke takes a band out of the sampling area; an adding one far away reaches
|
||||
// past the window, and the plan's rectangle grows to hold it.
|
||||
let p = plan(json!({"sampling": "rectangular", "samplingStrokes": [{"mode": "subtract", "size": 10, "points": [[210, 110], [390, 110]]}]})).unwrap();
|
||||
assert!((count(&base.source) - count(&p.source)).abs_diff(10 * 190) < 60);
|
||||
let p = plan(json!({"sampling": "rectangular", "samplingStrokes": [{"mode": "add", "size": 20, "points": [[540, 350], [580, 350]]}]})).unwrap();
|
||||
assert!(p.rect.contains(560, 350) && !p.window.contains(560, 350));
|
||||
assert!(count(&p.source) > count(&base.source) + 600);
|
||||
// Custom from strokes alone; bad edits are errors.
|
||||
let p = plan(json!({"sampling": "custom", "samplingStrokes": [{"mode": "add", "size": 20, "points": [[100, 100], [160, 100]]}]})).unwrap();
|
||||
assert!((1450..1600).contains(&count(&p.source)), "a 20 px brush along 60 px: {}", count(&p.source));
|
||||
for bad in [
|
||||
json!({"samplingStrokes": [{"mode": "paint", "size": 10, "points": [[0, 0]]}]}),
|
||||
json!({"samplingStrokes": [{"size": 0, "points": [[0, 0]]}]}),
|
||||
json!({"fillEdits": [{"op": "add", "points": [[0, "x"]]}]}),
|
||||
json!({"fillEdits": [{"op": "expand", "by": -3}]}),
|
||||
] {
|
||||
assert!(plan(bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
// The command fills with the same plan; `layer` names the layer even when another is active.
|
||||
let r = s.execute("edit.contentAwareFill", json!({"sampling": "rectangular", "fillEdits": [{"op": "expand", "by": 2}], "layer": id.0})).unwrap();
|
||||
assert!(r["filled"].as_u64().unwrap() > 2800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_aware_fill_outputs_and_sampling() {
|
||||
let mut s = blob_session(8);
|
||||
@@ -427,6 +473,35 @@ fn define_custom_shape_and_preset_manager() {
|
||||
assert!(t.execute("edit.presets.exportImportPresets", json!({"action": "import", "data": {"format": "other"}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_manager_brush_rename_follows_current_preset() {
|
||||
let mut s = Session::new();
|
||||
s.execute("brush.presets.setCurrent", json!({"name": "Chalk"})).unwrap();
|
||||
s.execute("edit.presets.presetManager", json!({"action": "rename", "kind": "brushes", "name": "Chalk", "newName": "Chalky"})).unwrap();
|
||||
assert_eq!(s.tools.current_preset.as_deref(), Some("Chalky"));
|
||||
s.execute("brush.presets.update", json!({})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_manager_brush_rename_refuses_duplicate_name() {
|
||||
let mut s = Session::new();
|
||||
let before: Vec<String> = s.tools.presets.iter().map(|b| b.name.clone()).collect();
|
||||
let r = s.execute("edit.presets.presetManager", json!({"action": "rename", "kind": "brushes", "name": "Soft Round", "newName": "hard round"}));
|
||||
assert!(r.is_err(), "a case-insensitive duplicate name must be refused");
|
||||
assert_eq!(s.tools.presets.iter().map(|b| b.name.clone()).collect::<Vec<_>>(), before);
|
||||
// Renaming to a different case of its own name is fine.
|
||||
s.execute("edit.presets.presetManager", json!({"action": "rename", "kind": "brushes", "name": "Soft Round", "newName": "soft round"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_manager_brush_delete_clears_current_preset() {
|
||||
let mut s = Session::new();
|
||||
s.execute("brush.presets.setCurrent", json!({"name": "Chalk"})).unwrap();
|
||||
s.execute("edit.presets.presetManager", json!({"action": "delete", "kind": "brushes", "name": "Chalk"})).unwrap();
|
||||
assert!(!s.tools.presets.iter().any(|b| b.name == "Chalk"));
|
||||
assert_eq!(s.tools.current_preset, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn define_brush_preset_wraps_define_from_selection() {
|
||||
let mut s = session(8);
|
||||
|
||||
@@ -52,7 +52,8 @@ fn open_exr_parts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
precomp: p.get("precomp").and_then(Value::as_bool).unwrap_or(false),
|
||||
};
|
||||
let bytes = read_file(&path)?;
|
||||
let r = photocraft_io::exr_parts::import(&stem(&path), &bytes, &sel).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let mut r = photocraft_io::exr_parts::import(&stem(&path), &bytes, &sel).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::file_cmds::tag_exr_colorspace(s, &path, &mut r.document);
|
||||
let (i, color) = s.open_document(r.document, Some(path.clone()));
|
||||
Ok(json!({"document": i, "color": color, "warnings": r.warnings}))
|
||||
}
|
||||
@@ -173,6 +174,25 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// File Handling › "Open color-less EXR as ACEScg" applies to Open EXR Parts too.
|
||||
#[test]
|
||||
fn open_exr_parts_honours_the_aces_cg_preference() {
|
||||
let dir = std::env::temp_dir().join(format!("pcraft-exr-aces-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("aov.exr");
|
||||
write_multipart(&path);
|
||||
let linear = photocraft_cms::Builtin::LinearSrgb.profile().to_bytes();
|
||||
let aces = photocraft_cms::Builtin::AcesCg.profile().to_bytes();
|
||||
for (on, want) in [(false, &linear), (true, &aces)] {
|
||||
let mut s = session();
|
||||
s.prefs.edit(|p| p.file_handling.exr_aces_cg = on);
|
||||
s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), "part/beauty": true})).unwrap();
|
||||
let doc = &s.active().unwrap().doc;
|
||||
assert_eq!(doc.icc_profile.as_deref().map(|v| v.as_slice()), Some(want.as_slice()), "exr_aces_cg = {on}");
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_exr_parts_refuses_an_empty_selection_and_unknown_names() {
|
||||
let dir = std::env::temp_dir().join(format!("pcraft-exr-empty-{}", std::process::id()));
|
||||
|
||||
@@ -335,15 +335,18 @@ fn clouds(s: &mut Session, p: &Value, difference: bool) -> Result<Value> {
|
||||
const TRANSFORM_TARGET: &str =
|
||||
r#"{"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask turns alone)}"#;
|
||||
|
||||
/// Flips and turns about the centre of the box Free Transform would open: with a selection
|
||||
/// limiting the transform it is the selection's own bounds, as in Photoshop, so Select All
|
||||
/// mirrors across the canvas (#6928); otherwise the content's bounds.
|
||||
fn transform_preset(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
// A targeted alpha channel, the Quick Mask or an unlinked layer mask turns by itself (#2835).
|
||||
let (b, mut params) = match crate::transform_cmds::lone_target(&d.doc, d.active_layer, p)? {
|
||||
Some(surf) => (crate::transform_cmds::target_bounds(&d.doc, surf), json!({"target": p.get("target")})),
|
||||
Some(surf) => (crate::transform_cmds::target_frame(&d.doc, surf), json!({"target": p.get("target")})),
|
||||
None => {
|
||||
let id = d.active_layer.ok_or(EngineError::Other("no active layer".into()))?;
|
||||
let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
(crate::transform_cmds::transform_bounds(&d.doc, l), json!({"layer": id.0}))
|
||||
(crate::transform_cmds::transform_frame(&d.doc, l), json!({"layer": id.0}))
|
||||
}
|
||||
};
|
||||
if b.is_empty() {
|
||||
@@ -1469,6 +1472,49 @@ mod tests {
|
||||
assert_eq!(pixel(&s, 10, 10), vec![0.0, 1.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
/// #6928: with a selection, the presets flip and turn about the selection's own bounds, as
|
||||
/// in Photoshop: Select All mirrors across the canvas, a marquee across its own centre.
|
||||
/// Without a selection they keep turning about the content (above).
|
||||
#[test]
|
||||
fn transform_presets_turn_about_the_selection() {
|
||||
let mut s = session(8);
|
||||
paint_square(&mut s, Rect::new(10, 10, 20, 14), [1.0, 0.0, 0.0, 1.0]);
|
||||
paint_square(&mut s, Rect::new(10, 10, 11, 11), [0.0, 1.0, 0.0, 1.0]);
|
||||
let green = vec![0.0, 1.0, 0.0, 1.0];
|
||||
// Select All on the 40 × 30 canvas: across the canvas's centre lines.
|
||||
s.execute("select.all", json!({})).unwrap();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
s.execute("edit.transform.flipHorizontal", json!({})).unwrap();
|
||||
assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(20, 10, 30, 14));
|
||||
assert_eq!(pixel(&s, 29, 10), green);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past + 1, "one undo step");
|
||||
s.execute("edit.transform.flipVertical", json!({})).unwrap();
|
||||
assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(20, 16, 30, 20));
|
||||
assert_eq!(pixel(&s, 29, 19), green);
|
||||
s.execute("edit.transform.rotate180", json!({})).unwrap();
|
||||
assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(10, 10, 20, 14));
|
||||
assert_eq!(pixel(&s, 10, 10), green);
|
||||
// A marquee (0, 0)–(24, 30) holding the whole square: across x = 12.
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 24, "height": 30})).unwrap();
|
||||
s.execute("edit.transform.flipHorizontal", json!({})).unwrap();
|
||||
assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(4, 10, 14, 14));
|
||||
assert_eq!(pixel(&s, 13, 10), green);
|
||||
// Undo puts it back where it was.
|
||||
s.undo();
|
||||
assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(10, 10, 20, 14));
|
||||
// A targeted alpha channel turns about the selection's bounds too.
|
||||
s.execute("channel.new", json!({})).unwrap();
|
||||
s.edit("paint channel", |doc, _| {
|
||||
doc.channels[0].surface.fill_rect(Rect::new(10, 10, 20, 20), &[1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("channel.target", json!({"channel": 0})).unwrap();
|
||||
s.execute("select.all", json!({})).unwrap();
|
||||
s.execute("edit.transform.flipHorizontal", json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.channels[0].surface.content_bounds(), Rect::new(20, 10, 30, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reselect_restores_the_last_selection() {
|
||||
let mut s = session(8);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Face detection (#3603): the faces in the document, with their box and five landmarks, from
|
||||
//! [`photocraft_algo::faces`]. Select › Color Range's Detect Faces and Face-Aware Liquify read
|
||||
//! faces through [`faces_in`]; `image.detectFaces` reports them to scripts and agents.
|
||||
|
||||
use photocraft_algo::faces::{self, Face};
|
||||
use photocraft_doc::{Document, LayerId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Faces scoring below this are left out unless asked for (libfacedetection's own threshold for a
|
||||
/// reliable face).
|
||||
pub const MIN_SCORE: f32 = 0.5;
|
||||
|
||||
/// Why there are no faces in the browser build (see [`photocraft_algo::faces`]).
|
||||
const UNAVAILABLE: &str = "face detection isn't available in the browser version of PhotoCraft; use the desktop app";
|
||||
|
||||
/// The faces in `px` (`w × h` straight RGBA), as [`faces::detect`] finds them.
|
||||
pub fn detect(px: &[[f32; 4]], w: usize, h: usize, min_score: f32) -> Result<Vec<Face>> {
|
||||
if !faces::AVAILABLE {
|
||||
return Err(EngineError::Other(UNAVAILABLE.into()));
|
||||
}
|
||||
faces::detect(px, w, h, min_score).ok_or_else(|| EngineError::Other("face detection failed".into()))
|
||||
}
|
||||
|
||||
/// The faces in `doc`, best first: in the composite, or in the active layer's pixels when
|
||||
/// `all_layers` is off. Coordinates are document pixels.
|
||||
pub fn faces_in(doc: &Document, active: Option<LayerId>, all_layers: bool, min_score: f32) -> Result<Vec<Face>> {
|
||||
let area = doc.bounds();
|
||||
let (w, h) = (area.width() as usize, area.height() as usize);
|
||||
let surface = active.and_then(|id| doc.layer(id)).and_then(|l| l.surface()).filter(|_| !all_layers);
|
||||
let px = match surface {
|
||||
Some(surf) => {
|
||||
let mut px = vec![[0.0f32; 4]; w * h];
|
||||
surf.read_rgba_into(area, &mut px);
|
||||
px
|
||||
}
|
||||
None => photocraft_compose::render(doc, area).px,
|
||||
};
|
||||
let found = detect(&px, w, h, min_score)?;
|
||||
let (x0, y0) = (area.x0 as f32, area.y0 as f32);
|
||||
Ok(found
|
||||
.into_iter()
|
||||
.map(|f| Face { rect: [f.rect[0] + x0, f.rect[1] + y0, f.rect[2], f.rect[3]], landmarks: f.landmarks.map(|[x, y]| [x + x0, y + y0]), ..f })
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn face_json(f: &Face) -> Value {
|
||||
let pt = |i: usize| json!([f.landmarks[i][0], f.landmarks[i][1]]);
|
||||
json!({
|
||||
"score": f.score,
|
||||
"rect": f.rect,
|
||||
"landmarks": {"rightEye": pt(0), "leftEye": pt(1), "nose": pt(2), "mouthRight": pt(3), "mouthLeft": pt(4)},
|
||||
})
|
||||
}
|
||||
|
||||
fn detect_faces(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "image.detectFaces";
|
||||
let bad = |msg: &str| EngineError::BadParams { cmd: CMD.into(), msg: msg.into() };
|
||||
let min_score = match p.get("minScore") {
|
||||
None => MIN_SCORE,
|
||||
Some(v) => v.as_f64().filter(|v| (0.0..=1.0).contains(v)).ok_or_else(|| bad("minScore must be a number in 0..1"))? as f32,
|
||||
};
|
||||
let all_layers = match p.get("sampleAllLayers") {
|
||||
None => true,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad("sampleAllLayers must be true or false"))?,
|
||||
};
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let found = faces_in(&d.doc, d.active_layer, all_layers, min_score)?;
|
||||
Ok(json!({ "faces": found.iter().map(face_json).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![CommandSpec {
|
||||
id: "image.detectFaces",
|
||||
label: "Detect Faces",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"minScore":0..1=0.5,"sampleAllLayers":bool=true} → {"faces":[{"score","rect":[x,y,w,h],"landmarks":{"rightEye","leftEye","nose","mouthRight","mouthLeft"}: [x,y] each},…]} (best first; "rightEye" is the one on the image's left)"##,
|
||||
enabled: |s| {
|
||||
if !faces::AVAILABLE {
|
||||
return Err(UNAVAILABLE.into());
|
||||
}
|
||||
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
|
||||
},
|
||||
run: detect_faces,
|
||||
journal: false,
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn detect_faces_reports_no_faces_on_a_plain_image_and_validates_params() {
|
||||
let mut s = Session::new();
|
||||
assert!(s.execute("image.detectFaces", json!({})).is_err(), "no document");
|
||||
assert!(!s.is_enabled("image.detectFaces"));
|
||||
s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap();
|
||||
assert!(s.is_enabled("image.detectFaces"));
|
||||
s.execute("select.all", json!({})).unwrap();
|
||||
assert_eq!(s.execute("image.detectFaces", json!({})).unwrap(), json!({"faces": []}));
|
||||
assert_eq!(s.execute("image.detectFaces", json!({"minScore": 0, "sampleAllLayers": false})).unwrap(), json!({"faces": []}));
|
||||
// No history step of its own: Undo undoes the Select All before it.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active().unwrap().doc.selection.is_none());
|
||||
for bad in [json!({"minScore": 2}), json!({"minScore": "high"}), json!({"sampleAllLayers": 1})] {
|
||||
assert!(s.execute("image.detectFaces", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,40 @@ pub(crate) fn stem(path: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` name the same file: the same text, or, when both exist, the same file on
|
||||
/// disk (a hard link, a different case on a case-insensitive volume, `./` or `..` in one of them).
|
||||
pub fn same_file(a: &str, b: &str) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
|
||||
return x.dev() == y.dev() && x.ino() == y.ino();
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// NTFS and FAT compare names without case.
|
||||
if let (Ok(x), Ok(y)) = (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
|
||||
return x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase();
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// A path as command results report it: `/` between components, whatever the platform.
|
||||
///
|
||||
/// `join` already builds with `/`, but a directory from the caller or from `std::path::absolute`
|
||||
/// carries the native separator, which would leave a reported path mixed. The control channel
|
||||
/// takes `/` and rejects alternate separators (`AuthorizedWorkspace` in `crates/automation`), so
|
||||
/// a result spelt this way can be handed straight back to it. Verbatim and UNC prefixes keep
|
||||
/// theirs: Win32 does not accept `/` after a leading `\\`.
|
||||
pub(crate) fn forward_slashes(path: String) -> String {
|
||||
if path.starts_with(r"\\") { path } else { path.replace('\\', "/") }
|
||||
}
|
||||
|
||||
pub(crate) fn join(dir: &str, name: &str) -> String {
|
||||
if dir.is_empty() {
|
||||
name.to_string()
|
||||
@@ -340,6 +374,8 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into<SaveOpts>) -> R
|
||||
tga_bits: save.tga_bits,
|
||||
image_previews: save.image_previews,
|
||||
psd_maximize_compatibility: save.psd_maximize_compatibility,
|
||||
// Linked files a PSD embeds are resolved against the document being worked on.
|
||||
document_path: crate::doc_path::current(),
|
||||
..Default::default()
|
||||
};
|
||||
// No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP
|
||||
@@ -461,7 +497,7 @@ fn save_a_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// With File Handling's ACEScg option on, such an EXR opens as ACEScg instead (AP1,
|
||||
/// linear) — the working space ACES render pipelines write. Only the io default is
|
||||
/// replaced; an embedded or EXIF-declared profile is kept untouched.
|
||||
fn tag_exr_colorspace(s: &Session, name: &str, doc: &mut Document) {
|
||||
pub(crate) fn tag_exr_colorspace(s: &Session, name: &str, doc: &mut Document) {
|
||||
if !name.to_ascii_lowercase().ends_with(".exr") || !s.prefs.get().file_handling.exr_aces_cg {
|
||||
return;
|
||||
}
|
||||
@@ -578,7 +614,16 @@ fn place(s: &mut Session, p: &Value, linked: bool) -> Result<Value> {
|
||||
let cmd = if linked { "file.placeLinked" } else { "file.placeEmbedded" };
|
||||
let path = str_param(p, "path", cmd)?.to_string();
|
||||
let bytes = read_file(&path)?;
|
||||
place_bytes(s, &path, bytes, linked.then(|| path.clone()), p)
|
||||
// The link is stored absolute: a relative one would later be taken from the document's
|
||||
// folder (`doc_path`), not the working directory the path was given in.
|
||||
let link = linked.then(|| {
|
||||
if std::path::Path::new(&path).is_relative() {
|
||||
std::path::absolute(&path).map_or_else(|_| path.clone(), |a| a.to_string_lossy().into_owned())
|
||||
} else {
|
||||
path.clone()
|
||||
}
|
||||
});
|
||||
place_bytes(s, &path, bytes, link, p)
|
||||
}
|
||||
|
||||
// ---------- File Info (XMP) ----------
|
||||
@@ -1432,7 +1477,7 @@ fn bake_cube_layers(size: usize, title: &str, layers: &[&Layer]) -> String {
|
||||
lattice.layers.push(a);
|
||||
}
|
||||
let out = photocraft_compose::flatten(&lattice);
|
||||
let mut s = format!("TITLE \"{}\"\n# Created by Photocraft\nLUT_3D_SIZE {n}\nDOMAIN_MIN 0.0 0.0 0.0\nDOMAIN_MAX 1.0 1.0 1.0\n", title.replace('"', "'"));
|
||||
let mut s = format!("TITLE \"{}\"\n# Created by PhotoCraft\nLUT_3D_SIZE {n}\nDOMAIN_MIN 0.0 0.0 0.0\nDOMAIN_MAX 1.0 1.0 1.0\n", title.replace('"', "'"));
|
||||
// .cube order: red changes fastest, then green, then blue.
|
||||
for b in 0..n {
|
||||
for g in 0..n {
|
||||
@@ -1761,4 +1806,38 @@ mod aces_exr_tests {
|
||||
let d = &s.active().unwrap().doc;
|
||||
assert_eq!(d.icc_profile.as_deref().map(|v| v.as_slice()), Some(aces.as_slice()));
|
||||
}
|
||||
|
||||
/// The desktop app opens files as background jobs (File > Open, drag-drop, Open Recent):
|
||||
/// the ACEScg preference applies there too, not only to `file.openAs`.
|
||||
#[test]
|
||||
fn exr_aces_cg_preference_applies_to_background_opens() {
|
||||
use crate::jobs::{OpenSource, Started};
|
||||
let image = photocraft_codecs::Image::from_f32(4, 4, photocraft_codecs::ChannelLayout::Rgb, &[0.5; 48]).unwrap();
|
||||
let bytes =
|
||||
std::sync::Arc::new(photocraft_codecs::encode(&image, photocraft_codecs::Format::OpenExr, &photocraft_codecs::EncodeOptions::default()).unwrap());
|
||||
let linear = photocraft_cms::Builtin::LinearSrgb.profile().to_bytes();
|
||||
let aces = photocraft_cms::Builtin::AcesCg.profile().to_bytes();
|
||||
for (on, want) in [(false, &linear), (true, &aces)] {
|
||||
let mut s = Session::new();
|
||||
s.prefs.edit(|p| p.file_handling.exr_aces_cg = on);
|
||||
let v = match s.start_open("shot.exr", OpenSource::Bytes(std::sync::Arc::clone(&bytes))).unwrap() {
|
||||
Started::Job(id) => s.wait_job(id).unwrap(),
|
||||
Started::Done(v) => v,
|
||||
};
|
||||
// ACEScg isn't the working space, so Ask When Opening holds it for the prompt, which
|
||||
// names the ACEScg profile; linear sRGB (io's HDR default) opens without asking.
|
||||
let profile = match v.get("held").and_then(Value::as_u64) {
|
||||
Some(held) => {
|
||||
assert!(on, "{v}");
|
||||
assert!(v["color"]["embedded"].as_str().unwrap().starts_with("ACEScg"), "{v}");
|
||||
s.take_held_open(held).unwrap().0.icc_profile
|
||||
}
|
||||
None => {
|
||||
assert!(v.get("document").is_some(), "{v}");
|
||||
s.active().unwrap().doc.icc_profile.clone()
|
||||
}
|
||||
};
|
||||
assert_eq!(profile.as_deref().map(|v| v.as_slice()), Some(want.as_slice()), "exr_aces_cg = {on}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1033,7 +1033,7 @@ fn reveal_in_finder_reveals_the_documents_saved_path() {
|
||||
let path = "/tmp/pics/a.psd";
|
||||
s.active_mut().unwrap().path = Some(path.into());
|
||||
let r = s.execute("file.revealInFinder", json!({"dryRun": true})).unwrap();
|
||||
let (program, args) = crate::layer_menu_cmds::reveal_command(path);
|
||||
let (program, args) = crate::layer_menu_cmds::reveal_command(path).unwrap();
|
||||
assert_eq!(r["program"], json!(program));
|
||||
assert_eq!(r["args"], json!(args));
|
||||
// The tab menu names a background tab: the active document is not the one revealed.
|
||||
|
||||
@@ -334,30 +334,6 @@ pub(crate) fn read_window(surf: &Surface, r: Rect, cancelled: &(dyn Fn() -> bool
|
||||
if cancelled() { None } else { Some(out) }
|
||||
}
|
||||
|
||||
/// Which pixels of `r` a selection (or mask) covers at all: channel 0 above zero, read in
|
||||
/// parallel bands as [`read_window`] does, without holding the coverage itself.
|
||||
pub(crate) fn read_window_cover(surf: &Surface, r: Rect, cancelled: &(dyn Fn() -> bool + Sync)) -> Option<Vec<bool>> {
|
||||
use rayon::prelude::*;
|
||||
const BAND: usize = 64;
|
||||
let n = surf.channels().max(1);
|
||||
let (w, h) = (r.width() as usize, r.height() as usize);
|
||||
let mut out = vec![false; w * h];
|
||||
if out.is_empty() {
|
||||
return Some(out);
|
||||
}
|
||||
out.par_chunks_mut(BAND * w).enumerate().for_each_init(Vec::new, |buf, (i, chunk)| {
|
||||
if cancelled() {
|
||||
return;
|
||||
}
|
||||
let y0 = r.y0 + (i * BAND) as i32;
|
||||
surf.read_region_into(Rect::new(r.x0, y0, r.x1, y0 + (chunk.len() / w) as i32), buf);
|
||||
for (o, px) in chunk.iter_mut().zip(buf.chunks_exact(n)) {
|
||||
*o = px[0] > 0.0;
|
||||
}
|
||||
});
|
||||
if cancelled() { None } else { Some(out) }
|
||||
}
|
||||
|
||||
/// Channel 0 of [`read_window`] (a selection's or an alpha channel's coverage).
|
||||
pub(crate) fn read_window_mask(surf: &Surface, r: Rect, cancelled: &(dyn Fn() -> bool + Sync)) -> Option<Vec<f32>> {
|
||||
use rayon::prelude::*;
|
||||
|
||||
@@ -466,13 +466,15 @@ fn map_from_path(path: &str, layer: Option<u64>) -> Result<Option<algo::Image>>
|
||||
|
||||
/// The session-free part of [`resolve`]: Displace's `mapPath`. Smart filters record params, not
|
||||
/// the map, so their re-render reads the map again here; without it the filter passed the pixels
|
||||
/// through. `mapDocument` needs the session and is left unresolved.
|
||||
/// through. The path is stored in the document, so it is followed only as
|
||||
/// [`crate::doc_path::resolve`] allows. `mapDocument` needs the session and is left unresolved.
|
||||
pub(crate) fn resolve_map_path(fp: &mut FilterParams, p: &Value) -> Result<()> {
|
||||
if let FilterParams::Displace { map, .. } = fp
|
||||
&& map.is_none()
|
||||
&& let Some(path) = map_path(p)
|
||||
{
|
||||
*map = map_from_path(path, p.get("mapLayer").and_then(Value::as_u64))?.map(Arc::new);
|
||||
let file = crate::doc_path::resolve(path).map_err(|why| crate::doc_path::refusal("the displacement map", path, why))?;
|
||||
*map = map_from_path(&file.to_string_lossy(), p.get("mapLayer").and_then(Value::as_u64))?.map(Arc::new);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -323,6 +323,9 @@ pub fn run<T: Send + 'static>(
|
||||
) -> Result<Value> {
|
||||
let authorization = s.authorize.or_else(|| RENDER_AUTHORIZATION.with(std::cell::Cell::get));
|
||||
let _authorization = AuthorizationScope::enter(authorization);
|
||||
// The job's document: stored paths it follows are resolved against it on any thread.
|
||||
let document = s.active().and_then(|d| d.path.as_deref()).map(std::path::PathBuf::from);
|
||||
let _document = crate::doc_path::scope(document.as_deref());
|
||||
if !s.jobs.spawn || cfg!(target_arch = "wasm32") {
|
||||
let t = work(&s.jobs.inline.clone().unwrap_or_default())?;
|
||||
return apply(s, t);
|
||||
@@ -337,8 +340,10 @@ pub fn run<T: Send + 'static>(
|
||||
};
|
||||
let ctx = JobCtx::new();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Result<Payload>>();
|
||||
let apply_document = document.clone();
|
||||
let erased: Apply = Box::new(move |s, any| {
|
||||
let _authorization = AuthorizationScope::enter(authorization);
|
||||
let _document = crate::doc_path::scope(apply_document.as_deref());
|
||||
let t = any.downcast::<T>().map_err(|_| EngineError::Other("internal error: job result type mismatch".into()))?;
|
||||
apply(s, *t)
|
||||
});
|
||||
@@ -352,6 +357,7 @@ pub fn run<T: Send + 'static>(
|
||||
// no way to get it back, so the error is reported instead.
|
||||
let spawned = std::thread::Builder::new().name(format!("photocraft-job-{}", id.0)).spawn(move || {
|
||||
let _authorization = AuthorizationScope::enter(authorization);
|
||||
let _document = crate::doc_path::scope(document.as_deref());
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(&wctx).map(|t| Box::new(t) as Payload)))
|
||||
.unwrap_or_else(|_| Err(EngineError::Other(format!("`{label_w}` failed with an internal error (logged); the document is unchanged"))));
|
||||
// The receiver is gone when the job was cancelled: nothing to report.
|
||||
@@ -503,7 +509,9 @@ impl Session {
|
||||
warm_layer_bounds(&imported.document, || ctx.check())?;
|
||||
Ok(imported)
|
||||
},
|
||||
move |s, r: photocraft_io::ImportResult| {
|
||||
move |s, mut r: photocraft_io::ImportResult| {
|
||||
// File Handling's ACEScg option, before the colour prompt looks at the profile.
|
||||
crate::file_cmds::tag_exr_colorspace(s, &name_a, &mut r.document);
|
||||
// Ask When Opening: held back until the prompt is answered (`take_held_open`). A
|
||||
// developed raw has no embedded profile to ask about.
|
||||
let raw = r.warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE));
|
||||
@@ -543,7 +551,7 @@ impl Session {
|
||||
job.ctx.cancel();
|
||||
Err(e)
|
||||
}
|
||||
(Err(e), None) => Err(e),
|
||||
(Err(e), None) => Err(self.explain_missing_layer(e)),
|
||||
(Ok(_), Some(mut job)) => {
|
||||
job.command = command.to_string();
|
||||
job.params = params;
|
||||
@@ -694,6 +702,7 @@ impl Session {
|
||||
gate(id, ¶ms)?;
|
||||
}
|
||||
let _authorization = AuthorizationScope::enter(self.authorize);
|
||||
let _document = crate::doc_path::scope(self.active().and_then(|d| d.path.as_deref()).map(std::path::Path::new));
|
||||
// Last Filter runs as the filter it repeats: journaled, recorded in an action, faded and
|
||||
// run as a job like that filter, so a recorded step replays the same filter anywhere
|
||||
// (#3089), as Transform Again records its transform (#1440). With no filter yet it falls
|
||||
@@ -740,7 +749,7 @@ impl Session {
|
||||
job.ctx.cancel();
|
||||
Err(e)
|
||||
}
|
||||
(Err(e), None) => Err(e),
|
||||
(Err(e), None) => Err(self.explain_missing_layer(e)),
|
||||
(Ok(_), Some(mut job)) => {
|
||||
job.command = id.to_string();
|
||||
job.params = params;
|
||||
@@ -756,6 +765,18 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that failed on a layer id the document doesn't have says which ids it does have
|
||||
/// (#5058): ids are not positions (an opened PNG's Background is not layer 1), and an agent
|
||||
/// should not need `document.inspect` to recover.
|
||||
fn explain_missing_layer(&self, e: EngineError) -> EngineError {
|
||||
match (e, self.active()) {
|
||||
(EngineError::NoLayer(id), Some(st)) if st.doc.layer(id).is_none() => {
|
||||
EngineError::UnknownLayer { id, known: crate::commands::layer_listing(&st.doc) }
|
||||
}
|
||||
(e, _) => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bookkeeping after a command (or a job's apply) succeeded.
|
||||
///
|
||||
/// `nested_from` is the journal length when the command started. A replayable command stands
|
||||
|
||||
@@ -373,30 +373,56 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let SmartSource::Linked { path } = &sm.source else {
|
||||
return Err(other("embedded smart objects have no file to reveal"));
|
||||
};
|
||||
reveal(path, p.get("dryRun").and_then(Value::as_bool).unwrap_or(false))
|
||||
// The path is stored in the document: shown only when it would be followed.
|
||||
let file =
|
||||
crate::doc_path::resolve_in(path, d.path.as_deref().map(std::path::Path::new)).map_err(|why| crate::doc_path::refusal("the linked file", path, why))?;
|
||||
reveal(&file.to_string_lossy(), p.get("dryRun").and_then(Value::as_bool).unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Show `path` in the platform file manager ([`reveal_command`] + [`spawn`]); `dry_run` answers
|
||||
/// the program and arguments instead (tests). Shared by the smart-object command and the document
|
||||
/// tab's Reveal in Finder (UI-217-6).
|
||||
pub(crate) fn reveal(path: &str, dry_run: bool) -> Result<Value> {
|
||||
let (program, args) = reveal_command(path);
|
||||
let (program, args) = reveal_command(path)?;
|
||||
if dry_run {
|
||||
return Ok(json!({"program": program, "args": args}));
|
||||
}
|
||||
ensure_folder(program, &args)?;
|
||||
spawn(program, &args)?;
|
||||
Ok(json!({"path": path}))
|
||||
}
|
||||
|
||||
/// `xdg-open` opens whatever it is given in its default application, so it only gets a folder: a
|
||||
/// linked path such as `tool.jar/a.png` would otherwise launch `tool.jar` instead of showing it.
|
||||
fn ensure_folder(program: &str, args: &[String]) -> Result<()> {
|
||||
if program != "xdg-open" {
|
||||
return Ok(());
|
||||
}
|
||||
match args.first() {
|
||||
Some(dir) if std::path::Path::new(dir).is_dir() => Ok(()),
|
||||
Some(dir) => Err(other(format!("{dir} is not a folder, so it can't be shown in the file manager"))),
|
||||
None => Err(other("no folder to show in the file manager")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file-manager invocation that selects `path` on this platform.
|
||||
pub fn reveal_command(path: &str) -> (&'static str, Vec<String>) {
|
||||
if cfg!(target_os = "macos") {
|
||||
("open", vec!["-R".into(), path.into()])
|
||||
} else if cfg!(target_os = "windows") {
|
||||
("explorer", vec![format!("/select,{path}")])
|
||||
} else {
|
||||
let parent = std::path::Path::new(path).parent().map_or_else(|| ".".to_string(), |p| p.to_string_lossy().into_owned());
|
||||
("xdg-open", vec![parent])
|
||||
pub fn reveal_command(path: &str) -> Result<(&'static str, Vec<String>)> {
|
||||
reveal_command_on(std::env::consts::OS, path)
|
||||
}
|
||||
|
||||
/// [`reveal_command`] on the platform `os` (a `std::env::consts::OS` value).
|
||||
fn reveal_command_on(os: &str, path: &str) -> Result<(&'static str, Vec<String>)> {
|
||||
match os {
|
||||
// `--` ends the options: a path starting with `-` is not read as one.
|
||||
"macos" => Ok(("open", vec!["-R".into(), "--".into(), path.into()])),
|
||||
// Explorer splits `/select,` arguments at commas and reads quotes itself, so such a path
|
||||
// would select something else.
|
||||
"windows" if path.contains([',', '"']) => Err(other(format!("{path} can't be shown in Explorer: its path contains a comma or a quotation mark"))),
|
||||
"windows" => Ok(("explorer", vec![format!("/select,{path}")])),
|
||||
_ => {
|
||||
let parent = std::path::Path::new(path).parent().map_or_else(|| ".".to_string(), |p| p.to_string_lossy().into_owned());
|
||||
Ok(("xdg-open", vec![parent]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -465,16 +465,13 @@ fn stack_mode_rejects_sources_without_visible_layers() {
|
||||
|
||||
#[test]
|
||||
fn reveal_in_finder_dry_run_and_enabled() {
|
||||
let link = std::env::temp_dir().join("pics").join("a.png").to_string_lossy().into_owned();
|
||||
let mut s = session(8, "rgb");
|
||||
assert!(!s.is_enabled("layer.smartObjects.revealInFinder"));
|
||||
s.edit("linked", |doc, active| {
|
||||
let mut l = Layer::new(
|
||||
"linked",
|
||||
LayerContent::Smart(photocraft_doc::SmartObject::new(
|
||||
SmartSource::Linked { path: "/tmp/pics/a.png".into() },
|
||||
photocraft_geom::Affine::IDENTITY,
|
||||
None,
|
||||
)),
|
||||
LayerContent::Smart(photocraft_doc::SmartObject::new(SmartSource::Linked { path: link.clone() }, photocraft_geom::Affine::IDENTITY, None)),
|
||||
);
|
||||
l.visible = true;
|
||||
*active = Some(doc.insert_above(*active, l));
|
||||
@@ -483,11 +480,61 @@ fn reveal_in_finder_dry_run_and_enabled() {
|
||||
.unwrap();
|
||||
assert!(s.is_enabled("layer.smartObjects.revealInFinder"));
|
||||
let r = s.execute("layer.smartObjects.revealInFinder", json!({"dryRun": true})).unwrap();
|
||||
let (prog, args) = reveal_command("/tmp/pics/a.png");
|
||||
let (prog, args) = reveal_command(&link).unwrap();
|
||||
assert_eq!(r["program"], json!(prog));
|
||||
assert_eq!(r["args"], json!(args));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_follows_linked_paths_like_the_link_and_passes_paths_as_paths() {
|
||||
let mut s = session(8, "rgb");
|
||||
let folder = std::env::temp_dir().join("pics");
|
||||
s.active_mut().unwrap().path = Some(folder.join("doc.pcraft").to_string_lossy().into_owned());
|
||||
let mut link = |path: &str| {
|
||||
s.edit("linked", |doc, active| {
|
||||
let so = photocraft_doc::SmartObject::new(SmartSource::Linked { path: path.into() }, photocraft_geom::Affine::IDENTITY, None);
|
||||
*active = Some(doc.insert_above(*active, Layer::new("linked", LayerContent::Smart(so))));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("layer.smartObjects.revealInFinder", json!({"dryRun": true})).map_err(|e| e.to_string())
|
||||
};
|
||||
let err = link(r"\\192.0.2.1\share\x.png").unwrap_err();
|
||||
assert!(err.contains("is not opened: the path is on another computer"), "{err}");
|
||||
// A relative link is shown next to the document.
|
||||
let r = link("a.png").unwrap();
|
||||
assert_eq!(r["args"], json!(reveal_command(&folder.join("a.png").to_string_lossy()).unwrap().1));
|
||||
// The document's own file is shown wherever it is, a network share included.
|
||||
s.active_mut().unwrap().path = Some(r"\\192.0.2.1\share\doc.psd".into());
|
||||
s.execute("file.revealInFinder", json!({"dryRun": true})).unwrap();
|
||||
|
||||
assert_eq!(reveal_command_on("macos", "-a.png").unwrap(), ("open", vec!["-R".to_string(), "--".into(), "-a.png".into()]));
|
||||
assert_eq!(reveal_command_on("windows", r"C:\pics\a.png").unwrap(), ("explorer", vec![r"/select,C:\pics\a.png".to_string()]));
|
||||
for path in [r"C:\pics\a,b.png", r#"C:\pics\a".png"#] {
|
||||
assert!(reveal_command_on("windows", path).is_err(), "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `xdg-open` launches a file it is given, so Reveal hands it the link's folder only when that is a
|
||||
/// folder: a link stored as `tool.jar/a.png` names a file, not a folder, as its parent.
|
||||
#[test]
|
||||
fn reveal_on_linux_never_hands_xdg_open_a_file() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-reveal-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("tool.jar");
|
||||
std::fs::write(&file, b"not a folder").unwrap();
|
||||
let (program, args) = reveal_command_on("linux", &file.join("a.png").to_string_lossy()).unwrap();
|
||||
assert_eq!(args, vec![file.to_string_lossy().into_owned()]);
|
||||
let err = ensure_folder(program, &args).unwrap_err();
|
||||
assert!(err.to_string().contains("is not a folder"), "{err}");
|
||||
let (program, args) = reveal_command_on("linux", &dir.join("a.png").to_string_lossy()).unwrap();
|
||||
ensure_folder(program, &args).unwrap();
|
||||
assert!(ensure_folder("xdg-open", &[]).is_err());
|
||||
// Finder and Explorer select the file itself, whatever it is.
|
||||
ensure_folder("open", &["-R".into(), "--".into(), file.to_string_lossy().into_owned()]).unwrap();
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_all_objects_masks_the_subject() {
|
||||
let mut s = session(8, "rgb");
|
||||
|
||||
@@ -147,6 +147,9 @@ pub fn raw_params(cmd: &str, p: &Value) -> Result<CameraRaw> {
|
||||
cr.noise_luminance_detail = cr.noise_luminance_detail.clamp(0.0, 100.0);
|
||||
cr.noise_color = cr.noise_color.clamp(0.0, 100.0);
|
||||
cr.noise_color_detail = cr.noise_color_detail.clamp(0.0, 100.0);
|
||||
// `exposure` becomes `2^exposure`, which overflows f32 to infinity from 128 up (and then
|
||||
// writes inf/NaN into 32-bit layers). Clamp to the documented -5..5.
|
||||
cr.exposure = cr.exposure.clamp(-5.0, 5.0);
|
||||
Ok(cr)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The Photocraft engine façade: open documents, history, and the command registry.
|
||||
//! The PhotoCraft engine façade: open documents, history, and the command registry.
|
||||
//!
|
||||
//! Every user-visible action is a command with a stable id (Photoshop-style, such as
|
||||
//! `layer.newAdjustmentLayer.invert`) and JSON parameters. Every frontend goes through the same
|
||||
@@ -34,6 +34,7 @@ mod crop_fill;
|
||||
pub mod cutout_cmds;
|
||||
pub mod display_color;
|
||||
pub mod distort_cmds;
|
||||
pub mod doc_path;
|
||||
pub mod document_preset_cmds;
|
||||
pub mod edit_cmds;
|
||||
pub mod edit_menu_cmds;
|
||||
@@ -41,6 +42,7 @@ pub mod eraser_cmds;
|
||||
pub mod export_preset_cmds;
|
||||
pub mod exr_cmds;
|
||||
pub mod extra_cmds;
|
||||
pub mod face_cmds;
|
||||
pub mod file_cmds;
|
||||
pub mod fill_cmds;
|
||||
pub mod fill_key_cmds;
|
||||
@@ -155,6 +157,10 @@ pub enum EngineError {
|
||||
NoDocument,
|
||||
#[error("no such layer {0:?}")]
|
||||
NoLayer(LayerId),
|
||||
/// [`Self::NoLayer`] as a command reports it: with the layers the document does have, as
|
||||
/// ids are not positions (#5058).
|
||||
#[error("no such layer {}; {known}", id.0)]
|
||||
UnknownLayer { id: LayerId, known: String },
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
/// A background job was cancelled (see [`jobs`]); nothing changed.
|
||||
@@ -480,6 +486,9 @@ pub struct Session {
|
||||
/// the same check a top-level request sees. `None` runs everything, which is what a local
|
||||
/// UI and `photocraft-cli run` do.
|
||||
pub authorize: Option<fn(&str, &serde_json::Value) -> Result<()>>,
|
||||
/// A further route for File › Print when neither `lp` nor a CUPS server over IPP is
|
||||
/// reachable (the XDG Print portal in a Flatpak); see `print_cmds::Spooler`.
|
||||
pub print_spooler: Option<print_cmds::Spooler>,
|
||||
/// Background jobs (see [`jobs`]).
|
||||
jobs: jobs::Jobs,
|
||||
/// Color Sampler points dropped by the last canvas-size change (crop, Canvas Size, Image Size,
|
||||
@@ -782,6 +791,7 @@ impl Session {
|
||||
// Every mutation path (linked-file refresh, transform, place) re-renders smart objects
|
||||
// through this entry; the requesting session's authority must reach those renders.
|
||||
let _authorization = crate::jobs::render_authorization_scope(self.authorize);
|
||||
let _document = crate::doc_path::scope(self.active().and_then(|d| d.path.as_deref()).map(std::path::Path::new));
|
||||
let restrict = self.color_restrict;
|
||||
let sampler_map = self.sampler_map.take();
|
||||
let st = self.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
//! keeps waiting for Update Modified Content, as in Photoshop); one that can't be read (another app
|
||||
//! is still writing it) is tried again once it changes again, not on every look. Only smart objects
|
||||
//! of the document itself follow their files, not ones nested in another smart object's contents.
|
||||
//! While a background job runs on the document, its files aren't looked at: an update would replace
|
||||
//! the snapshot the job works on and its result would be thrown away. The first look after the job
|
||||
//! ends catches up.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
@@ -32,15 +35,17 @@ pub type Stamp = (u64, u128);
|
||||
/// ids, stamp)]).
|
||||
pub type LinkScan = Arc<Mutex<Option<(u64, Vec<(String, Vec<u64>, Option<Stamp>)>)>>>;
|
||||
|
||||
/// Stamps `file`, a linked path resolved by [`crate::doc_path`]; `None` (not looked at, like a
|
||||
/// missing file) when it is not followed.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn stamp(path: &str) -> Option<Stamp> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
fn stamp(file: Option<&std::path::Path>) -> Option<Stamp> {
|
||||
let meta = std::fs::metadata(file?).ok()?;
|
||||
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
|
||||
Some((meta.len(), modified.as_nanos()))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn stamp(_: &str) -> Option<Stamp> {
|
||||
fn stamp(_: Option<&std::path::Path>) -> Option<Stamp> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -61,11 +66,24 @@ pub fn linked_files(d: &Document) -> Vec<(String, Vec<u64>)> {
|
||||
/// the layers of the ones that changed.
|
||||
pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let (doc, files) = (st.doc.id.0, linked_files(&st.doc));
|
||||
let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
|
||||
let (doc, files) = (st.doc.id.0, resolved_files(st));
|
||||
let now = files.into_iter().map(|(p, file, ids)| (p, ids, stamp(file.as_deref()))).collect();
|
||||
s.apply_link_stamps(doc, now)
|
||||
}
|
||||
|
||||
/// [`linked_files`] of `st`'s document with the file each path resolves to, or `None` for a path
|
||||
/// that is not followed ([`crate::doc_path`]).
|
||||
fn resolved_files(st: &crate::DocState) -> Vec<(String, Option<std::path::PathBuf>, Vec<u64>)> {
|
||||
let document = st.path.as_deref().map(std::path::Path::new);
|
||||
linked_files(&st.doc)
|
||||
.into_iter()
|
||||
.map(|(p, ids)| {
|
||||
let file = crate::doc_path::resolve_in(&p, document).ok();
|
||||
(p, file, ids)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Start stamping the active document's linked files on a worker thread, unless a look is
|
||||
/// still running (or the build has no files to look at: the web app).
|
||||
@@ -74,14 +92,15 @@ impl Session {
|
||||
return;
|
||||
}
|
||||
let Some(st) = self.active() else { return };
|
||||
let (doc, files) = (st.doc.id.0, linked_files(&st.doc));
|
||||
if files.is_empty() {
|
||||
let (doc, files) = (st.doc.id.0, resolved_files(st));
|
||||
// Nothing to look at: no links, or none that is followed.
|
||||
if files.iter().all(|(_, file, _)| file.is_none()) {
|
||||
return;
|
||||
}
|
||||
let slot: LinkScan = Arc::default();
|
||||
let out = slot.clone();
|
||||
let spawned = std::thread::Builder::new().name("link-watch".into()).spawn(move || {
|
||||
let stamps = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
|
||||
let stamps = files.into_iter().map(|(p, file, ids)| (p, ids, stamp(file.as_deref()))).collect();
|
||||
*out.lock().unwrap_or_else(PoisonError::into_inner) = Some((doc, stamps));
|
||||
});
|
||||
if spawned.is_ok() {
|
||||
@@ -103,6 +122,12 @@ impl Session {
|
||||
if self.active().map(|d| d.doc.id.0) != Some(doc) {
|
||||
return Ok(json!({"updated": []}));
|
||||
}
|
||||
// A background job holds the document: refreshing now would replace the snapshot it works
|
||||
// on and its result would be discarded. The stamps stay as they were, so the change is
|
||||
// seen again by the first look after the job ends.
|
||||
if self.active_job().is_some() {
|
||||
return Ok(json!({"updated": []}));
|
||||
}
|
||||
// This document's last stamps; those of closed documents are dropped.
|
||||
let open: HashSet<u64> = self.documents().iter().map(|d| d.doc.id.0).collect();
|
||||
let mut before: HashMap<String, Stamp> = HashMap::new();
|
||||
@@ -155,7 +180,7 @@ mod tests {
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn dir(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("photocraft-link-watch-{name}-{}", std::process::id()));
|
||||
@@ -280,4 +305,53 @@ mod tests {
|
||||
assert!(is(centre(&s), BLUE));
|
||||
assert!(s.link_scan.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refresh_waits_for_a_running_job_on_the_document() {
|
||||
let d = dir("job");
|
||||
let path = d.join("art.png");
|
||||
std::fs::write(&path, png(10, 10, "#ff0000")).unwrap();
|
||||
let (mut s, id) = placed(&path);
|
||||
s.execute("layer.smartObjects.updateChanged", json!({})).unwrap();
|
||||
let (release, job) = gated_job(&mut s);
|
||||
save(&path, &png(10, 10, "#0000ff"));
|
||||
let snapshot = s.active().unwrap().doc.clone();
|
||||
// The path the desktop app's timer takes: it doesn't go through the command conflict check.
|
||||
assert_eq!(super::update_changed(&mut s, &json!({})).unwrap(), json!({"updated": []}));
|
||||
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &snapshot), "the job's snapshot must stay in place");
|
||||
release.send(()).unwrap();
|
||||
s.wait_job(job).unwrap();
|
||||
// The job finished; the change that came meanwhile is picked up exactly once.
|
||||
assert_eq!(s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(), json!({"updated": [id]}));
|
||||
assert!(is(centre(&s), BLUE));
|
||||
assert_eq!(s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(), json!({"updated": []}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_job_on_another_document_does_not_hold_up_a_refresh() {
|
||||
let d = dir("job-other");
|
||||
let path = d.join("art.png");
|
||||
std::fs::write(&path, png(10, 10, "#ff0000")).unwrap();
|
||||
let (mut s, id) = placed(&path);
|
||||
s.execute("layer.smartObjects.updateChanged", json!({})).unwrap();
|
||||
s.execute("file.new", json!({"width": 10, "height": 10})).unwrap();
|
||||
let (release, job) = gated_job(&mut s);
|
||||
s.set_active(0);
|
||||
save(&path, &png(10, 10, "#0000ff"));
|
||||
assert_eq!(super::update_changed(&mut s, &json!({})).unwrap(), json!({"updated": [id]}));
|
||||
assert!(is(centre(&s), BLUE));
|
||||
release.send(()).unwrap();
|
||||
s.wait_job(job).unwrap();
|
||||
}
|
||||
|
||||
/// A background job that locks the active document until something is sent on the returned
|
||||
/// sender.
|
||||
fn gated_job(s: &mut Session) -> (std::sync::mpsc::Sender<()>, crate::jobs::JobId) {
|
||||
let (release, gate) = std::sync::mpsc::channel::<()>();
|
||||
let r = s.start_job("test.gated", json!({}), "Gated job", true, move |_| gate.recv().map_err(|_| EngineError::Cancelled), |_, ()| Ok(json!(null)));
|
||||
match r.unwrap() {
|
||||
crate::jobs::Started::Job(j) => (release, j),
|
||||
other => panic!("expected a background job, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -444,6 +444,10 @@ fn parse_inks(cmd: &str, v: &Value, n: usize) -> Result<Vec<DuotoneInk>> {
|
||||
if let Some(pts) = ink.get("curve").and_then(Value::as_array) {
|
||||
let mut curve: Vec<CurvePoint> =
|
||||
pts.iter().filter_map(|p| Some(CurvePoint { input: p.get(0)?.as_f64()? as f32 / 100.0, output: p.get(1)?.as_f64()? as f32 / 100.0 })).collect();
|
||||
// 1e39 is a valid JSON number but inf as f32, and a .pcraft with an inf curve can't be saved.
|
||||
if curve.iter().any(|p| !p.input.is_finite() || !p.output.is_finite()) {
|
||||
return Err(bad(cmd, "curve points must be finite numbers 0..100"));
|
||||
}
|
||||
curve.sort_by(|a, b| a.input.total_cmp(&b.input));
|
||||
if curve.len() >= 2 {
|
||||
d.curve = curve;
|
||||
|
||||
@@ -353,6 +353,20 @@ fn duotone_inks_and_display() {
|
||||
assert!(!session(4, 4, 8, "rgb").is_enabled("image.mode.duotone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duotone_curve_points_must_be_finite() {
|
||||
let mut s = session(4, 4, 8, "gray");
|
||||
// 1e39 overflows f32 to inf, which the .pcraft writer refuses to save.
|
||||
for curve in [json!([[0, 0], [100, 1e39]]), json!([[0, 0], [1e39, 50]]), json!([[-1e39, 0], [100, 100]])] {
|
||||
let r = s.execute("image.mode.duotone", json!({"type": "monotone", "inks": [{"curve": curve}]}));
|
||||
assert!(r.is_err(), "{curve} accepted");
|
||||
assert_eq!(doc(&s).mode, ColorMode::Grayscale);
|
||||
}
|
||||
s.execute("image.mode.duotone", json!({"type": "monotone", "inks": [{"curve": [[0, 0], [50, 70], [100, 100]]}]})).unwrap();
|
||||
assert_eq!(doc(&s).mode, ColorMode::Duotone);
|
||||
photocraft_format::save_to_bytes(doc(&s), &Default::default()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_round_trip_through_pcraft() {
|
||||
let mut s = session(12, 12, 8, "rgb");
|
||||
|
||||
@@ -106,6 +106,9 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
n.position = [nx, ny];
|
||||
// The popup travels with its icon.
|
||||
n.popup = [n.popup[0] + dx, n.popup[1] + dy, n.popup[2] + dx, n.popup[3] + dy];
|
||||
if !n.popup.iter().all(|v| v.is_finite()) {
|
||||
return Err(bad(CMD, "the move is too far: the note popup would leave the representable range"));
|
||||
}
|
||||
}
|
||||
if n == d.notes[i] {
|
||||
return Ok(json!({"index": i, "note": note_json(i, &n), "changed": false}));
|
||||
@@ -279,6 +282,19 @@ mod tests {
|
||||
assert!(s.execute("notes.set", json!({"index": 0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_move_is_rejected_and_document_stays_saveable() {
|
||||
let mut s = session();
|
||||
s.execute("notes.add", json!({"x": 1.7e308, "y": 0})).unwrap();
|
||||
let before = s.active().unwrap().doc.notes[0].clone();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
assert!(s.execute("notes.set", json!({"index": 0, "x": -1.7e308})).is_err());
|
||||
let a = s.active().unwrap();
|
||||
assert_eq!(a.doc.notes[0], before);
|
||||
assert_eq!(a.history.past_len(), past);
|
||||
assert!(photocraft_format::save_to_bytes(&a.doc, &Default::default()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -168,10 +168,29 @@ impl PresetState {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Restore presets saved by [`PresetState::to_json`]. Missing sets keep their defaults; a
|
||||
/// malformed blob is ignored (presets are never worth refusing to start over).
|
||||
/// Restore presets saved by [`PresetState::to_json`]. Missing or malformed sets keep their
|
||||
/// defaults (presets are never worth refusing to start over). Each set loads on its own: the
|
||||
/// shell writes back only the keys that changed, so one damaged set would otherwise discard
|
||||
/// every other set, saved document presets included, at each launch (#5602).
|
||||
pub fn load_presets_json(&mut self, v: Value) {
|
||||
let Ok(p) = serde_json::from_value::<Persisted>(v) else { return };
|
||||
let Value::Object(mut m) = v else { return };
|
||||
fn take<T: serde::de::DeserializeOwned>(m: &mut serde_json::Map<String, Value>, key: &str) -> Option<T> {
|
||||
m.remove(key).filter(|v| !v.is_null()).and_then(|v| serde_json::from_value(v).ok())
|
||||
}
|
||||
let p = Persisted {
|
||||
gradients: take(&mut m, "gradients"),
|
||||
gradient: take(&mut m, "gradient"),
|
||||
styles: take(&mut m, "styles"),
|
||||
shapes: take(&mut m, "shapes"),
|
||||
pattern_groups: take(&mut m, "pattern_groups"),
|
||||
tool_presets: take(&mut m, "tool_presets"),
|
||||
documents: take(&mut m, "documents"),
|
||||
recent_documents: take(&mut m, "recent_documents"),
|
||||
export_presets: take(&mut m, "export_presets"),
|
||||
custom_shapes: take(&mut m, "custom_shapes"),
|
||||
layer_defaults: take(&mut m, "layer_defaults"),
|
||||
swatches: take(&mut m, "swatches"),
|
||||
};
|
||||
let st = &mut self.presets;
|
||||
if let Some(g) = p.gradients {
|
||||
st.gradients = g;
|
||||
|
||||
@@ -748,3 +748,29 @@ fn clone_source_errors_and_overlay() {
|
||||
assert_eq!(v["width"], json!(1000.0));
|
||||
assert_eq!(v["rotation"], json!(-90.0));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ persistence
|
||||
|
||||
/// #5602: one damaged preset set in the preferences file must not discard the saved New
|
||||
/// Document presets (or any other set) at the next launch.
|
||||
#[test]
|
||||
fn a_damaged_preset_set_keeps_the_other_sets() {
|
||||
let mut s = Session::new();
|
||||
let settings = json!({"width": 1600, "height": 900, "resolution": 144});
|
||||
s.execute("document.presets.save", json!({"name": "Banner", "settings": settings})).unwrap();
|
||||
let saved = s.presets.to_json(&s);
|
||||
for key in ["gradients", "gradient", "styles", "shapes", "pattern_groups", "custom_shapes", "layer_defaults", "swatches"] {
|
||||
let mut blob = saved.clone();
|
||||
blob[key] = json!("not a preset set");
|
||||
let mut next = Session::new();
|
||||
next.load_presets_json(blob);
|
||||
assert_eq!(next.presets.documents, s.presets.documents, "damaged `{key}`");
|
||||
assert_eq!(next.presets.tool_presets.len(), Session::new().presets.tool_presets.len(), "damaged `{key}`");
|
||||
}
|
||||
// Null sets keep their defaults, as before.
|
||||
let mut next = Session::new();
|
||||
next.load_presets_json(json!({"tool_presets": null, "gradients": null, "documents": saved["documents"].clone()}));
|
||||
assert_eq!(next.presets.tool_presets.len(), Session::new().presets.tool_presets.len());
|
||||
assert_eq!(next.presets.gradients.len(), Session::new().presets.gradients.len());
|
||||
assert_eq!(next.presets.documents, s.presets.documents);
|
||||
}
|
||||
+232
-19
@@ -8,6 +8,12 @@
|
||||
//! `"dryRun": true` reports the backend command without spooling; a PDF is kept only at an
|
||||
//! explicit output path. Shared spooler transport files are private and automatically removed.
|
||||
//!
|
||||
//! Where `lp` is not on the path (a Flatpak has none), the job goes to CUPS over IPP in-process
|
||||
//! (`photocraft-ipp`, through the sandbox's `--socket=cups`), and when no print server or no
|
||||
//! printer is reachable, to the app's spooler, the XDG Print portal (`Session::print_spooler`;
|
||||
//! `PHOTOCRAFT_PRINT_PORTAL=1` prefers it, `=0` removes it). `"printServer"` names a server for
|
||||
//! one job and always takes the IPP route; `file.printers` lists the queues (#2728).
|
||||
//!
|
||||
//! Paths to Illustrator writes the document's paths as an Adobe Illustrator 3 compatible
|
||||
//! PostScript file (`m`/`L`/`C` path construction, `n`/`N` unpainted closed/open paths, `*u`/`*U`
|
||||
//! compound paths), following the public PostScript Language Reference and the AI3 format notes.
|
||||
@@ -31,6 +37,191 @@ fn other(e: impl std::fmt::Display) -> EngineError {
|
||||
EngineError::Other(e.to_string())
|
||||
}
|
||||
|
||||
// ---------- Spooling ----------
|
||||
|
||||
/// A rendered page on its way to the printer: what the IPP route and the app's spooler need.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PrintJob {
|
||||
pub pdf: Vec<u8>,
|
||||
/// The printer asked for; `None` means the default.
|
||||
pub printer: Option<String>,
|
||||
pub copies: u32,
|
||||
/// The queue entry's name: the document's.
|
||||
pub title: String,
|
||||
/// A print server named in the request (`printServer`), else the system's.
|
||||
pub server: Option<String>,
|
||||
/// The page size in points, as laid out (landscape already applied).
|
||||
pub paper: (f64, f64),
|
||||
pub landscape: bool,
|
||||
}
|
||||
|
||||
/// A spooler's work: the job in, where it went out.
|
||||
pub type SpoolFn = std::sync::Arc<dyn Fn(&PrintJob) -> std::result::Result<String, String> + Send + Sync>;
|
||||
|
||||
/// A further route to the printer, installed by the app on `Session::print_spooler`: the XDG
|
||||
/// Print portal, which shows the system print dialog from inside a sandbox. It takes the jobs
|
||||
/// no other route can (no print server reachable, no printer set up), or every job when
|
||||
/// `preferred`.
|
||||
#[derive(Clone)]
|
||||
pub struct Spooler {
|
||||
/// Reported as the job's `backend`.
|
||||
pub name: &'static str,
|
||||
pub preferred: bool,
|
||||
/// Returns where the job went ("the system print dialog").
|
||||
pub run: SpoolFn,
|
||||
}
|
||||
|
||||
/// Why the IPP route could not spool: no route at all (the app's spooler may have one), or a
|
||||
/// failure on a route that exists (a wrong printer name, a rejected job).
|
||||
enum SpoolError {
|
||||
NoRoute(String),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Sends the job and says where it went and through what. A named print server takes the IPP
|
||||
/// route. Otherwise: the app's spooler when it is preferred; else `craft-print` (`lp`, or the
|
||||
/// Windows spooler) when it is there; else CUPS over IPP; and the app's spooler when that had
|
||||
/// no route either. A route that exists but fails (an unknown printer, a refused job) is an
|
||||
/// error, never a fall-through.
|
||||
fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -> Result<(String, String)> {
|
||||
let app = |sp: &Spooler| (sp.run)(job).map(|m| (m, sp.name.to_string())).map_err(other);
|
||||
if job.server.is_none() {
|
||||
if let Some(sp) = spooler.filter(|sp| sp.preferred) {
|
||||
return app(sp);
|
||||
}
|
||||
if let Some(name) = system_spooler() {
|
||||
return craft_print::submit(&job.pdf, lp).map(|m| (m, name.to_string())).map_err(other);
|
||||
}
|
||||
}
|
||||
match cups_spool(job) {
|
||||
Ok(m) => Ok((m, "ipp".into())),
|
||||
Err(SpoolError::NoRoute(why)) => match spooler {
|
||||
Some(sp) => app(sp),
|
||||
None => Err(other(why)),
|
||||
},
|
||||
Err(SpoolError::Failed(why)) => Err(other(why)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The spooler `craft-print` can talk to, as the job's `backend` name: `lp` when CUPS' command
|
||||
/// is on the path, the Windows spooler on Windows. `None` in a sandbox without `lp` and in the
|
||||
/// browser.
|
||||
fn system_spooler() -> Option<&'static str> {
|
||||
match craft_print::backend()? {
|
||||
craft_print::Backend::WindowsPdf => Some("windows"),
|
||||
craft_print::Backend::Cups => {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
std::env::split_paths(&path).any(|dir| dir.join("lp").is_file()).then_some("lp")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The printer a job goes to: the one asked for when the server knows it, else the default,
|
||||
/// else the only one; otherwise an error that names what there is.
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
|
||||
fn resolve_printer(requested: Option<&str>, default: Option<&str>, printers: &[photocraft_ipp::Printer]) -> std::result::Result<String, SpoolError> {
|
||||
let names: Vec<&str> = printers.iter().map(|p| p.name.as_str()).collect();
|
||||
let known = |n: &str| names.iter().find(|k| k.eq_ignore_ascii_case(n)).map(|k| (*k).to_string());
|
||||
if let Some(r) = requested {
|
||||
let choices = if names.is_empty() { "none".to_string() } else { names.join(", ") };
|
||||
return known(r).ok_or_else(|| SpoolError::Failed(format!("no printer called `{r}`; the print server knows: {choices}")));
|
||||
}
|
||||
if printers.is_empty() {
|
||||
return Err(SpoolError::NoRoute("no printer is set up on this computer; add one in the system settings, or print to PDF".into()));
|
||||
}
|
||||
if let Some(d) = default.and_then(known) {
|
||||
return Ok(d);
|
||||
}
|
||||
match names.as_slice() {
|
||||
[only] => Ok((*only).to_string()),
|
||||
_ => Err(SpoolError::Failed(format!("no default printer; choose one in the Printer field: {}", names.join(", ")))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
|
||||
fn cups_server(named: Option<&str>) -> std::result::Result<photocraft_ipp::Endpoint, SpoolError> {
|
||||
match named {
|
||||
Some(server) => {
|
||||
let ep = photocraft_ipp::Endpoint::parse(server);
|
||||
if !ep.reachable() {
|
||||
return Err(SpoolError::Failed(format!("no print server at {ep}")));
|
||||
}
|
||||
Ok(ep)
|
||||
}
|
||||
None => photocraft_ipp::Endpoint::detect()
|
||||
.ok_or_else(|| SpoolError::NoRoute("no print server: CUPS is not running, or not reachable from this sandbox; print to PDF instead".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The IPP route: the job to the system's (or the named) CUPS server.
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
|
||||
fn cups_spool(job: &PrintJob) -> std::result::Result<String, SpoolError> {
|
||||
let failed = |e: photocraft_ipp::Error| SpoolError::Failed(e.to_string());
|
||||
let ep = cups_server(job.server.as_deref())?;
|
||||
let printers = photocraft_ipp::printers(&ep).map_err(failed)?;
|
||||
let default = printers.iter().find(|p| p.is_default).map(|p| p.name.clone());
|
||||
let printer = resolve_printer(job.printer.as_deref(), default.as_deref(), &printers)?;
|
||||
let is_class = printers.iter().any(|p| p.name == printer && p.is_class);
|
||||
let user = std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_else(|_| "photocraft".into());
|
||||
let id = photocraft_ipp::print_job(
|
||||
&ep,
|
||||
&photocraft_ipp::Job { printer: &printer, is_class, title: &job.title, user: &user, copies: job.copies, document: &job.pdf, format: "application/pdf" },
|
||||
)
|
||||
.map_err(failed)?;
|
||||
Ok(format!("job {id} on {printer}"))
|
||||
}
|
||||
|
||||
/// No IPP route here: a named server is a failure, the system's a missing route (the app's
|
||||
/// spooler may have one).
|
||||
#[cfg(any(target_arch = "wasm32", target_os = "windows"))]
|
||||
fn cups_spool(job: &PrintJob) -> std::result::Result<String, SpoolError> {
|
||||
let why = if cfg!(target_arch = "wasm32") {
|
||||
"printing needs the desktop app"
|
||||
} else {
|
||||
"a print server over IPP is not available on Windows yet; print to PDF instead"
|
||||
};
|
||||
match job.server {
|
||||
Some(_) => Err(SpoolError::Failed(why.into())),
|
||||
None => Err(SpoolError::NoRoute(why.into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// `file.printers`: the printers the system's (or `printServer`'s) print server knows, with the default. From
|
||||
/// `craft-print` (`lpstat`, the Windows spooler) where that is the route; over IPP for a named
|
||||
/// server and where there is no `lp` (a sandbox).
|
||||
fn system_printers(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let named = p.get("printServer").and_then(Value::as_str).filter(|v| !v.is_empty());
|
||||
if named.is_none() && system_spooler().is_some() {
|
||||
let list = craft_print::printers();
|
||||
let default = list.iter().find(|p| p.default).map(|p| p.name.clone());
|
||||
let printers: Vec<Value> = list.into_iter().map(|p| json!({"name": p.name, "default": p.default})).collect();
|
||||
return Ok(json!({"server": Value::Null, "default": default, "printers": printers}));
|
||||
}
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
|
||||
{
|
||||
let ep = match cups_server(named) {
|
||||
Ok(ep) => ep,
|
||||
Err(SpoolError::NoRoute(why) | SpoolError::Failed(why)) => {
|
||||
return Ok(json!({"server": Value::Null, "default": Value::Null, "printers": [], "note": why}));
|
||||
}
|
||||
};
|
||||
let list = photocraft_ipp::printers(&ep).map_err(other)?;
|
||||
let default = list.iter().find(|p| p.is_default).map(|p| p.name.clone());
|
||||
let printers: Vec<Value> = list
|
||||
.iter()
|
||||
.map(|p| {
|
||||
json!({"name": p.name, "info": p.info, "state": match p.state { 4 => "printing", 5 => "stopped", _ => "idle" }, "acceptingJobs": p.accepting_jobs, "default": p.is_default, "class": p.is_class})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"server": ep.to_string(), "default": default, "printers": printers}))
|
||||
}
|
||||
#[cfg(not(all(not(target_arch = "wasm32"), not(target_os = "windows"))))]
|
||||
{
|
||||
let _ = named;
|
||||
Ok(json!({"server": Value::Null, "default": Value::Null, "printers": [], "note": "listing printers is not available on this platform yet"}))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- PDF ----------
|
||||
|
||||
/// Paper sizes in points (portrait).
|
||||
@@ -343,6 +534,7 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
let params = p.clone();
|
||||
let worker_params = params.clone();
|
||||
let command = cmd.to_string();
|
||||
let spooler = s.print_spooler.clone();
|
||||
if send && !dry {
|
||||
// Session::execute remains synchronous; the desktop's Session::start snapshots here
|
||||
// and runs PDF composition and the potentially slow driver on a worker.
|
||||
@@ -352,7 +544,7 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
false,
|
||||
move |ctx| {
|
||||
ctx.progress(0.0, "Print");
|
||||
render_and_spool(&doc, &worker_params, &command)
|
||||
render_and_spool(&doc, &worker_params, &command, spooler.as_ref())
|
||||
},
|
||||
move |s, result| {
|
||||
remember_print(s, ¶ms);
|
||||
@@ -370,13 +562,13 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
},
|
||||
);
|
||||
}
|
||||
let result = render_and_spool(&doc, p, cmd)?;
|
||||
let result = render_and_spool(&doc, p, cmd, spooler.as_ref())?;
|
||||
remember_print(s, p);
|
||||
crate::automate_cmds::fire_event(s, "print");
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result<Value> {
|
||||
fn render_and_spool(doc: &Document, p: &Value, cmd: &str, spooler: Option<&Spooler>) -> Result<Value> {
|
||||
let PrintLayout { paper: (pw, ph), rect: (x, y, w, h), scale, marks, emulsion_down } = layout(doc, p, cmd)?;
|
||||
let (img, color) = print_image(doc, p, cmd)?;
|
||||
let description = p
|
||||
@@ -394,7 +586,12 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result<Value> {
|
||||
// Sending follows the shared command contract; fuzz tests explicitly request dryRun.
|
||||
// The worker returns its result before repeat settings and hooks are applied once.
|
||||
let handling = p.get("colorHandling").and_then(Value::as_str).unwrap_or("printerManages");
|
||||
if cfg!(windows) && send && !dry && output.is_none() && native_rgb_compatible(&page, handling) {
|
||||
// A named print server always takes the IPP route and a preferred app spooler takes every
|
||||
// job (the route order in `spool_job`), on Windows too: neither may enter the native path
|
||||
// below, which would print on the local default printer instead.
|
||||
let named_server = p.get("printServer").and_then(Value::as_str).is_some_and(|v| !v.is_empty());
|
||||
let skip_native = named_server || spooler.is_some_and(|sp| sp.preferred);
|
||||
if cfg!(windows) && !skip_native && send && !dry && output.is_none() && native_rgb_compatible(&page, handling) {
|
||||
require_printing_enabled()?;
|
||||
let printed = print_windows(&page, p, &doc.name, copies)?;
|
||||
return Ok(json!({
|
||||
@@ -402,10 +599,11 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result<Value> {
|
||||
"copies": copies, "command": Value::Null, "sent": true, "spooler": printed, "color": color,
|
||||
}));
|
||||
}
|
||||
if cfg!(windows) && send && !dry && output.is_none() && printer_settings(p)?.is_some() {
|
||||
if cfg!(windows) && !skip_native && send && !dry && output.is_none() && printer_settings(p)?.is_some() {
|
||||
return Err(other("the selected color profile or page text requires PDF printing. Save as PDF, or clear Print Settings to use PDF printing."));
|
||||
}
|
||||
let pdf = print_pdf(&page);
|
||||
let pdf_len = pdf.len();
|
||||
let job = craft_print::Job {
|
||||
printer: p.get("printer").and_then(Value::as_str).filter(|v| !v.is_empty()).map(str::to_string),
|
||||
copies,
|
||||
@@ -425,13 +623,25 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result<Value> {
|
||||
};
|
||||
let mut sent = false;
|
||||
let mut spool = Value::Null;
|
||||
let mut backend = Value::Null;
|
||||
if send && !dry {
|
||||
require_printing_enabled()?;
|
||||
spool = json!(craft_print::submit(&pdf, &job).map_err(other)?);
|
||||
let print_job = PrintJob {
|
||||
printer: job.printer.clone(),
|
||||
copies,
|
||||
title: doc.name.clone(),
|
||||
server: p.get("printServer").and_then(Value::as_str).filter(|v| !v.is_empty()).map(str::to_string),
|
||||
paper: (pw, ph),
|
||||
landscape: p.get("orientation").and_then(Value::as_str) == Some("landscape"),
|
||||
pdf,
|
||||
};
|
||||
let (where_, via) = spool_job(spooler, &print_job, &job)?;
|
||||
spool = json!(where_);
|
||||
backend = json!(via);
|
||||
sent = true;
|
||||
}
|
||||
Ok(
|
||||
json!({"pdf": pdf_path, "bytes": pdf.len(), "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, "copies": copies, "command": command, "sent": sent, "spooler": spool, "color": color}),
|
||||
json!({"pdf": pdf_path, "bytes": pdf_len, "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, "copies": copies, "command": command, "sent": sent, "spooler": spool, "backend": backend, "color": color}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -573,15 +783,6 @@ fn require_printing_enabled() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The printers the system spooler knows (Print's Printer menu): CUPS queues and driverless
|
||||
/// destinations on macOS and Linux, `Win32_Printer` on Windows, none in the browser.
|
||||
fn system_printers(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
let list = craft_print::printers();
|
||||
let default = list.iter().find(|p| p.default).map(|p| p.name.clone());
|
||||
let printers: Vec<Value> = list.into_iter().map(|p| json!({"name": p.name, "default": p.default})).collect();
|
||||
Ok(json!({"printers": printers, "default": default}))
|
||||
}
|
||||
|
||||
fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
do_print(s, p, "file.print")
|
||||
}
|
||||
@@ -602,8 +803,15 @@ fn print_one_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.package";
|
||||
let dir = p.get("dir").and_then(Value::as_str).filter(|v| !v.is_empty()).ok_or_else(|| bad(cmd, "missing \"dir\" (where to create the package folder)"))?;
|
||||
// The copies are relinked by absolute path: a relative one would be resolved against the
|
||||
// package document's folder, not the working directory `dir` is relative to.
|
||||
// `folder`, `document` and `links` are built on it with `join`, so it is spelt the same way:
|
||||
// `/` throughout, not a native prefix with `/` after it.
|
||||
let dir = std::path::absolute(dir).map_or_else(|_| dir.to_string(), |d| d.to_string_lossy().into_owned());
|
||||
let dir = crate::file_cmds::forward_slashes(dir);
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let doc = (*d.doc).clone();
|
||||
let document = d.path.clone();
|
||||
let name = stem(&d.path.clone().unwrap_or_else(|| doc.name.clone()));
|
||||
// The native format keeps external links; our PSD writer has no `lnkE` (external linked
|
||||
// file) support yet, so a PSD package would embed or flatten them.
|
||||
@@ -612,7 +820,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(f) => return Err(bad(cmd, format!("unknown package format `{f}` (pcraft|psd|psb)"))),
|
||||
None => "pcraft".into(),
|
||||
};
|
||||
let folder = join(dir, &crate::file_cmds::sanitize(&name));
|
||||
let folder = join(&dir, &crate::file_cmds::sanitize(&name));
|
||||
let links_dir = join(&folder, "Links");
|
||||
// Copy every linked file once; relink the copy of the document to the copies.
|
||||
let mut copied: Vec<(String, String)> = Vec::new();
|
||||
@@ -629,7 +837,12 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fname = format!("{}-{}", copied.len(), fname);
|
||||
}
|
||||
let t = join(&links_dir, &fname);
|
||||
match read_file(path) {
|
||||
// A path that is not followed is packaged as missing, like a file that is gone.
|
||||
let Ok(file) = crate::doc_path::resolve_in(path, document.as_deref().map(std::path::Path::new)) else {
|
||||
missing.push(path.clone());
|
||||
continue;
|
||||
};
|
||||
match read_file(&file.to_string_lossy()) {
|
||||
Ok(bytes) => {
|
||||
write_file(&t, &bytes)?;
|
||||
copied.push((path.clone(), t.clone()));
|
||||
@@ -750,7 +963,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run }
|
||||
};
|
||||
}
|
||||
const PRINT_PARAMS: &str = r##"{"printerSettings":hex? (Windows driver settings),"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"dryRun":bool=false (render the PDF, report the backend command, don't spool; write a PDF only at an explicit output path)} → {pdf:path|null (null without explicit output), imageRect, command:diagnostic array|null, sent}"##;
|
||||
const PRINT_PARAMS: &str = r##"{"printerSettings":hex? (Windows driver settings),"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"printServer":"host[:port]|socket path"? (default: the system's CUPS; a named server is always printed to over IPP),"dryRun":bool=false (render the PDF, report the backend command, don't spool; write a PDF only at an explicit output path)} → {pdf:path|null (null without explicit output), imageRect, command:diagnostic array|null, sent, spooler:where the job went|null, backend:"lp|windows|ipp|portal"|null}"##;
|
||||
|
||||
vec![
|
||||
spec!("file.print", "Print…", &["File"], Some("Cmd+P"), PRINT_PARAMS, native_doc, print),
|
||||
|
||||
@@ -5,7 +5,9 @@ fn tmp(name: &str) -> String {
|
||||
let d = std::env::temp_dir().join(format!("photocraft-print-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.to_string_lossy().into_owned()
|
||||
// Spelt the way command results spell paths, so an expectation built with `/` matches on
|
||||
// Windows too. Win32 takes `/` as a separator, so the directory is still usable as one.
|
||||
d.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
fn session(mode: &str, depth: u32) -> Session {
|
||||
@@ -254,7 +256,12 @@ fn package_copies_links_and_relinks() {
|
||||
let copied = format!("{dir}/pkg/Print Me/Links/art.png");
|
||||
assert_eq!(r["links"], json!([copied]));
|
||||
assert!(std::path::Path::new(&copied).is_file());
|
||||
// Reported paths are `/`-spelt on every platform, not the native separator up to `dir` and
|
||||
// `/` after it: a client hands them back to the control channel, which takes only `/`.
|
||||
let docp = r["document"].as_str().unwrap();
|
||||
for p in [r["folder"].as_str().unwrap(), docp, r["links"][0].as_str().unwrap()] {
|
||||
assert!(!p.contains('\\'), "{p}");
|
||||
}
|
||||
assert!(docp.ends_with("Print Me/Print Me.pcraft"));
|
||||
let back = photocraft_io::import("Print Me.pcraft", &std::fs::read(docp).unwrap()).unwrap().document;
|
||||
let linked: Vec<String> = back
|
||||
@@ -360,7 +367,7 @@ fn spool_transport_streams_pdf_and_propagates_refusal() {
|
||||
let printed = &s.documents().iter().find(|st| st.doc.id == source).unwrap().doc;
|
||||
assert_eq!((printed.size.width, printed.size.height), (150, 300), "print hook ran on the source document");
|
||||
assert_eq!(out["sent"], true);
|
||||
assert_eq!(out["spooler"], "request id is Office-42");
|
||||
assert_eq!((out["spooler"].as_str(), out["backend"].as_str()), (Some("request id is Office-42"), Some("lp")));
|
||||
assert!(out["pdf"].is_null(), "spool-only printing leaves no diagnostic PDF");
|
||||
let pdf = std::fs::read(format!("{dir}/received.pdf")).unwrap();
|
||||
assert!(pdf.starts_with(b"%PDF-1.4"));
|
||||
@@ -434,3 +441,292 @@ fn printers_lists_the_spooler_destinations_without_a_document() {
|
||||
None => assert!(list.iter().all(|p| p["default"] == false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spooling tests run in a child process: `disable_printing` is process-wide and a test above
|
||||
/// sets it, and CI sets `PHOTOCRAFT_NO_PRINT` for the whole shell. True in the child, which runs
|
||||
/// the test body; the parent starts the child and checks that it passed.
|
||||
fn in_child(test: &str, envs: &[(&str, &str)]) -> bool {
|
||||
const CHILD: &str = "PHOTOCRAFT_TEST_PRINT_CHILD";
|
||||
if std::env::var_os(CHILD).is_some() {
|
||||
return true;
|
||||
}
|
||||
let mut cmd = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
cmd.args(["--exact", test, "--nocapture"]).env(CHILD, "1").env_remove("PHOTOCRAFT_NO_PRINT");
|
||||
for (k, v) in envs {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
// Bounded: a child stuck in a printer dialog or a driver must not hang the suite.
|
||||
let mut child = cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()).spawn().unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
break Some(status);
|
||||
}
|
||||
if std::time::Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
break None;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
};
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(
|
||||
status.is_some_and(|s| s.success()),
|
||||
"{}\nstdout: {}\nstderr: {}",
|
||||
if status.is_none() { "the child did not finish within 120 s and was killed" } else { "the child failed" },
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
/// A spooler that keeps every job it gets.
|
||||
fn recording_spooler(preferred: bool, seen: &std::sync::Arc<std::sync::Mutex<Vec<PrintJob>>>) -> Spooler {
|
||||
let seen = std::sync::Arc::clone(seen);
|
||||
Spooler {
|
||||
name: "test",
|
||||
preferred,
|
||||
run: std::sync::Arc::new(move |job: &PrintJob| {
|
||||
seen.lock().unwrap().push(job.clone());
|
||||
Ok("queued".into())
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preferred_app_spooler_takes_the_job() {
|
||||
if !in_child("print_cmds::tests::a_preferred_app_spooler_takes_the_job", &[]) {
|
||||
return;
|
||||
}
|
||||
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let mut s = session("rgb", 8);
|
||||
s.print_spooler = Some(recording_spooler(true, &seen));
|
||||
let r = s.execute("file.print", json!({"copies": 2, "paper": "a4", "orientation": "landscape"})).unwrap();
|
||||
assert_eq!((r["sent"].as_bool(), r["backend"].as_str(), r["spooler"].as_str()), (Some(true), Some("test"), Some("queued")));
|
||||
assert!(r["pdf"].is_null(), "{r}");
|
||||
let jobs = seen.lock().unwrap();
|
||||
let job = &jobs[0];
|
||||
assert!(job.pdf.starts_with(b"%PDF-1.4"));
|
||||
assert_eq!((job.copies, job.title.as_str(), job.printer.as_deref(), job.landscape), (2, "Print Me.psd", None, true));
|
||||
assert!((job.paper.0 - 841.89).abs() < 0.01 && (job.paper.1 - 595.28).abs() < 0.01);
|
||||
drop(jobs);
|
||||
// A named print server asks for the IPP route, preferred spooler or not.
|
||||
let e = s.execute("file.print", json!({"printServer": "127.0.0.1:1"})).unwrap_err().to_string();
|
||||
assert!(e.contains("no print server at 127.0.0.1:1") || e.contains("not available on Windows"), "{e}");
|
||||
assert_eq!(seen.lock().unwrap().len(), 1);
|
||||
// "output" keeps the file and does not spool unless asked.
|
||||
let out = format!("{}/out.pdf", tmp("output"));
|
||||
let r = s.execute("file.print", json!({"output": out})).unwrap();
|
||||
assert_eq!((r["sent"].as_bool(), r["pdf"].as_str()), (Some(false), Some(out.as_str())));
|
||||
assert!(std::path::Path::new(&out).exists());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod cups {
|
||||
use super::*;
|
||||
use photocraft_ipp::{
|
||||
Attribute, OP_CUPS_GET_CLASSES, OP_CUPS_GET_DEFAULT, OP_CUPS_GET_PRINTERS, OP_PRINT_JOB, Printer, Request, TAG_JOB_GROUP, TAG_NAME, TAG_PRINTER_GROUP,
|
||||
};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::sync::mpsc;
|
||||
|
||||
fn printer(name: &str, is_default: bool) -> Printer {
|
||||
Printer { name: name.into(), info: String::new(), state: 3, accepting_jobs: true, is_default, is_class: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_printer_is_the_requested_one_else_the_default_else_the_only_one() {
|
||||
let two = [printer("Office", false), printer("Lab", true)];
|
||||
assert_eq!(resolve_printer(None, Some("Lab"), &two).ok(), Some("Lab".into()));
|
||||
assert_eq!(resolve_printer(Some("office"), Some("Lab"), &two).ok(), Some("Office".into()));
|
||||
assert_eq!(resolve_printer(None, None, &two[..1]).ok(), Some("Office".into()));
|
||||
match resolve_printer(None, None, &two) {
|
||||
Err(SpoolError::Failed(e)) => assert!(e.contains("no default printer") && e.contains("Office, Lab"), "{e}"),
|
||||
_ => panic!("several printers and no default must fail"),
|
||||
}
|
||||
match resolve_printer(Some("Nope"), Some("Lab"), &two) {
|
||||
Err(SpoolError::Failed(e)) => assert!(e.contains("`Nope`") && e.contains("Office, Lab"), "{e}"),
|
||||
_ => panic!("an unknown printer must fail"),
|
||||
}
|
||||
match resolve_printer(None, None, &[]) {
|
||||
Err(SpoolError::NoRoute(e)) => assert!(e.contains("no printer is set up"), "{e}"),
|
||||
_ => panic!("no printers is no route"),
|
||||
}
|
||||
// A printer asked for by name on a server without any is a failure, not a missing route.
|
||||
match resolve_printer(Some("Office"), None, &[]) {
|
||||
Err(SpoolError::Failed(e)) => assert!(e.contains("`Office`") && e.contains("none"), "{e}"),
|
||||
_ => panic!("a named printer never falls through"),
|
||||
}
|
||||
// A stale default that the server no longer lists falls through to the only printer.
|
||||
assert_eq!(resolve_printer(None, Some("Gone"), &two[..1]).ok(), Some("Office".into()));
|
||||
}
|
||||
|
||||
/// What the fake received per request: op, attributes, document.
|
||||
type Seen = (u16, Vec<Attribute>, Vec<u8>);
|
||||
|
||||
/// A fake CUPS on a TCP port: lists `printers` and `classes`, names `default`, accepts jobs
|
||||
/// as id 42. Answers for as long as the test process lives.
|
||||
fn fake_cups(printers: &'static [&'static str], classes: &'static [&'static str], default: Option<&'static str>) -> (String, mpsc::Receiver<Seen>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let server = format!("127.0.0.1:{}", listener.local_addr().unwrap().port());
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(stream) = stream else { break };
|
||||
let mut reader = BufReader::new(stream);
|
||||
let (mut first, mut line, mut len) = (String::new(), String::new(), 0usize);
|
||||
loop {
|
||||
line.clear();
|
||||
if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
|
||||
break;
|
||||
}
|
||||
if first.is_empty() {
|
||||
first = line.clone();
|
||||
}
|
||||
if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") {
|
||||
len = v.trim().parse().unwrap();
|
||||
}
|
||||
}
|
||||
if first.is_empty() {
|
||||
continue; // a reachability probe
|
||||
}
|
||||
let mut body = vec![0u8; len];
|
||||
reader.read_exact(&mut body).unwrap();
|
||||
let (op, attrs, doc) = photocraft_ipp::parse_request(&body).unwrap();
|
||||
let list = |names: &[&str]| {
|
||||
if names.is_empty() {
|
||||
return Request::new(0x0406, 1).end();
|
||||
}
|
||||
let mut r = Request::new(0, 1);
|
||||
for p in names {
|
||||
r.group(TAG_PRINTER_GROUP);
|
||||
r.attr(TAG_NAME, "printer-name", p.as_bytes());
|
||||
}
|
||||
r.end()
|
||||
};
|
||||
let reply = match op {
|
||||
OP_CUPS_GET_PRINTERS => list(printers),
|
||||
OP_CUPS_GET_CLASSES => list(classes),
|
||||
OP_CUPS_GET_DEFAULT => match default {
|
||||
Some(d) => {
|
||||
let mut r = Request::new(0, 1);
|
||||
r.group(TAG_PRINTER_GROUP);
|
||||
r.attr(TAG_NAME, "printer-name", d.as_bytes());
|
||||
r.end()
|
||||
}
|
||||
None => Request::new(0x0406, 1).end(),
|
||||
},
|
||||
OP_PRINT_JOB => {
|
||||
let mut r = Request::new(0, 1);
|
||||
r.group(TAG_JOB_GROUP);
|
||||
r.integer("job-id", 42);
|
||||
r.end()
|
||||
}
|
||||
_ => Request::new(0x0501, 1).end(),
|
||||
};
|
||||
let _ = tx.send((op, attrs, doc));
|
||||
let mut out = reader.into_inner();
|
||||
let _ = out.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\n\r\n", reply.len()).as_bytes());
|
||||
let _ = out.write_all(&reply);
|
||||
let _ = out.flush();
|
||||
}
|
||||
});
|
||||
(server, rx)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_server_is_printed_to_over_ipp() {
|
||||
if !in_child("print_cmds::tests::cups::a_named_server_is_printed_to_over_ipp", &[]) {
|
||||
return;
|
||||
}
|
||||
let (server, rx) = fake_cups(&["Office"], &["Lab"], Some("Lab"));
|
||||
let mut s = session("rgb", 8);
|
||||
let r = s.execute("file.print", json!({"printServer": server, "copies": 3})).unwrap();
|
||||
assert_eq!((r["sent"].as_bool(), r["backend"].as_str(), r["spooler"].as_str()), (Some(true), Some("ipp"), Some("job 42 on Lab")));
|
||||
assert!(r["pdf"].is_null());
|
||||
let job = std::iter::from_fn(|| rx.try_recv().ok()).find(|(op, _, _)| *op == OP_PRINT_JOB).expect("a Print-Job request");
|
||||
let attr = |name: &str| job.1.iter().find(|a| a.name == name).unwrap_or_else(|| panic!("{name}"));
|
||||
assert!(attr("printer-uri").text().ends_with("/classes/Lab"), "{}", attr("printer-uri").text());
|
||||
assert_eq!(
|
||||
(attr("copies").int(), attr("job-name").text().as_str(), attr("document-format").text().as_str()),
|
||||
(Some(3), "Print Me.psd", "application/pdf")
|
||||
);
|
||||
assert!(job.2.starts_with(b"%PDF-1.4"));
|
||||
// The requested printer, in any case; an unknown one names the choices.
|
||||
let r = s.execute("file.print", json!({"printServer": server, "printer": "office"})).unwrap();
|
||||
assert_eq!(r["spooler"], "job 42 on Office");
|
||||
let job = std::iter::from_fn(|| rx.try_recv().ok()).filter(|(op, _, _)| *op == OP_PRINT_JOB).last().expect("the second Print-Job");
|
||||
assert!(job.1.iter().any(|a| a.name == "printer-uri" && a.text().ends_with("/printers/Office")));
|
||||
let e = s.execute("file.print", json!({"printServer": server, "printer": "Nope"})).unwrap_err().to_string();
|
||||
assert!(e.contains("`Nope`") && e.contains("Office, Lab"), "{e}");
|
||||
// Printers can be listed.
|
||||
let r = s.execute("file.printers", json!({"printServer": server})).unwrap();
|
||||
let names: Vec<&str> = r["printers"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect();
|
||||
assert_eq!((names, r["default"].as_str()), (vec!["Office", "Lab"], Some("Lab")));
|
||||
assert_eq!(
|
||||
(r["printers"][1]["default"].as_bool(), r["printers"][1]["class"].as_bool(), r["printers"][0]["class"].as_bool()),
|
||||
(Some(true), Some(true), Some(false))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_printer_the_app_spooler_takes_over() {
|
||||
if !in_child("print_cmds::tests::cups::without_a_printer_the_app_spooler_takes_over", &[]) {
|
||||
return;
|
||||
}
|
||||
let (server, _rx) = fake_cups(&[], &[], None);
|
||||
let mut s = session("gray", 8);
|
||||
let e = s.execute("file.print", json!({"printServer": server})).unwrap_err().to_string();
|
||||
assert!(e.contains("no printer is set up"), "{e}");
|
||||
// Asking for a printer by name never falls through to another route.
|
||||
let e = s.execute("file.print", json!({"printServer": server, "printer": "Office"})).unwrap_err().to_string();
|
||||
assert!(e.contains("`Office`"), "{e}");
|
||||
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
s.print_spooler = Some(recording_spooler(false, &seen));
|
||||
let r = s.execute("file.print", json!({"printServer": server})).unwrap();
|
||||
assert_eq!((r["backend"].as_str(), r["spooler"].as_str()), (Some("test"), Some("queued")));
|
||||
assert_eq!(seen.lock().unwrap().len(), 1);
|
||||
// An unreachable named server is a failure, not a missing route.
|
||||
let e = s.execute("file.print", json!({"printServer": "127.0.0.1:1"})).unwrap_err().to_string();
|
||||
assert!(e.contains("no print server at"), "{e}");
|
||||
let r = s.execute("file.printers", json!({"printServer": "127.0.0.1:1"})).unwrap();
|
||||
assert!(r["printers"].as_array().unwrap().is_empty() && r["note"].as_str().unwrap().contains("no print server"));
|
||||
}
|
||||
|
||||
/// The Flatpak case: no `lp` on the path, so the job goes to the system's CUPS over IPP
|
||||
/// (`CUPS_SERVER` names it here), and with no print server at all to the app's spooler.
|
||||
/// Runs in a child process, since the path is process-wide.
|
||||
#[test]
|
||||
fn without_lp_the_job_goes_over_ipp_then_to_the_app_spooler() {
|
||||
const CHILD: &str = "PHOTOCRAFT_TEST_PRINT_NO_LP";
|
||||
if std::env::var_os(CHILD).is_some() {
|
||||
let mut s = session("rgb", 8);
|
||||
let r = s.execute("file.print", json!({"copies": 2})).unwrap();
|
||||
assert_eq!((r["sent"].as_bool(), r["backend"].as_str(), r["spooler"].as_str()), (Some(true), Some("ipp"), Some("job 42 on Office")));
|
||||
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
s.print_spooler = Some(recording_spooler(false, &seen));
|
||||
// The IPP route first, then the app's spooler only when there is no server.
|
||||
let r = s.execute("file.print", json!({})).unwrap();
|
||||
assert_eq!(r["backend"], "ipp");
|
||||
assert!(seen.lock().unwrap().is_empty());
|
||||
// (CUPS_SERVER is read per job; an unreachable one is no route.)
|
||||
let r = s.execute("file.print", json!({"printServer": "127.0.0.1:1"})).unwrap_err().to_string();
|
||||
assert!(r.contains("no print server at"), "{r}");
|
||||
println!("child ok");
|
||||
return;
|
||||
}
|
||||
let (server, rx) = fake_cups(&["Office"], &[], Some("Office"));
|
||||
let no_lp = tempfile::tempdir().unwrap();
|
||||
let out = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "print_cmds::tests::cups::without_lp_the_job_goes_over_ipp_then_to_the_app_spooler", "--nocapture"])
|
||||
.env(CHILD, "1")
|
||||
.env("PATH", no_lp.path())
|
||||
.env("CUPS_SERVER", &server)
|
||||
.env_remove("PHOTOCRAFT_NO_PRINT")
|
||||
.output()
|
||||
.unwrap();
|
||||
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
assert!(out.status.success() && stdout.contains("child ok"), "stdout: {stdout}\nstderr: {stderr}");
|
||||
let jobs = std::iter::from_fn(|| rx.try_recv().ok()).filter(|(op, _, _)| *op == OP_PRINT_JOB).count();
|
||||
assert_eq!(jobs, 2, "both prints reached the fake CUPS");
|
||||
}
|
||||
}
|
||||
@@ -377,6 +377,35 @@ mod tests {
|
||||
assert!((x - 40.0).abs() < 1e-6 && (y - 50.0).abs() < 1e-6, "{pos:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flips_and_right_angle_turns_keep_points_on_their_pixel() {
|
||||
use crate::canvas_geom::Turn;
|
||||
for (id, turn) in [
|
||||
("image.imageRotation.flipCanvasHorizontal", Turn::FlipHorizontal),
|
||||
("image.imageRotation.flipCanvasVertical", Turn::FlipVertical),
|
||||
("image.imageRotation.180", Turn::Rotate180),
|
||||
("image.imageRotation.90cw", Turn::Cw90),
|
||||
("image.imageRotation.90ccw", Turn::Ccw90),
|
||||
] {
|
||||
// A black pixel at (3, 3) of a white 100×80 document, with a point on it.
|
||||
let mut s = session();
|
||||
s.edit("paint", |doc, _| {
|
||||
let surf = doc.layers[0].surface_mut().unwrap();
|
||||
let black = photocraft_raster::from_rgba(&surf.format(), [0.0, 0.0, 0.0, 1.0]);
|
||||
surf.fill_rect(Rect::new(3, 3, 4, 4), &black);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("view.colorSamplers.add", json!({"x": 3, "y": 3})).unwrap();
|
||||
s.execute(id, json!({})).unwrap();
|
||||
let (x, y) = turn.pixel_map(100, 80)(3, 3);
|
||||
let p = points(&mut s);
|
||||
assert_eq!(p.len(), 1, "{id}");
|
||||
assert_eq!(p[0]["position"], json!([f64::from(x), f64::from(y)]), "{id}");
|
||||
assert_eq!(p[0]["rgb"], json!([0, 0, 0]), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_crop_moves_points() {
|
||||
// The crop frame is centred on the point, so the point lands at the new canvas centre.
|
||||
|
||||
@@ -77,8 +77,13 @@ fn base_name(path: &str) -> String {
|
||||
path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
|
||||
}
|
||||
|
||||
/// A linked file's bytes; `None` when it is missing or its path is not followed
|
||||
/// ([`crate::doc_path::resolve`]).
|
||||
fn read_file(path: &str) -> Option<Vec<u8>> {
|
||||
if path.is_empty() { None } else { photocraft_format::read_file(std::path::Path::new(path)).ok() }
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
photocraft_format::read_file(&crate::doc_path::resolve(path).ok()?).ok()
|
||||
}
|
||||
|
||||
/// The source file of a smart object: embedded bytes, the PSD's embedded linked-layer data (PSD
|
||||
@@ -492,10 +497,14 @@ fn refresh_or_fail(doc: &mut Document, id: LayerId) -> Result<()> {
|
||||
|
||||
fn refresh_or_fail_in(doc: &mut Document, id: LayerId, ctx: &JobCtx) -> Result<()> {
|
||||
if refresh_in(doc, id, ctx)? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(other("the smart object's contents are unavailable (missing linked file?), so it can't be re-rendered"))
|
||||
return Ok(());
|
||||
}
|
||||
if let SmartSource::Linked { path } = &smart(doc, id)?.source
|
||||
&& let Err(why @ crate::doc_path::NotFollowed::Refused(_)) = crate::doc_path::resolve(path)
|
||||
{
|
||||
return Err(crate::doc_path::refusal("the linked file", path, why));
|
||||
}
|
||||
Err(other("the smart object's contents are unavailable (missing linked file?), so it can't be re-rendered"))
|
||||
}
|
||||
|
||||
fn smart(doc: &Document, id: LayerId) -> Result<&SmartObject> {
|
||||
|
||||
@@ -89,10 +89,21 @@ pub fn layer_rect(doc: &Document, id: LayerId) -> Option<[f64; 4]> {
|
||||
}
|
||||
|
||||
fn rect_of(doc: &Document, l: &photocraft_doc::Layer) -> Option<[f64; 4]> {
|
||||
if l.is_group() || (matches!(l.content, LayerContent::Adjustment(_)) && l.mask.is_none()) {
|
||||
return None;
|
||||
}
|
||||
let b = crate::transform_cmds::transform_bounds(doc, l);
|
||||
snappable(l).then(|| crate::transform_cmds::transform_bounds(doc, l)).and_then(to_rect)
|
||||
}
|
||||
|
||||
/// Bounds of a layer as a Snap To Layers target: all of its content, whatever the selection
|
||||
/// holds of it (#6290: clipped to the selection, every layer's edges and centre were the
|
||||
/// selection's, so a moved selection snapped back to where it started).
|
||||
fn target_rect_of(l: &photocraft_doc::Layer) -> Option<[f64; 4]> {
|
||||
snappable(l).then(|| crate::transform_cmds::whole_layer_bounds(l)).and_then(to_rect)
|
||||
}
|
||||
|
||||
fn snappable(l: &photocraft_doc::Layer) -> bool {
|
||||
!(l.is_group() || (matches!(l.content, LayerContent::Adjustment(_)) && l.mask.is_none()))
|
||||
}
|
||||
|
||||
fn to_rect(b: photocraft_geom::Rect) -> Option<[f64; 4]> {
|
||||
(!b.is_empty()).then_some([b.x0 as f64, b.y0 as f64, b.x1 as f64, b.y1 as f64])
|
||||
}
|
||||
|
||||
@@ -121,7 +132,7 @@ impl SnapTargets {
|
||||
if !l.visible || exclude.contains(&l.id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = rect_of(doc, l) {
|
||||
if let Some(r) = target_rect_of(l) {
|
||||
t.add_rect(r, SnapKind::LayerEdge, SnapKind::LayerCenter);
|
||||
}
|
||||
}
|
||||
@@ -365,6 +376,17 @@ mod tests {
|
||||
let t = SnapTargets::from_document(&doc, &SnapOptions { guides: false, ..opts }, &[id]);
|
||||
assert!(!t.x.iter().any(|t| t.kind == SnapKind::Guide || (t.kind == SnapKind::LayerEdge && t.pos == 30.0)));
|
||||
assert_eq!(layer_rect(&doc, id), Some([10.0, 20.0, 30.0, 60.0]));
|
||||
// #6290: a selection doesn't move the layers' targets onto its own bounds.
|
||||
let mut sel = photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8);
|
||||
sel.fill_rect(photocraft_geom::Rect::new(64, 66, 124, 86), &[1.0]);
|
||||
doc.selection = Some(sel);
|
||||
let t = SnapTargets::from_document(&doc, &SnapOptions { selection: false, ..opts }, &[]);
|
||||
assert!(t.x.iter().any(|t| t.kind == SnapKind::LayerEdge && t.pos == 30.0));
|
||||
assert!(
|
||||
!t.x.iter()
|
||||
.chain(&t.y)
|
||||
.any(|t| matches!(t.kind, SnapKind::LayerEdge | SnapKind::LayerCenter) && [64.0, 66.0, 76.0, 86.0, 94.0, 124.0].contains(&t.pos))
|
||||
);
|
||||
assert_eq!(union([[0.0, 0.0, 1.0, 1.0], [2.0, -1.0, 3.0, 0.5]]), Some([0.0, -1.0, 3.0, 1.0]));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Stamping (#217): merge layers into a layer while keeping the originals.
|
||||
//!
|
||||
//! - **Stamp Visible** (⌘⌥⇧E, or ⌥ + Layer › Merge Visible): every visible layer composited into a
|
||||
//! new layer above the active one.
|
||||
//! new layer above the active one, or into the active layer when it is an empty pixel layer (as
|
||||
//! in Photoshop, so a recorded New Layer + Stamp Visible leaves one layer, #6785).
|
||||
//! - **Stamp Down** (⌘⌥E, or ⌥ + Layer › Merge Down): a copy of the active layer merged into the
|
||||
//! pixel layer below it; with several layers selected, a copy of them merged into a new layer
|
||||
//! above the top one.
|
||||
@@ -65,6 +66,13 @@ fn stamp_visible(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !doc.walk().iter().any(|(path, _, l)| !l.is_group() && (1..=path.len()).all(|n| doc.layer_at(&path[..n]).is_some_and(|a| a.visible))) {
|
||||
return Err(EngineError::Other("there are no visible layers to stamp".into()));
|
||||
}
|
||||
// Like Photoshop, an empty pixel layer that is active (Layer > New > Layer, then Stamp
|
||||
// Visible, as recorded actions do) takes the stamp itself instead of getting a second layer.
|
||||
if let Some(empty) = (*active).filter(|id| is_empty_pixel_layer(doc, *id)) {
|
||||
let surf = photocraft_compose::flatten_to_surface(doc, with_alpha(doc), None);
|
||||
*crate::pixels_mut(doc.layer_mut(empty).ok_or(EngineError::NoLayer(empty))?)? = surf;
|
||||
return Ok(empty);
|
||||
}
|
||||
let layer = stamp_layer(doc, doc.next_layer_name("Layer"))?;
|
||||
let id = doc.insert_above(*active, layer);
|
||||
*active = Some(id);
|
||||
@@ -74,6 +82,18 @@ fn stamp_visible(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "layer": id.0 }))
|
||||
}
|
||||
|
||||
/// A plain, unlocked pixel layer with no pixels, mask or clipping: safe to stamp into.
|
||||
fn is_empty_pixel_layer(doc: &Document, id: LayerId) -> bool {
|
||||
let Some(l) = doc.layer(id) else { return false };
|
||||
let locks = doc.effective_locks(id);
|
||||
matches!(&l.content, LayerContent::Raster(s) if s.content_bounds().is_empty())
|
||||
&& l.visible
|
||||
&& l.mask.is_none()
|
||||
&& l.vector_mask.is_none()
|
||||
&& !l.clipped
|
||||
&& !(locks.all || locks.pixels || locks.transparency)
|
||||
}
|
||||
|
||||
fn stamp_down(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
object(p, "layer.stampDown")?;
|
||||
let sel = crate::layer_multi_cmds::selected(s);
|
||||
@@ -148,7 +168,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Stamp Visible",
|
||||
menu: &[],
|
||||
shortcut: Some("Cmd+Alt+Shift+E"),
|
||||
params: r##"{} → {layer} (every visible layer merged into a new layer above the active one; the originals stay)"##,
|
||||
params: r##"{} → {layer} (visible layers merged into a new layer above the active one, or into the active one if it is an empty pixel layer)"##,
|
||||
enabled: has_doc,
|
||||
journal: true,
|
||||
run: stamp_visible,
|
||||
@@ -235,6 +255,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Recorded actions do New Layer, then Stamp Visible: the stamp lands in the new empty layer.
|
||||
#[test]
|
||||
fn stamp_visible_fills_an_empty_active_layer_instead_of_adding_one() {
|
||||
let (mut s, bg, hidden, blue) = session(8);
|
||||
s.execute("layer.new.layer", json!({"name": "Empty"})).unwrap();
|
||||
let empty = s.active().unwrap().active_layer.unwrap();
|
||||
let r = s.execute("layer.stampVisible", json!({})).unwrap();
|
||||
assert_eq!(r["layer"].as_u64(), Some(empty.0));
|
||||
assert_eq!(ids(&s), [bg, hidden, blue, empty], "no extra layer");
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!((st.active_layer, st.selected_layers.clone()), (Some(empty), vec![empty]));
|
||||
assert!(close(rgba(&s, empty, 30, 30), [0.0, 0.0, 1.0, 1.0]));
|
||||
assert!(close(rgba(&s, empty, 2, 2), [1.0, 0.0, 0.0, 1.0]));
|
||||
// A second stamp: the layer now has pixels, so this one adds a layer.
|
||||
let r2 = s.execute("layer.stampVisible", json!({})).unwrap();
|
||||
assert_ne!(r2["layer"].as_u64(), Some(empty.0));
|
||||
assert_eq!(ids(&s).len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamp_visible_respects_opacity_blend_and_groups() {
|
||||
let (mut s, bg, _, blue) = session(8);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Edit › Free Transform / Transform: projective transforms of layers (and the selection).
|
||||
|
||||
use photocraft_algo::transform::{Homography, Interp, warp_output_bounds, warp_surface, warp_surface_with};
|
||||
use photocraft_algo::transform::{Homography, Interp, warp_output_size, warp_surface, warp_surface_with};
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks};
|
||||
use photocraft_geom::{Affine, Rect};
|
||||
@@ -54,7 +54,7 @@ fn framed_by_selection(doc: &Document, bounds: Rect) -> Rect {
|
||||
}
|
||||
|
||||
/// A group moves all its descendants, so its frame must not clip raster children to the selection.
|
||||
fn whole_layer_bounds(layer: &Layer) -> Rect {
|
||||
pub(crate) fn whole_layer_bounds(layer: &Layer) -> Rect {
|
||||
let content = match &layer.content {
|
||||
LayerContent::Group(g) => g.children.iter().map(whole_layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)),
|
||||
_ => layer.surface().map_or(Rect::EMPTY, photocraft_compose::bounds::content_bounds),
|
||||
@@ -273,10 +273,13 @@ const BUDGET: u64 = 1 << 20;
|
||||
/// Refuse a warp of the content inside `src` by `h` whose result would exceed the pixel budget,
|
||||
/// before anything is built. `what` names the surface in the message.
|
||||
pub(crate) fn check_budget(src: Rect, h: &Homography, what: &str) -> Result<()> {
|
||||
let Some(b) = warp_output_bounds(src, h) else { return Ok(()) };
|
||||
let (w, ht) = (u64::from(b.width()), u64::from(b.height()));
|
||||
if w.saturating_mul(ht) > BUDGET {
|
||||
let detail = format!("the transformed {what} would be {w} x {ht} px, more than the {BUDGET} px limit");
|
||||
check_budget_with_limit(src, h, what, BUDGET)
|
||||
}
|
||||
|
||||
fn check_budget_with_limit(src: Rect, h: &Homography, what: &str, budget: u64) -> Result<()> {
|
||||
let Some((w, ht)) = warp_output_size(src, h) else { return Ok(()) };
|
||||
if w.saturating_mul(ht) > budget {
|
||||
let detail = format!("the transformed {what} would be {w} x {ht} px, more than the {budget} px limit");
|
||||
return Err(crate::allocation::error("transforming pixels", &detail));
|
||||
}
|
||||
Ok(())
|
||||
@@ -735,6 +738,24 @@ mod tests {
|
||||
|
||||
// ---- Pixel budget (#3773). Tests run with a 2²⁰ px budget, so nothing here comes near the real one. ----
|
||||
|
||||
#[test]
|
||||
fn huge_transform_scales_exceed_the_production_budget_before_clipping() {
|
||||
let src = Rect::new(0, 0, 24, 16);
|
||||
for scale in [1e7, 1e28, 1e306] {
|
||||
for (sx, sy) in [(scale, 1.0), (1.0, scale), (-scale, 1.0)] {
|
||||
let h = Homography([sx, 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 1.0]);
|
||||
assert!(check_budget_with_limit(src, &h, "layer", MAX_TRANSFORM_PIXELS).is_err(), "{sx} x {sy} must be refused");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_transform_remains_within_the_production_budget() {
|
||||
let src = Rect::new(0, 0, 24, 16);
|
||||
let h = Homography([10_000.0, 0.0, 900_000.0, 0.0, 1.0, -900_000.0, 0.0, 0.0, 1.0]);
|
||||
assert!(check_budget_with_limit(src, &h, "layer", MAX_TRANSFORM_PIXELS).is_ok());
|
||||
}
|
||||
|
||||
/// A 16 x 16 document whose active layer is a 1 x 8 column at x = 10.
|
||||
fn column() -> Session {
|
||||
let mut s = Session::new();
|
||||
|
||||
Loaded 100 of 249 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user