mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-18 05:06:55 -04:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9e1c10c30 | ||
|
|
4a1acca879 | ||
|
|
a95b3f0a85 | ||
|
|
b7eab1a377 | ||
|
|
25a8200110 | ||
|
|
6443f5fb4c |
No files matched your search
@@ -12,7 +12,7 @@
|
||||
gpu = { max-threads = 4 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool|background_redraw::|floating_group_titles|swap_arrow::tests::rendered_glyph/)))'
|
||||
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))'
|
||||
test-group = 'gpu'
|
||||
|
||||
# CI (`--profile ci`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its
|
||||
|
||||
+29
-142
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
fuzz:
|
||||
@@ -23,8 +22,8 @@ env:
|
||||
PHOTOCRAFT_NO_PRINT: "1"
|
||||
LPDEST: photocraft-no-such-printer
|
||||
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
|
||||
# debug info: faster codegen and links, and a smaller target dir (the Linux test job ran out of
|
||||
# disk with full debug info). The Windows test legs drop debuginfo entirely (see the test job).
|
||||
# debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough
|
||||
# for the Linux job's second, craft-fonts build (it ran out of disk with full debug info).
|
||||
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||
|
||||
# A newer push to a PR branch cancels its older run. Runs on main are never cancelled: a merge
|
||||
@@ -44,7 +43,7 @@ jobs:
|
||||
name: select platforms
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
legs: ${{ steps.pick.outputs.legs }}
|
||||
os: ${{ steps.pick.outputs.os }}
|
||||
web: ${{ steps.pick.outputs.web }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -58,15 +57,9 @@ jobs:
|
||||
run: |
|
||||
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
|
||||
# release.yml builds and tests macOS (a failing release is fixed and rebuilt).
|
||||
# Windows tests run as two legs in parallel: the unit tests and the integration tests. As
|
||||
# one job they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting
|
||||
# the integration tests over a third runner (nextest --partition) saved ~2 min of wall
|
||||
# time but cost ~7 runner-minutes and one more Windows slot per PR.
|
||||
linux='{"os":"ubuntu-latest","part":"all"}'
|
||||
win() { printf '{"os":"windows-latest","part":"%s"}' "$1"; }
|
||||
all="[$linux,$(win unit),$(win integration)]"
|
||||
all='["ubuntu-latest","windows-latest"]'
|
||||
if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then
|
||||
echo "legs=$all" >> "$GITHUB_OUTPUT"
|
||||
echo "os=$all" >> "$GITHUB_OUTPUT"
|
||||
echo "web=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
@@ -79,25 +72,20 @@ jobs:
|
||||
echo "web=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
|
||||
echo "legs=$all" >> "$GITHUB_OUTPUT"
|
||||
echo "os=$all" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "legs=[$linux]" >> "$GITHUB_OUTPUT"
|
||||
echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
test:
|
||||
timeout-minutes: 100
|
||||
name: test (${{ matrix.os }}${{ matrix.part != 'all' && format(', {0}', matrix.part) || '' }})
|
||||
name: test (${{ matrix.os }})
|
||||
needs: platforms
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include: ${{ fromJSON(needs.platforms.outputs.legs) }}
|
||||
env:
|
||||
# No debuginfo on Windows: writing PDBs for ~200 test binaries cost 2.2 of ~21 build minutes.
|
||||
# A panic still names its file and line; only backtrace frames lose theirs (Linux keeps
|
||||
# line-tables-only, so a backtrace with lines is one OS away).
|
||||
CARGO_PROFILE_DEV_DEBUG: ${{ startsWith(matrix.os, 'windows') && '0' || 'line-tables-only' }}
|
||||
os: ${{ fromJSON(needs.platforms.outputs.os) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -120,44 +108,21 @@ jobs:
|
||||
# Only main saves caches; PRs restore main's. Every PR used to save its own set (up to
|
||||
# 3.8 GB), usable by that PR only, so the repo's 10 GB cache budget kept evicting main's.
|
||||
# A failing test still saves: the cache holds compiled dependencies, not test results.
|
||||
# The Windows legs share one key (same dependencies); the integration leg saves it,
|
||||
# because it also runs Clippy and so leaves Clippy's dependency metadata in it.
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }}
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-on-failure: true
|
||||
|
||||
# sccache caches the workspace crates' compiled libraries, which rust-cache leaves out:
|
||||
# warm, each Windows leg built 4.5 min faster. Like rust-cache, only main writes (PRs read
|
||||
# main's entries); a full set is ~90 MB, so it barely touches the 10 GB budget (#2688).
|
||||
# Test executables can't be cached, so a PR still links its tests.
|
||||
- name: Install sccache (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.18.0
|
||||
|
||||
- name: Use sccache (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
env:
|
||||
MAIN: ${{ github.ref == 'refs/heads/main' }}
|
||||
run: |
|
||||
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
|
||||
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
|
||||
if [ "$MAIN" != true ]; then echo "SCCACHE_GHA_RW_MODE=READ_ONLY" >> "$GITHUB_ENV"; fi
|
||||
shell: bash
|
||||
|
||||
# The cheap checks run first: a format, lint, layering or stale-scorecard failure is red
|
||||
# after a few minutes instead of after the test passes (#6119). Clippy restores main's cache.
|
||||
- name: Format
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
# Keep formatting, strict Clippy, layering and scorecard checks ahead of tests.
|
||||
- name: Clippy
|
||||
if: matrix.part == 'all' || matrix.part == 'integration'
|
||||
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
|
||||
|
||||
- name: Dependency layering
|
||||
if: matrix.part == 'all' || matrix.part == 'integration'
|
||||
run: cargo xtask layers
|
||||
|
||||
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
|
||||
@@ -166,114 +131,36 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo xtask scorecard --check
|
||||
|
||||
# nextest runs the test binaries in parallel (cargo test runs them one at a time), with the
|
||||
# GPU tests capped by the `gpu` group in .config/nextest.toml (#2204).
|
||||
# cargo-nextest runs the test binaries in parallel (`cargo test` runs them one at a time) and
|
||||
# its `ci` profile (.config/nextest.toml) stops at the first failure and fails hung tests.
|
||||
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
|
||||
with:
|
||||
tool: [email protected]
|
||||
|
||||
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
|
||||
# `--lib --bins --tests` leaves out the examples, which cargo test would build and link but
|
||||
# never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so
|
||||
# they get their own step.
|
||||
#
|
||||
# On Windows the unit leg runs `--lib --bins` and the integration leg `--test '*'` (every
|
||||
# integration-test target); the feature selection stays the same, so both legs share the
|
||||
# dependency cache. The `ci` profile fails fast and terminates hung tests
|
||||
# (.config/nextest.toml); assertion failures are not retried.
|
||||
# nextest doesn't run doctests, so `cargo test --doc` does, with the same selection.
|
||||
# Separate native commands into steps so Windows reports either command's failure.
|
||||
- name: Test
|
||||
env:
|
||||
PART: ${{ matrix.part }}
|
||||
run: |
|
||||
case "$PART" in
|
||||
unit) targets=(--lib --bins) ;;
|
||||
integration) targets=(--test '*') ;;
|
||||
*) targets=(--lib --bins --tests) ;;
|
||||
esac
|
||||
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)'
|
||||
run: cargo nextest run --workspace --locked --features heif --profile ci
|
||||
|
||||
- name: Doctests
|
||||
if: matrix.part == 'all' || matrix.part == 'unit'
|
||||
run: cargo test --workspace --locked --features heif --doc
|
||||
|
||||
# The Windows tests used to be one job named `test (windows-latest)`; they're now legs named
|
||||
# `test (windows-latest, unit)` and `test (windows-latest, integration)`. This job keeps the old
|
||||
# name reporting, so a required status check on it (none today) would keep working. It fails
|
||||
# unless the whole test matrix and both Linux variants passed. `needs` reports one result
|
||||
# for all matrix legs; when a PR runs no Windows legs it still checks every Linux result.
|
||||
windows-gate:
|
||||
timeout-minutes: 5
|
||||
name: test (windows-latest)
|
||||
needs: [platforms, test, test-variants]
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- env:
|
||||
PLATFORMS: ${{ needs.platforms.result }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
VARIANTS: ${{ needs.test-variants.result }}
|
||||
run: |
|
||||
echo "platforms: $PLATFORMS, test: $TEST, variants: $VARIANTS"
|
||||
[ "$PLATFORMS" = success ] && [ "$TEST" = success ] && [ "$VARIANTS" = success ]
|
||||
|
||||
# The two other builds of the Linux tests, in parallel with the test job instead of after it (they
|
||||
# added ~10 min to its critical path). Each restores the test job's cache (`shared-key: test` is
|
||||
# that job's key) and saves none: the dependencies are the same, and the 10 GB cache budget is
|
||||
# tight (#2688).
|
||||
test-variants:
|
||||
timeout-minutes: 100
|
||||
name: test (${{ matrix.variant }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
variant: [without heif, with craft-fonts]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Linux system dependencies (egui/winit/rfd)
|
||||
run: |
|
||||
nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \
|
||||
/opt/hostedtoolcache/CodeQL </dev/null >/dev/null 2>&1 &
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
|
||||
libgl1-mesa-dev libgtk-3-dev
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: test
|
||||
save-if: false
|
||||
|
||||
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
|
||||
with:
|
||||
tool: [email protected]
|
||||
run: cargo test --doc --workspace --locked --features heif
|
||||
|
||||
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
|
||||
# error for distributors who leave it out.
|
||||
- name: Test without heif
|
||||
if: matrix.variant == 'without heif'
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci
|
||||
|
||||
# The tests built with the optional craft-fonts input (crates/text/build.rs), so the Japanese
|
||||
# font tests that skip without it run. Keep the test job's exact `--workspace --features heif
|
||||
# --lib --bins --tests`, so the restored dependencies match and only the workspace rebuilds.
|
||||
# Filter execution to the three crates that read the optional font input (#6127).
|
||||
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
|
||||
# Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace
|
||||
# --features heif` of the Test step: any other package or feature selection resolves a
|
||||
# different feature set, recompiles the whole dependency graph into the same target dir and
|
||||
# runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800).
|
||||
# Only the tests of the crates that read `CRAFT_FONTS` run (the `-E` filter changes what runs,
|
||||
# not what is built); engine and the apps only pass text on to them (#6120).
|
||||
- name: Check out craft-fonts
|
||||
if: matrix.variant == 'with craft-fonts'
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
@@ -281,12 +168,12 @@ jobs:
|
||||
path: craft-fonts
|
||||
|
||||
- name: Test with craft-fonts
|
||||
if: matrix.variant == 'with craft-fonts'
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: >-
|
||||
cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests
|
||||
cargo nextest run --workspace --locked --features heif --profile ci
|
||||
-E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)'
|
||||
|
||||
corpus:
|
||||
|
||||
@@ -27,10 +27,38 @@ permissions:
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
cargo-build-extra-args: "--features heif"
|
||||
custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1"
|
||||
run-package-check: true
|
||||
name: FreeBSD 14 (build + test + package)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build, test and package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked --features heif
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc
|
||||
# The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a GPU
|
||||
# device at once, so the GPU integration tests run one at a time here.
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1
|
||||
# The release package, as release.yml builds it. Free the debug build's disk first.
|
||||
rm -rf target/debug
|
||||
CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/photocraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/photocraft$|/bin/photocraft-cli$|/share/applications/ai\.storyteller\.photocraft\.desktop$|/share/doc/photocraft/LICENSE-MIT$'
|
||||
@@ -1,33 +0,0 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
+2
-4
@@ -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`, `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/*.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/{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,12 +38,11 @@ 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`, 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/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/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) |
|
||||
@@ -58,7 +57,6 @@ 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
+30
-43
@@ -1222,11 +1222,6 @@ 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"
|
||||
@@ -4618,7 +4613,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"async-io",
|
||||
@@ -4651,7 +4646,6 @@ dependencies = [
|
||||
"rfd",
|
||||
"ron",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"winit",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
@@ -4663,7 +4657,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-affinity"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4673,7 +4667,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-algo"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4686,7 +4680,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-automation"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"cap-std",
|
||||
@@ -4708,7 +4702,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cli"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
@@ -4722,7 +4716,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cms"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"moxcms",
|
||||
"rayon",
|
||||
@@ -4730,7 +4724,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-codecs"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"exr",
|
||||
"flate2",
|
||||
@@ -4751,7 +4745,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-color"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"serde",
|
||||
@@ -4759,7 +4753,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-compose"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-color",
|
||||
@@ -4774,7 +4768,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-doc"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4785,7 +4779,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-engine"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"craft-print",
|
||||
@@ -4801,7 +4795,6 @@ dependencies = [
|
||||
"photocraft-format",
|
||||
"photocraft-geom",
|
||||
"photocraft-io",
|
||||
"photocraft-ipp",
|
||||
"photocraft-ops",
|
||||
"photocraft-paint",
|
||||
"photocraft-plugins",
|
||||
@@ -4821,7 +4814,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-format"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc32fast",
|
||||
@@ -4842,14 +4835,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-geom"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-gpu"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"half",
|
||||
"log",
|
||||
@@ -4866,17 +4859,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-heif"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"craft-docpath",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"photocraft-affinity",
|
||||
@@ -4901,20 +4893,16 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ipp"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ops"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-doc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-paint"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4925,7 +4913,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-plugins"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4940,7 +4928,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-psd"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"photocraft-psd",
|
||||
@@ -4951,7 +4939,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raster"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4961,7 +4949,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raw"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4976,7 +4964,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-tablet"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
@@ -4988,7 +4976,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"photocraft-color",
|
||||
@@ -4999,7 +4987,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-text"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"parley",
|
||||
@@ -5013,7 +5001,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ui-egui"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-print",
|
||||
@@ -5049,13 +5037,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"toml",
|
||||
"unicode-bidi",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-vector"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
@@ -5066,7 +5053,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5088,7 +5075,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web-boot"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"js-sys",
|
||||
@@ -8675,7 +8662,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+6
-32
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -36,11 +36,9 @@ 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"
|
||||
@@ -77,18 +75,9 @@ opt-level = 2
|
||||
opt-level = 1
|
||||
# Line tables keep file:line in panic backtraces but drop variable info, which roughly halves link
|
||||
# time and PDB size for the ~260 test binaries (#2204). CI sets the same through
|
||||
# CARGO_PROFILE_DEV_DEBUG (and no debuginfo at all for the Windows tests). To inspect locals in a
|
||||
# debugger, build with CARGO_PROFILE_DEV_DEBUG=true.
|
||||
# CARGO_PROFILE_DEV_DEBUG. To inspect locals in a debugger, build with CARGO_PROFILE_DEV_DEBUG=true.
|
||||
debug = "line-tables-only"
|
||||
|
||||
# Test builds compile ui-egui without optimization: its library and unit tests were the biggest
|
||||
# test-build units after engine's (12.6 of ~63 compile minutes on the Windows CI runner), and its
|
||||
# tests run no heavier pixel code of their own (that lives in the crates that stay optimized).
|
||||
# Measured on CI: Windows build 18.9 -> 15.4 min, test run 10.0 -> 10.5. `cargo run` and
|
||||
# `cargo build` use the dev profile and are unaffected.
|
||||
[profile.test.package.photocraft-ui-egui]
|
||||
opt-level = 0
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
@@ -138,32 +127,17 @@ 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),
|
||||
# so the CPU compositor is not the browser's hot path there; "z" for compose keeps the wasm
|
||||
# under the 24 MiB gate (measured −122 KB vs opt-level 3). algo's filters run per pixel on
|
||||
# every interactive filter application; "z" there is the size lever of last resort (−331 KB),
|
||||
# taken because the gate is enforced in CI: if browser filters feel too slow, restore 3 here
|
||||
# and reclaim the bytes from a feature-gated decoder instead.
|
||||
# Pixel processing keeps its speed-oriented optimization level.
|
||||
[profile.wasm-release.package.photocraft-raster]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-color]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-cms]
|
||||
opt-level = "z"
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-compose]
|
||||
opt-level = "z"
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-algo]
|
||||
opt-level = "z"
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-paint]
|
||||
opt-level = 3
|
||||
|
||||
@@ -16,7 +16,6 @@ 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,8 +275,6 @@ 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).
|
||||
@@ -302,19 +300,6 @@ 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", "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"] }
|
||||
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"] }
|
||||
|
||||
[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 pickers return browser file contents asynchronously through `Services::file_dialog`;
|
||||
//! this includes File → Open and commands such as Place Embedded;
|
||||
//! - File → Open uses the browser file picker; bytes arrive asynchronously through
|
||||
//! `Services::inbox`;
|
||||
//! - saving/exporting triggers a browser download;
|
||||
//! - dropped files are read asynchronously by `web::WebShell` and delivered through `Services::inbox`.
|
||||
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the 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,8 +1,6 @@
|
||||
//! 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};
|
||||
|
||||
@@ -81,13 +79,11 @@ 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 drag = listen_drag(&pen_target);
|
||||
let mut app = PhotocraftApp::new(session, services(inbox.clone(), place_inbox.clone(), drag_pos_service(pen_target.clone(), drag)));
|
||||
let mut app = PhotocraftApp::new(session, services(inbox.clone()));
|
||||
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);
|
||||
}
|
||||
@@ -101,7 +97,7 @@ pub fn start() {
|
||||
}
|
||||
let unsaved = Arc::new(AtomicBool::new(false));
|
||||
guard_unload(unsaved.clone());
|
||||
Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets }))
|
||||
Ok(Box::new(WebShell { app, inbox, unsaved, served, database, presets }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -144,38 +140,6 @@ 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`].
|
||||
@@ -255,13 +219,10 @@ 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 inboxes.
|
||||
/// so the app's own drop path can't handle them) and feed them through the inbox.
|
||||
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,
|
||||
@@ -324,26 +285,17 @@ 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));
|
||||
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());
|
||||
for f in dropped {
|
||||
let inbox = self.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 {
|
||||
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}"),
|
||||
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();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -381,7 +333,7 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services {
|
||||
fn services(inbox: Inbox) -> Services {
|
||||
Services {
|
||||
screen_pick: screen_color_service(),
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
|
||||
@@ -432,8 +384,6 @@ fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::Cu
|
||||
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,8 +65,6 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
|
||||
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
photocraft-cms = { workspace = true }
|
||||
# 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,9 +9,6 @@
|
||||
//! 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};
|
||||
@@ -63,379 +60,6 @@ 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()) {
|
||||
@@ -599,300 +223,6 @@ 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,61 +70,25 @@ 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 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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,69 +126,4 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -443,14 +443,6 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless
|
||||
/// turned on), read before the window opens: it picks the surface's present mode.
|
||||
pub fn read_low_latency(path: Option<&Path>) -> bool {
|
||||
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
let default = photocraft_engine::prefs::Preferences::default().performance.low_latency_drawing;
|
||||
v.get("performance").and_then(|p| p.get("lowLatencyDrawing")).and_then(Value::as_bool).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Read the policy leniently at startup, including old settings without renderingMode.
|
||||
pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) {
|
||||
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
@@ -471,23 +463,6 @@ mod tests {
|
||||
Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_latency_drawing_is_read_before_the_window_opens_and_defaults_off() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-low-latency-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("preferences.json");
|
||||
let read = |json: &str| {
|
||||
std::fs::write(&file, json).unwrap();
|
||||
read_low_latency(Some(&file))
|
||||
};
|
||||
assert!(read(r#"{"performance":{"lowLatencyDrawing":true}}"#));
|
||||
assert!(!read(r#"{"performance":{"lowLatencyDrawing":false}}"#));
|
||||
assert!(!read(r#"{"performance":{"lowLatencyDrawing":"nope"}}"#), "garbage keeps the default (off)");
|
||||
assert!(!read("not json"));
|
||||
assert!(!read_low_latency(None), "no settings file: the default (off)");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_server_preference_is_read_before_the_window_opens() {
|
||||
use photocraft_engine::prefs::LinuxDisplayServer;
|
||||
|
||||
+12
-174
@@ -4,9 +4,7 @@
|
||||
//! 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. A start while another
|
||||
//! instance is still running leaves that instance's log alone and writes `photocraft-<pid>.log`
|
||||
//! (see [`rotate`]).
|
||||
//! `.2`), so the log of a run that crashed survives the next launch.
|
||||
//!
|
||||
//! 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`,
|
||||
@@ -17,7 +15,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, OpenOptions, TryLockError};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
@@ -118,76 +116,10 @@ fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("photocraft.{n}.log")) }
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// 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)> {
|
||||
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),
|
||||
@@ -196,40 +128,7 @@ fn rotate_locked(dir: &Path) -> std::io::Result<LogFile> {
|
||||
}
|
||||
let path = numbered(dir, 0);
|
||||
let file = File::create(&path)?;
|
||||
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>,
|
||||
Ok((path, file))
|
||||
}
|
||||
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
@@ -306,20 +205,17 @@ 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)), owner: Mutex::new(None) }
|
||||
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
|
||||
}
|
||||
|
||||
/// 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 LogFile { path, file, owner } = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let (path, file) = 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)
|
||||
}
|
||||
}
|
||||
@@ -422,7 +318,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 LogFile { path, file: _file, owner: _owner } = rotate(&dir).expect("rotate");
|
||||
let (path, _file) = 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");
|
||||
@@ -434,64 +330,6 @@ 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");
|
||||
@@ -517,7 +355,7 @@ mod tests {
|
||||
#[test]
|
||||
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
|
||||
let dir = temp_dir("pending");
|
||||
let LogFile { path, file, .. } = rotate(&dir).expect("rotate");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(MAX_FILE_BYTES);
|
||||
sink.write("early 1\n");
|
||||
sink.write("early 2\n");
|
||||
@@ -530,7 +368,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
|
||||
let dir = temp_dir("bounded");
|
||||
let LogFile { path, file, .. } = rotate(&dir).expect("rotate");
|
||||
let (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"));
|
||||
@@ -546,7 +384,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_file_stops_at_its_size_cap_with_one_note() {
|
||||
let dir = temp_dir("cap");
|
||||
let LogFile { path, file, .. } = rotate(&dir).expect("rotate");
|
||||
let (path, file) = rotate(&dir).expect("rotate");
|
||||
let mut sink = Sink::new(100);
|
||||
sink.attach(file);
|
||||
for i in 0..50 {
|
||||
|
||||
+24
-226
@@ -1,23 +1,8 @@
|
||||
//! 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…]`
|
||||
//! (`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
|
||||
//! `--control` launch (see `single_instance`).
|
||||
//!
|
||||
//! `--present-mode` (`fifo`, `autovsync`, `novsync`) and `--frame-latency` (1–3) override how
|
||||
//! frames reach the display for this launch (`PHOTOCRAFT_PRESENT_MODE` / `…_FRAME_LATENCY`;
|
||||
//! `presentation`); `--stroke-trace` times each brush stroke's frames (`PHOTOCRAFT_STROKE_TRACE`;
|
||||
//! `photocraft_ui_egui::stroke_trace`).
|
||||
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [files…]`
|
||||
//!
|
||||
//! `--safe-gpu` starts with the CPU renderer (no GPU canvas; a software adapter for the window
|
||||
//! where the platform has one) for this launch, e.g. after a graphics driver crash. A start that
|
||||
@@ -59,18 +44,8 @@ mod linux_libs;
|
||||
mod logging;
|
||||
mod monitor_profile;
|
||||
mod photoshop_settings;
|
||||
// Save As through the desktop portal, which reports the file type picked (#6121).
|
||||
#[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).
|
||||
#[cfg(any(not(target_os = "macos"), test))]
|
||||
mod single_instance;
|
||||
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", test))]
|
||||
mod tablet;
|
||||
@@ -157,63 +132,21 @@ 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 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> {
|
||||
/// 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> {
|
||||
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::{parse_control_port, usage_exit_code};
|
||||
use super::{control_args_exit_code, parse_control_port};
|
||||
|
||||
#[test]
|
||||
fn control_port_errors_exit_non_zero() {
|
||||
assert_eq!(usage_exit_code(&[]), None);
|
||||
assert_eq!(control_args_exit_code(&[]), None);
|
||||
let errors = vec![parse_control_port("nope", "--control").unwrap_err()];
|
||||
assert_eq!(usage_exit_code(&errors), Some(2));
|
||||
assert_eq!(control_args_exit_code(&errors), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -241,11 +174,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 usage_errors: Vec<String> = Vec::new();
|
||||
let mut control_arg_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) => usage_errors.push(error),
|
||||
Err(error) => control_arg_errors.push(error),
|
||||
}
|
||||
}
|
||||
let mut control_token = None;
|
||||
@@ -258,98 +191,45 @@ fn main() -> eframe::Result {
|
||||
// reported in a notice once the app is up; a control token is a usage error like a bad port.
|
||||
let mut unreadable_paths: Vec<String> = Vec::new();
|
||||
let mut safe_gpu = false;
|
||||
let mut new_instance = false;
|
||||
// Options that apply only to the process they're given to; a launch with any keeps its own.
|
||||
let mut launch_options: Vec<&'static str> = Vec::new();
|
||||
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) => usage_errors.push(error),
|
||||
Err(error) => control_arg_errors.push(error),
|
||||
},
|
||||
None => usage_errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
|
||||
None => control_arg_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)) => usage_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
|
||||
Some(Err(raw)) => control_arg_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),
|
||||
Some("--automation-read-root") => {
|
||||
launch_options.push("--automation-read-root");
|
||||
automation_read_root = args.next().map(std::path::PathBuf::from);
|
||||
}
|
||||
Some("--automation-write-root") => {
|
||||
launch_options.push("--automation-write-root");
|
||||
automation_write_root = args.next().map(std::path::PathBuf::from);
|
||||
}
|
||||
Some("--safe-gpu") => {
|
||||
launch_options.push("--safe-gpu");
|
||||
safe_gpu = true;
|
||||
}
|
||||
Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => {
|
||||
launch_options.push(match flag {
|
||||
"--present-mode" => "--present-mode",
|
||||
"--frame-latency" => "--frame-latency",
|
||||
_ => "--stroke-trace",
|
||||
});
|
||||
let flag = flag.to_string();
|
||||
match args.next().map(std::ffi::OsString::into_string) {
|
||||
Some(Ok(v)) => match flag.as_str() {
|
||||
"--present-mode" => present_mode_arg = Some(v),
|
||||
"--frame-latency" => frame_latency_arg = Some(v),
|
||||
_ => stroke_trace_arg = Some(v),
|
||||
},
|
||||
// A bad value is reported and ignored; it never fails the launch.
|
||||
_ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"),
|
||||
}
|
||||
}
|
||||
Some("--in-window-menus") => {
|
||||
launch_options.push("--in-window-menus");
|
||||
in_window_menus = true;
|
||||
}
|
||||
Some("--new-instance") => new_instance = true,
|
||||
Some("--automation-read-root") => automation_read_root = args.next().map(std::path::PathBuf::from),
|
||||
Some("--automation-write-root") => automation_write_root = args.next().map(std::path::PathBuf::from),
|
||||
Some("--safe-gpu") => safe_gpu = true,
|
||||
Some("--in-window-menus") => in_window_menus = true,
|
||||
Some("--version") => {
|
||||
print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
|
||||
println!("photocraft {}", 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), 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 {
|
||||
// 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 {
|
||||
eprintln!("photocraft: {error}");
|
||||
}
|
||||
eprintln!("Run `photocraft --help` for usage.");
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
@@ -363,48 +243,6 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
|
||||
// One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs
|
||||
// become tabs in its window. Only with the per-user settings folder (a portable copy's may be
|
||||
// shared between users), and not for automation, `--new-instance` or per-process options.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let single_instance = {
|
||||
let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok();
|
||||
let context = single_instance::LaunchContext {
|
||||
per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform,
|
||||
new_instance_flag: new_instance,
|
||||
new_instance_env: new_instance_env.as_deref(),
|
||||
control: control_port.is_some(),
|
||||
unreadable_paths: !unreadable_paths.is_empty(),
|
||||
launch_options: &launch_options,
|
||||
};
|
||||
match (single_instance::skip_reason(&context), services::config_dir()) {
|
||||
(Some(reason), _) => {
|
||||
log::info!("running as a separate PhotoCraft: {reason}");
|
||||
None
|
||||
}
|
||||
(None, None) => None,
|
||||
(None, Some(dir)) => {
|
||||
let paths: Vec<String> = files.iter().map(|f| single_instance::absolute(f)).collect();
|
||||
match single_instance::claim(&dir, &paths) {
|
||||
single_instance::Launch::Forwarded => {
|
||||
log::info!("handed {} file(s) to the running PhotoCraft", paths.len());
|
||||
return Ok(());
|
||||
}
|
||||
single_instance::Launch::Primary(primary) => Some(primary),
|
||||
single_instance::Launch::Standalone(reason) => {
|
||||
log::warn!("starting a separate PhotoCraft: {reason}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
// Lives until the event loop returns, then `shutdown` lets the next launch become the primary.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let single_instance = &single_instance;
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = (new_instance, &launch_options);
|
||||
|
||||
// The display server to open the window on: Xwayland for a pen on Wayland, which gives this
|
||||
// app no pen input (#639).
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -526,21 +364,6 @@ fn main() -> eframe::Result {
|
||||
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
|
||||
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
|
||||
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
|
||||
// Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about
|
||||
// 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers.
|
||||
if gpu_startup::read_low_latency(services::prefs_file().as_deref()) {
|
||||
options.wgpu_options.surface.present_mode = eframe::wgpu::PresentMode::AutoNoVsync;
|
||||
}
|
||||
// Testers' present mode and frame latency overrides (#1343), after the preference.
|
||||
let present = presentation::Overrides::from_args_or_env(present_mode_arg.as_deref(), frame_latency_arg.as_deref());
|
||||
options.wgpu_options.surface = present.apply(options.wgpu_options.surface);
|
||||
let present_desc = presentation::describe(&options.wgpu_options.surface, &present);
|
||||
// Without vsync, frames are capped at 240 fps (#4472) so repaint loops don't spin flat out.
|
||||
let uncapped = !matches!(
|
||||
options.wgpu_options.surface.present_mode,
|
||||
eframe::wgpu::PresentMode::Fifo | eframe::wgpu::PresentMode::FifoRelaxed | eframe::wgpu::PresentMode::AutoVsync
|
||||
);
|
||||
log::info!("presentation: {present_desc}");
|
||||
let sentinel_ms = t_sentinel.elapsed().as_secs_f64() * 1000.0;
|
||||
log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan);
|
||||
let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu;
|
||||
@@ -568,16 +391,7 @@ fn main() -> eframe::Result {
|
||||
services::xwayland_command(var("FLATPAK_ID").as_deref(), var("APPIMAGE").as_deref(), std::env::var_os("DISPLAY").is_some());
|
||||
}
|
||||
}
|
||||
#[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);
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.custom_titlebar = custom_titlebar;
|
||||
// Only the title bar's free gap drags the window, never the menus (mac_window.rs).
|
||||
@@ -609,10 +423,6 @@ fn main() -> eframe::Result {
|
||||
// composites on the CPU).
|
||||
let info = &mut app.perf.gpu_info;
|
||||
info.preference = pref.name().to_string();
|
||||
info.present = present_desc.clone();
|
||||
if let Some(v) = &stroke_trace_arg {
|
||||
app.stroke_trace.set(v);
|
||||
}
|
||||
info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() };
|
||||
info.fallback = match (plan.reason.clone(), gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()) {
|
||||
(Some(reason), Some(note)) => Some(format!("{reason}. {note}")),
|
||||
@@ -675,11 +485,6 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = in_window_menus;
|
||||
// Files from later launches (Explorer, file managers) open here as new tabs.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if let Some(primary) = single_instance {
|
||||
app.services.os_events = Some(primary.connect(&cc.egui_ctx));
|
||||
}
|
||||
// Where file drags and drops are (winit 0.30 doesn't say).
|
||||
app.services.cursor_pos = cursor::service(cc);
|
||||
// The custom title bar's undecorated window must stay borderless, or its caption and
|
||||
@@ -717,13 +522,6 @@ fn main() -> eframe::Result {
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
);
|
||||
// The window is gone: refuse further files and free the lock, so a launch that is waiting (or
|
||||
// the CPU-compatibility relaunch below) becomes the primary instead of handing its files to a
|
||||
// process that is exiting.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if let Some(primary) = single_instance {
|
||||
primary.shutdown();
|
||||
}
|
||||
// Closed or failed outside graphics initialization: not a driver crash. A renderer error
|
||||
// keeps the marker, so the next start tries a safer backend, unless there is no safer one:
|
||||
// a failed CPU start would otherwise pin every later start to CPU.
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
//! File › Save As through the XDG desktop portal on Linux (#6121, #4328, #4394).
|
||||
//!
|
||||
//! The GTK file chooser behind the portal keeps the typed name when the user picks another file
|
||||
//! type, and rfd 0.17 reads only the file name from the portal's answer, so `image.psd` with PNG
|
||||
//! picked came back as `image.psd` and was written as a PSD. The answer does say which type was
|
||||
//! picked (`current_filter`), so Save As asks the portal itself and gives a name the dialog left
|
||||
//! alone that type's extension ([`crate::services::with_chosen_type`]). rfd stays the fallback
|
||||
//! (zenity, or no portal), and this can go once an rfd release reads the picked type
|
||||
//! (PolyMeilex/rfd#341).
|
||||
//!
|
||||
//! The dialog is attached to the window on X11. On Wayland it has no parent: naming the window to
|
||||
//! the portal needs an xdg-foreign handle, which takes `unsafe` code the workspace forbids.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
|
||||
|
||||
/// One file type in the dialog: its label and extensions (`("PNG", ["png"])`).
|
||||
pub type Filter = (String, Vec<String>);
|
||||
|
||||
/// A Save dialog to show.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SaveRequest {
|
||||
/// The portal's parent window identifier (`x11:<xid>`), or empty.
|
||||
pub parent: String,
|
||||
/// The name the dialog shows: the stem of the suggested file (`test`), see
|
||||
/// `services::dialog_file_name`.
|
||||
pub name: Option<String>,
|
||||
/// The suggested file's name (`test.psd`): a name that comes back with its extension still
|
||||
/// takes the extension of the type picked ([`crate::services::with_chosen_type`]).
|
||||
pub suggested: Option<String>,
|
||||
pub folder: Option<PathBuf>,
|
||||
pub filters: Vec<Filter>,
|
||||
/// The filter the dialog starts on.
|
||||
pub current: Option<usize>,
|
||||
}
|
||||
|
||||
/// What the user chose: the path, as typed, and the label of the file type picked.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Saved {
|
||||
pub path: PathBuf,
|
||||
pub filter: Option<String>,
|
||||
}
|
||||
|
||||
/// The portal's parent window identifier for `window`: `x11:<xid>` on X11, empty elsewhere.
|
||||
pub fn parent_id(window: Option<eframe::wgpu::rwh::RawWindowHandle>) -> String {
|
||||
use eframe::wgpu::rwh::RawWindowHandle;
|
||||
match window {
|
||||
Some(RawWindowHandle::Xlib(h)) => format!("x11:{:x}", h.window),
|
||||
Some(RawWindowHandle::Xcb(h)) => format!("x11:{:x}", h.window.get()),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the portal's Save dialog and waits for it. `Ok(None)` is Cancel; `Err` means the portal
|
||||
/// couldn't show it (no portal, no FileChooser), so the caller falls back to rfd.
|
||||
pub fn save_file(conn: &zbus::blocking::Connection, req: &SaveRequest) -> Result<Option<Saved>, String> {
|
||||
use futures_lite::StreamExt;
|
||||
use zbus::blocking::Proxy;
|
||||
let unique = conn.unique_name().ok_or("Portal connection has no unique name")?.as_str().trim_start_matches(':').replace('.', "_");
|
||||
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let token = format!("photocraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{unique}/{token}");
|
||||
// Listen before asking, so a quick answer isn't missed.
|
||||
let request = Proxy::new(conn, "org.freedesktop.portal.Desktop", path.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
|
||||
let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?;
|
||||
let chooser = Proxy::new(conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let handle: OwnedObjectPath = chooser.call("SaveFile", &(req.parent.as_str(), "Save As", options(req, &token))).map_err(|e| e.to_string())?;
|
||||
// A portal older than 0.9 ignores `handle_token` and answers on a request path of its own,
|
||||
// which it returns: listen there instead, as the Request documentation asks.
|
||||
let reply = if handle.as_str() == path {
|
||||
futures_lite::future::block_on(replies.next())
|
||||
} else {
|
||||
let request = Proxy::new(conn, "org.freedesktop.portal.Desktop", handle.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
|
||||
let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?;
|
||||
futures_lite::future::block_on(replies.next())
|
||||
}
|
||||
.ok_or("The save dialog closed without an answer")?;
|
||||
let (code, results): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
|
||||
answer(code, &results)
|
||||
}
|
||||
|
||||
/// The `SaveFile` options for `req`.
|
||||
fn options<'a>(req: &'a SaveRequest, token: &'a str) -> HashMap<&'static str, Value<'a>> {
|
||||
let mut o: HashMap<&'static str, Value<'a>> = HashMap::from([("handle_token", Value::from(token)), ("modal", Value::from(true))]);
|
||||
if let Some(name) = &req.name {
|
||||
o.insert("current_name", Value::from(name.as_str()));
|
||||
}
|
||||
if let Some(folder) = &req.folder {
|
||||
// A byte string with its terminating NUL, as the portal specifies.
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let mut bytes = folder.as_os_str().as_bytes().to_vec();
|
||||
bytes.push(0);
|
||||
o.insert("current_folder", Value::from(bytes));
|
||||
}
|
||||
let wire = |(label, exts): &Filter| (label.clone(), exts.iter().map(|e| (0u32, format!("*.{e}"))).collect::<Vec<_>>());
|
||||
if !req.filters.is_empty() {
|
||||
o.insert("filters", Value::from(req.filters.iter().map(wire).collect::<Vec<_>>()));
|
||||
}
|
||||
if let Some(f) = req.current.and_then(|i| req.filters.get(i)) {
|
||||
o.insert("current_filter", Value::from(wire(f)));
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// Reads the portal's `Response`: code 0 with the first `uris` entry. 1 is Cancel, and 2 a dialog
|
||||
/// closed some other way, such as with Esc in GNOME's: no file either, and not a reason to show
|
||||
/// rfd's dialog instead (it did, so Esc opened a second Save As).
|
||||
fn answer(code: u32, results: &HashMap<String, OwnedValue>) -> Result<Option<Saved>, String> {
|
||||
match code {
|
||||
0 => {}
|
||||
1 | 2 => return Ok(None),
|
||||
_ => return Err(format!("the save dialog failed (portal response {code})")),
|
||||
}
|
||||
let uris: Vec<String> = results
|
||||
.get("uris")
|
||||
.ok_or("no file in the portal's answer")?
|
||||
.try_clone()
|
||||
.map_err(|e| e.to_string())?
|
||||
.try_into()
|
||||
.map_err(|e: zbus::zvariant::Error| e.to_string())?;
|
||||
let uri = uris.first().ok_or("no file in the portal's answer")?;
|
||||
let path = file_uri_path(uri).ok_or_else(|| format!("not a local file: {uri}"))?;
|
||||
let filter = results.get("current_filter").and_then(|v| {
|
||||
let (label, _globs): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
|
||||
Some(label)
|
||||
});
|
||||
Ok(Some(Saved { path, filter }))
|
||||
}
|
||||
|
||||
/// `file:///home/me/A%20b.psd` → `/home/me/A b.psd`; `None` for other schemes.
|
||||
fn file_uri_path(uri: &str) -> Option<PathBuf> {
|
||||
let rest = uri.strip_prefix("file://")?;
|
||||
// Only local files: `file:///…` or `file://localhost/…`.
|
||||
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
|
||||
if !rest.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
let bytes = rest.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%'
|
||||
&& let Some(b) = bytes.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok())
|
||||
{
|
||||
out.push(b);
|
||||
i += 3;
|
||||
} else {
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
Some(PathBuf::from(std::ffi::OsString::from_vec(out)))
|
||||
}
|
||||
|
||||
/// Save As on Linux: the portal's dialog, with the name given the extension of the file type
|
||||
/// picked. When that changes the name to one that already exists, the dialog opens again under
|
||||
/// the new name, so the portal asks before replacing it (it asked only about the typed name).
|
||||
pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Result<Option<PathBuf>, String> {
|
||||
loop {
|
||||
let Some(saved) = save_file(conn, &req)? else { return Ok(None) };
|
||||
let picked = saved.filter.as_deref().and_then(|label| req.filters.iter().position(|(l, _)| l == label));
|
||||
// With one type offered there is nothing to pick, and a typed extension is the user's.
|
||||
let exts: &[String] = match picked {
|
||||
Some(i) if req.filters.len() > 1 => &req.filters[i].1,
|
||||
_ => &[],
|
||||
};
|
||||
let path = crate::services::with_chosen_type(&saved.path, req.suggested.as_deref(), exts);
|
||||
if path == saved.path || !path.exists() {
|
||||
return Ok(Some(path));
|
||||
}
|
||||
req.name = path.file_name().map(|n| n.to_string_lossy().into_owned());
|
||||
req.suggested = req.name.clone();
|
||||
req.folder = path.parent().map(Path::to_path_buf);
|
||||
req.current = picked;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,232 +0,0 @@
|
||||
use super::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[test]
|
||||
fn file_uris_become_local_paths() {
|
||||
assert_eq!(file_uri_path("file:///home/me/Untitled-1.psd"), Some(PathBuf::from("/home/me/Untitled-1.psd")));
|
||||
assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.png"), Some(PathBuf::from("/home/me/A bé.png")));
|
||||
assert_eq!(file_uri_path("file://localhost/tmp/x.png"), Some(PathBuf::from("/tmp/x.png")));
|
||||
assert_eq!(file_uri_path("sftp://host/x.png"), None);
|
||||
assert_eq!(file_uri_path("file://host/x.png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x11_windows_are_named_to_the_portal() {
|
||||
use eframe::wgpu::rwh::{RawWindowHandle, XlibWindowHandle};
|
||||
assert_eq!(parent_id(Some(RawWindowHandle::Xlib(XlibWindowHandle::new(0x3a0000f)))), "x11:3a0000f");
|
||||
assert_eq!(parent_id(None), "");
|
||||
}
|
||||
|
||||
/// What the mock portal answers to one `SaveFile` call: the response code, and the file and
|
||||
/// filter label the user "picked".
|
||||
type Script = Vec<(u32, Option<(&'static str, &'static str)>)>;
|
||||
|
||||
/// The options of each `SaveFile` call the mock portal received: current_name and the label of
|
||||
/// current_filter.
|
||||
type Seen = Arc<Mutex<Vec<(Option<String>, Option<String>)>>>;
|
||||
|
||||
/// A private session bus (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser` that
|
||||
/// answers `SaveFile` from `script`. `None` when there is no `dbus-daemon` to run.
|
||||
fn mock_portal(script: Script) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
|
||||
mock_portal_with(script, false)
|
||||
}
|
||||
|
||||
/// [`mock_portal`]; with `own_paths` it is a portal older than 0.9, which ignores `handle_token`,
|
||||
/// answers on a request path of its own and returns that from `SaveFile`, as a real dialog does
|
||||
/// once the user is done with it (here after 300 ms).
|
||||
fn mock_portal_with(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
|
||||
use std::io::BufRead;
|
||||
let mut daemon =
|
||||
std::process::Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(std::process::Stdio::piped()).spawn().ok()?;
|
||||
let mut address = String::new();
|
||||
std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
|
||||
let address = address.trim().to_string();
|
||||
let server = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap();
|
||||
let seen: Seen = Arc::default();
|
||||
let log = seen.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut script = script.into_iter();
|
||||
for msg in zbus::blocking::MessageIterator::from(&server) {
|
||||
let Ok(msg) = msg else { break };
|
||||
let header = msg.header();
|
||||
if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") {
|
||||
continue;
|
||||
}
|
||||
let (_parent, _title, options): (String, String, HashMap<String, OwnedValue>) = msg.body().deserialize().unwrap();
|
||||
let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok());
|
||||
let filter = options.get("current_filter").and_then(|v| {
|
||||
let (label, _): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
|
||||
Some(label)
|
||||
});
|
||||
log.lock().unwrap().push((text("current_name"), filter));
|
||||
let sender = header.sender().unwrap().as_str().trim_start_matches(':').replace('.', "_");
|
||||
let token = if own_paths { format!("old{}", log.lock().unwrap().len()) } else { text("handle_token").unwrap() };
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
|
||||
server.reply(&header, &OwnedObjectPath::try_from(path.as_str()).unwrap()).unwrap();
|
||||
if own_paths {
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
let (code, pick) = script.next().unwrap_or((2, None));
|
||||
let mut results: HashMap<&str, Value<'_>> = HashMap::new();
|
||||
if let Some((uri, label)) = pick {
|
||||
results.insert("uris", Value::from(vec![uri.to_string()]));
|
||||
results.insert("current_filter", Value::from((label.to_string(), vec![(0u32, "*.x".to_string())])));
|
||||
}
|
||||
server
|
||||
.emit_signal(
|
||||
header.sender().cloned().map(zbus::names::BusName::from),
|
||||
path.as_str(),
|
||||
"org.freedesktop.portal.Request",
|
||||
"Response",
|
||||
&(code, results),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
let client = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().build().unwrap();
|
||||
Some((client, seen, daemon))
|
||||
}
|
||||
|
||||
fn request(name: &str) -> SaveRequest {
|
||||
let filters = [("PSD Document", &["psd", "psb"][..]), ("PNG", &["png"]), ("JPEG", &["jpg"])];
|
||||
SaveRequest {
|
||||
parent: String::new(),
|
||||
name: Some(name.into()),
|
||||
suggested: Some(name.into()),
|
||||
folder: Some(PathBuf::from("/tmp")),
|
||||
filters: filters.iter().map(|(l, e)| (l.to_string(), e.iter().map(|s| s.to_string()).collect())).collect(),
|
||||
current: Some(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// #6121 end to end through a real D-Bus: the name comes back as typed with the type picked,
|
||||
/// and Save As writes it with that type's extension.
|
||||
#[test]
|
||||
fn save_as_follows_the_file_type_picked_in_the_portal_dialog() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-portal-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let typed = format!("file://{}/Untitled-1.psd", dir.display());
|
||||
let typed: &'static str = Box::leak(typed.into_boxed_str());
|
||||
let Some((conn, seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "PNG"))), (1, None), (0, Some((typed, "PSD Document")))]) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
// PNG picked with `Untitled-1.psd` still in the name: written as Untitled-1.png.
|
||||
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(dir.join("Untitled-1.png")));
|
||||
assert_eq!(seen.lock().unwrap()[0], (Some("Untitled-1.psd".into()), Some("PSD Document".into())), "the request carried the name and type");
|
||||
// Cancel.
|
||||
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), None);
|
||||
// The type the name already has: unchanged.
|
||||
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(dir.join("Untitled-1.psd")));
|
||||
daemon.kill().ok();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// The dialog opens on the bare name (`test`, see `services::dialog_file_name`), so GTK doesn't
|
||||
/// ask to replace test.psd when PNG is picked: the type picked gives the extension, and a name
|
||||
/// that then exists is asked about under that name.
|
||||
#[test]
|
||||
fn a_bare_name_takes_the_picked_type_and_an_existing_target_is_asked_about() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-portal-bare-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("test.psd"), b"open document").unwrap();
|
||||
let uri = |name: &str| -> &'static str { Box::leak(format!("file://{}/{name}", dir.display()).into_boxed_str()) };
|
||||
let Some((conn, seen, mut daemon)) = mock_portal(vec![
|
||||
(0, Some((uri("test"), "PNG"))),
|
||||
(0, Some((uri("test.psd"), "PNG"))),
|
||||
(0, Some((uri("test"), "PSD Document"))),
|
||||
(0, Some((uri("test.psd"), "PSD Document"))),
|
||||
]) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
// As File › Save As opens it: `test` shown, `test.psd` suggested.
|
||||
let opened = || SaveRequest { suggested: Some("test.psd".into()), ..request("test") };
|
||||
// PNG picked: test.png, and nothing was asked about test.psd.
|
||||
assert_eq!(save_as(&conn, opened()).unwrap(), Some(dir.join("test.png")));
|
||||
// The suggested extension back in the name (typed again, or GTK's completion) with PNG
|
||||
// picked: test.png too, not a PSD written over test.psd.
|
||||
assert_eq!(save_as(&conn, opened()).unwrap(), Some(dir.join("test.png")));
|
||||
// The type untouched: test.psd, which exists, so the dialog asks again as test.psd (the
|
||||
// portal's replace prompt is then about the file that is written).
|
||||
assert_eq!(save_as(&conn, opened()).unwrap(), Some(dir.join("test.psd")));
|
||||
let seen = seen.lock().unwrap().clone();
|
||||
let names: Vec<_> = seen.iter().map(|(name, _)| name.as_deref()).collect();
|
||||
assert_eq!(names, [Some("test"), Some("test"), Some("test"), Some("test.psd")], "{seen:?}");
|
||||
assert_eq!(seen[3].1.as_deref(), Some("PSD Document"));
|
||||
daemon.kill().ok();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// GNOME's dialog closed with Esc answers 2 ("ended some other way"): no file, like Cancel, and
|
||||
/// no fallback to rfd, which opened a second Save As.
|
||||
#[test]
|
||||
fn a_dialog_closed_some_other_way_is_a_cancel() {
|
||||
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(2, None)]) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
assert_eq!(save_as(&conn, request("test")), Ok(None));
|
||||
daemon.kill().ok();
|
||||
}
|
||||
|
||||
/// An extension the user typed is theirs: `art.png` with the PSD type left as the dialog opened
|
||||
/// saves a PNG, as it did before the type was read.
|
||||
#[test]
|
||||
fn a_typed_extension_wins_over_an_untouched_file_type() {
|
||||
let typed: &'static str = "file:///tmp/photocraft-none/art.png";
|
||||
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "PSD Document")))]) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/art.png")));
|
||||
daemon.kill().ok();
|
||||
}
|
||||
|
||||
/// A portal older than 0.9 answers on a request path of its own: the wait follows the handle
|
||||
/// `SaveFile` returns, as the Request documentation asks, instead of the predicted path.
|
||||
#[test]
|
||||
fn the_answer_is_awaited_on_the_handle_the_portal_returns() {
|
||||
let typed: &'static str = "file:///tmp/photocraft-none/Untitled-1.psd";
|
||||
let Some((conn, _seen, mut daemon)) = mock_portal_with(vec![(0, Some((typed, "PNG")))], true) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Untitled-1.png")));
|
||||
daemon.kill().ok();
|
||||
}
|
||||
|
||||
/// When the extension the type gives names a file that exists, the dialog opens again under that
|
||||
/// name (so the portal asks before replacing it) instead of overwriting it unasked.
|
||||
#[test]
|
||||
fn a_renamed_file_that_exists_is_confirmed_in_the_dialog() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-portal-exists-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("art.png"), b"old").unwrap();
|
||||
let typed: &'static str = Box::leak(format!("file://{}/art.psd", dir.display()).into_boxed_str());
|
||||
let confirmed: &'static str = Box::leak(format!("file://{}/art.png", dir.display()).into_boxed_str());
|
||||
let Some((conn, seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "PNG"))), (0, Some((confirmed, "PNG")))]) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
assert_eq!(save_as(&conn, request("art.psd")).unwrap(), Some(dir.join("art.png")));
|
||||
let seen = seen.lock().unwrap().clone();
|
||||
assert_eq!(seen.len(), 2, "asked again: {seen:?}");
|
||||
assert_eq!(seen[1], (Some("art.png".into()), Some("PNG".into())), "under the new name, on the picked type");
|
||||
assert_eq!(std::fs::read(dir.join("art.png")).unwrap(), b"old", "nothing written by the dialog");
|
||||
daemon.kill().ok();
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// With only one type offered (the Swatches panel's .aco) a typed extension is kept.
|
||||
#[test]
|
||||
fn a_single_file_type_keeps_the_typed_extension() {
|
||||
let typed: &'static str = "file:///tmp/photocraft-none/Swatches.ase";
|
||||
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "Color Swatches")))]) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let req = SaveRequest { filters: vec![("Color Swatches".into(), vec!["aco".into()])], ..request("Swatches.aco") };
|
||||
assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.ase")));
|
||||
daemon.kill().ok();
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
//! Launch overrides for how frames reach the display, so testers can compare input-to-pixel
|
||||
//! latency without a rebuild (#1343): `PHOTOCRAFT_PRESENT_MODE` (`fifo`, `autovsync`, or `novsync`,
|
||||
//! also spelled `immediate`, `mailbox` or `autonovsync`: no vsync, wgpu's Immediate → Mailbox → Fifo
|
||||
//! by what the driver offers) and `PHOTOCRAFT_FRAME_LATENCY` (`1` to `3`, frames the swap chain may
|
||||
//! queue). Unset, the platform default applies. A value that isn't one of these is ignored with a
|
||||
//! warning in the log, never a failed start; a mode the driver may not offer is never requested.
|
||||
//! The result is logged at start and shown in the stroke trace.
|
||||
|
||||
use eframe::egui_wgpu::SurfaceConfig;
|
||||
use eframe::wgpu::PresentMode;
|
||||
|
||||
/// The present mode and frame latency this launch asked for (`None`: the default).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Overrides {
|
||||
pub mode: Option<PresentMode>,
|
||||
pub latency: Option<u32>,
|
||||
}
|
||||
|
||||
/// Parse the two variables' values. Returns the overrides and a warning per rejected value.
|
||||
pub fn parse(mode: Option<&str>, latency: Option<&str>) -> (Overrides, Vec<String>) {
|
||||
let mut warnings = Vec::new();
|
||||
let mode = mode.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| {
|
||||
let m = match v.to_ascii_lowercase().as_str() {
|
||||
// Only modes every surface takes: wgpu crashes on an explicit Immediate, Mailbox or
|
||||
// FifoRelaxed the driver doesn't offer (X11/NVIDIA has no Mailbox, #1343). Without
|
||||
// vsync, wgpu picks Immediate, else Mailbox, else Fifo.
|
||||
"fifo" | "vsync" => PresentMode::Fifo,
|
||||
"autovsync" => PresentMode::AutoVsync,
|
||||
"novsync" | "immediate" | "mailbox" | "autonovsync" => PresentMode::AutoNoVsync,
|
||||
_ => {
|
||||
warnings.push(format!("PHOTOCRAFT_PRESENT_MODE={v:?} isn't fifo, autovsync or novsync (immediate, mailbox); ignored"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(m)
|
||||
});
|
||||
let latency = latency.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| match v.parse::<u32>() {
|
||||
Ok(n @ 1..=3) => Some(n),
|
||||
_ => {
|
||||
warnings.push(format!("PHOTOCRAFT_FRAME_LATENCY={v:?} isn't 1, 2 or 3; ignored"));
|
||||
None
|
||||
}
|
||||
});
|
||||
(Overrides { mode, latency }, warnings)
|
||||
}
|
||||
|
||||
impl Overrides {
|
||||
/// `--present-mode` / `--frame-latency` when given, else `PHOTOCRAFT_PRESENT_MODE` /
|
||||
/// `PHOTOCRAFT_FRAME_LATENCY`; rejected values are logged.
|
||||
pub fn from_args_or_env(mode: Option<&str>, latency: Option<&str>) -> Self {
|
||||
let env_mode = std::env::var("PHOTOCRAFT_PRESENT_MODE").ok();
|
||||
let env_latency = std::env::var("PHOTOCRAFT_FRAME_LATENCY").ok();
|
||||
let (o, warnings) = parse(mode.or(env_mode.as_deref()), latency.or(env_latency.as_deref()));
|
||||
for w in warnings {
|
||||
log::warn!("{w}");
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// `surface` with the overrides applied.
|
||||
pub fn apply(&self, surface: SurfaceConfig) -> SurfaceConfig {
|
||||
SurfaceConfig {
|
||||
present_mode: self.mode.unwrap_or(surface.present_mode),
|
||||
desired_maximum_frame_latency: self.latency.or(surface.desired_maximum_frame_latency),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How `surface` presents, for the stroke trace and System Info: "fifo, latency 2", with
|
||||
/// "(override)" after a value an environment variable set. wgpu's GL backend ignores the latency.
|
||||
pub fn describe(surface: &SurfaceConfig, overrides: &Overrides) -> String {
|
||||
let mode = match surface.present_mode {
|
||||
PresentMode::AutoVsync => "autovsync",
|
||||
PresentMode::AutoNoVsync => "autonovsync",
|
||||
PresentMode::Fifo => "fifo",
|
||||
PresentMode::FifoRelaxed => "fiforelaxed",
|
||||
PresentMode::Immediate => "immediate",
|
||||
PresentMode::Mailbox => "mailbox",
|
||||
};
|
||||
let latency = surface.desired_maximum_frame_latency.map_or("default".to_string(), |n| n.to_string());
|
||||
let tag = |set: bool| if set { " (override)" } else { "" };
|
||||
format!("{mode}{}, latency {latency}{}", tag(overrides.mode.is_some()), tag(overrides.latency.is_some()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn values_parse_and_bad_ones_are_ignored_with_a_warning() {
|
||||
assert_eq!(parse(None, None), (Overrides::default(), vec![]));
|
||||
assert_eq!(parse(Some(""), Some(" ")), (Overrides::default(), vec![]));
|
||||
let (o, w) = parse(Some(" Mailbox "), Some("1"));
|
||||
assert_eq!((o.mode, o.latency, w.len()), (Some(PresentMode::AutoNoVsync), Some(1), 0));
|
||||
assert_eq!(parse(Some("vsync"), None).0.mode, Some(PresentMode::Fifo));
|
||||
// Never a mode a driver may lack (wgpu crashes on it): no vsync falls back by support.
|
||||
for v in ["IMMEDIATE", "mailbox", "novsync", "autonovsync"] {
|
||||
assert_eq!(parse(Some(v), None).0.mode, Some(PresentMode::AutoNoVsync), "{v}");
|
||||
}
|
||||
for (mode, latency) in
|
||||
[(Some("fast"), None), (None, Some("0")), (None, Some("4")), (None, Some("-1")), (None, Some("one")), (None, Some("99999999999"))]
|
||||
{
|
||||
let (o, w) = parse(mode, latency);
|
||||
assert_eq!((o, w.len()), (Overrides::default(), 1), "{mode:?} {latency:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overrides_replace_only_what_they_set_and_are_described() {
|
||||
let default = SurfaceConfig::HIGH_THROUGHPUT;
|
||||
assert_eq!(Overrides::default().apply(default), default);
|
||||
let latency_only = Overrides { mode: None, latency: Some(1) };
|
||||
let s = latency_only.apply(default);
|
||||
assert_eq!((s.present_mode, s.desired_maximum_frame_latency), (PresentMode::AutoVsync, Some(1)));
|
||||
assert_eq!(describe(&s, &latency_only), "autovsync, latency 1 (override)");
|
||||
let novsync = Overrides { mode: Some(PresentMode::AutoNoVsync), latency: None };
|
||||
assert_eq!(describe(&novsync.apply(default), &novsync), "autonovsync (override), latency 2");
|
||||
let none = SurfaceConfig { present_mode: PresentMode::Fifo, desired_maximum_frame_latency: None };
|
||||
assert_eq!(describe(&none, &Overrides::default()), "fifo, latency default");
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
//! 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")));
|
||||
}
|
||||
}
|
||||
+17
-127
@@ -95,51 +95,18 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
|
||||
v
|
||||
}
|
||||
|
||||
/// `path` with the extension of the file type picked in the save dialog (`chosen`), where the
|
||||
/// dialog left the name alone (#6121): the format follows the extension, and the GTK file chooser
|
||||
/// keeps the typed name when another type is picked. Only a name that still has the extension the
|
||||
/// dialog opened with (`opened_with`, `Untitled-1.psd` with PNG picked) or none gets the type's
|
||||
/// extension, as PolyMeilex/rfd#341 does. An extension the user typed that PhotoCraft saves
|
||||
/// (`art.png` with the PSD type untouched, the way to save a PNG before this) is theirs and stays,
|
||||
/// and so does one of the type's own (`.psb` for PSD). One PhotoCraft doesn't save (`scan.2024`)
|
||||
/// is part of the name, and the type's extension is added after it.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
pub(crate) fn with_chosen_type(path: &Path, opened_with: Option<&str>, chosen: &[String]) -> PathBuf {
|
||||
let Some(first) = chosen.first() else { return path.to_path_buf() };
|
||||
let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
|
||||
let opened_ext = opened_with.and_then(|n| Path::new(n).extension()).and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
|
||||
match ext {
|
||||
Some(ext) if chosen.contains(&ext) => path.to_path_buf(),
|
||||
Some(ext) if opened_ext.as_ref() == Some(&ext) => path.with_extension(first),
|
||||
Some(ext) if SAVE_FILTERS.iter().chain(OTHER_SAVE_FILTERS).any(|(_, exts)| exts.contains(&ext.as_str())) => path.to_path_buf(),
|
||||
_ => {
|
||||
let mut name = path.as_os_str().to_owned();
|
||||
name.push(".");
|
||||
name.push(first);
|
||||
PathBuf::from(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name the save dialog starts with. Where the type picked gives a bare name its extension
|
||||
/// (`adds_extension`), the dialog gets the stem and the suggested type (listed first by
|
||||
/// [`save_filters`]) supplies `.psd`:
|
||||
/// - On Windows a visible extension stays put when the user picks another type (with File name
|
||||
/// extensions shown, `image.psd` saved as PhotoCraft stayed `image.psd`, #4394).
|
||||
/// - On Linux the portal's dialog keeps the name as typed, and before returning asks whether to
|
||||
/// replace a file of that name, so `test.psd` with PNG picked asked to replace test.psd, which
|
||||
/// the save leaves alone (#6121). From the stem [`with_chosen_type`] gives the picked type's
|
||||
/// extension, and a name that then exists is asked about under that name
|
||||
/// (`portal_save::save_as`).
|
||||
///
|
||||
/// A stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case
|
||||
/// The name the save dialog starts with. On Windows a visible extension stays put when the user
|
||||
/// picks another type (with File name extensions shown, `image.psd` saved as PhotoCraft stayed
|
||||
/// `image.psd`, #4394), while a bare name gets the chosen type's extension, so there the dialog
|
||||
/// gets the stem and the suggested type (listed first by [`save_filters`]) supplies `.psd`. A
|
||||
/// stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case
|
||||
/// extension (the user's spelling) keeps the whole name. Elsewhere the whole name is kept: the
|
||||
/// macOS panel has no type list, and rfd's Linux dialogs don't say which type was picked.
|
||||
fn dialog_file_name(suggested: &str, adds_extension: bool) -> Option<String> {
|
||||
/// macOS panel has no type list, and on Linux rfd doesn't report which type was picked.
|
||||
fn dialog_file_name(suggested: &str, windows: bool) -> Option<String> {
|
||||
let path = Path::new(suggested);
|
||||
let name = path.file_name()?.to_string_lossy().into_owned();
|
||||
let (Some(stem), Some(ext)) = (path.file_stem(), path.extension().and_then(|e| e.to_str())) else { return Some(name) };
|
||||
let bare = adds_extension && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase();
|
||||
let bare = windows && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase();
|
||||
Some(if bare { stem.to_string_lossy().into_owned() } else { name })
|
||||
}
|
||||
|
||||
@@ -181,52 +148,17 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
|
||||
}
|
||||
FileDialogRequest::Save { suggested } => {
|
||||
let filters = save_filters(&suggested);
|
||||
let name = dialog_file_name(&suggested, cfg!(windows));
|
||||
let dir = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()).map(Path::to_path_buf);
|
||||
for (name, exts) in &filters {
|
||||
dialog = dialog.add_filter(name, exts);
|
||||
for (name, exts) in save_filters(&suggested) {
|
||||
dialog = dialog.add_filter(name, &exts);
|
||||
}
|
||||
if let Some(name) = &name {
|
||||
if let Some(name) = dialog_file_name(&suggested, cfg!(windows)) {
|
||||
dialog = dialog.set_file_name(name);
|
||||
}
|
||||
if let Some(dir) = &dir {
|
||||
if let Some(dir) = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
// On Linux the portal's own dialog, which reports the file type picked (#6121); rfd if
|
||||
// the portal can't show it.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use eframe::wgpu::rwh::HasWindowHandle;
|
||||
let parent = crate::portal_save::parent_id(parent.and_then(|p| p.window_handle().ok()).map(|h| h.as_raw()));
|
||||
let req = crate::portal_save::SaveRequest {
|
||||
parent,
|
||||
name: dialog_file_name(&suggested, true),
|
||||
suggested: Path::new(&suggested).file_name().map(|n| n.to_string_lossy().into_owned()),
|
||||
folder: dir,
|
||||
filters,
|
||||
current: Some(0),
|
||||
};
|
||||
Box::pin(async move {
|
||||
let portal = zbus::blocking::connection::Builder::session()
|
||||
.and_then(|b| b.build())
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|conn| crate::portal_save::save_as(&conn, req));
|
||||
match portal {
|
||||
Ok(path) => path.map(|p| FileDialogAnswer::SaveTo(p.to_string_lossy().into_owned())),
|
||||
Err(e) => {
|
||||
log::info!("portal save dialog unavailable ({e}), using rfd");
|
||||
dialog.save_file().await.map(|file| FileDialogAnswer::SaveTo(path_of(&file)))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = filters;
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
|
||||
}
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
|
||||
}
|
||||
};
|
||||
// Without the thread the reply is dropped unanswered, which the app takes as Cancel.
|
||||
@@ -264,8 +196,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
|
||||
/// except, on Windows, crash-recovery autosaves; see [`crate::app_dirs`].
|
||||
/// portable mode, else the platform convention. Everything the app persists lives under it; see
|
||||
/// [`crate::app_dirs`].
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
crate::app_dirs::config_dir()
|
||||
}
|
||||
@@ -280,10 +212,8 @@ 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> {
|
||||
crate::app_dirs::recovery_dir()
|
||||
config_dir().map(|d| d.join("Recovery"))
|
||||
}
|
||||
|
||||
/// Write `bytes` crash-safely (temp file beside the target, fsync, rename, directory fsync; see
|
||||
@@ -506,11 +436,6 @@ 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,
|
||||
@@ -660,41 +585,6 @@ mod tests {
|
||||
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// #6121: the file type picked in the save dialog gives the name its extension where the
|
||||
/// dialog left the one it opened with, and only there.
|
||||
#[test]
|
||||
fn the_picked_file_type_gives_the_name_its_extension() {
|
||||
let exts = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let (png, psd) = (exts(&["png"]), exts(&["psd", "psb"]));
|
||||
let opened = Some("Untitled-1.psd");
|
||||
let cases = [
|
||||
// PNG picked, the name as the dialog opened (or renamed, its extension kept).
|
||||
("/pics/Untitled-1.psd", opened, &png, "/pics/Untitled-1.png"),
|
||||
("/pics/art.psd", opened, &png, "/pics/art.png"),
|
||||
("/pics/ART.PSD", opened, &png, "/pics/ART.png"),
|
||||
// No extension: the type's. One PhotoCraft doesn't save is part of the name.
|
||||
("/pics/art", opened, &png, "/pics/art.png"),
|
||||
("/pics/scan.2024", opened, &png, "/pics/scan.2024.png"),
|
||||
// Typed, an extension PhotoCraft saves is the user's: kept, whatever the type says
|
||||
// (the PSD type untouched, or PNG picked and .jpg typed).
|
||||
("/pics/art.png", opened, &psd, "/pics/art.png"),
|
||||
("/pics/art.jpg", opened, &png, "/pics/art.jpg"),
|
||||
("/pics/art.pcraft", opened, &png, "/pics/art.pcraft"),
|
||||
// Already the type's: kept, including its other extensions.
|
||||
("/pics/art.png", opened, &png, "/pics/art.png"),
|
||||
("/pics/art.PNG", opened, &png, "/pics/art.PNG"),
|
||||
("/pics/big.psb", opened, &psd, "/pics/big.psb"),
|
||||
// The dialog opened on a .pcraft: that is the extension the type replaces.
|
||||
("/pics/art.pcraft", Some("art.pcraft"), &png, "/pics/art.png"),
|
||||
("/pics/art.psd", Some("art.pcraft"), &png, "/pics/art.psd"),
|
||||
];
|
||||
for (typed, opened, chosen, want) in cases {
|
||||
assert_eq!(with_chosen_type(Path::new(typed), opened, chosen), PathBuf::from(want), "{typed} (opened {opened:?})");
|
||||
}
|
||||
// No type picked: the name as typed.
|
||||
assert_eq!(with_chosen_type(Path::new("/pics/art.psd"), opened, &[]), PathBuf::from("/pics/art.psd"));
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn open_filters_cover_uppercase_and_mixed_case_extensions() {
|
||||
|
||||
@@ -1,648 +0,0 @@
|
||||
//! One PhotoCraft per user on Windows and Linux (#3708): a file opened from Explorer or a file
|
||||
//! manager lands as a new tab in the running window, like Photoshop, instead of starting a second
|
||||
//! copy of the program.
|
||||
//!
|
||||
//! The first launch takes an exclusive OS lock on `instance.lock` in the settings directory (the OS
|
||||
//! drops it when the process ends, crash included), listens on a loopback port and writes the port
|
||||
//! plus a fresh 256-bit token to `instance.json`. A later launch that finds the lock taken sends its
|
||||
//! paths there; the primary queues them for the UI as [`OsEvent::Open`], which opens each one and
|
||||
//! brings the window forward. Explorer starts one process per file when several are opened at once;
|
||||
//! the lock decides the race, so they all end up as tabs in one window.
|
||||
//!
|
||||
//! The primary answers `ok` only once the UI has taken the paths. A launch that hears anything else
|
||||
//! (the primary is still starting, hung, or quitting) keeps retrying the lock, so it becomes the
|
||||
//! primary itself as soon as the old one lets go, and when its time runs out it starts a separate
|
||||
//! instance: a file is never acknowledged and then dropped. [`Primary::shutdown`] runs when the
|
||||
//! event loop ends (and before the CPU-compatibility relaunch), so a closing PhotoCraft refuses new
|
||||
//! files and frees the lock.
|
||||
//!
|
||||
//! Only for the per-user settings folder ([`skip_reason`]): a portable copy's folder beside the
|
||||
//! executable, or a `PHOTOCRAFT_CONFIG_DIR`, may be shared between users.
|
||||
//! macOS doesn't need this: LaunchServices already sends a running app an open-documents event.
|
||||
|
||||
// Only the tests use it on macOS.
|
||||
#![cfg_attr(target_os = "macos", allow(dead_code))]
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{BufReader, Read, Write};
|
||||
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{SyncSender, sync_channel};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use photocraft_automation::security::{LineRead, generate_token, read_bounded_line, token_matches};
|
||||
use photocraft_ui_egui::{OsEvent, OsEventsFn};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const LOCK_FILE: &str = "instance.lock";
|
||||
const INFO_FILE: &str = "instance.json";
|
||||
/// More paths than this in one request is not a file manager.
|
||||
const MAX_PATHS: usize = 4096;
|
||||
/// Launches served at once; a stalled client holds only its own slot, never the next launch.
|
||||
const MAX_CLIENTS: usize = 8;
|
||||
/// Kept out of the hand-off's budget, so the primary answers before the launch gives up on it.
|
||||
const REPLY_MARGIN: Duration = Duration::from_millis(500);
|
||||
|
||||
/// How long each side waits (shortened in tests).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Timing {
|
||||
/// A later launch's whole budget for handing its paths over (or taking the lock over).
|
||||
pub handoff: Duration,
|
||||
/// How long the primary waits for its UI to take a launch's paths before refusing them.
|
||||
pub ack: Duration,
|
||||
/// Total time a connection gets to send its request line.
|
||||
pub request: Duration,
|
||||
}
|
||||
|
||||
/// Generous enough for a primary that is still opening its window.
|
||||
pub const TIMING: Timing = Timing { handoff: Duration::from_secs(10), ack: Duration::from_secs(8), request: Duration::from_secs(5) };
|
||||
|
||||
/// Outcome of [`claim`].
|
||||
pub enum Launch {
|
||||
/// This process is the running instance; keep [`Primary`] alive and attach it to the app.
|
||||
Primary(Primary),
|
||||
/// The running instance took the paths; this process should exit.
|
||||
Forwarded,
|
||||
/// Couldn't do either (reason given): run as an independent instance.
|
||||
Standalone(String),
|
||||
}
|
||||
|
||||
/// What decides whether a launch takes part (see [`skip_reason`]).
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct LaunchContext<'a> {
|
||||
/// The settings folder is the platform's per-user one (not portable, not an override).
|
||||
pub per_user_settings: bool,
|
||||
/// `--new-instance`.
|
||||
pub new_instance_flag: bool,
|
||||
/// `PHOTOCRAFT_NEW_INSTANCE`, if set.
|
||||
pub new_instance_env: Option<&'a str>,
|
||||
/// `--control` or `PHOTOCRAFT_CONTROL_PORT`: automation gets its own process.
|
||||
pub control: bool,
|
||||
/// Paths that aren't valid Unicode: this launch reports them.
|
||||
pub unreadable_paths: bool,
|
||||
/// Options that only apply to the process they're given to (`--safe-gpu`, …).
|
||||
pub launch_options: &'a [&'static str],
|
||||
}
|
||||
|
||||
/// Why this launch runs as its own instance, or `None` to take part in single-instance.
|
||||
pub fn skip_reason(c: &LaunchContext) -> Option<String> {
|
||||
if !c.per_user_settings {
|
||||
return Some("the settings folder isn't the per-user one (portable or PHOTOCRAFT_CONFIG_DIR)".into());
|
||||
}
|
||||
if c.new_instance_flag || c.new_instance_env.is_some_and(|v| !v.is_empty() && v != "0") {
|
||||
return Some("--new-instance".into());
|
||||
}
|
||||
if c.control {
|
||||
return Some("automation (--control) runs its own process".into());
|
||||
}
|
||||
if c.unreadable_paths {
|
||||
return Some("some paths aren't valid Unicode".into());
|
||||
}
|
||||
if !c.launch_options.is_empty() {
|
||||
return Some(format!("{} only applies to a new process", c.launch_options.join(", ")));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The running instance: holds the lock and receives other launches' paths.
|
||||
pub struct Primary {
|
||||
lock: Mutex<Option<File>>,
|
||||
shared: Arc<Shared>,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
inbox: Mutex<Inbox>,
|
||||
/// Set by [`Primary::shutdown`]: new connections are dropped, waiting ones refused.
|
||||
closed: AtomicBool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inbox {
|
||||
pending: Vec<Pending>,
|
||||
ctx: Option<egui::Context>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
/// Paths waiting for the UI; `taken` tells the connection that sent them.
|
||||
struct Pending {
|
||||
id: u64,
|
||||
paths: Vec<String>,
|
||||
taken: SyncSender<()>,
|
||||
}
|
||||
|
||||
impl Primary {
|
||||
/// The queue the UI drains (see `Services::os_events`); a later launch wakes `ctx`.
|
||||
pub fn connect(&self, ctx: &egui::Context) -> OsEventsFn {
|
||||
let mut inbox = self.shared.inbox.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if !inbox.pending.is_empty() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
inbox.ctx = Some(ctx.clone());
|
||||
drop(inbox);
|
||||
let shared = Arc::clone(&self.shared);
|
||||
Box::new(move || shared.take())
|
||||
}
|
||||
|
||||
/// Stop taking files and release the lock: launches waiting on this one are refused and take
|
||||
/// the lock over themselves. Call when the event loop has ended, and before relaunching.
|
||||
pub fn shutdown(&self) {
|
||||
self.shared.closed.store(true, Ordering::Release);
|
||||
// Dropping the senders refuses every connection still waiting for the UI.
|
||||
self.shared.inbox.lock().unwrap_or_else(PoisonError::into_inner).pending.clear();
|
||||
drop(self.lock.lock().unwrap_or_else(PoisonError::into_inner).take());
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Primary {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
/// Everything queued, acknowledged to the senders while the inbox is locked (see `hand_over`).
|
||||
fn take(&self) -> Vec<OsEvent> {
|
||||
let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
inbox
|
||||
.pending
|
||||
.drain(..)
|
||||
.map(|p| {
|
||||
let _ = p.taken.try_send(());
|
||||
OsEvent::Open(p.paths)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Queue `paths` for the UI and wait up to `wait` for it to take them. False (and nothing left
|
||||
/// queued) when it didn't: the sender then retries or starts its own instance.
|
||||
fn hand_over(&self, paths: Vec<String>, wait: Duration) -> bool {
|
||||
let (taken, rx) = sync_channel(1);
|
||||
let id = {
|
||||
let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return false;
|
||||
}
|
||||
let id = inbox.next_id;
|
||||
inbox.next_id = id.wrapping_add(1);
|
||||
inbox.pending.push(Pending { id, paths, taken });
|
||||
if let Some(ctx) = &inbox.ctx {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
id
|
||||
};
|
||||
if rx.recv_timeout(wait).is_ok() {
|
||||
return true;
|
||||
}
|
||||
// Timed out or refused: withdraw, unless the UI took them in the meantime (it acknowledges
|
||||
// while holding the lock, so the answer is settled here).
|
||||
let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if rx.try_recv().is_ok() {
|
||||
return true;
|
||||
}
|
||||
inbox.pending.retain(|p| p.id != id);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Become the running instance under `dir` or hand `paths` (absolute) to the one that is.
|
||||
pub fn claim(dir: &Path, paths: &[String]) -> Launch {
|
||||
claim_with(dir, paths, TIMING)
|
||||
}
|
||||
|
||||
/// [`claim`] with explicit timeouts. Until `timing.handoff` runs out, each round tries the lock
|
||||
/// (free once a quitting or crashed primary is gone) and then the running primary.
|
||||
pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
return Launch::Standalone(format!("{}: {e}", dir.display()));
|
||||
}
|
||||
let lock_path = dir.join(LOCK_FILE);
|
||||
let lock = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&lock_path) {
|
||||
Ok(lock) => lock,
|
||||
Err(e) => return Launch::Standalone(format!("{}: {e}", lock_path.display())),
|
||||
};
|
||||
let deadline = Instant::now() + timing.handoff;
|
||||
let mut last = "no instance.json".to_string();
|
||||
loop {
|
||||
match lock.try_lock() {
|
||||
Ok(()) => {
|
||||
return match serve(dir, lock, timing) {
|
||||
Ok(primary) => Launch::Primary(primary),
|
||||
Err(e) => Launch::Standalone(e),
|
||||
};
|
||||
}
|
||||
Err(std::fs::TryLockError::WouldBlock) => {}
|
||||
Err(std::fs::TryLockError::Error(e)) => return Launch::Standalone(format!("{}: {e}", lock_path.display())),
|
||||
}
|
||||
if let Some((port, token)) = read_info(dir) {
|
||||
match send(port, &token, paths, deadline) {
|
||||
Ok(()) => return Launch::Forwarded,
|
||||
Err(e) => last = e,
|
||||
}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Launch::Standalone(format!("the running PhotoCraft didn't take the files ({last})"));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(dir: &Path, lock: File, timing: Timing) -> Result<Primary, String> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("loopback listener: {e}"))?;
|
||||
let port = listener.local_addr().map_err(|e| format!("loopback listener: {e}"))?.port();
|
||||
let token = generate_token().map_err(|e| e.to_string())?;
|
||||
write_info(dir, port, &token)?;
|
||||
let shared = Arc::new(Shared { inbox: Mutex::default(), closed: AtomicBool::new(false) });
|
||||
let listening = Arc::clone(&shared);
|
||||
let spawned = std::thread::Builder::new().name("single-instance".into()).spawn(move || {
|
||||
let token = Arc::new(token);
|
||||
let clients = Arc::new(AtomicUsize::new(0));
|
||||
loop {
|
||||
let stream = match listener.accept() {
|
||||
Ok((stream, _)) => stream,
|
||||
// Back off instead of spinning when accept keeps failing (e.g. out of descriptors).
|
||||
Err(_) => {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Closing: the sender sees the connection drop and takes the lock over.
|
||||
if listening.closed.load(Ordering::Acquire) {
|
||||
continue;
|
||||
}
|
||||
// Each launch on its own thread, so one that connects and stalls doesn't hold the
|
||||
// ones after it; past the cap a connection is dropped and that launch retries.
|
||||
if clients.fetch_add(1, Ordering::AcqRel) >= MAX_CLIENTS {
|
||||
clients.fetch_sub(1, Ordering::AcqRel);
|
||||
continue;
|
||||
}
|
||||
let (token, shared, slot) = (Arc::clone(&token), Arc::clone(&listening), Arc::clone(&clients));
|
||||
let spawned = std::thread::Builder::new().name("single-instance-client".into()).spawn(move || {
|
||||
serve_client(stream, &token, &shared, timing);
|
||||
slot.fetch_sub(1, Ordering::AcqRel);
|
||||
});
|
||||
if spawned.is_err() {
|
||||
clients.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
});
|
||||
spawned.map_err(|e| format!("single-instance listener thread: {e}"))?;
|
||||
Ok(Primary { lock: Mutex::new(Some(lock)), shared })
|
||||
}
|
||||
|
||||
/// Read one request, hand its paths to the UI and answer `ok` only if the UI took them.
|
||||
fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing) {
|
||||
let taken = match read_request(&stream, timing.request) {
|
||||
Some(line) => match parse_request(&line, token) {
|
||||
Some((paths, wait)) => shared.hand_over(paths, wait.min(timing.ack)),
|
||||
None => false,
|
||||
},
|
||||
None => return,
|
||||
};
|
||||
let reply = if taken { json!({"ok": true}) } else { json!({"ok": false, "error": "not taken"}) };
|
||||
let mut out = stream;
|
||||
let _ = out.set_write_timeout(Some(timing.request));
|
||||
let _ = writeln!(out, "{reply}").and_then(|()| out.flush());
|
||||
}
|
||||
|
||||
/// A reader that fails once `until` passes, however slowly the bytes trickle in.
|
||||
struct Deadline<'a> {
|
||||
stream: &'a TcpStream,
|
||||
until: Instant,
|
||||
}
|
||||
|
||||
impl Read for Deadline<'_> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
let left = self.until.checked_duration_since(Instant::now()).filter(|d| !d.is_zero());
|
||||
let left = left.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::TimedOut, "request deadline passed"))?;
|
||||
self.stream.set_read_timeout(Some(left))?;
|
||||
let mut stream = self.stream;
|
||||
stream.read(buf)
|
||||
}
|
||||
}
|
||||
|
||||
/// The request line, if it arrives complete within `total`.
|
||||
fn read_request(stream: &TcpStream, total: Duration) -> Option<String> {
|
||||
let mut reader = BufReader::new(Deadline { stream, until: Instant::now() + total });
|
||||
let mut line = String::new();
|
||||
matches!(read_bounded_line(&mut reader, &mut line), Ok(LineRead::Line)).then_some(line)
|
||||
}
|
||||
|
||||
/// Port and token, written to a temporary file and renamed so a reader never sees half of it.
|
||||
/// On Unix the file is the user's alone (0600): another local user who read the token could make
|
||||
/// this app open any path. (Windows keeps the per-user ACL of the settings folder.)
|
||||
fn write_info(dir: &Path, port: u16, token: &str) -> Result<(), String> {
|
||||
let path = dir.join(INFO_FILE);
|
||||
let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id()));
|
||||
let body = json!({"port": port, "token": token, "pid": std::process::id()}).to_string();
|
||||
// A leftover from a crash could carry wider permissions; the mode applies only on create.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
let written = options.open(&tmp).and_then(|mut f| f.write_all(body.as_bytes())).and_then(|()| std::fs::rename(&tmp, &path));
|
||||
written.map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
format!("{}: {e}", path.display())
|
||||
})
|
||||
}
|
||||
|
||||
fn read_info(dir: &Path) -> Option<(u16, String)> {
|
||||
let v: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).ok()?).ok()?;
|
||||
let port = u16::try_from(v.get("port")?.as_u64()?).ok()?;
|
||||
Some((port, v.get("token")?.as_str()?.to_string()))
|
||||
}
|
||||
|
||||
/// `{"token":…,"open":[…],"wait_ms":…}`: the paths, and how long the sender will wait for the UI.
|
||||
fn parse_request(line: &str, token: &str) -> Option<(Vec<String>, Duration)> {
|
||||
let v: Value = serde_json::from_str(line).ok()?;
|
||||
if !token_matches(token, v.get("token")?.as_str()?) {
|
||||
return None;
|
||||
}
|
||||
let open = v.get("open")?.as_array()?;
|
||||
if open.len() > MAX_PATHS {
|
||||
return None;
|
||||
}
|
||||
let paths = open.iter().map(|p| p.as_str().map(str::to_string)).collect::<Option<Vec<_>>>()?;
|
||||
let wait = Duration::from_millis(v.get("wait_ms").and_then(Value::as_u64).unwrap_or(0));
|
||||
Some((paths, wait))
|
||||
}
|
||||
|
||||
/// Offer `paths` to the primary on `port`; `Ok` once its UI has taken them.
|
||||
fn send(port: u16, token: &str, paths: &[String], deadline: Instant) -> Result<(), String> {
|
||||
let left = deadline.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()).ok_or("out of time")?;
|
||||
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
|
||||
let mut stream = TcpStream::connect_timeout(&addr, left).map_err(|e| e.to_string())?;
|
||||
// The primary must answer before our deadline, so it waits a little less than we do.
|
||||
let wait_ms = u64::try_from(left.saturating_sub(REPLY_MARGIN).as_millis()).unwrap_or(u64::MAX);
|
||||
let request = json!({"token": token, "open": paths, "wait_ms": wait_ms});
|
||||
stream.set_write_timeout(Some(left)).map_err(|e| e.to_string())?;
|
||||
writeln!(stream, "{request}").and_then(|()| stream.flush()).map_err(|e| e.to_string())?;
|
||||
let mut reader = BufReader::new(Deadline { stream: &stream, until: deadline });
|
||||
let mut line = String::new();
|
||||
match read_bounded_line(&mut reader, &mut line) {
|
||||
Ok(LineRead::Line) => {}
|
||||
Ok(_) => return Err("no reply".into()),
|
||||
Err(e) => return Err(e.to_string()),
|
||||
}
|
||||
let ok = serde_json::from_str::<Value>(&line).ok().and_then(|v| v.get("ok")?.as_bool()) == Some(true);
|
||||
if ok { Ok(()) } else { Err("not taken".into()) }
|
||||
}
|
||||
|
||||
/// Absolute form of a command-line path, so the running instance (another working directory)
|
||||
/// opens the same file.
|
||||
pub fn absolute(path: &str) -> String {
|
||||
std::path::absolute(path).map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|_| path.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
const FAST: Timing = Timing { handoff: Duration::from_millis(1500), ack: Duration::from_millis(500), request: Duration::from_millis(800) };
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-single-instance-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
/// Launch with `paths` on another thread (it blocks until the primary's UI takes them).
|
||||
fn launch(dir: &Path, paths: &[&str], timing: Timing) -> std::thread::JoinHandle<Launch> {
|
||||
let (dir, paths): (PathBuf, Vec<String>) = (dir.to_path_buf(), paths.iter().map(|p| p.to_string()).collect());
|
||||
std::thread::spawn(move || claim_with(&dir, &paths, timing))
|
||||
}
|
||||
|
||||
/// Play the UI: drain until `n` events arrived (or 5 s passed).
|
||||
fn drain(ui: &mut OsEventsFn, n: usize) -> Vec<OsEvent> {
|
||||
let mut got = Vec::new();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while got.len() < n && Instant::now() < deadline {
|
||||
got.extend(ui());
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
got
|
||||
}
|
||||
|
||||
fn primary(dir: &Path) -> Primary {
|
||||
match claim_with(dir, &[], FAST) {
|
||||
Launch::Primary(p) => p,
|
||||
_ => panic!("first launch should be primary"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_launch_hands_its_paths_to_the_first() {
|
||||
let dir = temp_dir("handoff");
|
||||
let primary = primary(&dir);
|
||||
let mut ui = primary.connect(&egui::Context::default());
|
||||
let paths = ["C:\\art\\one.psd", "/home/a/two é.png"];
|
||||
let second = launch(&dir, &paths, TIMING);
|
||||
assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(paths.iter().map(|p| p.to_string()).collect())]);
|
||||
assert!(matches!(second.join().unwrap(), Launch::Forwarded));
|
||||
// A plain second start (no files) just brings the window forward.
|
||||
let third = launch(&dir, &[], TIMING);
|
||||
assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(Vec::new())]);
|
||||
assert!(matches!(third.join().unwrap(), Launch::Forwarded));
|
||||
drop(primary);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simultaneous_launches_all_land_in_one_instance() {
|
||||
let dir = temp_dir("race");
|
||||
let (tx, rx) = channel();
|
||||
for i in 0..6 {
|
||||
let (dir, tx) = (dir.clone(), tx.clone());
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(claim(&dir, &[format!("file{i}.psd")]));
|
||||
});
|
||||
}
|
||||
drop(tx);
|
||||
let (mut primaries, mut forwarded, mut events) = (Vec::new(), 0, Vec::new());
|
||||
let mut ui: Option<OsEventsFn> = None;
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while primaries.len() + forwarded < 6 && Instant::now() < deadline {
|
||||
while let Ok(launch) = rx.try_recv() {
|
||||
match launch {
|
||||
Launch::Primary(p) => primaries.push(p),
|
||||
Launch::Forwarded => forwarded += 1,
|
||||
Launch::Standalone(reason) => panic!("standalone: {reason}"),
|
||||
}
|
||||
}
|
||||
if ui.is_none()
|
||||
&& let Some(p) = primaries.first()
|
||||
{
|
||||
ui = Some(p.connect(&egui::Context::default()));
|
||||
}
|
||||
if let Some(ui) = ui.as_mut() {
|
||||
events.extend(ui());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert_eq!((primaries.len(), forwarded, events.len()), (1, 5, 5));
|
||||
drop(primaries);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crashed_instance_leaves_nothing_in_the_way() {
|
||||
let dir = temp_dir("stale");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// instance.json from a process that is gone; the lock is free.
|
||||
std::fs::write(dir.join(INFO_FILE), r#"{"port":1,"token":"x","pid":1}"#).unwrap();
|
||||
let primary = primary(&dir);
|
||||
assert!(read_info(&dir).is_some_and(|(port, _)| port != 1));
|
||||
drop(primary);
|
||||
// Released with the handle, as on process exit.
|
||||
assert!(matches!(claim_with(&dir, &[], FAST), Launch::Primary(_)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Review of #3755, point 1 (and the quitting half of point 3): a launch waiting on a primary
|
||||
/// that shuts down (closing, or about to relaunch itself in CPU mode) isn't told `ok`; it takes
|
||||
/// the lock over and opens the file itself.
|
||||
#[test]
|
||||
fn a_launch_waiting_on_a_closing_instance_takes_over() {
|
||||
let dir = temp_dir("closing");
|
||||
let primary = primary(&dir);
|
||||
// The UI never drains (it is shutting down, or never came up).
|
||||
let waiting = launch(&dir, &["a.psd"], FAST);
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
primary.shutdown();
|
||||
assert!(matches!(waiting.join().unwrap(), Launch::Primary(_)), "the waiting launch becomes the primary");
|
||||
// After shutdown new connections are refused too.
|
||||
let (port, token) = read_info(&dir).unwrap();
|
||||
drop(primary);
|
||||
let short = Instant::now() + Duration::from_millis(300);
|
||||
assert!(send(port, &token, &["b.psd".into()], short).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Review of #3755, point 3: a primary whose UI never takes the paths (hung) doesn't
|
||||
/// acknowledge them, and doesn't keep them queued to open later as well.
|
||||
#[test]
|
||||
fn a_hung_instance_never_swallows_a_file() {
|
||||
let dir = temp_dir("hung-ui");
|
||||
let primary = primary(&dir);
|
||||
let mut ui = primary.connect(&egui::Context::default());
|
||||
let started = Instant::now();
|
||||
assert!(matches!(claim_with(&dir, &["a.psd".into()], FAST), Launch::Standalone(_)));
|
||||
assert!(started.elapsed() < FAST.handoff + Duration::from_secs(1), "{:?}", started.elapsed());
|
||||
assert!(ui().is_empty(), "withdrawn, not opened later");
|
||||
drop(primary);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_without_the_token_or_malformed_are_rejected() {
|
||||
let token = generate_token().unwrap();
|
||||
let ok = parse_request(&json!({"token": token, "open": ["a"], "wait_ms": 250}).to_string(), &token);
|
||||
assert_eq!(ok, Some((vec!["a".to_string()], Duration::from_millis(250))));
|
||||
let wrong = generate_token().unwrap();
|
||||
assert_eq!(parse_request(&json!({"token": wrong, "open": ["a"]}).to_string(), &token), None);
|
||||
for bad in ["", "not json", "{}", r#"{"token":5}"#, &json!({"token": token}).to_string(), &json!({"token": token, "open": [1]}).to_string()] {
|
||||
assert_eq!(parse_request(bad, &token), None, "{bad:?}");
|
||||
}
|
||||
let many: Vec<String> = (0..=MAX_PATHS).map(|i| i.to_string()).collect();
|
||||
assert_eq!(parse_request(&json!({"token": token, "open": many}).to_string(), &token), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stalled_client_does_not_hold_up_the_next_launch() {
|
||||
let dir = temp_dir("stalled");
|
||||
let primary = primary(&dir);
|
||||
let mut ui = primary.connect(&egui::Context::default());
|
||||
let (port, _) = read_info(&dir).unwrap();
|
||||
// Connects and never sends a line (each would otherwise hold the listener).
|
||||
let stalled: Vec<TcpStream> = (0..3).map(|_| TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap()).collect();
|
||||
let started = Instant::now();
|
||||
let next = launch(&dir, &["b.psd"], FAST);
|
||||
assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(vec!["b.psd".into()])]);
|
||||
assert!(matches!(next.join().unwrap(), Launch::Forwarded));
|
||||
assert!(started.elapsed() < FAST.request, "took {:?}", started.elapsed());
|
||||
drop(stalled);
|
||||
drop(primary);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A client that trickles a byte at a time can't keep its slot past the request deadline.
|
||||
#[test]
|
||||
fn a_trickling_client_is_cut_off_at_the_request_deadline() {
|
||||
let dir = temp_dir("trickle");
|
||||
let primary = primary(&dir);
|
||||
let (port, _) = read_info(&dir).unwrap();
|
||||
let mut slow = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
|
||||
let started = Instant::now();
|
||||
let mut cut_off = false;
|
||||
while started.elapsed() < FAST.request * 4 {
|
||||
if slow.write_all(b"{").is_err() {
|
||||
cut_off = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
assert!(cut_off, "still connected after {:?}", started.elapsed());
|
||||
assert!(started.elapsed() < FAST.request * 3, "{:?}", started.elapsed());
|
||||
drop(primary);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_token_file_is_private_to_the_user() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = temp_dir("mode");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// A wide leftover temporary file from a crashed start doesn't widen the new one.
|
||||
let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id()));
|
||||
std::fs::write(&tmp, "{}").unwrap();
|
||||
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
write_info(&dir, 1, &generate_token().unwrap()).unwrap();
|
||||
let mode = std::fs::metadata(dir.join(INFO_FILE)).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreachable_instance_falls_back_to_a_standalone_launch() {
|
||||
let dir = temp_dir("unreachable");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Someone holds the lock but nothing listens on the advertised port.
|
||||
let held = File::create(dir.join(LOCK_FILE)).unwrap();
|
||||
held.lock().unwrap();
|
||||
let closed = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port();
|
||||
write_info(&dir, closed, &generate_token().unwrap()).unwrap();
|
||||
assert!(matches!(claim_with(&dir, &["a.psd".into()], FAST), Launch::Standalone(_)));
|
||||
drop(held);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Review of #3755, point 2 and the smaller points: who takes part in single-instance.
|
||||
#[test]
|
||||
fn only_per_user_launches_without_process_options_take_part() {
|
||||
let base = LaunchContext {
|
||||
per_user_settings: true,
|
||||
new_instance_flag: false,
|
||||
new_instance_env: None,
|
||||
control: false,
|
||||
unreadable_paths: false,
|
||||
launch_options: &[],
|
||||
};
|
||||
assert_eq!(skip_reason(&base), None);
|
||||
// A portable copy's folder or a PHOTOCRAFT_CONFIG_DIR may be shared between users.
|
||||
assert!(skip_reason(&LaunchContext { per_user_settings: false, ..base }).is_some());
|
||||
assert!(skip_reason(&LaunchContext { new_instance_flag: true, ..base }).is_some());
|
||||
assert!(skip_reason(&LaunchContext { new_instance_env: Some("1"), ..base }).is_some());
|
||||
assert_eq!(skip_reason(&LaunchContext { new_instance_env: Some("0"), ..base }), None);
|
||||
assert_eq!(skip_reason(&LaunchContext { new_instance_env: Some(""), ..base }), None);
|
||||
assert!(skip_reason(&LaunchContext { control: true, ..base }).is_some());
|
||||
assert!(skip_reason(&LaunchContext { unreadable_paths: true, ..base }).is_some());
|
||||
let reason = skip_reason(&LaunchContext { launch_options: &["--safe-gpu"], ..base });
|
||||
assert!(reason.is_some_and(|r| r.contains("--safe-gpu")));
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
//! `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}");
|
||||
}
|
||||
@@ -22,18 +22,6 @@ fn main() {
|
||||
use photocraft_ui_egui::native_menu;
|
||||
use serde_json::json;
|
||||
|
||||
// Without a harness nothing answers cargo-nextest's `--list`, so it ran the whole test as its
|
||||
// listing and failed on the output. List the one test, as libtest's terse format would, and
|
||||
// nothing for `--ignored` (nextest asks twice). Running it passes `<name> --exact`, which falls
|
||||
// through to the test below.
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.iter().any(|a| a == "--list") {
|
||||
if !args.iter().any(|a| a == "--ignored") {
|
||||
println!("mac_menu_appkit: test");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread");
|
||||
let nsapp = NSApplication::sharedApplication(mtm);
|
||||
nsapp.finishLaunching();
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 281 B |
@@ -9,8 +9,8 @@
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<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" />
|
||||
<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" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 502 B After Width: | Height: | Size: 466 B |
@@ -162,10 +162,3 @@ 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,8 +94,11 @@ 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" => {}
|
||||
b"IEND" => {
|
||||
if !image_data {
|
||||
return Err(Error::Malformed("PNG has no image data"));
|
||||
}
|
||||
}
|
||||
_ if kind.first().is_some_and(u8::is_ascii_uppercase) => {
|
||||
return Err(Error::Unsupported("unknown critical PNG chunk"));
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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.
|
||||
[email protected]
|
||||
|
||||
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,48 +79,23 @@ 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. 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.
|
||||
// Columns first at the kept rows, then rows at the kept columns.
|
||||
let mut tmp = vec![0.0f32; p.w * h2];
|
||||
rows(&mut tmp, p.w, |y2, row| {
|
||||
par_rows(&mut tmp, p.w, 1, |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] * src[k][x]).sum();
|
||||
*o = (0..5).map(|k| TAPS[k] * p.v[mirror(y + k as isize - 2, p.h) * p.w + x]).sum();
|
||||
}
|
||||
});
|
||||
let mut out = vec![0.0f32; w2 * h2];
|
||||
rows(&mut out, w2, |y2, row| {
|
||||
par_rows(&mut out, w2, 1, |y2, row| {
|
||||
let src = &tmp[y2 * p.w..(y2 + 1) * p.w];
|
||||
for (x2, o) in row.iter_mut().enumerate() {
|
||||
let x = x2 * step;
|
||||
*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()
|
||||
};
|
||||
let x = (x2 * step) as isize;
|
||||
*o = (0..5).map(|k| TAPS[k] * src[mirror(x + k as isize - 2, p.w)]).sum();
|
||||
}
|
||||
});
|
||||
Plane { w: w2, h: h2, v: out }
|
||||
@@ -132,45 +107,14 @@ 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 (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();
|
||||
}
|
||||
});
|
||||
}
|
||||
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()
|
||||
};
|
||||
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;
|
||||
}
|
||||
});
|
||||
Plane { w, h, v: out }
|
||||
}
|
||||
blur_step(&z, 1)
|
||||
}
|
||||
|
||||
/// Levels down to about 2 pixels on the short side.
|
||||
@@ -210,7 +154,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() };
|
||||
rows(&mut m.v, u.w, |_, row| {
|
||||
par_rows(&mut m.v, u.w, 1, |_, row| {
|
||||
for v in row.iter_mut() {
|
||||
*v = remap(*v, r);
|
||||
}
|
||||
@@ -219,7 +163,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);
|
||||
rows(&mut lap[l], lw, |y, row| {
|
||||
par_rows(&mut lap[l], lw, 1, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let i = y * lw + x;
|
||||
let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0);
|
||||
@@ -393,62 +337,6 @@ 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,133 +566,6 @@ 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::*;
|
||||
@@ -704,43 +577,6 @@ 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};
|
||||
|
||||
+19
-36
@@ -28,47 +28,32 @@ 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, hx, hy) = ellipse(ctx.bounds);
|
||||
let (cx, cy, rr) = centre(ctx.bounds);
|
||||
let a = angle.to_radians();
|
||||
remap(src, out, ctx, Edge::Repeat, |x, y| {
|
||||
if hx <= 0.0 || hy <= 0.0 {
|
||||
let (dx, dy) = (x - cx, y - cy);
|
||||
let r = (dx * dx + dy * dy).sqrt();
|
||||
if r >= rr || rr <= 0.0 {
|
||||
return (x, y);
|
||||
}
|
||||
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 t = a * (1.0 - r / rr);
|
||||
let (s, c) = t.sin_cos();
|
||||
(cx + (u * c - v * s) * hx, cy + (u * s + v * c) * hy)
|
||||
(cx + dx * c - dy * s, cy + dx * s + dy * c)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn pinch(src: &Image, out: Rect, ctx: &Ctx, amount: f32) -> Vec<f32> {
|
||||
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
|
||||
let (cx, cy, rr) = centre(ctx.bounds);
|
||||
let a = (amount / 100.0).clamp(-1.0, 1.0);
|
||||
remap(src, out, ctx, Edge::Repeat, |x, y| {
|
||||
if hx <= 0.0 || hy <= 0.0 {
|
||||
let (dx, dy) = (x - cx, y - cy);
|
||||
let r = (dx * dx + dy * dy).sqrt();
|
||||
if r >= rr || r <= 0.0 {
|
||||
return (x, y);
|
||||
}
|
||||
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)
|
||||
let rs = rr * (r / rr).powf(1.0 - a * 0.5);
|
||||
(cx + dx * rs / r, cy + dy * rs / r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -79,22 +64,20 @@ 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 (cx, cy, hx, hy) = ellipse(ctx.bounds);
|
||||
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 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 => {
|
||||
if hx <= 0.0 || hy <= 0.0 {
|
||||
let r = (dx * dx + dy * dy).sqrt();
|
||||
if r >= rr || r <= 0.0 {
|
||||
return (x, y);
|
||||
}
|
||||
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)
|
||||
let rs = rr * sphere_1d(r / rr, a);
|
||||
(cx + dx * rs / r, cy + dy * rs / r)
|
||||
}
|
||||
SpherizeMode::HorizontalOnly => {
|
||||
if dx.abs() >= hx {
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
+54
-262
@@ -60,98 +60,25 @@ 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> {
|
||||
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;
|
||||
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
|
||||
};
|
||||
#[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));
|
||||
pass(&pass(v, true), false)
|
||||
}
|
||||
|
||||
/// Harris corners: at most `max` corners at least `min_dist` pixels apart, strongest first,
|
||||
@@ -161,40 +88,28 @@ 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]);
|
||||
// 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 y in 1..h - 1 {
|
||||
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));
|
||||
xxr[x] = gx * gx;
|
||||
yyr[x] = gy * gy;
|
||||
xyr[x] = gx * gy;
|
||||
let i = y * w + x;
|
||||
xx[i] = gx * gx;
|
||||
yy[i] = gy * gy;
|
||||
xy[i] = 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 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 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 peak = r.iter().copied().fold(0.0f32, f32::max);
|
||||
if peak <= 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let m = PATCH_RADIUS + 1;
|
||||
// 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();
|
||||
let mut cand: Vec<Corner> = Vec::new();
|
||||
for y in m..h - m {
|
||||
for x in m..w - m {
|
||||
let v = r[y * w + x];
|
||||
if v < peak * 0.01 {
|
||||
@@ -210,11 +125,9 @@ pub fn harris(w: usize, h: usize, img: &[f32], max: usize, min_dist: f32, valid:
|
||||
continue;
|
||||
}
|
||||
}
|
||||
found.push(Corner { x: x as f32, y: y as f32, score: v });
|
||||
cand.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);
|
||||
@@ -263,37 +176,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]
|
||||
};
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
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
|
||||
})
|
||||
d
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn hamming(a: &Descriptor, b: &Descriptor) -> u32 {
|
||||
@@ -647,124 +560,3 @@ 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,7 +32,6 @@ mod distort;
|
||||
mod distort2;
|
||||
pub mod erase;
|
||||
pub mod exif;
|
||||
pub mod faces;
|
||||
pub mod features;
|
||||
mod fxutil;
|
||||
mod gallery;
|
||||
|
||||
@@ -8,8 +8,7 @@ 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. CMYK samples are
|
||||
/// ink, so there it runs on `1 - ink` and a positive offset brightens.
|
||||
/// kept). Kernels shorter than 25 entries are zero-padded.
|
||||
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);
|
||||
@@ -17,7 +16,6 @@ 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;
|
||||
@@ -25,10 +23,8 @@ 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) {
|
||||
// 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);
|
||||
let sum: f32 = k.iter().map(|&(dx, dy, w)| src.get(x + dx, y + dy, c) * w).sum();
|
||||
*v = sum / s + off;
|
||||
}
|
||||
}
|
||||
res
|
||||
|
||||
+86
-583
@@ -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, par_rows, solve_dense};
|
||||
use crate::photo_util::{Normal, Rng, par_map, solve_dense};
|
||||
use crate::transform::Homography;
|
||||
|
||||
/// Photomerge layouts.
|
||||
@@ -996,99 +996,58 @@ 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];
|
||||
par_rows(&mut tmp, nw, ch, |y, row| {
|
||||
let line = &src[y * w * ch..(y + 1) * w * ch];
|
||||
for y in 0..h {
|
||||
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 (kv, xx) in k.iter().zip(xs) {
|
||||
s += kv * line[xx + c];
|
||||
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];
|
||||
}
|
||||
row[x * ch + c] = s / 16.0;
|
||||
tmp[(y * nw + x) * ch + c] = s / 16.0;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut out = vec![0.0f32; nw * nh * ch];
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
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];
|
||||
// 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 = fx as usize;
|
||||
(x0 * ch, (x0 + 1).min(sw - 1) * ch, fx - x0 as f32)
|
||||
})
|
||||
.collect();
|
||||
par_rows(&mut out, w, ch, |y, row| {
|
||||
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 = fy as usize;
|
||||
let ty = fy - y0 as f32;
|
||||
let (y0, ty) = (fy.floor() as usize, fy - fy.floor());
|
||||
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;
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1099,75 +1058,41 @@ 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];
|
||||
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 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];
|
||||
}
|
||||
}
|
||||
});
|
||||
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];
|
||||
}
|
||||
}
|
||||
for j in 0..nw * nh {
|
||||
if cw[j] > 0.0 {
|
||||
for c in 0..ch {
|
||||
cpx[j * ch + c] /= cw[j];
|
||||
}
|
||||
}
|
||||
});
|
||||
// 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;
|
||||
cw[j] = 1.0;
|
||||
}
|
||||
}
|
||||
if cw.contains(&0.0) && cw.iter().any(|v| *v > 0.0) {
|
||||
push_pull(nw, nh, ch, &mut cpx, &cw);
|
||||
}
|
||||
par_rows(px, w, ch, |y, row| {
|
||||
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 {
|
||||
row[x * ch + c] = a * row[x * ch + c] + (1.0 - a) * cpx[j * ch + c];
|
||||
px[i * ch + c] = a * px[i * 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
|
||||
@@ -1194,37 +1119,22 @@ 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];
|
||||
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 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);
|
||||
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> = par_map1(&cov, |a| if a > 0.5 { 1.0 } else { 0.0 });
|
||||
}
|
||||
let bin: Vec<f32> = cov.iter().map(|a| if *a > 0.5 { 1.0 } else { 0.0 }).collect();
|
||||
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();
|
||||
@@ -1238,7 +1148,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, par_zip(&cur, &up, |a, b| a - b)));
|
||||
lap.push((cw, chh, cur.iter().zip(&up).map(|(a, b)| a - b).collect()));
|
||||
gau.push(cwt.clone());
|
||||
let (_, _, wdown) = reduce(cw, chh, 1, &cwt);
|
||||
cw = nw;
|
||||
@@ -1248,57 +1158,41 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
|
||||
}
|
||||
(gx0, gy0, lap, gau)
|
||||
});
|
||||
// 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 k = g[x];
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
row[px] += k;
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
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 {
|
||||
for x in 0..*lw {
|
||||
let px = ox + x;
|
||||
if px >= pw {
|
||||
break;
|
||||
}
|
||||
let k = g[x];
|
||||
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 {
|
||||
row[px * ch + c] += k * band[x * ch + c];
|
||||
acc[l][p * ch + c] += k * band[i * ch + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
for l in 0..levels {
|
||||
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 };
|
||||
}
|
||||
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.
|
||||
@@ -1307,27 +1201,22 @@ 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 = par_zip(&acc[l], &up, |a, b| a + b);
|
||||
cur = acc[l].iter().zip(&up).map(|(a, b)| a + b).collect();
|
||||
}
|
||||
// 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.
|
||||
// Coverage: union of the images.
|
||||
let mut cov = vec![0.0f32; w * 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 im in imgs {
|
||||
for y in 0..im.h {
|
||||
for x in 0..im.w {
|
||||
let px = im.x0 + x as i32;
|
||||
if px < 0 || px as usize >= 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 = px as usize;
|
||||
row[p] = row[p].max(im.alpha[y as usize * im.w + x]);
|
||||
let p = py as usize * w + px as usize;
|
||||
cov[p] = cov[p].max(im.alpha[y * im.w + x]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
(cur, cov)
|
||||
}
|
||||
|
||||
@@ -1542,389 +1431,3 @@ 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,9 +146,8 @@ 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 (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);
|
||||
// Screen keeps highlights below white for display-referred values.
|
||||
c[k] = 1.0 - (1.0 - c[k]) * (1.0 - a.min(1.0));
|
||||
}
|
||||
set_rgba(ctx, px, c);
|
||||
}
|
||||
|
||||
+52
-393
@@ -23,184 +23,85 @@ pub enum Stat {
|
||||
Variance,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
fn stat(v: &mut [f32], s: Stat) -> 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: u32| v.iter().map(|x| ipow(x - mean, k)).sum::<f32>() / n;
|
||||
let m = |k: i32| v.iter().map(|x| (x - mean).powi(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) / ipow(sd, 3) / 4.0 + 0.5).clamp(0.0, 1.0) }
|
||||
if sd < 1e-6 { 0.5 } else { (m(3) / sd.powi(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) }
|
||||
}
|
||||
// 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,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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];
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
if alpha <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
for c in 0..3 {
|
||||
o[c] = f(&mut samples[c][..k], &mut scratch);
|
||||
}
|
||||
o[3] = alpha;
|
||||
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]);
|
||||
}
|
||||
alpha = alpha.max(p[3]);
|
||||
}
|
||||
}
|
||||
if alpha <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
for c in 0..3 {
|
||||
o[c] = stat(&mut samples[c], s);
|
||||
}
|
||||
o[3] = alpha;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -267,246 +168,4 @@ 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,24 +414,6 @@ 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,35 +435,6 @@ 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]);
|
||||
@@ -637,28 +608,6 @@ 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,37 +231,14 @@ 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). Use [`warp_output_size`] to price the full result before
|
||||
/// admitting a transform: clipping these bounds must not hide an excessive scale.
|
||||
/// infinity (the result is then empty). Lets a caller price a warp before allocating it.
|
||||
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;
|
||||
}
|
||||
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)),
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
fn corner_bounds(corners: &[(f64, f64); 4]) -> Option<Rect> {
|
||||
@@ -533,31 +510,6 @@ 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,27 +135,7 @@ pub fn repeat_auth_reply(line: &str) -> Option<Value> {
|
||||
}
|
||||
|
||||
fn read_token_file(path: &Path) -> Result<String, AutomationError> {
|
||||
// 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 = std::fs::read_to_string(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
let token = token.trim().to_owned();
|
||||
validate_token(&token)?;
|
||||
Ok(token)
|
||||
@@ -453,27 +433,6 @@ 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);
|
||||
|
||||
@@ -325,11 +325,10 @@ pub struct UiSetParams {
|
||||
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
|
||||
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
|
||||
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
|
||||
/// active layer's storage tiles). layerFilter (an object patching by ("kind", "name" or
|
||||
/// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a
|
||||
/// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off":
|
||||
/// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not
|
||||
/// over this channel). Other fields are an error.
|
||||
/// active layer's storage tiles). layerFilter (an object patching the Layers panel filter:
|
||||
/// by: kind/name/mode; kinds: a list of pixel/adjustment/type/shape/smart; name: a string;
|
||||
/// mode: a blend mode name such as Multiply or Pass Through). Omitted filter fields keep
|
||||
/// their current values. Other fields are an error.
|
||||
pub fields: Value,
|
||||
}
|
||||
|
||||
|
||||
@@ -369,10 +369,6 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
|
||||
}
|
||||
// With a `path` the file is written there; without one the bytes come back as `data`.
|
||||
"actions.save" | "swatches.export" => params.get("path").is_some(),
|
||||
// Shortcut sets: a .kys read or written by path, or a Summarize page written by path.
|
||||
"edit.keyboardShortcuts" => {
|
||||
params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some())
|
||||
}
|
||||
"plugin.reload" => true,
|
||||
_ => false,
|
||||
}
|
||||
@@ -716,12 +712,6 @@ mod tests {
|
||||
assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err());
|
||||
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err());
|
||||
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "srgb"})).is_ok());
|
||||
// Shortcut sets: text in and out is fine; a path reads or writes the filesystem.
|
||||
let ks = |p: serde_json::Value| authorize_engine_command("edit.keyboardShortcuts", &p);
|
||||
assert!(ks(serde_json::json!({"exportKys": true, "summarize": true, "importKys": "<photoshop-keyboard-shortcuts/>"})).is_ok());
|
||||
for key in ["importKys", "exportKys", "summarize"] {
|
||||
assert!(ks(serde_json::json!({ key: {"path": "/outside/set.kys"} })).is_err(), "{key}");
|
||||
}
|
||||
assert!(authorize_engine_command("filter.distort.displace", &serde_json::json!({"mapPath": "outside.png"})).is_err());
|
||||
assert!(authorize_engine_command("layer.setAdjustment", &serde_json::json!({"file": "outside.cube"})).is_err());
|
||||
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "colorSettings.workingRgb", "value": "outside.icc"})).is_err());
|
||||
|
||||
@@ -4,15 +4,14 @@
|
||||
use photocraft_automation::Headless;
|
||||
use serde_json::json;
|
||||
|
||||
/// 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.
|
||||
/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0).
|
||||
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 _ in 0..n {
|
||||
out.extend(bgra);
|
||||
for b in bgra {
|
||||
out.extend(std::iter::repeat_n(b, n));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
//! Stored smart-filter parameters must not grant automation ambient filesystem access.
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use photocraft_automation::{AuthorizedWorkspace, Headless, workspace::authorize_desktop_engine_step};
|
||||
use photocraft_doc::{Document, LayerContent, LayerId};
|
||||
use photocraft_engine::{Session, actions_cmds::Action, jobs::Started, smart_cmds};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let base = std::env::temp_dir().join(format!("pc-filter-auth-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&base).unwrap();
|
||||
std::fs::create_dir(base.join("workspace")).unwrap();
|
||||
std::fs::create_dir(base.join("outside")).unwrap();
|
||||
let f = Self(base);
|
||||
std::fs::write(f.root().join("stripes.png"), png(|x| if (x / 2) % 2 == 0 { 0 } else { 255 })).unwrap();
|
||||
std::fs::write(f.outside("black.png"), png(|_| 0)).unwrap();
|
||||
std::fs::write(f.outside("white.png"), png(|_| 255)).unwrap();
|
||||
std::fs::write(f.outside("black.cube"), cube(0)).unwrap();
|
||||
std::fs::write(f.outside("white.cube"), cube(1)).unwrap();
|
||||
f
|
||||
}
|
||||
|
||||
fn root(&self) -> PathBuf {
|
||||
self.0.join("workspace")
|
||||
}
|
||||
fn outside(&self, name: &str) -> PathBuf {
|
||||
self.0.join("outside").join(name)
|
||||
}
|
||||
fn headless(&self) -> Headless {
|
||||
Headless::with_workspace(AuthorizedWorkspace::new(Some(&self.root()), Some(&self.root())).unwrap())
|
||||
}
|
||||
fn setup(&self, lookup: bool) -> (Headless, LayerId) {
|
||||
let mut h = self.headless();
|
||||
h.handle("doc.new", json!({"width": 32, "height": 32, "background": "#808080"})).unwrap();
|
||||
h.handle("doc.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
let layer = LayerId(h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let (id, params) = if lookup {
|
||||
("image.adjustments.colorLookup", json!({"layer": layer.0, "lut": "none"}))
|
||||
} else {
|
||||
("filter.distort.displace", json!({"layer": layer.0, "mapDocument": 0, "horizontal": 50, "vertical": 0}))
|
||||
};
|
||||
h.session.execute(id, params).unwrap();
|
||||
(h, layer)
|
||||
}
|
||||
fn saved_filter(&self, lookup: bool) -> (Document, LayerId) {
|
||||
let (h, layer) = self.setup(lookup);
|
||||
let mut doc = (*h.session.active().unwrap().doc).clone();
|
||||
let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!("smart object") };
|
||||
sm.smart_filters[0].params = path_params(lookup, &self.outside(if lookup { "white.cube" } else { "white.png" }));
|
||||
let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap();
|
||||
std::fs::write(self.root().join("stored.pcraft"), bytes).unwrap();
|
||||
(doc, layer)
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn png(value: impl Fn(u32) -> u8) -> Vec<u8> {
|
||||
let mut pixels = Vec::new();
|
||||
for _ in 0..32 {
|
||||
for x in 0..32 {
|
||||
pixels.extend([value(x); 3]);
|
||||
}
|
||||
}
|
||||
let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, pixels).unwrap();
|
||||
photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()
|
||||
}
|
||||
fn cube(value: u8) -> String {
|
||||
format!("LUT_3D_SIZE 2\n{}", format!("{value} {value} {value}\n").repeat(8))
|
||||
}
|
||||
fn path_params(lookup: bool, path: &Path) -> Value {
|
||||
if lookup { json!({"file": path}) } else { json!({"mapPath": path, "mapDocument": null}) }
|
||||
}
|
||||
fn assert_denied<T: std::fmt::Debug>(result: Result<T, impl std::fmt::Display>) {
|
||||
match result {
|
||||
Err(error) => assert!(error.to_string().contains("ambient filesystem"), "{error}"),
|
||||
Ok(value) => panic!("ambient file access was accepted: {value:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_edits_reject_outside_images_and_luts_without_changing_the_document() {
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (mut h, layer) = f.setup(lookup);
|
||||
let before = h.render_png(None, 64).unwrap();
|
||||
let doc = h.session.active().unwrap().doc.clone();
|
||||
let journal = h.session.journal.len();
|
||||
for name in if lookup { ["black.cube", "white.cube"] } else { ["black.png", "white.png"] } {
|
||||
let params = path_params(lookup, &f.outside(name));
|
||||
let id = if lookup { "image.adjustments.colorLookup" } else { "filter.distort.displace" };
|
||||
assert_denied(h.session.execute(id, params.clone()));
|
||||
assert_denied(h.handle("engine.execute", json!({"command": "layer.smartFilter.setParams", "params": {"layer": layer.0, "params": params}})));
|
||||
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
|
||||
assert_eq!(h.session.journal.len(), journal);
|
||||
assert_eq!(h.render_png(None, 64).unwrap(), before);
|
||||
}
|
||||
// Safe numeric and embedded-data edits remain usable.
|
||||
let safe = if lookup { json!({"data": cube(0), "fileName": "embedded.cube"}) } else { json!({"horizontal": 10}) };
|
||||
h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_playback_cannot_hide_a_smart_filter_path_edit() {
|
||||
let f = Fixture::new();
|
||||
let (mut h, layer) = f.setup(false);
|
||||
let doc = h.session.active().unwrap().doc.clone();
|
||||
h.session.actions.list.push(Action::new(
|
||||
"edit filter",
|
||||
vec![("layer.smartFilter.setParams".into(), json!({"layer": layer.0, "params": path_params(false, &f.outside("white.png"))}))],
|
||||
));
|
||||
let result = h.session.execute("actions.play", json!({"action": "edit filter"})).unwrap();
|
||||
assert!(result["failed"]["error"].as_str().unwrap().contains("ambient filesystem"), "{result}");
|
||||
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_filters_are_checked_after_merging_even_when_the_edit_contains_no_path() {
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (_, layer) = f.saved_filter(lookup);
|
||||
let mut h = f.headless();
|
||||
h.open(Path::new("stored.pcraft")).unwrap();
|
||||
let doc = h.session.active().unwrap().doc.clone();
|
||||
assert_denied(h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": {"horizontal": 10, "dither": false}})));
|
||||
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
|
||||
// Removing an ambient input makes the filter usable again.
|
||||
let safe = if lookup { json!({"file": "", "lut": "none"}) } else { json!({"mapPath": ""}) };
|
||||
h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_filters_cannot_read_on_rerender_or_reuse_a_trusted_cached_stage() {
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (doc, layer) = f.saved_filter(lookup);
|
||||
for warm_cache in [false, true] {
|
||||
if warm_cache {
|
||||
let mut trusted = doc.clone();
|
||||
assert!(smart_cmds::refresh(&mut trusted, layer).unwrap());
|
||||
}
|
||||
let mut h = f.headless();
|
||||
h.open(Path::new("stored.pcraft")).unwrap();
|
||||
let before = h.session.active().unwrap().doc.clone();
|
||||
assert_denied(h.session.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})));
|
||||
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_rerender_keeps_the_desktop_request_authority() {
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (doc, layer) = f.saved_filter(lookup);
|
||||
let mut s = Session::new();
|
||||
s.add_document(doc, None);
|
||||
s.authorize = Some(authorize_desktop_engine_step);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let started = s.start("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap();
|
||||
// The desktop restores UI authority as soon as the automation request returns.
|
||||
s.authorize = None;
|
||||
let Started::Job(job) = started else { panic!("expected a background render") };
|
||||
assert_denied(s.wait_job(job));
|
||||
assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc));
|
||||
// The worker's restriction must not leak into subsequent trusted UI work.
|
||||
s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transferring_stored_filters_cannot_read_outside_files() {
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (_, from) = f.saved_filter(lookup);
|
||||
let mut h = f.headless();
|
||||
h.open(Path::new("stored.pcraft")).unwrap();
|
||||
let to = h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let before = h.session.active().unwrap().doc.clone();
|
||||
for copy in [false, true] {
|
||||
assert_denied(h.session.execute("layer.smartFilter.transfer", json!({"from": from.0, "to": to, "copy": copy})));
|
||||
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_parameter_edits_still_read_maps_and_luts_after_a_denied_request() {
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (mut h, layer) = f.setup(lookup);
|
||||
let edit = |path: &Path| json!({"layer": layer.0, "params": path_params(lookup, path)});
|
||||
assert_denied(h.session.execute("layer.smartFilter.setParams", edit(&f.outside("white.png"))));
|
||||
let mut trusted = Headless::trusted_local();
|
||||
trusted.session.add_document((*h.session.active().unwrap().doc).clone(), None);
|
||||
let mut renders = Vec::new();
|
||||
for name in if lookup { ["black.cube", "white.cube", "black.cube"] } else { ["black.png", "white.png", "black.png"] } {
|
||||
trusted.session.execute("layer.smartFilter.setParams", edit(&f.outside(name))).unwrap();
|
||||
renders.push(trusted.render_png(None, 64).unwrap());
|
||||
}
|
||||
assert_ne!(renders[0], renders[1], "trusted path-based filter still works");
|
||||
assert_eq!(renders[0], renders[2], "trusted rendering remains deterministic");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_unfiltered() {
|
||||
// link-watch and transform paths re-render through Session::edit, not Session::execute or
|
||||
// jobs::run; the gate must reach them and the denial must not silently pass pixels through
|
||||
// into the process-wide stack cache.
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (doc, layer) = f.saved_filter(lookup);
|
||||
let mut h = f.headless();
|
||||
h.open(Path::new("stored.pcraft")).unwrap();
|
||||
let before = h.session.active().unwrap().doc.clone();
|
||||
// Session::edit is the outside-execute mutation path (an edit closure the UI or a
|
||||
// linked-file change runs); the stored filter must be denied there too.
|
||||
assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
}));
|
||||
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
|
||||
// A trusted session (no gate) still renders the same document.
|
||||
let mut trusted = Session::new();
|
||||
trusted.add_document(doc, None);
|
||||
let mut d = (*trusted.active().unwrap().doc).clone();
|
||||
assert!(smart_cmds::refresh(&mut d, layer).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() {
|
||||
// The denial path must fail the render before any stage is cached process-wide.
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (doc, layer) = f.saved_filter(lookup);
|
||||
let mut s = Session::new();
|
||||
s.add_document(doc, None);
|
||||
s.authorize = Some(authorize_desktop_engine_step);
|
||||
let bytes = smart_cmds::stack_cache_bytes();
|
||||
assert_denied(s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})));
|
||||
assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage");
|
||||
}
|
||||
}
|
||||
@@ -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` (read) / built in (RLE write, one scanline per packet) |
|
||||
| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` |
|
||||
| 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, exr_color_channels};
|
||||
use crate::image::{ChannelLayout, DecodeWarning, ExrChannelInfo, ExrPartInfo, Image, SampleType, base_channel_name};
|
||||
use crate::options::{EncodeOptions, ExrCompression, Limits};
|
||||
|
||||
const F: Format = Format::OpenExr;
|
||||
@@ -123,9 +123,20 @@ 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 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 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 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,7 +10,6 @@ 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,21 +66,6 @@ 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())..]
|
||||
}
|
||||
@@ -284,7 +269,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result<Image, CodecError>
|
||||
_ => return Err(err("unknown PAM header field")),
|
||||
}
|
||||
}
|
||||
r.pam_endhdr()?;
|
||||
r.single_ws()?;
|
||||
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")),
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
//! 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,6 +1,5 @@
|
||||
//! Formats delegated to the `image` crate (pure-Rust features only):
|
||||
//! GIF, BMP, TGA, ICO, QOI, Radiance HDR, and AVIF encode (feature `avif`). Targa is written by
|
||||
//! [`super::tga`] and only read here.
|
||||
//! GIF, BMP, TGA, ICO, QOI, Radiance HDR, and AVIF encode (feature `avif`).
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
@@ -164,10 +163,6 @@ 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,73 +321,18 @@ 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 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.
|
||||
/// 4 RGBA, 3 RGB, 2 YA, 1 any-single-channel, 0 anything else (e.g. `Z` beside `A`).
|
||||
/// The auto-pick takes the highest rank; ties go to the earlier part.
|
||||
pub fn color_rank(&self) -> u8 {
|
||||
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 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 };
|
||||
}
|
||||
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;
|
||||
if has("Y") {
|
||||
return if has("A") { 2 } else { 1 };
|
||||
}
|
||||
u8::from(self.channels.len() == 1)
|
||||
}
|
||||
best.or_else(|| (count == 1).then(|| (ChannelLayout::Gray, vec![0])))
|
||||
}
|
||||
|
||||
/// Strip an optional `layer.` prefix from a channel name (`diffuse.R` → `R`).
|
||||
|
||||
@@ -8,9 +8,7 @@ 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, probe_size,
|
||||
};
|
||||
use photocraft_codecs::{ChannelLayout, DecodeOptions, DecodeWarning, Format, Limits, SampleType, decode, decode_as_with, decode_exr_part, exr_info};
|
||||
|
||||
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());
|
||||
@@ -228,107 +226,3 @@ 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);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
//! 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,12 +30,14 @@ impl MeasurementScale {
|
||||
}
|
||||
/// Photoshop's "Scale" column text, e.g. `1 pixels = 1.0000 pixels`.
|
||||
pub fn describe(&self) -> String {
|
||||
// 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)
|
||||
format!("{} pixels = {:.4} {}", trim(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)]
|
||||
@@ -131,17 +133,6 @@ 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.
|
||||
|
||||
+1
-17
@@ -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 = ink.density(density);
|
||||
let d = curve_eval(&ink.curve, 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,22 +209,6 @@ 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,10 +43,6 @@ 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,18 +296,6 @@ 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,12 +1121,8 @@ 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);
|
||||
// `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 };
|
||||
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 };
|
||||
// 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).
|
||||
@@ -1147,7 +1143,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_px * 0.3 } else { f64::from(bar.y1) + font_px };
|
||||
let baseline = if text_top { f64::from(bar.y0) - font_size * 0.3 } else { f64::from(bar.y1) + font_size };
|
||||
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, LayerContent, LayerId};
|
||||
use photocraft_doc::ColorMode;
|
||||
|
||||
fn session(w: u32, h: u32, depth: u32) -> Session {
|
||||
let mut s = Session::new();
|
||||
@@ -355,27 +355,6 @@ 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>_<index:04>_<artboard name>.<format>` per board.
|
||||
/// File › Export › Artboards to Files: `<prefix>_<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,9 +538,8 @@ 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 (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)) };
|
||||
for one in chosen_documents(&doc, p)? {
|
||||
let name = if prefix.is_empty() { sanitize(&one.name) } else { format!("{}_{}", 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_0000_Empty_One.png"), "{files:?}");
|
||||
assert!(files[1].ends_with("site_0001_Red Board.png"), "{files:?}");
|
||||
assert!(files[0].ends_with("site_Empty_One.png"), "{files:?}");
|
||||
assert!(files[1].ends_with("site_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,10 +1986,6 @@ 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") {
|
||||
@@ -2036,16 +2032,7 @@ 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)));
|
||||
// `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")),
|
||||
}
|
||||
};
|
||||
d = d.with("Usng", enm("FlCn", if jstr(p, "contents") == Some("pattern") { "Pttn" } else { "FrgC" }));
|
||||
if let Some(o) = jnum(p, "opacity") {
|
||||
d = d.with("Opct", pct(o));
|
||||
}
|
||||
@@ -2067,29 +2054,6 @@ 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,55 +432,6 @@ 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,19 +793,13 @@ 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}`")));
|
||||
}
|
||||
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)) {
|
||||
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`
|
||||
@@ -869,16 +863,9 @@ 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);
|
||||
// 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 mut v = vec![0.0f32; w * h];
|
||||
let sc = sel.channels();
|
||||
// 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")?;
|
||||
let sv = sel.read_region(area);
|
||||
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,26 +442,6 @@ 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,29 +109,21 @@ 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)?;
|
||||
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) {
|
||||
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.to_string();
|
||||
pr.name = new.clone();
|
||||
pr.builtin = false;
|
||||
if was_current {
|
||||
s.tools.current_preset = Some(new.to_string());
|
||||
s.tools.current_preset = Some(new.clone());
|
||||
s.tools.brush_changed();
|
||||
}
|
||||
s.brush_presets_changed();
|
||||
}
|
||||
Ok(())
|
||||
Ok(json!({ "name": new }))
|
||||
}
|
||||
|
||||
fn move_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -64,9 +64,10 @@ 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()) => {
|
||||
return Err(bad("`color` as an array must hold 3 or 4 numbers in 0..1"));
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Some(_) => return Err(bad("`color` must be \"#rrggbb\" or [r, g, b] in 0..1")),
|
||||
}
|
||||
|
||||
@@ -117,10 +117,7 @@ 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));
|
||||
// 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.set_sampler_map(a);
|
||||
}
|
||||
s.edit(label, |doc, _| {
|
||||
crate::canvas_geom::turn_canvas(doc, t);
|
||||
@@ -298,8 +295,7 @@ 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; a name that matches no layer exactly matches regardless of case, so
|
||||
/// `"background"` finds the Background). `None` when the param is absent.
|
||||
/// in another document). `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)) => {
|
||||
@@ -307,41 +303,19 @@ pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result<Option<LayerId>>
|
||||
}
|
||||
Some(Value::String(name)) => {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let layers = d.doc.walk();
|
||||
let mut top_down = layers.iter().rev();
|
||||
top_down
|
||||
.clone()
|
||||
d.doc
|
||||
.walk()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.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}\"; {}", layer_listing(&d.doc))))
|
||||
.ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\"")))
|
||||
}
|
||||
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)? {
|
||||
@@ -1448,7 +1422,6 @@ 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());
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,227 +0,0 @@
|
||||
//! 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,12 +110,6 @@ 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()));
|
||||
@@ -364,169 +358,38 @@ fn delete_and_fill(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
content_aware_fill_as(s, &json!({"sampling": "rectangular"}), "edit.deleteAndFillSelection", "Delete and Fill Selection")
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
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() {
|
||||
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));
|
||||
let custom_mask: Option<Surface> = p.get("channel").and_then(|v| channel_mask(&doc, v)).cloned();
|
||||
let custom_rect = if p.get("area").is_some() { Some(rect_param(p, "area", cmd)?) } else { None };
|
||||
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`")),
|
||||
// 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)
|
||||
}
|
||||
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")),
|
||||
@@ -539,77 +402,6 @@ fn caf_params<'a>(doc: &'a Document, id: LayerId, p: &Value, cmd: &str) -> Resul
|
||||
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
|
||||
@@ -620,127 +412,56 @@ pub fn content_aware_plan_with(doc: &Document, id: LayerId, p: &Value, cmd: &str
|
||||
};
|
||||
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(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.
|
||||
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.
|
||||
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 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 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 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()?;
|
||||
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);
|
||||
}
|
||||
});
|
||||
// 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)?;
|
||||
}
|
||||
Ok((layer, rect, fmt, output, FilledWindow { filled, bounds: (bx0, by0, bx1, by1), original, cover, n_hole }))
|
||||
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 (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 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 })
|
||||
},
|
||||
move |s, (layer, rect, fmt, output, done)| apply_content_aware_fill(s, label, layer, &output, fmt, rect, done),
|
||||
move |s, done| apply_content_aware_fill(s, label, id, &output, fmt, window, done),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1306,18 +1027,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() {
|
||||
// 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)?,
|
||||
"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;
|
||||
}
|
||||
"patterns" => s.patterns.items[i].name = new,
|
||||
_ => s.edit_state.custom_shapes[i].name = new,
|
||||
}
|
||||
}
|
||||
"delete" => match kind.as_str() {
|
||||
"brushes" => {
|
||||
let gone = s.tools.presets.remove(i);
|
||||
crate::brush_cmds::forget_deleted_preset(s, &gone.name);
|
||||
}
|
||||
"brushes" => drop(s.tools.presets.remove(i)),
|
||||
"patterns" => drop(s.patterns.items.remove(i)),
|
||||
_ => drop(s.edit_state.custom_shapes.remove(i)),
|
||||
},
|
||||
@@ -1448,7 +1169,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?,"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}"##,
|
||||
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}"##,
|
||||
can_caf,
|
||||
content_aware_fill
|
||||
),
|
||||
|
||||
@@ -266,52 +266,6 @@ 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);
|
||||
@@ -473,35 +427,6 @@ 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,8 +52,7 @@ 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 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 r = photocraft_io::exr_parts::import(&stem(&path), &bytes, &sel).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let (i, color) = s.open_document(r.document, Some(path.clone()));
|
||||
Ok(json!({"document": i, "color": color, "warnings": r.warnings}))
|
||||
}
|
||||
@@ -174,25 +173,6 @@ 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,18 +335,15 @@ 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_frame(&d.doc, surf), json!({"target": p.get("target")})),
|
||||
Some(surf) => (crate::transform_cmds::target_bounds(&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_frame(&d.doc, l), json!({"layer": id.0}))
|
||||
(crate::transform_cmds::transform_bounds(&d.doc, l), json!({"layer": id.0}))
|
||||
}
|
||||
};
|
||||
if b.is_empty() {
|
||||
@@ -1472,49 +1469,6 @@ 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);
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
//! 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,40 +219,6 @@ 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()
|
||||
@@ -374,8 +340,6 @@ 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
|
||||
@@ -497,7 +461,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.
|
||||
pub(crate) fn tag_exr_colorspace(s: &Session, name: &str, doc: &mut Document) {
|
||||
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;
|
||||
}
|
||||
@@ -614,16 +578,7 @@ 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)?;
|
||||
// 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)
|
||||
place_bytes(s, &path, bytes, linked.then(|| path.clone()), p)
|
||||
}
|
||||
|
||||
// ---------- File Info (XMP) ----------
|
||||
@@ -1477,7 +1432,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 {
|
||||
@@ -1806,38 +1761,4 @@ 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).unwrap();
|
||||
let (program, args) = crate::layer_menu_cmds::reveal_command(path);
|
||||
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,6 +334,30 @@ 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::*;
|
||||
|
||||
@@ -174,9 +174,8 @@ pub fn params_for(id: &str, p: &Value) -> Option<FilterParams> {
|
||||
/// Runs filter command `id` with JSON `params` on `surf` (the pixel function behind every
|
||||
/// `filter.*` command; smart filters re-run it on re-render). `bounds` frames distortions;
|
||||
/// `selection` limits the result. Like layer filters, neighbourhood filters repeat the edge pixels
|
||||
/// `None` for ids that aren't pixel filters. `Err` when the requesting session's authority denies
|
||||
/// the filter's params (an ambient path the session may not read): the caller must surface the
|
||||
/// error rather than render unfiltered pixels.
|
||||
/// of `canvas` ∪ the surface's content instead of fading in transparency, and the output is
|
||||
/// clipped to it. `None` for ids that aren't pixel filters.
|
||||
pub fn apply_filter_to_surface(
|
||||
id: &str,
|
||||
params: &Value,
|
||||
@@ -184,36 +183,31 @@ pub fn apply_filter_to_surface(
|
||||
bounds: photocraft_geom::Rect,
|
||||
selection: Option<&photocraft_raster::Surface>,
|
||||
canvas: photocraft_geom::Rect,
|
||||
) -> Result<Option<photocraft_raster::Surface>> {
|
||||
// Replays bypass command dispatch; nested paths must still obey the requesting session's
|
||||
// authority, including adjustment LUTs and plug-ins, before any resolver can read them.
|
||||
crate::jobs::authorize_render_command(id, params)?;
|
||||
) -> Option<photocraft_raster::Surface> {
|
||||
// Liquify / Puppet Warp / Perspective Warp smart filters (distort_cmds).
|
||||
if let Some(out) = crate::distort_cmds::apply_to_surface(id, params, surf, canvas) {
|
||||
return Ok(Some(out));
|
||||
return Some(out);
|
||||
}
|
||||
// Lens Correction / Adaptive Wide Angle / Camera Raw smart filters (lens_cmds).
|
||||
if let Some(out) = crate::lens_cmds::apply_to_surface(id, params, surf, canvas) {
|
||||
return Ok(Some(out));
|
||||
return Some(out);
|
||||
}
|
||||
// Image › Adjustments recorded as smart filters (Levels, Curves, Shadows/Highlights… from PSD).
|
||||
if let Some(kind) = id.strip_prefix("image.adjustments.") {
|
||||
return Ok(crate::adjust_cmds::adjust_as_filter(kind, params, surf));
|
||||
return crate::adjust_cmds::adjust_as_filter(kind, params, surf);
|
||||
}
|
||||
// WebAssembly plug-in smart filters (plugin_cmds).
|
||||
if id == crate::plugin_cmds::RUN {
|
||||
return Ok(crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas));
|
||||
return crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas);
|
||||
}
|
||||
let Some(mut fp) = params_for(id, params) else { return Ok(None) };
|
||||
let mut fp = params_for(id, params)?;
|
||||
// A map that can no longer be read (moved or deleted since) leaves the pixels alone, like an
|
||||
// unknown filter; the command itself already refused an unreadable map when it was added.
|
||||
if crate::filters_ext::resolve_map_path(&mut fp, params).is_err() {
|
||||
return Ok(None);
|
||||
}
|
||||
crate::filters_ext::resolve_map_path(&mut fp, params).ok()?;
|
||||
let sel_bounds = selection.map(photocraft_raster::Surface::content_bounds);
|
||||
let content = surf.content_bounds();
|
||||
let area = algo::output_area(&fp, content, bounds, sel_bounds);
|
||||
Ok(Some(algo::apply_in(surf, &fp, area, bounds, selection, canvas.union(&content))))
|
||||
Some(algo::apply_in(surf, &fp, area, bounds, selection, canvas.union(&content)))
|
||||
}
|
||||
|
||||
pub(crate) fn has_filterable_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
@@ -466,15 +466,13 @@ 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. 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.
|
||||
/// through. `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)
|
||||
{
|
||||
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);
|
||||
*map = map_from_path(path, p.get("mapLayer").and_then(Value::as_u64))?.map(Arc::new);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -29,43 +29,6 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
type Authorization = fn(&str, &Value) -> Result<()>;
|
||||
|
||||
thread_local! {
|
||||
// Smart-filter rendering has no Session argument. Carry the requesting session's gate
|
||||
// through synchronous rendering and explicitly reinstall it on background workers.
|
||||
static RENDER_AUTHORIZATION: std::cell::Cell<Option<Authorization>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
pub(crate) struct AuthorizationScope(Option<Authorization>);
|
||||
|
||||
impl AuthorizationScope {
|
||||
fn enter(gate: Option<Authorization>) -> Self {
|
||||
Self(RENDER_AUTHORIZATION.with(|current| current.replace(gate.or(current.get()))))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AuthorizationScope {
|
||||
fn drop(&mut self) {
|
||||
RENDER_AUTHORIZATION.with(|current| current.set(self.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// [`AuthorizationScope`] entered with `gate` (or the one already installed, so trusted work on
|
||||
/// this thread keeps ambient paths usable). [`Session::edit`] and the job paths use this to carry
|
||||
/// the requesting session's authority into every smart-object re-render.
|
||||
pub(crate) fn render_authorization_scope(gate: Option<Authorization>) -> AuthorizationScope {
|
||||
AuthorizationScope::enter(gate)
|
||||
}
|
||||
|
||||
/// Recheck a stored filter's command and parameters before evaluating it or using its cache.
|
||||
pub(crate) fn authorize_render_command(id: &str, params: &Value) -> Result<()> {
|
||||
match RENDER_AUTHORIZATION.with(std::cell::Cell::get) {
|
||||
Some(gate) => gate(id, params),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies one background job for the session's lifetime.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, serde::Deserialize)]
|
||||
pub struct JobId(pub u64);
|
||||
@@ -321,11 +284,6 @@ pub fn run<T: Send + 'static>(
|
||||
work: impl FnOnce(&JobCtx) -> Result<T> + Send + 'static,
|
||||
apply: impl FnOnce(&mut Session, T) -> Result<Value> + 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);
|
||||
@@ -340,10 +298,7 @@ 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)
|
||||
});
|
||||
@@ -356,8 +311,6 @@ pub fn run<T: Send + 'static>(
|
||||
// The work closure is moved into the thread. If spawning fails (thread limits), there is
|
||||
// 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.
|
||||
@@ -509,9 +462,7 @@ impl Session {
|
||||
warm_layer_bounds(&imported.document, || ctx.check())?;
|
||||
Ok(imported)
|
||||
},
|
||||
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);
|
||||
move |s, r: photocraft_io::ImportResult| {
|
||||
// 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));
|
||||
@@ -551,7 +502,7 @@ impl Session {
|
||||
job.ctx.cancel();
|
||||
Err(e)
|
||||
}
|
||||
(Err(e), None) => Err(self.explain_missing_layer(e)),
|
||||
(Err(e), None) => Err(e),
|
||||
(Ok(_), Some(mut job)) => {
|
||||
job.command = command.to_string();
|
||||
job.params = params;
|
||||
@@ -701,8 +652,6 @@ impl Session {
|
||||
if let Some(gate) = self.authorize {
|
||||
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
|
||||
@@ -749,7 +698,7 @@ impl Session {
|
||||
job.ctx.cancel();
|
||||
Err(e)
|
||||
}
|
||||
(Err(e), None) => Err(self.explain_missing_layer(e)),
|
||||
(Err(e), None) => Err(e),
|
||||
(Ok(_), Some(mut job)) => {
|
||||
job.command = id.to_string();
|
||||
job.params = params;
|
||||
@@ -765,18 +714,6 @@ 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
|
||||
|
||||
+13
-14
@@ -160,14 +160,12 @@ fn key(label: &str) -> String {
|
||||
l.trim_end_matches('…').trim().to_lowercase()
|
||||
}
|
||||
|
||||
/// The engine's own candidates: every command in a menu (the engine's, then the shell's), in
|
||||
/// command order.
|
||||
/// The engine's own candidates: every command in a menu, in command order.
|
||||
pub fn command_candidates() -> Vec<Candidate> {
|
||||
let engine = crate::command_specs().iter().map(|c| (c.id, c.label, c.menu, c.shortcut));
|
||||
engine
|
||||
.chain(crate::shell_commands::SHELL_COMMANDS.iter().copied())
|
||||
.filter(|c| !c.2.is_empty())
|
||||
.map(|(id, label, _, default)| Candidate { id: id.to_string(), label: label.to_string(), default: default.map(str::to_string) })
|
||||
crate::command_specs()
|
||||
.iter()
|
||||
.filter(|c| !c.menu.is_empty())
|
||||
.map(|c| Candidate { id: c.id.to_string(), label: c.label.to_string(), default: c.shortcut.map(str::to_string) })
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -221,8 +219,8 @@ pub fn plan(candidates: impl IntoIterator<Item = Candidate>, set: &KysSet) -> Pl
|
||||
}
|
||||
|
||||
/// The tool keys of `set` matched to `tools` (our tool id and Photoshop's name for it, which is
|
||||
/// ours too: "Move Tool"): tool id → key (normalised: a letter, or an F-key), and the names with no
|
||||
/// tool here. A row PhotoCraft wrote names the tool by id. A key with modifiers is skipped.
|
||||
/// ours too: "Move Tool"): tool id → key (a single letter, upper case), and the names with no
|
||||
/// tool here. A row PhotoCraft wrote names the tool by id. A key that isn't one letter is skipped.
|
||||
pub fn plan_tools<'a>(tools: impl IntoIterator<Item = (&'a str, &'a str)>, set: &KysSet) -> (BTreeMap<String, String>, Vec<String>) {
|
||||
let tools: Vec<(&str, &str)> = tools.into_iter().collect();
|
||||
let mut keys = BTreeMap::new();
|
||||
@@ -234,10 +232,11 @@ pub fn plan_tools<'a>(tools: impl IntoIterator<Item = (&'a str, &'a str)>, set:
|
||||
unknown.push(row.name.clone());
|
||||
continue;
|
||||
};
|
||||
// One key without modifiers (Photoshop's letters; our tools may also take an F-key).
|
||||
let key = row.shortcuts.first().and_then(|k| normalize_shortcut(k.trim()));
|
||||
if let Some(k) = key.filter(|k| !k.contains('+') || k == "+") {
|
||||
keys.insert((*id).to_string(), k);
|
||||
let mut letters = row.shortcuts.first().map(|k| k.trim()).unwrap_or_default().chars();
|
||||
if let (Some(c), None) = (letters.next(), letters.next())
|
||||
&& c.is_ascii_alphanumeric()
|
||||
{
|
||||
keys.insert((*id).to_string(), c.to_ascii_uppercase().to_string());
|
||||
}
|
||||
}
|
||||
(keys, unknown)
|
||||
@@ -449,7 +448,7 @@ mod tests {
|
||||
assert_eq!(keys.get("tools.select.move").map(String::as_str), Some("V"));
|
||||
assert_eq!(keys.get("tools.select.pencil").map(String::as_str), Some("N"), "case-insensitive, upper-cased");
|
||||
assert_eq!(keys.get("tools.select.brush").map(String::as_str), Some("B"), "by pc-id");
|
||||
assert!(!keys.contains_key("tools.select.lasso"), "a tool key has no modifiers");
|
||||
assert!(!keys.contains_key("tools.select.lasso"), "a tool key is one letter");
|
||||
assert_eq!(unknown, vec!["Imaginary Tool".to_string()]);
|
||||
// Our own export reads back by id.
|
||||
let out = write("T", &[Entry { id: "tools.select.pencil".into(), label: "Pencil Tool".into(), tool: true, keys: vec!["F".into()] }]);
|
||||
|
||||
@@ -373,56 +373,30 @@ 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"));
|
||||
};
|
||||
// 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))
|
||||
reveal(path, 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) -> 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]))
|
||||
}
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -465,13 +465,16 @@ 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: link.clone() }, photocraft_geom::Affine::IDENTITY, None)),
|
||||
LayerContent::Smart(photocraft_doc::SmartObject::new(
|
||||
SmartSource::Linked { path: "/tmp/pics/a.png".into() },
|
||||
photocraft_geom::Affine::IDENTITY,
|
||||
None,
|
||||
)),
|
||||
);
|
||||
l.visible = true;
|
||||
*active = Some(doc.insert_above(*active, l));
|
||||
@@ -480,61 +483,11 @@ 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(&link).unwrap();
|
||||
let (prog, args) = reveal_command("/tmp/pics/a.png");
|
||||
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,9 +147,6 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 297 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user