mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-20 22:24:10 -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
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
+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
+43
-74
@@ -849,7 +849,7 @@ dependencies = [
|
||||
"maybe-owned",
|
||||
"rustix 1.1.5",
|
||||
"rustix-linux-procfs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
"winx",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
@@ -1896,7 +1891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1934,18 +1929,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exacl"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de9e551ced9d700a31dacb8b168129640240fcd7a279a3fa4d59608b84ebcae"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"log",
|
||||
"scopeguard",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exr"
|
||||
version = "1.74.2"
|
||||
@@ -3045,7 +3028,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3707,7 +3690,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4453,7 +4436,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4630,7 +4613,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"async-io",
|
||||
@@ -4663,7 +4646,6 @@ dependencies = [
|
||||
"rfd",
|
||||
"ron",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"winit",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
@@ -4675,7 +4657,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-affinity"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4685,7 +4667,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-algo"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4698,7 +4680,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-automation"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"cap-std",
|
||||
@@ -4711,7 +4693,6 @@ dependencies = [
|
||||
"photocraft-geom",
|
||||
"photocraft-io",
|
||||
"rmcp",
|
||||
"rustix 1.1.5",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4721,7 +4702,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cli"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
@@ -4735,7 +4716,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cms"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"moxcms",
|
||||
"rayon",
|
||||
@@ -4743,7 +4724,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-codecs"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"exr",
|
||||
"flate2",
|
||||
@@ -4764,7 +4745,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-color"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"serde",
|
||||
@@ -4772,7 +4753,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-compose"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-color",
|
||||
@@ -4787,7 +4768,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-doc"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4798,7 +4779,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-engine"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"craft-print",
|
||||
@@ -4814,7 +4795,6 @@ dependencies = [
|
||||
"photocraft-format",
|
||||
"photocraft-geom",
|
||||
"photocraft-io",
|
||||
"photocraft-ipp",
|
||||
"photocraft-ops",
|
||||
"photocraft-paint",
|
||||
"photocraft-plugins",
|
||||
@@ -4825,7 +4805,6 @@ dependencies = [
|
||||
"photocraft-vector",
|
||||
"rayon",
|
||||
"roxmltree",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
@@ -4835,11 +4814,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-format"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc32fast",
|
||||
"exacl",
|
||||
"flate2",
|
||||
"libc",
|
||||
"photocraft-codecs",
|
||||
@@ -4853,19 +4831,18 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-geom"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-gpu"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"half",
|
||||
"log",
|
||||
@@ -4882,17 +4859,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-heif"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"craft-docpath",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"photocraft-affinity",
|
||||
@@ -4917,20 +4893,16 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ipp"
|
||||
version = "0.11.1"
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ops"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-doc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-paint"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4941,7 +4913,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-plugins"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4956,7 +4928,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-psd"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"photocraft-psd",
|
||||
@@ -4967,18 +4939,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raster"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"proptest",
|
||||
"rayon",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raw"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4993,7 +4964,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-tablet"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
@@ -5005,7 +4976,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"photocraft-color",
|
||||
@@ -5016,7 +4987,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-text"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"parley",
|
||||
@@ -5025,13 +4996,12 @@ dependencies = [
|
||||
"photocraft-geom",
|
||||
"photocraft-psd",
|
||||
"photocraft-raster",
|
||||
"rayon",
|
||||
"skrifa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ui-egui"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-print",
|
||||
@@ -5067,13 +5037,12 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
"toml",
|
||||
"unicode-bidi",
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-vector"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
@@ -5084,7 +5053,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5106,7 +5075,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web-boot"
|
||||
version = "0.11.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"js-sys",
|
||||
@@ -5499,7 +5468,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6018,7 +5987,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6085,7 +6054,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6723,7 +6692,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6950,10 +6919,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7353,7 +7322,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8113,7 +8082,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8693,7 +8662,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.11.1"
|
||||
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.11.1"
|
||||
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| {
|
||||
@@ -427,15 +379,11 @@ fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::Cu
|
||||
}
|
||||
})),
|
||||
write: Some(Box::new(|path: &str, bytes: &[u8]| download(path, bytes))),
|
||||
// A download may be cancelled and never reports back: it doesn't save the document.
|
||||
write_unconfirmed: true,
|
||||
encode_png: Some(Box::new(|w, h, rgba| {
|
||||
let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?;
|
||||
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: the platform convention keeps crash-recovery autosaves out of what is synced
|
||||
//! with the settings: the Windows roaming profile and the Linux config folder ([`recovery_dir`]).
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -63,396 +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`. Autosaves hold whole documents and are rewritten every few
|
||||
/// minutes, so with the platform convention they stay out of what is synced with the settings:
|
||||
/// - Windows (#5648): the machine-local `%LOCALAPPDATA%\Photocraft\Recovery`; the roaming
|
||||
/// `%APPDATA%` may be synced at sign-in and sign-out or capped by policy.
|
||||
/// - Linux and other XDG systems (#6612): `$XDG_STATE_HOME/photocraft/Recovery`, by default
|
||||
/// `~/.local/state/photocraft/Recovery`, where the XDG Base Directory spec puts state that
|
||||
/// should outlive a restart but isn't configuration; dotfile managers and config sync often copy
|
||||
/// `~/.config` whole. As the spec asks, a relative `XDG_STATE_HOME` is ignored.
|
||||
/// - macOS: `~/Library/Application Support/Photocraft/Recovery` as before. Apple's guidelines put
|
||||
/// app-created data that can't be regenerated there; `~/Library/Caches` may be purged by the
|
||||
/// system.
|
||||
///
|
||||
/// They are never in a temp or cache folder, which cleanup tools may empty before a crashed
|
||||
/// document is recovered. The override and portable folders keep everything in one place.
|
||||
pub fn resolve_recovery(data: &DataDir, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
if data.mode == Mode::Platform {
|
||||
if cfg!(windows) {
|
||||
if let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty()) {
|
||||
return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY));
|
||||
}
|
||||
} else if !cfg!(target_os = "macos") {
|
||||
let state = env("XDG_STATE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.filter(|s| s.is_absolute())
|
||||
.or_else(|| env("HOME").filter(|h| !h.is_empty()).map(|h| PathBuf::from(h).join(".local").join("state")));
|
||||
if let Some(state) = state {
|
||||
return Some(state.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()) {
|
||||
@@ -616,330 +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);
|
||||
}
|
||||
|
||||
/// #6612: on Linux (and other XDG systems) autosaves go to `$XDG_STATE_HOME`, by default
|
||||
/// `~/.local/state`, not the config folder; a relative `XDG_STATE_HOME` is ignored. Windows
|
||||
/// and macOS don't read it, and an override or portable folder keeps them inside itself.
|
||||
#[test]
|
||||
fn autosaves_go_to_the_xdg_state_folder() {
|
||||
let root = temp("recovery-xdg");
|
||||
let settings = root.join("home").join(".config").join("photocraft");
|
||||
let home = root.join("home");
|
||||
let state = root.join("state");
|
||||
let platform = DataDir { dir: Some(settings.clone()), mode: Mode::Platform, warning: None };
|
||||
let xdg = !cfg!(windows) && !cfg!(target_os = "macos");
|
||||
let expect = |xdg_path: PathBuf| Some(if xdg { xdg_path } else { settings.join("Recovery") });
|
||||
assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), expect(state.join("photocraft").join("Recovery")));
|
||||
let default = home.join(".local").join("state").join("photocraft").join("Recovery");
|
||||
assert_eq!(resolve_recovery(&platform, env_of(&[("HOME", &home)])), expect(default.clone()));
|
||||
let relative = PathBuf::from("relative/state");
|
||||
assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &relative), ("HOME", &home)])), expect(default.clone()));
|
||||
let empty = PathBuf::new();
|
||||
assert_eq!(resolve_recovery(&platform, env_of(&[("XDG_STATE_HOME", &empty), ("HOME", &home)])), expect(default));
|
||||
// No home at all: beside the settings, as before.
|
||||
assert_eq!(resolve_recovery(&platform, |_| None), Some(settings.join("Recovery")));
|
||||
for mode in [Mode::Override, Mode::Portable] {
|
||||
let d = DataDir { dir: Some(settings.clone()), mode, warning: None };
|
||||
assert_eq!(resolve_recovery(&d, env_of(&[("XDG_STATE_HOME", &state), ("HOME", &home)])), Some(settings.join("Recovery")), "{mode:?}");
|
||||
}
|
||||
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 _spawns = crate::no_child_spawns();
|
||||
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 _spawns = crate::no_child_spawns();
|
||||
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
-177
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -420,10 +316,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
|
||||
let _spawns = crate::no_child_spawns();
|
||||
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");
|
||||
@@ -435,66 +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 _spawns = crate::no_child_spawns();
|
||||
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 _spawns = crate::no_child_spawns();
|
||||
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");
|
||||
@@ -520,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");
|
||||
@@ -533,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"));
|
||||
@@ -549,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 {
|
||||
|
||||
+58
-426
@@ -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,188 +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());
|
||||
}
|
||||
|
||||
/// Everything the desktop launcher takes from the command line, on top of the environment
|
||||
/// defaults handed in to `parse_launch_args`.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct LaunchArgs {
|
||||
control_port: Option<u16>,
|
||||
control_token: Option<String>,
|
||||
control_token_file: Option<std::path::PathBuf>,
|
||||
automation_read_root: Option<std::path::PathBuf>,
|
||||
automation_write_root: Option<std::path::PathBuf>,
|
||||
files: Vec<String>,
|
||||
/// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1
|
||||
/// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are
|
||||
/// reported in a notice once the app is up; a control token is a usage error like a bad port.
|
||||
unreadable_paths: Vec<String>,
|
||||
safe_gpu: bool,
|
||||
new_instance: bool,
|
||||
/// Options that apply only to the process they're given to; a launch with any keeps its own.
|
||||
launch_options: Vec<&'static str>,
|
||||
present_mode_arg: Option<String>,
|
||||
frame_latency_arg: Option<String>,
|
||||
stroke_trace_arg: Option<String>,
|
||||
in_window_menus: bool,
|
||||
/// `--help` / `-h`: print [`USAGE`] and exit 0; parsing stops there.
|
||||
help: bool,
|
||||
/// `--version`: print the version and exit 0; parsing stops there.
|
||||
version: bool,
|
||||
/// Usage errors (a bad or missing control/automation value, an unknown option); a non-empty
|
||||
/// list fails the launch with exit status 2 (issues #701, #6429, #6431).
|
||||
errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parse the desktop launch arguments over the environment defaults in `out`. A control or
|
||||
/// automation flag with no value is a usage error naming the flag (issue #6429), never a silent
|
||||
/// fall back to a generated token or to no root: like `--control` with a bad port, it exits 2.
|
||||
/// So is an unknown option (#6431); a file whose name starts with `-` goes after `--`.
|
||||
fn parse_launch_args(args: impl Iterator<Item = std::ffi::OsString>, mut out: LaunchArgs) -> LaunchArgs {
|
||||
let mut args = args;
|
||||
// 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) => out.files.push(s),
|
||||
Err(raw) => out.unreadable_paths.push(raw.to_string_lossy().into_owned()),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match a.to_str() {
|
||||
Some("--") => only_files = true,
|
||||
Some("--help" | "-h") => {
|
||||
out.help = true;
|
||||
break;
|
||||
}
|
||||
Some("--control") => match args.next() {
|
||||
Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
|
||||
Ok(port) => out.control_port = Some(port),
|
||||
Err(error) => out.errors.push(error),
|
||||
},
|
||||
None => out.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)) => out.control_token = Some(token),
|
||||
Some(Err(raw)) => out.errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
|
||||
None => out.errors.push("--control-token: missing token value (expected `--control-token <token>`)".to_string()),
|
||||
},
|
||||
Some("--control-token-file") => match args.next() {
|
||||
Some(value) => out.control_token_file = Some(std::path::PathBuf::from(value)),
|
||||
None => out.errors.push("--control-token-file: missing file path value (expected `--control-token-file <path>`)".to_string()),
|
||||
},
|
||||
Some("--automation-read-root") => {
|
||||
out.launch_options.push("--automation-read-root");
|
||||
match args.next() {
|
||||
Some(value) => out.automation_read_root = Some(std::path::PathBuf::from(value)),
|
||||
None => out.errors.push("--automation-read-root: missing directory value (expected `--automation-read-root <dir>`)".to_string()),
|
||||
}
|
||||
}
|
||||
Some("--automation-write-root") => {
|
||||
out.launch_options.push("--automation-write-root");
|
||||
match args.next() {
|
||||
Some(value) => out.automation_write_root = Some(std::path::PathBuf::from(value)),
|
||||
None => out.errors.push("--automation-write-root: missing directory value (expected `--automation-write-root <dir>`)".to_string()),
|
||||
}
|
||||
}
|
||||
Some("--safe-gpu") => {
|
||||
out.launch_options.push("--safe-gpu");
|
||||
out.safe_gpu = true;
|
||||
}
|
||||
Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => {
|
||||
out.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" => out.present_mode_arg = Some(v),
|
||||
"--frame-latency" => out.frame_latency_arg = Some(v),
|
||||
_ => out.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") => {
|
||||
out.launch_options.push("--in-window-menus");
|
||||
out.in_window_menus = true;
|
||||
}
|
||||
Some("--new-instance") => out.new_instance = true,
|
||||
Some("--version") => {
|
||||
out.version = true;
|
||||
break;
|
||||
}
|
||||
// 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('-') => out.errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")),
|
||||
Some(s) => out.files.push(s.to_owned()),
|
||||
None => out.unreadable_paths.push(a.to_string_lossy().into_owned()),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[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]
|
||||
@@ -359,152 +167,71 @@ mod control_port_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod launch_args_tests {
|
||||
use super::{LaunchArgs, parse_launch_args, usage_exit_code};
|
||||
use std::ffi::OsString;
|
||||
|
||||
fn parse(values: &[&str]) -> LaunchArgs {
|
||||
parse_launch_args(values.iter().map(OsString::from), LaunchArgs::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_without_a_value_are_usage_errors() {
|
||||
// Issue #6429: a missing value must fail the launch, not silently fall back to a
|
||||
// generated token or to no root.
|
||||
for flag in ["--control", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"] {
|
||||
let parsed = parse(&[flag]);
|
||||
assert_eq!(parsed.errors.len(), 1, "{flag}: {:?}", parsed.errors);
|
||||
assert!(parsed.errors[0].starts_with(flag), "{}", parsed.errors[0]);
|
||||
assert!(parsed.errors[0].contains("missing"), "{}", parsed.errors[0]);
|
||||
assert_eq!(usage_exit_code(&parsed.errors), Some(2));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_value_does_not_clear_an_environment_default() {
|
||||
// `--automation-read-root` with no value must not drop a root set through the env var.
|
||||
let defaults = LaunchArgs { automation_read_root: Some("/env/read".into()), automation_write_root: Some("/env/write".into()), ..Default::default() };
|
||||
let parsed = parse_launch_args(["--automation-read-root"].iter().map(OsString::from), defaults);
|
||||
assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/env/read")));
|
||||
assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/env/write")));
|
||||
assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_are_parsed_and_no_errors_remain() {
|
||||
let parsed = parse(&[
|
||||
"--control",
|
||||
"50494",
|
||||
"--control-token",
|
||||
"abcd",
|
||||
"--control-token-file",
|
||||
"/tmp/token",
|
||||
"--automation-read-root",
|
||||
"/r",
|
||||
"--automation-write-root",
|
||||
"/w",
|
||||
"--safe-gpu",
|
||||
"--in-window-menus",
|
||||
"--new-instance",
|
||||
"--present-mode",
|
||||
"mailbox",
|
||||
"photo.psd",
|
||||
]);
|
||||
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
|
||||
assert_eq!(parsed.control_port, Some(50494));
|
||||
assert_eq!(parsed.control_token.as_deref(), Some("abcd"));
|
||||
assert_eq!(parsed.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token")));
|
||||
assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/r")));
|
||||
assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/w")));
|
||||
assert!(parsed.safe_gpu);
|
||||
assert!(parsed.in_window_menus);
|
||||
assert!(parsed.new_instance);
|
||||
assert_eq!(parsed.present_mode_arg.as_deref(), Some("mailbox"));
|
||||
assert_eq!(parsed.launch_options, vec!["--automation-read-root", "--automation-write-root", "--safe-gpu", "--in-window-menus", "--present-mode"]);
|
||||
assert_eq!(parsed.files, vec!["photo.psd".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_stops_parsing() {
|
||||
let parsed = parse(&["--version", "--control"]);
|
||||
assert!(parsed.version);
|
||||
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_stops_parsing_and_double_dash_ends_options() {
|
||||
let parsed = parse(&["-h", "--safegpu", "--control"]);
|
||||
assert!(parsed.help);
|
||||
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
|
||||
|
||||
let parsed = parse(&["--safegpu", "--", "--help", "-x"]);
|
||||
assert!(!parsed.help);
|
||||
assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors);
|
||||
assert!(parsed.errors[0].contains("unknown option `--safegpu`"), "{}", parsed.errors[0]);
|
||||
assert_eq!(parsed.files, vec!["--help".to_string(), "-x".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
crash_guard::install_hook();
|
||||
// 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 launch = LaunchArgs {
|
||||
automation_read_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from),
|
||||
automation_write_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from),
|
||||
in_window_menus: std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"),
|
||||
..Default::default()
|
||||
};
|
||||
let mut control_port: Option<u16> = None;
|
||||
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) => launch.control_port = Some(port),
|
||||
Err(error) => launch.errors.push(error),
|
||||
Ok(port) => control_port = Some(port),
|
||||
Err(error) => control_arg_errors.push(error),
|
||||
}
|
||||
}
|
||||
launch = parse_launch_args(std::env::args_os().skip(1), launch);
|
||||
if launch.help {
|
||||
print_stdout(USAGE);
|
||||
return Ok(());
|
||||
}
|
||||
if launch.version {
|
||||
print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
|
||||
return Ok(());
|
||||
let mut control_token = None;
|
||||
let mut control_token_file = None;
|
||||
let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from);
|
||||
let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from);
|
||||
let mut files = Vec::new();
|
||||
// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1
|
||||
// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are
|
||||
// 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 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);
|
||||
while let Some(a) = args.next() {
|
||||
match a.to_str() {
|
||||
Some("--control") => match args.next() {
|
||||
Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
|
||||
Ok(port) => control_port = Some(port),
|
||||
Err(error) => control_arg_errors.push(error),
|
||||
},
|
||||
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)) => 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") => 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") => {
|
||||
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_") => {}
|
||||
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), a control
|
||||
// or automation flag without its value must not silently fall back to a generated token or
|
||||
// to no root (issue #6429), 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(&launch.errors) {
|
||||
for error in &launch.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);
|
||||
}
|
||||
let LaunchArgs {
|
||||
control_port,
|
||||
control_token,
|
||||
control_token_file,
|
||||
automation_read_root,
|
||||
automation_write_root,
|
||||
files,
|
||||
unreadable_paths,
|
||||
safe_gpu,
|
||||
new_instance,
|
||||
launch_options,
|
||||
present_mode_arg,
|
||||
frame_latency_arg,
|
||||
stroke_trace_arg,
|
||||
in_window_menus,
|
||||
help: _,
|
||||
version: _,
|
||||
errors: _,
|
||||
} = launch;
|
||||
|
||||
// The log file lives under the settings directory; opened after the arguments, so `--version`
|
||||
// and usage errors leave no file behind. Records logged until now are written to it first.
|
||||
@@ -516,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")]
|
||||
@@ -679,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;
|
||||
@@ -721,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).
|
||||
@@ -762,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}")),
|
||||
@@ -828,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
|
||||
@@ -870,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.
|
||||
@@ -920,19 +565,6 @@ fn main() -> eframe::Result {
|
||||
result
|
||||
}
|
||||
|
||||
/// Tests that spawn child processes hold this for reading, tests that check who holds a file lock
|
||||
/// for writing ([`no_child_spawns`]): until its `exec`, a child holds a copy of every open file,
|
||||
/// `flock`s included, so a spawn in another test could keep a lock this process just dropped held
|
||||
/// for a moment.
|
||||
#[cfg(test)]
|
||||
pub(crate) static CHILD_SPAWNS: std::sync::RwLock<()> = std::sync::RwLock::new(());
|
||||
|
||||
/// Held by a test that checks who holds a file lock: no other test spawns a child meanwhile.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn no_child_spawns() -> std::sync::RwLockWriteGuard<'static, ()> {
|
||||
CHILD_SPAWNS.write().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
@@ -495,7 +495,6 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_hung_helper_is_stopped_and_big_replies_dont_block() {
|
||||
let _spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let t0 = std::time::Instant::now();
|
||||
let e = run_helper(std::process::Command::new("sleep").arg("30"), std::time::Duration::from_millis(300)).unwrap_err();
|
||||
assert!(e.contains("took longer than"), "{e}");
|
||||
|
||||
@@ -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,237 +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 spawns = crate::CHILD_SPAWNS.read().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut daemon =
|
||||
std::process::Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(std::process::Stdio::piped()).spawn().ok()?;
|
||||
drop(spawns);
|
||||
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();
|
||||
// A stream only gets the messages that arrive after it exists: made in the thread, it could
|
||||
// miss a `SaveFile` sent before the thread got going, and the call would wait forever.
|
||||
let messages = zbus::blocking::MessageIterator::from(&server);
|
||||
std::thread::spawn(move || {
|
||||
let mut script = script.into_iter();
|
||||
for msg in messages {
|
||||
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")));
|
||||
}
|
||||
}
|
||||
+32
-201
@@ -6,7 +6,7 @@ use photocraft_doc::{Document, Layer, Size};
|
||||
use photocraft_format::RecoveryStore;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::cell::RefCell;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
@@ -36,47 +36,16 @@ fn open_filter_extensions(extensions: &[&str]) -> Vec<String> {
|
||||
extensions.iter().map(|ext| if case_sensitive_globs { ext.chars().map(class).collect() } else { ext.to_string() }).collect()
|
||||
}
|
||||
|
||||
/// File › Open's file types after All Formats, as Photoshop lists them: one per format, so a folder
|
||||
/// of mixed files can be narrowed to one kind (#6337). Together they cover every extension of
|
||||
/// [`OPEN_EXTS`]. An explicit SVG filter also keeps vector artwork discoverable when a native
|
||||
/// picker defaults to another filter.
|
||||
const OPEN_FILTERS: &[(&str, &[&str])] = &[
|
||||
("PhotoCraft", &["pcraft"]),
|
||||
("Photoshop", &["psd", "psb", "psdt"]),
|
||||
("Affinity", &["af", "afdesign", "afphoto", "afpub"]),
|
||||
("Krita", &["kra", "krz"]),
|
||||
("OpenRaster", &["ora"]),
|
||||
("Paint.NET", &["pdn"]),
|
||||
("SVG", SVG_EXTS),
|
||||
("PNG", &["png"]),
|
||||
("JPEG", &["jpg", "jpeg"]),
|
||||
("HEIF", &["heic", "heif", "hif"]),
|
||||
("TIFF", &["tif", "tiff"]),
|
||||
("WebP", &["webp"]),
|
||||
("GIF", &["gif"]),
|
||||
("BMP", &["bmp"]),
|
||||
("Targa", &["tga"]),
|
||||
("ICO", &["ico"]),
|
||||
("QOI", &["qoi"]),
|
||||
("PCX", &["pcx"]),
|
||||
("DDS", &["dds"]),
|
||||
("XPM", &["xpm"]),
|
||||
("Portable Bitmap", &["pbm", "pgm", "ppm", "pam", "pfm"]),
|
||||
("OpenEXR", &["exr"]),
|
||||
("Radiance HDR", &["hdr"]),
|
||||
("FITS", &["fits", "fit", "fts"]),
|
||||
("DPX and Cineon", &["dpx", "cin"]),
|
||||
("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]),
|
||||
("Brushes", &["abr"]),
|
||||
("Gradients", &["grd"]),
|
||||
("Swatches", &["aco", "ase"]),
|
||||
("Keyboard Shortcuts", &["kys"]),
|
||||
];
|
||||
|
||||
/// An explicit SVG filter keeps vector artwork discoverable even when a native picker defaults to
|
||||
/// the PhotoCraft-only filter. SVG is also included in All Formats for normal multi-format opens.
|
||||
fn open_filters() -> Vec<(&'static str, Vec<String>)> {
|
||||
std::iter::once(("All Formats", open_filter_extensions(OPEN_EXTS)))
|
||||
.chain(OPEN_FILTERS.iter().map(|(name, exts)| (*name, open_filter_extensions(exts))))
|
||||
.collect()
|
||||
vec![
|
||||
("All Formats", open_filter_extensions(OPEN_EXTS)),
|
||||
("PhotoCraft", open_filter_extensions(&["pcraft"])),
|
||||
("OpenRaster", open_filter_extensions(&["ora"])),
|
||||
("Krita", open_filter_extensions(&["kra", "krz"])),
|
||||
("SVG", open_filter_extensions(SVG_EXTS)),
|
||||
]
|
||||
}
|
||||
|
||||
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
|
||||
@@ -126,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 })
|
||||
}
|
||||
|
||||
@@ -191,7 +127,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
let dialog = if let Some(exts) = extensions {
|
||||
dialog.add_filter("Supported Files", &open_filter_extensions(&exts.iter().map(String::as_str).collect::<Vec<_>>()))
|
||||
} else {
|
||||
open_filters().into_iter().fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions))
|
||||
open_filters()
|
||||
.into_iter()
|
||||
.fold(dialog, |dialog, (name, extensions)| dialog.add_filter(name, &extensions))
|
||||
.add_filter("Paint.NET", &open_filter_extensions(&["pdn"]))
|
||||
};
|
||||
if multiple {
|
||||
let picked = dialog.pick_files();
|
||||
@@ -209,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.
|
||||
@@ -292,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 and Linux, 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()
|
||||
}
|
||||
@@ -308,11 +212,8 @@ pub fn presets_dir() -> Option<PathBuf> {
|
||||
config_dir().map(|d| d.join("Presets"))
|
||||
}
|
||||
|
||||
/// Crash-recovery autosaves: under the settings directory, except where that is synced with the
|
||||
/// settings: the Windows roaming profile and the Linux config folder (see
|
||||
/// [`crate::app_dirs::resolve_recovery`]).
|
||||
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
|
||||
@@ -493,7 +394,6 @@ fn shell_word(s: &str) -> String {
|
||||
|
||||
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
|
||||
let clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
|
||||
let clip_unreadable: Rc<Cell<bool>> = Rc::default();
|
||||
let automation_read = automation.clone().map(|workspace| {
|
||||
Box::new(move |path: &str| {
|
||||
let bytes = workspace.read(path).map_err(|error| error.to_string())?;
|
||||
@@ -536,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,
|
||||
@@ -567,19 +462,11 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
}),
|
||||
clipboard_get_image: Some({
|
||||
let clip = clip.clone();
|
||||
let unreadable = clip_unreadable.clone();
|
||||
Box::new(move || {
|
||||
let mut slot = clip.try_borrow_mut().ok()?;
|
||||
let cb = match slot.as_mut() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
// arboard opens only when some backend works. In a Flatpak on Wayland none
|
||||
// does (#5031): the sandbox hides the data-control protocol from it, and
|
||||
// `--socket=fallback-x11` gives no X server while a compositor runs.
|
||||
let opened = arboard::Clipboard::new();
|
||||
unreadable.set(opened.is_err());
|
||||
slot.insert(opened.ok()?)
|
||||
}
|
||||
None => slot.insert(arboard::Clipboard::new().ok()?),
|
||||
};
|
||||
// Copied files first: Finder also puts the file's icon on the clipboard as an
|
||||
// image, which would otherwise paste instead of the file.
|
||||
@@ -595,7 +482,6 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
None
|
||||
})
|
||||
}),
|
||||
clipboard_unreadable: Some(Box::new(move || clip_unreadable.get())),
|
||||
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
|
||||
photoshop_shortcuts: Some(Box::new(|| crate::photoshop_settings::live_keyboard_shortcuts(&|k| std::env::var_os(k)))),
|
||||
save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))),
|
||||
@@ -699,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() {
|
||||
@@ -768,26 +619,6 @@ mod tests {
|
||||
assert!(svg_filter.iter().any(|glob| matches(glob, "svg")) && svg_filter.iter().any(|glob| matches(glob, "svgz")));
|
||||
}
|
||||
|
||||
/// #6337: after All Formats, every format File › Open reads has its own filter, and no filter
|
||||
/// offers an extension All Formats doesn't.
|
||||
#[test]
|
||||
fn every_open_format_has_its_own_filter() {
|
||||
let filters = open_filters();
|
||||
assert_eq!(filters[0], ("All Formats", open_filter_extensions(OPEN_EXTS)), "All Formats comes first");
|
||||
let mut named: Vec<&str> = OPEN_FILTERS.iter().flat_map(|(_, exts)| exts.iter().copied()).collect();
|
||||
let total = named.len();
|
||||
named.sort_unstable();
|
||||
named.dedup();
|
||||
assert_eq!(named.len(), total, "an extension is listed under two filters");
|
||||
let mut all = OPEN_EXTS.to_vec();
|
||||
all.sort_unstable();
|
||||
assert_eq!(named, all, "the named filters cover All Formats exactly");
|
||||
let names: Vec<&str> = filters.iter().map(|(name, _)| *name).collect();
|
||||
for name in ["Photoshop", "Affinity", "HEIF", "PNG", "JPEG", "TIFF", "Portable Bitmap", "OpenEXR", "Radiance HDR", "Paint.NET"] {
|
||||
assert!(names.contains(&name), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
#[test]
|
||||
fn open_filters_keep_literal_extensions_on_windows_and_macos() {
|
||||
|
||||
@@ -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 |
@@ -5,9 +5,6 @@
|
||||
- **Headless:** an in-process `Headless` session owns the engine and performs file I/O.
|
||||
- **Bridge:** `BridgeClient` forwards tools to a desktop application started with `photocraft --control <port>`.
|
||||
|
||||
For a source checkout, first [install the CLI on PATH and register the server](../../../docs/mcp.md#install-and-register).
|
||||
The installed binary survives `cargo clean`; a symlink into `target/release` does not.
|
||||
|
||||
Start the headless server with:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -159,25 +159,6 @@ sync_github_name = false
|
||||
real_name = "Ufuk Efe"
|
||||
sync_github_name = true
|
||||
|
||||
[people.nicolasfalesy]
|
||||
sync_github_name = true
|
||||
|
||||
[people.sidney]
|
||||
real_name = "Sidney Markowitz"
|
||||
sync_github_name = true
|
||||
|
||||
[people.TaylorBurke]
|
||||
sync_github_name = true
|
||||
|
||||
[people.1Git2Clone]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Byxis]
|
||||
sync_github_name = true
|
||||
|
||||
[people.00xJS]
|
||||
sync_github_name = true
|
||||
@@ -139,18 +139,11 @@ zstd window, 4096 saved revisions, array lengths no longer than the
|
||||
bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before
|
||||
anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of
|
||||
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
|
||||
nodes per curve, 4 levels of spot colours defined through spot colours, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
|
||||
nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
|
||||
stops; `photocraft-io` adds 300 000 pixels a side per document and 64 megapixels per image on the
|
||||
canvas. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
|
||||
crate has no panics outside tests.
|
||||
|
||||
Compound outlines have a separate active-reference cycle check and a depth cap of 64. Across
|
||||
the document, geometry expansion admits at most 500 000 combined outline calls and operand-slot
|
||||
visits (including nulls and non-object values), and four million points created or copied into
|
||||
compounds. Operand slots are charged before filtering, without first collecting an operand list.
|
||||
A compound that crosses a limit or contains a cycle is omitted
|
||||
with a warning as a whole; ordinary shared operands remain valid and keep each occurrence's transform.
|
||||
|
||||
## Privacy
|
||||
|
||||
Affinity documents can hold the folder they were saved in, user names, original image paths and
|
||||
|
||||
@@ -6,103 +6,10 @@ use crate::stream::{ObjId, Tag, Value};
|
||||
|
||||
/// Nodes per curve object.
|
||||
const MAX_NODES: usize = 4_000_000;
|
||||
const MAX_COMPOUND_DEPTH: usize = 64;
|
||||
const MAX_OUTLINE_VISITS: usize = 500_000;
|
||||
const MAX_OUTLINE_POINTS: usize = 4_000_000;
|
||||
|
||||
/// Geometry is expanded per occurrence, since parametric outlines can depend on their world
|
||||
/// transform. Bound work across the entire document instead of memoizing by object id alone.
|
||||
pub(crate) struct Budget {
|
||||
visits_left: usize,
|
||||
points_left: usize,
|
||||
}
|
||||
|
||||
impl Default for Budget {
|
||||
fn default() -> Self {
|
||||
Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS }
|
||||
}
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
/// Charge an outline call or a raw compound operand slot before inspecting it.
|
||||
fn visit(&mut self) -> Result<(), &'static str> {
|
||||
self.visits_left = self.visits_left.checked_sub(1).ok_or("compound geometry budget exhausted")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Charge both leaf geometry and each copy into a compound before allocating that copy.
|
||||
fn path(&mut self, path: &Path) -> Result<(), &'static str> {
|
||||
let points = path.subpaths.iter().fold(0usize, |n, s| n.saturating_add(1).saturating_add(s.segments.len().saturating_mul(3)));
|
||||
let Some(left) = self.points_left.checked_sub(points) else {
|
||||
// Do not rebuild an expensive leaf for every subsequent root after exhaustion.
|
||||
self.visits_left = 0;
|
||||
return Err("compound geometry budget exhausted");
|
||||
};
|
||||
self.points_left = left;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The node-space outline of a curve, shape or compound node, and whether it fills even-odd.
|
||||
/// Instances (master-page or symbol copies) take the geometry of their master.
|
||||
pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
|
||||
match outline_inner(r, id, class, world, &mut Vec::new()) {
|
||||
Ok(path) => path,
|
||||
Err(reason) => {
|
||||
r.warn(reason);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn outline_inner(r: &mut Reader, id: ObjId, class: Tag, world: Affine, active: &mut Vec<ObjId>) -> Result<Option<(Path, bool)>, &'static str> {
|
||||
r.geometry_budget.visit()?;
|
||||
if active.contains(&id) {
|
||||
return Err("cyclic compound shape");
|
||||
}
|
||||
// Independent of the layer-tree limit: Comp operands do not pass through Reader::node.
|
||||
if active.len() >= MAX_COMPOUND_DEPTH {
|
||||
return Err("compound depth exceeds 64 levels");
|
||||
}
|
||||
if class != Tag::of(b"Comp") {
|
||||
let path = leaf_outline(r, id, class, world);
|
||||
if let Some((path, _)) = &path {
|
||||
r.geometry_budget.path(path)?;
|
||||
}
|
||||
return Ok(path);
|
||||
}
|
||||
active.push(id);
|
||||
let result = compound(r, id, world, active);
|
||||
active.pop();
|
||||
result
|
||||
}
|
||||
|
||||
fn compound(r: &mut Reader, id: ObjId, world: Affine, active: &mut Vec<ObjId>) -> Result<Option<(Path, bool)>, &'static str> {
|
||||
let s = r.s;
|
||||
// One path of all operands' contours, filled even-odd, as before. A safety failure rejects
|
||||
// the whole outline: a partial add/subtract shape could silently change its meaning.
|
||||
let mut path = Path::default();
|
||||
// Charge every slot before filtering nulls or malformed values. Stream::objs would scan
|
||||
// and allocate the whole list first, repeating uncharged work for each shared occurrence.
|
||||
let children = if let Some(Value::Array(children)) = s.field(id, b"Chld") { children.as_slice() } else { &[] };
|
||||
for child in children {
|
||||
r.geometry_budget.visit()?;
|
||||
let Value::Obj(Some(c)) = child else { continue };
|
||||
let c = *c;
|
||||
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
|
||||
let Some(class) = s.class(c) else { return Ok(None) };
|
||||
if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
|
||||
r.warn("compound shapes that intersect or exclude (imported as add)");
|
||||
}
|
||||
if let Some((p, _)) = outline_inner(r, c, class, local.then(world), active)? {
|
||||
r.geometry_budget.path(&p)?;
|
||||
path.subpaths.extend(p.transformed(local).subpaths);
|
||||
}
|
||||
}
|
||||
Ok(Some((path, true)))
|
||||
}
|
||||
|
||||
fn leaf_outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
|
||||
let s = r.s;
|
||||
match &class.0.to_be_bytes() {
|
||||
b"PCrv" | b"TxtC" => {
|
||||
@@ -111,6 +18,23 @@ fn leaf_outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<
|
||||
let multi = path.subpaths.len() > 1;
|
||||
Some((path, multi))
|
||||
}
|
||||
b"Comp" => {
|
||||
// One path of all the operands' contours in the compound's space, filled even-odd: this
|
||||
// matches add and subtract operands in every public sample; a true union of overlapping
|
||||
// added operands would differ.
|
||||
let mut path = Path::default();
|
||||
for c in s.objs(id, b"Chld") {
|
||||
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
|
||||
let class = s.class(c)?;
|
||||
if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
|
||||
r.warn("compound shapes that intersect or exclude (imported as add)");
|
||||
}
|
||||
if let Some((p, _)) = outline(r, c, class, local.then(world)) {
|
||||
path.subpaths.extend(p.transformed(local).subpaths);
|
||||
}
|
||||
}
|
||||
Some((path, true))
|
||||
}
|
||||
_ => {
|
||||
let (shape, box_owner) = match s.obj(id, b"Shpe") {
|
||||
Some(sh) => (sh, id),
|
||||
|
||||
@@ -298,7 +298,6 @@ pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
warnings: BTreeMap<String, usize>,
|
||||
nodes: usize,
|
||||
active: HashSet<ObjId>,
|
||||
pub(crate) geometry_budget: crate::geometry::Budget,
|
||||
/// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so
|
||||
/// far hold).
|
||||
pixels_left: usize,
|
||||
@@ -306,16 +305,7 @@ pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
|
||||
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
fn new(s: &'s Stream, archive: &'b mut Archive<'a>, limits: Limits) -> Self {
|
||||
Self {
|
||||
s,
|
||||
archive,
|
||||
dpi: 72.0,
|
||||
warnings: BTreeMap::new(),
|
||||
nodes: 0,
|
||||
active: HashSet::new(),
|
||||
geometry_budget: crate::geometry::Budget::default(),
|
||||
pixels_left: limits.max_pixel_bytes,
|
||||
}
|
||||
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: limits.max_pixel_bytes }
|
||||
}
|
||||
|
||||
/// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's
|
||||
|
||||
@@ -134,19 +134,8 @@ fn floats(v: &Value) -> Option<Vec<f64>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spot colours defined through spot colours. A spot colour (`Pant`) is read through its RGB
|
||||
/// equivalent, else its base colour, which in real files hold plain colours. Colours are shared
|
||||
/// objects, so one can link to itself, and with both links followed at every level a colour read
|
||||
/// costs up to 2^(depth+1) lookups.
|
||||
const MAX_SPOT_DEPTH: usize = 4;
|
||||
|
||||
/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`).
|
||||
pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
|
||||
color_at(r, id, 0)
|
||||
}
|
||||
|
||||
/// [`color`] of an object `depth` spot colours down.
|
||||
fn color_at(r: &mut Reader, id: ObjId, depth: usize) -> Option<Color> {
|
||||
let s = r.s;
|
||||
let class = s.class(id)?;
|
||||
let col = || -> Option<Vec<f64>> {
|
||||
@@ -177,12 +166,8 @@ fn color_at(r: &mut Reader, id: ObjId, depth: usize) -> Option<Color> {
|
||||
Color::Lab { l: 100.0 * u(0)?, a: 255.0 * u(1)? - 128.0, b: 255.0 * u(2)? - 128.0, alpha: u(3)? }
|
||||
}
|
||||
b"Pant" => {
|
||||
if depth >= MAX_SPOT_DEPTH {
|
||||
r.warn("spot colours nested deeper than 4 levels (left empty)");
|
||||
return None;
|
||||
}
|
||||
r.warn("spot colours (imported as their RGB equivalent)");
|
||||
return s.obj(id, b"srgb").and_then(|c| color_at(r, c, depth + 1)).or_else(|| s.obj(id, b"base").and_then(|c| color_at(r, c, depth + 1)));
|
||||
return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c)));
|
||||
}
|
||||
b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 },
|
||||
_ => {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -538,174 +538,3 @@ mod pixels {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spot colours that link to themselves or to each other. Each document is read on a 1 MiB
|
||||
/// stack, the smallest main-thread stack PhotoCraft runs on (Windows), first in a child process
|
||||
/// that must finish within 20 s. A stack overflow aborts the process it happens in, so unbounded
|
||||
/// recursion fails the one test that caused it instead of taking the other tests in the binary
|
||||
/// down with it.
|
||||
mod spot_colours {
|
||||
use std::io::{Read, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::container::Limits;
|
||||
use crate::model::{Document, Kind};
|
||||
use crate::paint::{Color, Paint};
|
||||
use crate::stream::Tag;
|
||||
use crate::synth::{self, F, Method, tag};
|
||||
|
||||
/// A one-page document whose page holds `top`, read in a child process first and then here.
|
||||
fn read(top: Vec<F>) -> Document {
|
||||
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(top))]);
|
||||
let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
|
||||
let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None);
|
||||
let test = std::thread::current().name().unwrap_or("reader").to_owned();
|
||||
read_in_child(&bytes, &test);
|
||||
read_on_small_stack(bytes, &test).unwrap()
|
||||
}
|
||||
|
||||
/// Reads on a 1 MiB thread named after the test, so a stack overflow message names the test.
|
||||
fn read_on_small_stack(bytes: Vec<u8>, test: &str) -> Result<Document, crate::Error> {
|
||||
let reader = std::thread::Builder::new().name(test.to_owned()).stack_size(1 << 20).spawn(move || crate::read(&bytes, Limits::default())).unwrap();
|
||||
reader.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic))
|
||||
}
|
||||
|
||||
/// Set in the child process to the name of the test it reads for.
|
||||
const CHILD: &str = "PHOTOCRAFT_AFFINITY_READ_CHILD";
|
||||
|
||||
/// Runs `read_child` in a new process of this test binary on `bytes` and fails the test if
|
||||
/// that process crashes, panics or takes over 20 s, or never read the document.
|
||||
fn read_in_child(bytes: &[u8], test: &str) {
|
||||
let mut child = Command::new(std::env::current_exe().unwrap())
|
||||
.args(["tests::spot_colours::read_child", "--exact", "--ignored", "--nocapture", "--test-threads=1"])
|
||||
.env(CHILD, test)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let drain = |mut pipe: Box<dyn Read + Send>| {
|
||||
std::thread::spawn(move || {
|
||||
let mut text = String::new();
|
||||
let _ = pipe.read_to_string(&mut text);
|
||||
text
|
||||
})
|
||||
};
|
||||
let stdout = drain(Box::new(child.stdout.take().unwrap()));
|
||||
let stderr = drain(Box::new(child.stderr.take().unwrap()));
|
||||
// Fed from a thread, so a child that never reads cannot block the deadline below; a child
|
||||
// that dies first breaks the pipe and is reported by its exit status.
|
||||
let (mut stdin, input) = (child.stdin.take().unwrap(), bytes.to_vec());
|
||||
let feed = std::thread::spawn(move || {
|
||||
let _ = stdin.write_all(&input);
|
||||
});
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
break Some(status);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break None;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
feed.join().unwrap();
|
||||
let output = format!("{}{}", stdout.join().unwrap(), stderr.join().unwrap());
|
||||
match status {
|
||||
None => panic!("reading the document did not finish within 20 s:\n{output}"),
|
||||
Some(status) if !status.success() => panic!("reading the document failed in a child process ({status}):\n{output}"),
|
||||
// A filter that matched no test would also exit successfully, having read nothing.
|
||||
Some(_) => assert!(output.contains(&format!("read {} bytes", bytes.len())), "the child process did not read the document:\n{output}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Not a test of its own: `read_in_child` runs it in a child process to read the document on
|
||||
/// its stdin. Without the variable, as when run with `--ignored`, it does nothing.
|
||||
#[test]
|
||||
#[ignore = "run by read_in_child in a child process"]
|
||||
fn read_child() {
|
||||
let Some(test) = std::env::var_os(CHILD) else { return };
|
||||
let mut bytes = Vec::new();
|
||||
std::io::stdin().read_to_end(&mut bytes).unwrap();
|
||||
let len = bytes.len();
|
||||
let _ = read_on_small_stack(bytes, &test.to_string_lossy());
|
||||
println!("read {len} bytes");
|
||||
}
|
||||
|
||||
/// An ellipse filled with `colour`.
|
||||
fn filled(id: u32, colour: F) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"ShpN")],
|
||||
vec![
|
||||
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 1.0, 1.0])),
|
||||
(tag(b"Shpe"), F::Def(id + 1_000_000, vec![tag(b"ShpE")], vec![])),
|
||||
(tag(b"BFFl"), F::Shared(vec![F::Def(id + 2_000_000, vec![tag(b"FilS")], vec![(tag(b"Colr"), colour)])])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn spot(id: u32, fields: Vec<(Tag, F)>) -> F {
|
||||
F::Def(id, vec![tag(b"Pant")], fields)
|
||||
}
|
||||
|
||||
fn fills(doc: &Document) -> &[Paint] {
|
||||
match &doc.spreads[0].nodes[0].kind {
|
||||
Kind::Shape { fills, .. } => fills,
|
||||
other => panic!("expected a shape, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn warned(doc: &Document, what: &str) -> bool {
|
||||
doc.warnings.iter().any(|w| w.contains(what))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spot_colour_that_is_its_own_rgb_equivalent_is_refused() {
|
||||
let doc = read(vec![filled(10, spot(20, vec![(tag(b"srgb"), F::Ref(20))]))]);
|
||||
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
|
||||
assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_colours_that_are_each_others_base_and_rgb_equivalent_are_refused() {
|
||||
let doc = read(vec![filled(10, spot(20, vec![(tag(b"base"), spot(21, vec![(tag(b"srgb"), F::Ref(20))]))]))]);
|
||||
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
|
||||
assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_colours_of_spot_colours_do_not_take_exponential_time() {
|
||||
// Each level's RGB equivalent and base are the next level, and the last is in an unknown
|
||||
// model, so both are followed everywhere: 2^41 colour reads before the fix.
|
||||
fn level(id: u32, left: u32) -> F {
|
||||
if left == 0 {
|
||||
return F::Def(id, vec![tag(b"XXXX")], vec![]);
|
||||
}
|
||||
spot(id, vec![(tag(b"srgb"), level(id + 1, left - 1)), (tag(b"base"), F::Ref(id + 1))])
|
||||
}
|
||||
let doc = read(vec![filled(10, level(100, 40))]);
|
||||
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
|
||||
}
|
||||
|
||||
/// `spots` spot colours, each defined through the next, around an opaque RGB colour.
|
||||
fn spots(id: u32, spots: u32) -> F {
|
||||
if spots == 0 {
|
||||
let rgba: Vec<u8> = [1.0f32, 0.5, 0.25, 1.0].iter().flat_map(|c| c.to_le_bytes()).collect();
|
||||
return F::Def(id, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct(rgba))]);
|
||||
}
|
||||
spot(id, vec![(tag(b"srgb"), self::spots(id + 1, spots - 1))])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spot_colours_nest_four_levels_and_no_deeper() {
|
||||
let doc = read(vec![filled(10, spots(100, 4))]);
|
||||
assert_eq!(fills(&doc), [Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.25, a: 1.0 })]);
|
||||
let doc = read(vec![filled(10, spots(100, 5))]);
|
||||
assert!(fills(&doc).is_empty(), "{:?}", fills(&doc));
|
||||
assert!(warned(&doc, "spot colours nested deeper than 4 levels"), "{:?}", doc.warnings);
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
//! #6423: compound operands form a graph, not necessarily a tree.
|
||||
#![cfg(feature = "synth")]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use photocraft_affinity::model::{Document, Kind};
|
||||
use photocraft_affinity::synth::{self, F, Method, tag};
|
||||
use photocraft_affinity::{Limits, read};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn compound(id: u32, children: Vec<F>) -> F {
|
||||
F::Def(id, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::Shared(children))])
|
||||
}
|
||||
|
||||
fn rectangle() -> F {
|
||||
F::Def(100, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(101, vec![tag(b"ShNR")], vec![]))])
|
||||
}
|
||||
|
||||
fn document_bytes(definitions: Vec<F>, roots: Vec<F>) -> Vec<u8> {
|
||||
let data = synth::stream(&[
|
||||
(tag(b"Defs"), F::Shared(definitions)),
|
||||
(
|
||||
tag(b"DocR"),
|
||||
F::Def(
|
||||
1,
|
||||
vec![tag(b"DocN")],
|
||||
vec![(
|
||||
tag(b"Chld"),
|
||||
F::Shared(vec![F::Def(
|
||||
2,
|
||||
vec![tag(b"Sprd")],
|
||||
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(roots))],
|
||||
)]),
|
||||
)],
|
||||
),
|
||||
),
|
||||
]);
|
||||
synth::container(&[("doc.dat", &data, Method::Zlib)], None)
|
||||
}
|
||||
|
||||
fn document(definitions: Vec<F>, roots: Vec<F>) -> Document {
|
||||
read(&document_bytes(definitions, roots), Limits::default()).unwrap()
|
||||
}
|
||||
|
||||
// A stack overflow aborts rather than unwinds. Keep that failure (and an exponential hang)
|
||||
// inside a child process, so the regression fails without killing the entire test runner.
|
||||
fn isolated(name: &str, check: impl FnOnce()) {
|
||||
if std::env::var("PHOTOCRAFT_COMPOUND_CASE").as_deref() == Ok(name) {
|
||||
check();
|
||||
return;
|
||||
}
|
||||
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", name, "--nocapture"])
|
||||
.env("PHOTOCRAFT_COMPOUND_CASE", name)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
assert!(status.success(), "compound import failed: {status}");
|
||||
break;
|
||||
}
|
||||
if start.elapsed() > Duration::from_secs(10) {
|
||||
child.kill().unwrap();
|
||||
child.wait().unwrap();
|
||||
panic!("compound import exceeded ten seconds");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported(doc: &Document, warning: &str) {
|
||||
assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported));
|
||||
assert!(doc.warnings.iter().any(|w| w.contains(warning)), "{:?}", doc.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_referencing_compound_is_reported_without_aborting() {
|
||||
isolated("self_referencing_compound_is_reported_without_aborting", || {
|
||||
let doc = document(vec![], vec![compound(10, vec![F::Ref(10)]), rectangle()]);
|
||||
unsupported(&doc, "cyclic compound");
|
||||
assert!(matches!(doc.spreads[0].nodes[1].kind, Kind::Shape { .. }), "a cycle must not poison unrelated shapes");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indirectly_cyclic_compound_is_not_imported_as_a_partial_shape() {
|
||||
isolated("indirectly_cyclic_compound_is_not_imported_as_a_partial_shape", || {
|
||||
let doc = document(vec![rectangle()], vec![compound(10, vec![F::Ref(100), compound(11, vec![F::Ref(10)])])]);
|
||||
unsupported(&doc, "cyclic compound");
|
||||
});
|
||||
}
|
||||
|
||||
fn nested(levels: u32, repeated: bool) -> Document {
|
||||
// Flat definitions bypass the stream parser's nesting cap: only references form the depth.
|
||||
let mut definitions = vec![rectangle()];
|
||||
let mut previous = 100;
|
||||
for id in 200..200 + levels {
|
||||
let children = if repeated { vec![F::Ref(previous), F::Ref(previous)] } else { vec![F::Ref(previous)] };
|
||||
definitions.push(compound(id, children));
|
||||
previous = id;
|
||||
}
|
||||
document(definitions, vec![F::Ref(previous)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_compound_graph_is_bounded_on_a_small_stack() {
|
||||
isolated("deep_compound_graph_is_bounded_on_a_small_stack", || {
|
||||
std::thread::Builder::new().stack_size(512 * 1024).spawn(|| unsupported(&nested(1000, false), "compound depth")).unwrap().join().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_operands_cannot_expand_exponentially() {
|
||||
isolated("shared_operands_cannot_expand_exponentially", || unsupported(&nested(40, true), "compound geometry budget"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_empty_operands_also_have_a_work_limit() {
|
||||
isolated("shared_empty_operands_also_have_a_work_limit", || {
|
||||
let mut definitions = vec![compound(100, vec![])];
|
||||
let mut previous = 100;
|
||||
for id in 200..240 {
|
||||
definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)]));
|
||||
previous = id;
|
||||
}
|
||||
unsupported(&document(definitions, vec![F::Ref(previous)]), "compound geometry budget");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_budget_is_shared_across_layer_roots() {
|
||||
isolated("geometry_budget_is_shared_across_layer_roots", || {
|
||||
let mut definitions = vec![rectangle()];
|
||||
let mut previous = 100;
|
||||
for id in 200..214 {
|
||||
definitions.push(compound(id, vec![F::Ref(previous), F::Ref(previous)]));
|
||||
previous = id;
|
||||
}
|
||||
let mut roots = vec![F::Ref(previous); 12];
|
||||
roots.push(F::Ref(100));
|
||||
let doc = document(definitions, roots);
|
||||
let nodes = &doc.spreads[0].nodes;
|
||||
assert!(matches!(nodes[0].kind, Kind::Shape { .. }), "a modest shared graph still imports");
|
||||
assert!(matches!(nodes.last().unwrap().kind, Kind::Unsupported), "later roots cannot reset the exhausted budget");
|
||||
assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget")));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_shared_operands_keep_both_transformed_copies() {
|
||||
let mut translated = compound(201, vec![F::Ref(100)]);
|
||||
let F::Def(_, _, fields) = &mut translated else { unreachable!() };
|
||||
fields.push((tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 30.0, 0.0, 1.0, 40.0])));
|
||||
let doc = document(vec![rectangle(), compound(200, vec![F::Ref(100)]), translated], vec![compound(202, vec![F::Ref(200), F::Ref(201)])]);
|
||||
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
|
||||
let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") };
|
||||
assert_eq!(path.subpaths.len(), 2);
|
||||
for (a, b) in path.subpaths[0].segments.iter().flatten().zip(path.subpaths[1].segments.iter().flatten()) {
|
||||
assert_eq!(b.x, a.x + 30.0);
|
||||
assert_eq!(b.y, a.y + 40.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_operand_slots_cannot_bypass_geometry_budget() {
|
||||
isolated("null_operand_slots_cannot_bypass_geometry_budget", || {
|
||||
let bytes = document_bytes(vec![compound(10, vec![F::Null; 1_000_000])], vec![F::Ref(10); 20_000]);
|
||||
let start = Instant::now();
|
||||
let doc = read(&bytes, Limits::default()).unwrap();
|
||||
eprintln!("one million nulls / 20,000 placements: {} bytes, read {:.3}s", bytes.len(), start.elapsed().as_secs_f64());
|
||||
unsupported(&doc, "compound geometry budget");
|
||||
assert_eq!(doc.spreads[0].nodes.len(), 20_000);
|
||||
assert!(doc.spreads[0].nodes.iter().all(|n| matches!(n.kind, Kind::Unsupported)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_operand_slots_share_budget_across_layer_roots() {
|
||||
isolated("null_operand_slots_share_budget_across_layer_roots", || {
|
||||
let doc = document(vec![compound(10, vec![F::Null; 1_000])], vec![F::Ref(10); 1_000]);
|
||||
assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Shape { .. }), "a small empty compound remains valid");
|
||||
assert!(matches!(doc.spreads[0].nodes.last().unwrap().kind, Kind::Unsupported));
|
||||
assert!(doc.warnings.iter().any(|w| w.contains("compound geometry budget")));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_operand_slots_cannot_leave_a_partial_shape() {
|
||||
isolated("null_operand_slots_cannot_leave_a_partial_shape", || {
|
||||
let mut slots = vec![F::Ref(100)];
|
||||
slots.extend(vec![F::Null; 1_000_000]);
|
||||
let doc = document(vec![rectangle(), compound(10, slots)], vec![F::Ref(10)]);
|
||||
unsupported(&doc, "compound geometry budget");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_object_operand_slots_also_consume_budget() {
|
||||
isolated("non_object_operand_slots_also_consume_budget", || {
|
||||
let comp = F::Def(10, vec![tag(b"Comp")], vec![(tag(b"Chld"), F::U8s(vec![0; 1_000_000]))]);
|
||||
unsupported(&document(vec![comp], vec![F::Ref(10)]), "compound geometry budget");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_null_operand_slots_preserve_shared_contours() {
|
||||
let doc = document(vec![rectangle(), compound(10, vec![F::Null, F::Ref(100), F::Null, F::Ref(100), F::Null])], vec![F::Ref(10)]);
|
||||
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
|
||||
let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected compound outline") };
|
||||
assert_eq!(path.subpaths.len(), 2);
|
||||
assert_eq!(path.subpaths[0], path.subpaths[1]);
|
||||
}
|
||||
@@ -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.
@@ -1,42 +0,0 @@
|
||||
//! Time and peak memory of the Photomerge multi-band blend: `n` RGB sources of `mp` megapixels
|
||||
//! (3:2), each overlapping the next by half its width.
|
||||
//! `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]` (default 4 × 24 MP).
|
||||
//! Peak memory is the growth of the process's high-water mark during the blend (Linux only).
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_algo::panorama::{self, RoiImage};
|
||||
|
||||
/// The process's peak resident set in MiB (`VmHWM`), where `/proc` has it.
|
||||
fn peak_mib() -> Option<f64> {
|
||||
let s = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
let kb: f64 = s.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok()?;
|
||||
Some(kb / 1024.0)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let n: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(4).max(1);
|
||||
let mp: f64 = args.next().and_then(|a| a.parse().ok()).unwrap_or(24.0);
|
||||
let sh = ((mp * 1e6 / 1.5).sqrt().round() as usize).max(2);
|
||||
let sw = sh * 3 / 2;
|
||||
let step = sw / 2;
|
||||
let (cw, ch) = (step * (n - 1) + sw, sh);
|
||||
let rois: Vec<RoiImage> = (0..n)
|
||||
.map(|k| {
|
||||
let px = (0..sw * sh * 3).map(|i| ((i / 3 % sw * 7 + i / 3 / sw * 3 + k * 31 + i % 3 * 50) % 251) as f32 / 251.0).collect();
|
||||
RoiImage { x0: (step * k) as i32, y0: 0, w: sw, h: sh, ch: 3, px, alpha: vec![1.0; sw * sh] }
|
||||
})
|
||||
.collect();
|
||||
// Each canvas column belongs to the image whose centre is nearest, as a seam would split it.
|
||||
let wts: Vec<Vec<f32>> = (0..n)
|
||||
.map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(n - 1) == k { 1.0 } else { 0.0 }).collect())
|
||||
.collect();
|
||||
let levels = panorama::blend_levels(cw, ch).min(7);
|
||||
let before = peak_mib();
|
||||
let t0 = Instant::now();
|
||||
let (px, cov) = panorama::multiband(cw, ch, &rois, &wts, levels);
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
let peak = peak_mib().zip(before).map_or("n/a".to_string(), |(a, b)| format!("{:.0} MiB", a - b));
|
||||
println!("{n} × {sw}×{sh} ({mp} MP) RGB, canvas {cw}×{ch}, {levels} levels: {ms:.0} ms, peak +{peak} during the blend");
|
||||
std::hint::black_box((px, cov));
|
||||
}
|
||||
@@ -335,7 +335,7 @@ pub(crate) fn lens_blur(src: &Image, out: Rect, ctx: &Ctx, spec: &LensSpec) -> V
|
||||
}
|
||||
src.get(x, y, n - 1)
|
||||
}
|
||||
DepthSource::LayerMask | DepthSource::Channel => spec.depth_map?.get(x, y, 0),
|
||||
DepthSource::LayerMask => spec.depth_map?.get(x, y, 0),
|
||||
};
|
||||
Some(if spec.invert { 1.0 - d } else { d })
|
||||
};
|
||||
|
||||
@@ -67,16 +67,22 @@ impl Encoding {
|
||||
pub(crate) fn of(gamma: f32) -> Self {
|
||||
Encoding(if gamma.is_finite() && gamma > 0.0 { gamma.clamp(0.5, 4.0) } else { 0.0 })
|
||||
}
|
||||
/// A value in this encoding as linear light.
|
||||
pub(crate) fn decode(self, v: f32) -> f32 {
|
||||
if self.0 > 0.0 { v.max(0.0).powf(self.0) } else { srgb_to_linear(v) }
|
||||
}
|
||||
/// Linear light back into this encoding, the inverse of [`Encoding::decode`].
|
||||
pub(crate) fn encode(self, v: f32) -> f32 {
|
||||
if self.0 > 0.0 { v.max(0.0).powf(1.0 / self.0) } else { linear_to_srgb(v) }
|
||||
}
|
||||
}
|
||||
|
||||
fn srgb_encode(v: f32) -> f32 {
|
||||
linear_to_srgb(v)
|
||||
}
|
||||
|
||||
fn srgb_decode(v: f32) -> f32 {
|
||||
srgb_to_linear(v)
|
||||
}
|
||||
|
||||
fn luma_linear(c: [f32; 3]) -> f32 {
|
||||
luma(c)
|
||||
}
|
||||
|
||||
@@ -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,78 +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 }
|
||||
}
|
||||
|
||||
/// The source rows of [`up`]`(p, w, h)` at output row `y`, one per tap (`None` where the
|
||||
/// zero-stuffed row is all zeros).
|
||||
fn up_rows(p: &Plane, h: usize, y: usize) -> [Option<usize>; 5] {
|
||||
let lh = p.h.min(h.div_ceil(2));
|
||||
std::array::from_fn(|k| {
|
||||
let m = mirror(y as isize + k as isize - 2, h);
|
||||
(m.is_multiple_of(2) && m / 2 < lh).then_some(m / 2)
|
||||
})
|
||||
}
|
||||
|
||||
/// One sample of [`up`]`(p, w, h)`: column `x` of the output row whose [`up_rows`] are `rows`.
|
||||
/// It adds the same terms in the same order as `up`, so it gives the same bits.
|
||||
fn up_at(p: &Plane, w: usize, rows: &[Option<usize>; 5], x: usize) -> f32 {
|
||||
let lw = p.w.min(w.div_ceil(2));
|
||||
let col = |j: usize| -> f32 { (0..5).filter_map(|k| rows[k].map(|r| TAPS[k] * (p.v[r * p.w + j] * 4.0))).sum() };
|
||||
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] * col(i / 2))
|
||||
})
|
||||
.sum()
|
||||
} else {
|
||||
(0..5)
|
||||
.filter_map(|k| {
|
||||
let m = mirror(x as isize + k as isize - 2, w);
|
||||
(m.is_multiple_of(2) && m / 2 < lw).then(|| TAPS[k] * col(m / 2))
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
blur_step(&z, 1)
|
||||
}
|
||||
|
||||
/// Levels down to about 2 pixels on the short side.
|
||||
@@ -243,21 +154,21 @@ 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);
|
||||
}
|
||||
});
|
||||
let gr = gaussian(m, n);
|
||||
for l in 0..n - 1 {
|
||||
let (gl, fine, lw, lh, coarse) = (&g[l].v, &gr[l].v, g[l].w, g[l].h, &gr[l + 1]);
|
||||
rows(&mut lap[l], lw, |y, row| {
|
||||
let taps = up_rows(coarse, lh, y);
|
||||
let coarse = up(&gr[l + 1], gr[l].w, gr[l].h);
|
||||
let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w);
|
||||
par_rows(&mut lap[l], lw, 1, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let i = y * lw + x;
|
||||
let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0);
|
||||
if wgt > 0.0 {
|
||||
*o += wgt * (fine[i] - up_at(coarse, lw, &taps, x));
|
||||
*o += wgt * (fine[i] - coarse.v[i]);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -277,10 +188,8 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
|
||||
/// The `q` quantile (0 … 1) of `v`.
|
||||
fn quantile(v: &[f32], q: f32) -> f32 {
|
||||
let mut s = v.to_vec();
|
||||
// Selecting the one element is linear, a full sort is not (5 sorts of the work copy were a
|
||||
// fifteenth of a render). `total_cmp` ties are bit-identical, so it is the sorted element.
|
||||
let i = ((s.len() - 1) as f32 * q).round() as usize;
|
||||
*s.select_nth_unstable_by(i, f32::total_cmp).1
|
||||
s.sort_by(f32::total_cmp);
|
||||
s[((s.len() - 1) as f32 * q).round() as usize]
|
||||
}
|
||||
|
||||
/// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across.
|
||||
@@ -428,84 +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)] {
|
||||
let full = up(&p, uw, uh);
|
||||
assert_eq!(full.v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}");
|
||||
// The filter samples `up` one pixel at a time; every sample has the same bits.
|
||||
for y in 0..uh {
|
||||
let taps = up_rows(&p, uh, y);
|
||||
for x in 0..uw {
|
||||
assert_eq!(up_at(&p, uw, &taps, x).to_bits(), full.v[y * uw + x].to_bits(), "up_at {w}×{h} → {uw}×{uh} at {x},{y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantile_is_the_sorted_element() {
|
||||
let p = noise_plane(301, 211, 9);
|
||||
let mut v = p.v.clone();
|
||||
// Ties and signed zeros, which `total_cmp` orders by their bits.
|
||||
v.extend([0.0, -0.0, 0.0, -0.0, 3.5, 3.5, f32::MIN_POSITIVE]);
|
||||
let mut sorted = v.clone();
|
||||
sorted.sort_by(f32::total_cmp);
|
||||
for q in [0.0, 0.001, 0.01, 0.25, 0.5, 0.99, 1.0] {
|
||||
let want = sorted[((sorted.len() - 1) as f32 * q).round() as usize];
|
||||
assert_eq!(quantile(&v, q).to_bits(), want.to_bits(), "q {q}");
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -369,14 +369,14 @@ pub(crate) fn apply(px: &mut [[f32; 4]], width: usize, w: f32, b: f32, enc: Enco
|
||||
if per_channel {
|
||||
for c in 0..3 {
|
||||
let over = (lin[c] - 1.0).max(0.0);
|
||||
q[c] = enc.encode(super::srgb_to_linear(grey(super::linear_to_srgb(lin[c].min(1.0)))) + over);
|
||||
q[c] = enc.encode(super::srgb_decode(grey(super::srgb_encode(lin[c].min(1.0)))) + over);
|
||||
}
|
||||
} else {
|
||||
let y = super::luma_linear(lin).max(0.0);
|
||||
if y <= 1e-7 {
|
||||
continue;
|
||||
}
|
||||
let y2 = super::srgb_to_linear(grey(super::linear_to_srgb(y.min(1.0)))) + (y - 1.0).max(0.0);
|
||||
let y2 = super::srgb_decode(grey(super::srgb_encode(y.min(1.0)))) + (y - 1.0).max(0.0);
|
||||
let k = y2 / y;
|
||||
for c in 0..3 {
|
||||
q[c] = enc.encode(lin[c] * k);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+59
-264
@@ -45,9 +45,12 @@ pub const PATCH_RADIUS: usize = 15;
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
/// The next value of the SplitMix64 sequence.
|
||||
fn next(&mut self) -> u64 {
|
||||
crate::photo_util::splitmix64(&mut self.0)
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
fn unit(&mut self) -> f64 {
|
||||
(self.next() >> 11) as f64 / (1u64 << 53) as f64
|
||||
@@ -57,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,
|
||||
@@ -158,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 {
|
||||
@@ -207,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);
|
||||
@@ -260,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 {
|
||||
@@ -644,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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,13 +65,15 @@ const SOURCE_MOMENTS_MAX: usize = 1 << 20;
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Rng(u64);
|
||||
impl Rng {
|
||||
/// A generator whose sequence is fixed by `seed`.
|
||||
pub(crate) fn new(seed: u64) -> Self {
|
||||
Rng(seed ^ 0x9E37_79B9_7F4A_7C15)
|
||||
}
|
||||
/// The next value of the SplitMix64 sequence.
|
||||
pub(crate) fn next(&mut self) -> u64 {
|
||||
crate::photo_util::splitmix64(&mut self.0)
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
/// Skip `n` numbers (the same state as after `n` calls of [`Rng::next`]).
|
||||
pub(crate) fn skip(&mut self, n: u64) {
|
||||
|
||||
+3
-25
@@ -32,7 +32,6 @@ mod distort;
|
||||
mod distort2;
|
||||
pub mod erase;
|
||||
pub mod exif;
|
||||
pub mod faces;
|
||||
pub mod features;
|
||||
mod fxutil;
|
||||
mod gallery;
|
||||
@@ -482,8 +481,7 @@ pub enum FilterParams {
|
||||
distribution: Distribution,
|
||||
monochromatic: bool,
|
||||
seed: u32,
|
||||
/// Depth for [`DepthSource::LayerMask`] and [`DepthSource::Channel`] (channel 0, document
|
||||
/// coordinates), supplied by the caller.
|
||||
/// Depth for [`DepthSource::LayerMask`] (channel 0, document coordinates), supplied by the caller.
|
||||
#[serde(skip)]
|
||||
depth_map: Option<std::sync::Arc<Image>>,
|
||||
},
|
||||
@@ -745,8 +743,8 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
|
||||
FilterParams::AddNoise { amount, distribution, monochromatic, seed } => noise::add(src, out, ctx, *amount, *distribution, *monochromatic, *seed),
|
||||
FilterParams::Median { radius } => noise::median(src, out, *radius, None),
|
||||
FilterParams::DustAndScratches { radius, threshold } => noise::median(src, out, *radius, Some(*threshold)),
|
||||
FilterParams::Minimum { radius, preserve } => min_max_ink(src, out, ctx, *radius, false, *preserve),
|
||||
FilterParams::Maximum { radius, preserve } => min_max_ink(src, out, ctx, *radius, true, *preserve),
|
||||
FilterParams::Minimum { radius, preserve } => other::min_max_preserve(src, out, *radius, false, *preserve),
|
||||
FilterParams::Maximum { radius, preserve } => other::min_max_preserve(src, out, *radius, true, *preserve),
|
||||
FilterParams::Offset { horizontal, vertical, undefined } => other::offset(src, out, ctx, *horizontal, *vertical, *undefined),
|
||||
FilterParams::Mosaic { cell_size } => stylize::mosaic(src, out, ctx, *cell_size),
|
||||
FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount),
|
||||
@@ -910,23 +908,6 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimum / Maximum in the document's polarity. CMYK samples are ink, so there Maximum spreads
|
||||
/// paper white and Minimum spreads ink (as Photoshop does): the colour channels run inverted,
|
||||
/// alpha keeps its usual polarity.
|
||||
fn min_max_ink(src: &Image, out: Rect, ctx: &Ctx, radius: f32, max: bool, preserve: Preserve) -> Vec<f32> {
|
||||
if !fxutil::subtractive(ctx) {
|
||||
return other::min_max_preserve(src, out, radius, max, preserve);
|
||||
}
|
||||
let n = src.ch.max(1);
|
||||
let cc = if ctx.alpha { n - 1 } else { n };
|
||||
let flip = |data: &mut [f32]| data.chunks_exact_mut(n).for_each(|px| px.iter_mut().take(cc).for_each(|v| *v = 1.0 - *v));
|
||||
let mut inv = src.clone();
|
||||
flip(&mut inv.data);
|
||||
let mut res = other::min_max_preserve(&inv, out, radius, max, preserve);
|
||||
flip(&mut res);
|
||||
res
|
||||
}
|
||||
|
||||
/// Output tile size used by [`apply`].
|
||||
pub const TILE: i32 = 256;
|
||||
|
||||
@@ -990,7 +971,6 @@ fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 {
|
||||
}
|
||||
|
||||
/// [`apply`] / [`apply_in`] with an explicit tile size (tests use it to check tile independence).
|
||||
/// Nonpositive tile sizes are treated as one pixel.
|
||||
pub fn apply_tiled(
|
||||
surface: &Surface,
|
||||
params: &FilterParams,
|
||||
@@ -1033,8 +1013,6 @@ fn apply_tiled_with_impl(
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
prepare_fast_paths: bool,
|
||||
) -> Option<Surface> {
|
||||
// Every dispatch path needs a positive step, including Motion Blur's fallback.
|
||||
let tile = tile.max(1);
|
||||
if ctl.cancelled() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+105
-668
@@ -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,99 +1058,47 @@ 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])
|
||||
})
|
||||
}
|
||||
|
||||
/// Estimated bytes of band construction [`multiband`] keeps in flight at once. Images are built
|
||||
/// and accumulated in batches under it, so its peak memory doesn't grow with the image count
|
||||
/// (#7758). Four 24 MP RGB sources (~1.2 GB each) fit in one batch: building bands side by side
|
||||
/// uses a many-core machine better than one band's row-parallel work, and one at a time made the
|
||||
/// usual 2–4 image Photomerge 12–36% slower on a 32-thread desktop.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const BAND_BUDGET: usize = 5 << 30;
|
||||
/// Without threads the bands of a batch are built one after another anyway, so each is added as
|
||||
/// soon as it is built (the browser's address space is 4 GB).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const BAND_BUDGET: usize = 0;
|
||||
|
||||
/// Multi-band blend of `imgs` into a `w × h` panorama (origin 0,0) with one weight map per image
|
||||
/// over the same grid as its ROI (`weights[i]` has `w_i × h_i` entries). Returns the colour
|
||||
/// channels (`ch` per pixel) and the total coverage.
|
||||
pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], levels: usize) -> (Vec<f32>, Vec<f32>) {
|
||||
multiband_batched(w, h, imgs, weights, levels, BAND_BUDGET)
|
||||
}
|
||||
|
||||
/// [`multiband`], building at most `budget` estimated bytes of bands at a time (at least one
|
||||
/// image). Each pixel still adds the bands in image order, so the result doesn't depend on it.
|
||||
fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], levels: usize, budget: 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);
|
||||
@@ -1205,144 +1112,87 @@ fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>]
|
||||
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 grid = |im: &RoiImage| {
|
||||
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);
|
||||
(gx0, gy0, (gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize)
|
||||
};
|
||||
// Building one band holds its grid's colour and weight planes about three times over: the
|
||||
// pyramids are 4/3 of a plane, and the grid copy, coverage and `expand` scratch come on top.
|
||||
let cost = |im: &RoiImage| {
|
||||
let (_, _, gw, gh) = grid(im);
|
||||
gw.saturating_mul(gh).saturating_mul(ch + 1).saturating_mul(4 * 3)
|
||||
};
|
||||
let band = |k: usize| -> PyramidBand {
|
||||
let im = &imgs[k];
|
||||
let (gx0, gy0, gw, gh) = grid(im);
|
||||
// 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 (gw, gh) = ((gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) 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 });
|
||||
drop(cov);
|
||||
}
|
||||
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);
|
||||
drop(bin);
|
||||
// Laplacian of the colours, Gaussian of the weights. Planes move into the pyramids rather
|
||||
// than being copied: with one image built at a time, a copy is a serial pass over it.
|
||||
// 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));
|
||||
gau.push(cwt);
|
||||
lap.push((cw, chh, cur.clone()));
|
||||
gau.push(cwt.clone());
|
||||
break;
|
||||
}
|
||||
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);
|
||||
gau.push(cwt);
|
||||
cw = nw;
|
||||
chh = nh;
|
||||
cur = down;
|
||||
cwt = wdown;
|
||||
}
|
||||
(gx0, gy0, lap, gau)
|
||||
};
|
||||
// Build the bands a batch at a time and add each batch before building the next. Each pixel
|
||||
// adds the bands in the same order as the serial loop (per level, bands one after another;
|
||||
// batches follow each other in image order), but rows of a level are independent, so they
|
||||
// run in parallel: every row walks the batch's bands itself.
|
||||
let mut start = 0;
|
||||
while start < imgs.len() {
|
||||
let mut end = start + 1;
|
||||
let mut bytes = cost(&imgs[start]);
|
||||
while let Some(im) = imgs.get(end)
|
||||
&& bytes.saturating_add(cost(im)) <= budget
|
||||
{
|
||||
bytes = bytes.saturating_add(cost(im));
|
||||
end += 1;
|
||||
}
|
||||
let bands = par_map(end - start, |j| band(start + j));
|
||||
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 {
|
||||
let px = ox + x;
|
||||
if px >= pw {
|
||||
break;
|
||||
}
|
||||
let k = g[x];
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
for c in 0..ch {
|
||||
row[px * ch + c] += k * band[x * ch + c];
|
||||
}
|
||||
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];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -1351,27 +1201,22 @@ fn multiband_batched(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>]
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1586,411 +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());
|
||||
}
|
||||
|
||||
/// Batching the bands (#7758) changes when they are built, not the result. Budgets from none
|
||||
/// (one image per batch) up to everything in steps of a fifth pass through every split of the
|
||||
/// three images.
|
||||
#[test]
|
||||
fn multiband_batches_match_the_serial_code() {
|
||||
let budgets: Vec<usize> =
|
||||
std::iter::successors(Some(1024.0f64), |b| Some(b * 1.2)).take_while(|b| *b < 4e6).map(|b| b as usize).chain([0, usize::MAX]).collect();
|
||||
for (n, &(w, h)) in [(40usize, 30usize), (97, 61), (201, 129)].iter().enumerate() {
|
||||
for ch in [1usize, 3] {
|
||||
let (imgs, wts) = rois(w, h, ch, n as u64 * 17 + ch as u64);
|
||||
for levels in [1usize, 3, 5] {
|
||||
let want = ref_multiband(w, h, &imgs, &wts, levels);
|
||||
for &budget in &budgets {
|
||||
let got = multiband_batched(w, h, &imgs, &wts, levels, budget);
|
||||
assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels} budget {budget}");
|
||||
assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels} budget {budget}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
@@ -182,9 +182,6 @@ pub enum DepthSource {
|
||||
None,
|
||||
Transparency,
|
||||
LayerMask,
|
||||
/// An alpha channel of the document (Photoshop lists them after Layer Mask), supplied by the
|
||||
/// caller like the layer mask.
|
||||
Channel,
|
||||
}
|
||||
|
||||
/// Built-in Shape Blur kernels.
|
||||
|
||||
@@ -159,21 +159,15 @@ pub(crate) fn par_map<T: Send>(n: usize, f: impl Fn(usize) -> T + Sync + Send) -
|
||||
(0..n).map(f).collect()
|
||||
}
|
||||
|
||||
/// Advances `state` one SplitMix64 step and returns the mixed output.
|
||||
pub(crate) fn splitmix64(state: &mut u64) -> u64 {
|
||||
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = *state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// Deterministic 64-bit generator (SplitMix64).
|
||||
pub(crate) struct Rng(pub u64);
|
||||
impl Rng {
|
||||
/// The next value of the SplitMix64 sequence.
|
||||
pub fn next(&mut self) -> u64 {
|
||||
splitmix64(&mut self.0)
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
pub fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
@@ -189,7 +183,15 @@ pub(crate) fn hash01(x: i64, y: i64, seed: u64) -> f32 {
|
||||
(z >> 40) as f32 / (1u64 << 24) as f32
|
||||
}
|
||||
|
||||
pub(crate) use photocraft_color::convert::{linear_to_srgb, srgb_to_linear};
|
||||
/// sRGB transfer function, decode (extended linearly below 0 and as a power above 1).
|
||||
pub(crate) fn srgb_to_linear(v: f32) -> f32 {
|
||||
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
/// sRGB transfer function, encode.
|
||||
pub(crate) fn linear_to_srgb(v: f32) -> f32 {
|
||||
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
|
||||
}
|
||||
|
||||
/// Bilinear sample of channel `c` of a `w × h × ch` buffer, clamped to the edges.
|
||||
pub(crate) fn bilinear(buf: &[f32], w: usize, h: usize, ch: usize, c: usize, x: f32, y: f32) -> f32 {
|
||||
@@ -215,41 +217,6 @@ pub(crate) fn catmull_rom(t: f32) -> [f32; 4] {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The shared step and [`Rng`] reproduce the inlined copies they replaced, bit for bit.
|
||||
#[test]
|
||||
fn splitmix64_output_is_unchanged() {
|
||||
/// The SplitMix64 step as each generator inlined it before.
|
||||
fn inlined(state: &mut u64) -> u64 {
|
||||
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = *state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
let mut zero = 0;
|
||||
assert_eq!([0; 3].map(|_| splitmix64(&mut zero)), [0xE220_A839_7B1D_CDAF, 0x6E78_9E6A_A1B9_65F4, 0x06C4_5D18_8009_454F]);
|
||||
for seed in [0, 1, 42, 0x2545_F491_4F6C_DD1D, u64::MAX] {
|
||||
let (mut shared, mut copy, mut rng) = (seed, seed, Rng(seed));
|
||||
for _ in 0..10_000 {
|
||||
let want = inlined(&mut copy);
|
||||
assert_eq!(splitmix64(&mut shared), want);
|
||||
assert_eq!(rng.next(), want);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared sRGB functions match the removed copies bit for bit.
|
||||
#[test]
|
||||
fn srgb_transfer_output_is_unchanged() {
|
||||
let decode = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) };
|
||||
let encode = |v: f32| if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
|
||||
for i in -2000..=6000 {
|
||||
let v = i as f32 / 4000.0;
|
||||
assert_eq!(srgb_to_linear(v).to_bits(), decode(v).to_bits(), "decode {v}");
|
||||
assert_eq!(linear_to_srgb(v).to_bits(), encode(v).to_bits(), "encode {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dense_and_sparse_solvers_agree() {
|
||||
// Overdetermined line fit y = 2x + 1.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -274,13 +274,15 @@ pub fn composite(surface: &mut Surface, prims: &[Prim], clip: Rect, selection: O
|
||||
pub struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
/// A generator whose sequence is fixed by `seed` and `salt`.
|
||||
pub fn new(seed: u32, salt: u64) -> Rng {
|
||||
Rng(((seed as u64) << 32) ^ salt.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0x2545_F491_4F6C_DD1D)
|
||||
}
|
||||
/// The next value of the SplitMix64 sequence.
|
||||
pub fn next_u64(&mut self) -> u64 {
|
||||
crate::photo_util::splitmix64(&mut self.0)
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
/// Uniform in [0, 1).
|
||||
pub fn f(&mut self) -> f32 {
|
||||
|
||||
@@ -126,9 +126,7 @@ pub fn object_select(sampler: &dyn Sampler, canvas: Rect, rect: Rect, work_px: u
|
||||
}
|
||||
let (fg, bg) = grabcut(&img, &mut trimap, 8)?;
|
||||
let low: Vec<bool> = trimap.iter().map(|t| is_fg(*t)).collect();
|
||||
let low = super::clean_mask(&low, lw, lh, 0.1, 0.0);
|
||||
// Every enclosed hole, however small, fills only when it looks like the object, so background seen through it stays open.
|
||||
let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05);
|
||||
let low = super::clean_mask(&low, lw, lh, 0.1, 0.002);
|
||||
super::finish_region(sampler, window, step, &low, lw, lh, Some((&fg, &bg)))
|
||||
}
|
||||
|
||||
@@ -211,9 +209,7 @@ pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: u
|
||||
let mut seeds = vec![false; w * h];
|
||||
seeds[sy * w + sx] = true;
|
||||
let low = super::keep_seeded(&low, &seeds, w, h);
|
||||
let low = super::clean_mask(&low, w, h, 0.1, 0.0);
|
||||
// As in the rectangle mode: enclosed holes fill only when they look like the object.
|
||||
let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.0, 0.05);
|
||||
let low = super::clean_mask(&low, w, h, 0.1, 0.01);
|
||||
if !low.iter().any(|v| *v) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -912,61 +912,3 @@ fn select_subject_dump_pictures() {
|
||||
write("ring", &img, None);
|
||||
write("ring-selected", &img, reg.as_ref());
|
||||
}
|
||||
|
||||
/// A dark square on a light background with a square hole that shows the background colour.
|
||||
fn square_with_hole(hole: usize) -> RgbImage {
|
||||
let (w, h) = (400usize, 300usize);
|
||||
let mut rng = Rng::new(11);
|
||||
let (x0, y0) = (100usize, 50usize);
|
||||
RgbImage::from_fn(w, h, |x, y| {
|
||||
let n = 0.02 * rng.normal();
|
||||
let in_square = x >= x0 && x < x0 + 200 && y >= y0 && y < y0 + 200;
|
||||
let in_hole = x >= x0 + 96 && x < x0 + 96 + hole && y >= y0 + 96 && y < y0 + 96 + hole;
|
||||
let c = if in_square && !in_hole { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] };
|
||||
c.map(|v: f32| (v + n).clamp(0.0, 1.0))
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_select_keeps_small_background_hole_open() {
|
||||
let img = square_with_hole(8);
|
||||
let s = ImageSampler { img: &img, origin: (0, 0) };
|
||||
let canvas = Rect::new(0, 0, 400, 300);
|
||||
let reg = grabcut::object_select(&s, canvas, Rect::new(80, 30, 320, 270), 1_000_000).unwrap();
|
||||
assert!(reg.at(150, 100) >= 0.5, "the square itself is selected");
|
||||
assert!(reg.at(200, 150) < 0.5, "background seen through the object stays unselected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_at_keeps_small_background_hole_open() {
|
||||
let img = square_with_hole(16);
|
||||
let s = ImageSampler { img: &img, origin: (0, 0) };
|
||||
let canvas = Rect::new(0, 0, 400, 300);
|
||||
let reg = grabcut::object_at(&s, canvas, 130.0, 80.0, 1_000_000).unwrap();
|
||||
assert!(reg.at(150, 100) >= 0.5, "the square itself is selected");
|
||||
assert!(reg.at(203, 153) < 0.5, "background seen through the object stays unselected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hole_fill_without_specks_fills_object_coloured_holes_only() {
|
||||
// The hole check Object Selection uses: no hole fills just for being small, a speck of the object's colour still does.
|
||||
let (w, h) = (40usize, 40usize);
|
||||
let mut rng = Rng::new(5);
|
||||
let in_square = |x: usize, y: usize| (5..35).contains(&x) && (5..35).contains(&y);
|
||||
let bg_hole = |x: usize, y: usize| (10..12).contains(&x) && (10..12).contains(&y);
|
||||
let fg_hole = |x: usize, y: usize| (25..27).contains(&x) && (25..27).contains(&y);
|
||||
let img = RgbImage::from_fn(w, h, |x, y| {
|
||||
let n = 0.02 * rng.normal();
|
||||
let c = if in_square(x, y) && !bg_hole(x, y) { [0.1, 0.12, 0.15] } else { [0.9, 0.9, 0.85] };
|
||||
c.map(|v: f32| (v + n).clamp(0.0, 1.0))
|
||||
});
|
||||
let mask: Vec<bool> = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w) && !fg_hole(i % w, i / w)).collect();
|
||||
// The colour models, as GrabCut would fit them: the object colour vs. the backdrop colour.
|
||||
let dark: Vec<bool> = (0..w * h).map(|i| in_square(i % w, i / w) && !bg_hole(i % w, i / w)).collect();
|
||||
let (fs, bs): (Vec<_>, Vec<_>) = img.px.iter().zip(&dark).partition(|(_, m)| **m);
|
||||
let fit = |v: Vec<(&[f32; 3], &bool)>| Gmm::fit(&v.iter().map(|(p, _)| **p).collect::<Vec<_>>(), 3, gmm::DEFAULT_REG).unwrap();
|
||||
let (fg, bg) = (fit(fs), fit(bs));
|
||||
let out = fill_holes_by_model(&mask, &img, &fg, &bg, 0.0, 0.05);
|
||||
assert!(out[25 * w + 25], "a speck of the object's colour fills");
|
||||
assert!(!out[10 * w + 10], "a speck of the background's colour stays open");
|
||||
}
|
||||
+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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,27 +90,6 @@ fn radial_blur_quality_defaults_for_older_serialized_parameters() {
|
||||
assert_eq!(decoded, FilterParams::RadialBlur { amount: 20.0, method: RadialMethod::Spin, quality: RadialQuality::Good, center_x: 0.5, center_y: 0.5 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonpositive_tile_sizes_match_one_pixel_tiles() {
|
||||
let area = Rect::new(0, 0, 4, 3);
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let source = pattern(depth, area);
|
||||
for params in all_filters().into_iter().chain([FilterParams::ShapeBlur { radius: 3.0, shape: BlurShape::Star }]) {
|
||||
let expected = apply_tiled(&source, ¶ms, area, area, None, 1, Some(area));
|
||||
for tile in [0, -64, i32::MIN] {
|
||||
let actual = apply_tiled(&source, ¶ms, area, area, None, tile, Some(area));
|
||||
assert_eq!(actual.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label());
|
||||
let cancellable = apply_tiled_with(&source, ¶ms, area, area, None, tile, Some(area), &photocraft_raster::Interrupt::NONE)
|
||||
.expect("a nonpositive tile size is normalized, not rejected");
|
||||
assert_eq!(cancellable.read_region(area), expected.read_region(area), "{}: {depth:?}, tile={tile}", params.label());
|
||||
let cancelled = photocraft_raster::Interrupt::cancel_only(&|| true);
|
||||
assert!(apply_tiled_with(&source, ¶ms, area, area, None, tile, Some(area), &cancelled).is_none());
|
||||
}
|
||||
}
|
||||
assert_eq!(source.read_region(area), pattern(depth, area).read_region(area));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_filter_is_tile_independent() {
|
||||
let s = pattern(SampleType::U8, R);
|
||||
@@ -269,32 +248,6 @@ fn minimum_maximum_spread() {
|
||||
assert!((mn.pixel(10, 10)[0] - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// CMYK samples are ink, so Maximum spreads paper white and Minimum spreads ink, as Photoshop
|
||||
/// does: Maximum r 1 removes a 1-px C 100% line on white, Minimum widens it to 3 px. Alpha keeps
|
||||
/// the usual polarity (Maximum grows opaque areas, Minimum grows transparent ones).
|
||||
#[test]
|
||||
fn minimum_maximum_treat_cmyk_as_ink() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut s = Surface::new(PixelFormat::new(ColorMode::Cmyk, depth, true));
|
||||
s.fill_rect(R, &[0.0, 0.0, 0.0, 0.0, 1.0]);
|
||||
s.fill_rect(Rect::new(20, 0, 21, 30), &[1.0, 0.0, 0.0, 0.0, 1.0]);
|
||||
s.write_pixel(5, 5, &[0.0, 0.0, 0.0, 0.0, 0.0]);
|
||||
for preserve in [Preserve::Squareness, Preserve::Roundness] {
|
||||
let mx = run(&s, &FilterParams::Maximum { radius: 1.0, preserve });
|
||||
for x in 17..24 {
|
||||
assert!(mx.pixel(x, 15)[0].abs() < 1e-6, "{depth:?} {preserve:?} Maximum C at x {x}: {:?}", mx.pixel(x, 15));
|
||||
}
|
||||
assert!((mx.pixel(5, 5)[4] - 1.0).abs() < 1e-6, "{depth:?} {preserve:?} Maximum grows alpha");
|
||||
let mn = run(&s, &FilterParams::Minimum { radius: 1.0, preserve });
|
||||
for x in 17..24 {
|
||||
let want = if (19..=21).contains(&x) { 1.0 } else { 0.0 };
|
||||
assert!((mn.pixel(x, 15)[0] - want).abs() < 1e-6, "{depth:?} {preserve:?} Minimum C at x {x}: {:?}", mn.pixel(x, 15));
|
||||
}
|
||||
assert!(mn.pixel(6, 5)[4].abs() < 1e-6, "{depth:?} {preserve:?} Minimum shrinks alpha");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_roundness_grows_a_point_into_a_disc() {
|
||||
// One white pixel on black: Maximum spreads it over the structuring element itself.
|
||||
@@ -461,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]);
|
||||
|
||||
@@ -65,35 +65,6 @@ pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue:
|
||||
mask
|
||||
}
|
||||
|
||||
/// Replace Color's Result swatch: `color` after the hue (±180°), saturation and lightness (±100)
|
||||
/// shift, as a fully matched pixel of that colour becomes.
|
||||
pub fn replace_color_result(color: [f32; 3], hue: f32, saturation: f32, lightness: f32) -> [f32; 3] {
|
||||
let mut px = [[color[0], color[1], color[2], 1.0]];
|
||||
replace_color_masked(&mut px, &[1.0], hue, saturation, lightness);
|
||||
[px[0][0], px[0][1], px[0][2]]
|
||||
}
|
||||
|
||||
/// The hue (±180°), saturation and lightness (±100) shift that turns `from` into `to`, as
|
||||
/// Photoshop's Result Color Picker sets the sliders. A shift the sliders can't reach (a grey has
|
||||
/// no hue to turn, black or white no saturation to give) goes as far as they do.
|
||||
pub fn replace_color_shift(from: [f32; 3], to: [f32; 3]) -> (f32, f32, f32) {
|
||||
let [fh, fs, fl] = rgb_to_hsl(from);
|
||||
let [th, ts, tl] = rgb_to_hsl(to);
|
||||
// Hue turns the short way round; a grey target keeps the hue where it is.
|
||||
let hue = if ts <= 0.0 { 0.0 } else { ((th - fh + 0.5).rem_euclid(1.0) - 0.5) * 360.0 };
|
||||
// Inverse of the shift in `replace_color_masked`: up moves toward 1, down toward 0.
|
||||
let amount = |from: f32, to: f32| {
|
||||
if to >= from {
|
||||
if from >= 1.0 { 0.0 } else { (to - from) / (1.0 - from) * 100.0 }
|
||||
} else if from <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(to / from - 1.0) * 100.0
|
||||
}
|
||||
};
|
||||
(hue.clamp(-180.0, 180.0), amount(fs, ts).clamp(-100.0, 100.0), amount(fl, tl).clamp(-100.0, 100.0))
|
||||
}
|
||||
|
||||
/// Replace Color's hue, saturation and lightness shift, applied to each pixel as far as `mask`
|
||||
/// (`0..=1`, one per pixel: the colour match, localized or not) covers it.
|
||||
pub fn replace_color_masked(px: &mut [[f32; 4]], mask: &[f32], hue: f32, saturation: f32, lightness: f32) {
|
||||
@@ -306,28 +277,6 @@ pub fn hdr_toning(px: &mut [[f32; 4]], w: usize, h: usize, p: &HdrToning) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #6278: the Result swatch is the sampled colour after the shift, and a picked Result sets
|
||||
/// the sliders that give it.
|
||||
#[test]
|
||||
fn replace_color_result_and_shift_round_trip() {
|
||||
let from = [0.8, 0.2, 0.2];
|
||||
let near = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 2e-3);
|
||||
assert!(near(replace_color_result(from, 0.0, 0.0, 0.0), from), "no shift, no change");
|
||||
for to in [[0.2, 0.3, 0.9], [0.9, 0.85, 0.1], [0.4, 0.1, 0.1], [0.95, 0.6, 0.6], [0.1, 0.6, 0.3]] {
|
||||
let (h, s, l) = replace_color_shift(from, to);
|
||||
assert!((-180.0..=180.0).contains(&h) && (-100.0..=100.0).contains(&s) && (-100.0..=100.0).contains(&l));
|
||||
assert!(near(replace_color_result(from, h, s, l), to), "{to:?}: ({h}, {s}, {l}) gives {:?}", replace_color_result(from, h, s, l));
|
||||
}
|
||||
// Red to blue turns the short way (−120°, not +240°).
|
||||
assert!((replace_color_shift([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]).0 + 120.0).abs() < 0.5);
|
||||
// A grey target: saturation all the way down, the hue left alone.
|
||||
let (h, s, _) = replace_color_shift(from, [0.5, 0.5, 0.5]);
|
||||
assert_eq!((h, s), (0.0, -100.0));
|
||||
// Black has no saturation to raise: the slider stays put rather than dividing by zero.
|
||||
let (h, s, l) = replace_color_shift([0.0, 0.0, 0.0], [0.2, 0.6, 0.3]);
|
||||
assert!(h.is_finite() && s.is_finite() && l > 0.0);
|
||||
}
|
||||
|
||||
fn ramp(w: usize, h: usize) -> Vec<[f32; 4]> {
|
||||
(0..w * h)
|
||||
.map(|i| {
|
||||
|
||||
@@ -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> {
|
||||
@@ -333,12 +310,7 @@ fn warp(
|
||||
// A source straddling the horizon draws only its front side: the inverse w at a pixel has the
|
||||
// sign of the forward w at the source point it samples, and the other side would show as a
|
||||
// mirrored copy wrapped round infinity (#4863).
|
||||
// A clip wholly on one side of the inverse horizon only samples that side, so that side is drawn
|
||||
// (a Perspective Crop low on a converging photo has most of the layer beyond its horizon, #7557).
|
||||
let clip_w = clip.map(|c| [(c.x0, c.y0), (c.x1, c.y0), (c.x1, c.y1), (c.x0, c.y1)].map(|(x, y)| inv.w(x as f64, y as f64)));
|
||||
let clip_side = clip_w.and_then(|cw| [1.0, -1.0].into_iter().find(|&s| cw.iter().all(|&w| s * w > 0.0)));
|
||||
let front =
|
||||
side.is_none().then(|| clip_side.unwrap_or_else(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64])));
|
||||
let front = side.is_none().then(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64]));
|
||||
let bounds = corner_bounds(&corners);
|
||||
let dst = match (clip, bounds) {
|
||||
// The warped corners only bound the result while the whole source is in front of the horizon.
|
||||
@@ -538,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);
|
||||
@@ -718,22 +665,6 @@ mod tests {
|
||||
assert_eq!(ok.front_side([0.0, 0.0, 100.0, 100.0]), ok.w(50.0, 50.0).signum());
|
||||
}
|
||||
|
||||
/// #7557: a Perspective Crop quad low on a strongly converging photo puts the horizon across
|
||||
/// the layer with most of it (and its centre) on the far side. The clip maps wholly into the
|
||||
/// quad, so its side is the one drawn, not the source's majority.
|
||||
#[test]
|
||||
fn a_clip_wholly_on_one_side_draws_that_side() {
|
||||
let mut s = rgba();
|
||||
s.fill_rect(Rect::new(0, 0, 100, 100), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let q = [[40.0, 70.0], [60.0, 70.0], [90.0, 95.0], [10.0, 95.0]];
|
||||
let h = quad_to_rect(&q, 50.0, 39.0).unwrap();
|
||||
assert!(h.front_side([0.0, 0.0, 100.0, 100.0]) * h.w(50.0, 80.0) < 0.0, "the source's majority is behind the quad's side");
|
||||
for interp in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] {
|
||||
let w = warp_surface_clipped(&s, s.content_bounds(), &h, interp, Rect::new(0, 0, 50, 39)).unwrap();
|
||||
assert_eq!(w.rgba(25, 20), [1.0, 0.0, 0.0, 1.0], "{interp:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_and_translation_are_exact() {
|
||||
let mut s = rgba();
|
||||
|
||||
+10
-50
@@ -14,9 +14,8 @@
|
||||
//! later (lower-right) cell wins. Work is split per destination tile (parallel on native).
|
||||
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::{Rect, TileCoord};
|
||||
use photocraft_raster::{Surface, Tile};
|
||||
use std::sync::Arc;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
use crate::transform::{Interp, bicubic};
|
||||
|
||||
@@ -208,7 +207,7 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
||||
}
|
||||
let n = fmt.channels();
|
||||
let a = n - 1;
|
||||
let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(TileCoord, Arc<Tile>)> {
|
||||
let work = |(t, tris): (&Rect, &Vec<[usize; 3]>)| -> Option<(Rect, Vec<f32>)> {
|
||||
if tris.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -297,11 +296,6 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
||||
f64::from(px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c])
|
||||
}
|
||||
};
|
||||
// Offset of pixel (x, y) when the `taps` x `taps` block from it lies inside `foot`, so
|
||||
// the samplers can index `px` directly instead of bounds-checking every tap.
|
||||
let base = |x: i32, y: i32, taps: i32| -> Option<usize> {
|
||||
(x >= foot.x0 && y >= foot.y0 && x + taps <= foot.x1 && y + taps <= foot.y1).then(|| ((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n)
|
||||
};
|
||||
let mut outp = vec![0.0f32; w * h * n];
|
||||
let mut any = false;
|
||||
let mut acc = [0.0f64; 8];
|
||||
@@ -319,17 +313,6 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
||||
Interp::Bilinear => {
|
||||
let (ix, iy) = (u.floor() as i32, v.floor() as i32);
|
||||
let (fx, fy) = (u - f64::from(ix), v - f64::from(iy));
|
||||
if let Some(o) = base(ix, iy, 2) {
|
||||
for (dy, wy) in [(0, 1.0 - fy), (1, fy)] {
|
||||
for (dx, wx) in [(0, 1.0 - fx), (1, fx)] {
|
||||
let p = &px[o + (dy * fw + dx) * n..][..n];
|
||||
for (s, v) in acc.iter_mut().zip(p) {
|
||||
*s += f64::from(*v) * wx * wy;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (dy, wy) in [(0, 1.0 - fy), (1, fy)] {
|
||||
for (dx, wx) in [(0, 1.0 - fx), (1, fx)] {
|
||||
for (c, s) in acc.iter_mut().enumerate().take(n) {
|
||||
@@ -344,21 +327,6 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
||||
let (u, v) = (snap(u), snap(v));
|
||||
let (ix, iy) = (u.floor() as i32, v.floor() as i32);
|
||||
let (wx, wy) = (bicubic(u - f64::from(ix)), bicubic(v - f64::from(iy)));
|
||||
if let Some(o) = base(ix - 1, iy - 1, 4) {
|
||||
for (j, wyj) in wy.iter().enumerate() {
|
||||
for (i, wxi) in wx.iter().enumerate() {
|
||||
let k = wxi * wyj;
|
||||
if k == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let p = &px[o + (j * fw + i) * n..][..n];
|
||||
for (s, v) in acc.iter_mut().zip(p) {
|
||||
*s += f64::from(*v) * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (j, wyj) in wy.iter().enumerate() {
|
||||
for (i, wxi) in wx.iter().enumerate() {
|
||||
let k = wxi * wyj;
|
||||
@@ -410,27 +378,19 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
|
||||
}
|
||||
outp[b + a] = al as f32;
|
||||
}
|
||||
if !any {
|
||||
return None;
|
||||
}
|
||||
// Encoded (and dropped if fully transparent) here, in parallel, rather than one tile at
|
||||
// a time once all are done. `t` lies in one tile.
|
||||
let mut one = Surface::new(fmt);
|
||||
one.write_region(*t, &outp);
|
||||
one.prune();
|
||||
if one.tile_count() == 0 {
|
||||
return None;
|
||||
}
|
||||
one.take_tiles(*t).pop()
|
||||
any.then_some((*t, outp))
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let done: Vec<(TileCoord, Arc<Tile>)> = {
|
||||
let done: Vec<(Rect, Vec<f32>)> = {
|
||||
use rayon::prelude::*;
|
||||
tiles.par_iter().zip(bins.par_iter()).filter_map(work).collect()
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let done: Vec<(TileCoord, Arc<Tile>)> = tiles.iter().zip(bins.iter()).filter_map(work).collect();
|
||||
out.put_tiles(done);
|
||||
let done: Vec<(Rect, Vec<f32>)> = tiles.iter().zip(bins.iter()).filter_map(work).collect();
|
||||
for (r, v) in done {
|
||||
out.write_region(r, &v);
|
||||
}
|
||||
out.prune();
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -30,7 +30,3 @@ cap-std = "4"
|
||||
[dev-dependencies]
|
||||
rmcp = { version = "3.5", default-features = false, features = ["client", "server", "macros", "transport-io"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] }
|
||||
|
||||
# Reading back an extended attribute the write path should have preserved (#7095).
|
||||
[target.'cfg(unix)'.dev-dependencies]
|
||||
rustix = { version = "1.1", default-features = false, features = ["std", "fs"] }
|
||||
@@ -19,8 +19,7 @@ pub struct Opened {
|
||||
/// Decode a document that was read through a filesystem capability.
|
||||
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<Opened, AutomationError> {
|
||||
if Path::new(name).extension().is_some_and(|extension| extension.eq_ignore_ascii_case(photocraft_format::EXTENSION)) {
|
||||
let document = photocraft_format::load_from_bytes(bytes)?;
|
||||
return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false });
|
||||
return Ok(Opened { document: photocraft_format::load_from_bytes(bytes)?, warnings: Vec::new(), source_read_only: false });
|
||||
}
|
||||
let r = photocraft_io::import(name, bytes)?;
|
||||
Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only })
|
||||
@@ -30,8 +29,7 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<Opened, AutomationError> {
|
||||
/// natively; everything else goes through `photocraft-io` (PSD, PNG, …).
|
||||
pub fn open(path: &Path) -> Result<Opened, AutomationError> {
|
||||
if path.is_dir() {
|
||||
let document = photocraft_format::load_path(path)?;
|
||||
return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false });
|
||||
return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new(), source_read_only: false });
|
||||
}
|
||||
let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
|
||||
@@ -97,15 +97,11 @@ pub fn validate_token(token: &str) -> Result<(), AutomationError> {
|
||||
}
|
||||
|
||||
/// Compare fixed-width tokens without an early exit on a mismatching byte.
|
||||
///
|
||||
/// Hex letters match in either case: [`validate_token`] accepts uppercase, and the server keeps
|
||||
/// the token lowercased, so a client sending the token exactly as it was configured must still
|
||||
/// authenticate (#6425). Only `A`–`Z` fold, so a non-hex byte never matches a hex digit.
|
||||
pub fn token_matches(expected: &str, supplied: &str) -> bool {
|
||||
if expected.len() != TOKEN_HEX_LEN || supplied.len() != TOKEN_HEX_LEN {
|
||||
return false;
|
||||
}
|
||||
expected.bytes().zip(supplied.bytes()).fold(0u8, |different, (a, b)| different | (a.to_ascii_lowercase() ^ b.to_ascii_lowercase())) == 0
|
||||
expected.bytes().zip(supplied.bytes()).fold(0u8, |different, (a, b)| different | (a ^ b)) == 0
|
||||
}
|
||||
|
||||
/// Validate the first TCP frame without exposing any control method before authentication.
|
||||
@@ -139,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)
|
||||
@@ -372,45 +348,6 @@ mod tests {
|
||||
assert_eq!(reply["result"]["authenticated"], true);
|
||||
}
|
||||
|
||||
fn auth_line(token: &str) -> String {
|
||||
json!({"id": 1, "method": AUTH_METHOD, "params": {"token": token}}).to_string()
|
||||
}
|
||||
|
||||
/// #6425: `validate_token` accepts uppercase hex and the server keeps the token lowercased, so a
|
||||
/// client sending the token exactly as it was configured was refused.
|
||||
#[test]
|
||||
fn a_token_authenticates_in_any_letter_case() {
|
||||
let configured = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
||||
let mixed = "0123456789AbCdEf0123456789aBcDeF0123456789ABCDEF0123456789abcdef";
|
||||
let expected = server_token(Some(configured), None).unwrap();
|
||||
for supplied in [configured, &configured.to_ascii_lowercase(), mixed] {
|
||||
let (reply, authenticated) = authentication_reply(&auth_line(supplied), &expected);
|
||||
assert!(authenticated, "{supplied}: {reply}");
|
||||
assert_eq!(reply["result"]["authenticated"], true);
|
||||
}
|
||||
// The comparison is case-insensitive in both directions.
|
||||
assert!(token_matches(configured, &configured.to_ascii_lowercase()));
|
||||
|
||||
// A different token, or a non-hex character that only folds to a hex one, still fails.
|
||||
let other = "1123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
||||
let (_, authenticated) = authentication_reply(&auth_line(other), &expected);
|
||||
assert!(!authenticated);
|
||||
let not_hex = "0123456789ABCDEG0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
||||
assert!(!token_matches(&expected, not_hex));
|
||||
}
|
||||
|
||||
/// #6425: the same for a token file whose contents are uppercase.
|
||||
#[test]
|
||||
fn an_uppercase_token_file_authenticates_its_exact_contents() {
|
||||
let path = std::env::temp_dir().join(format!("photocraft-control-token-upper-{}-{}.txt", std::process::id(), generate_token().unwrap()));
|
||||
let contents = generate_token().unwrap().to_ascii_uppercase();
|
||||
create_token_file(&path, &contents).unwrap();
|
||||
let expected = server_token(None, Some(&path));
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
let (reply, authenticated) = authentication_reply(&auth_line(&contents), &expected.unwrap());
|
||||
assert!(authenticated, "{reply}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_auth_on_an_authenticated_connection_says_so() {
|
||||
let line = json!({"id": 3, "method": AUTH_METHOD, "params": {"token": "anything"}}).to_string();
|
||||
@@ -480,50 +417,22 @@ mod tests {
|
||||
#[test]
|
||||
fn an_authenticated_connection_outlives_the_authentication_deadline() {
|
||||
let (server, mut client) = tcp_pair();
|
||||
// The test thread writes the first line before the deadline starts. The sender writes the
|
||||
// second line once the connection has authenticated and the deadline has passed.
|
||||
client.write_all(b"one\n").unwrap();
|
||||
let limit = Duration::from_secs(1);
|
||||
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, limit));
|
||||
let deadline = Instant::now() + limit;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let sender = std::thread::spawn(move || {
|
||||
rx.recv().unwrap();
|
||||
std::thread::sleep((deadline + Duration::from_millis(300)).saturating_duration_since(Instant::now()));
|
||||
client.write_all(b"one\n").unwrap();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
client.write_all(b"two\n").unwrap();
|
||||
});
|
||||
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200)));
|
||||
let mut line = String::new();
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
|
||||
assert_eq!(line, "one\n");
|
||||
reader.get_mut().authenticated().unwrap();
|
||||
tx.send(()).unwrap();
|
||||
// Sent 300 ms after the deadline: only the idle timeout applies now.
|
||||
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
|
||||
assert_eq!(line, "two\n");
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@@ -74,13 +74,6 @@ impl AuthorizedWorkspace {
|
||||
/// as [`photocraft_format::atomic_write`], through the directory capability). On failure
|
||||
/// the previous file is untouched and the temporary file is removed.
|
||||
///
|
||||
/// Replacing a file carries its extended attributes — macOS Finder tags, Linux `user.*` —
|
||||
/// onto the file that takes its place (#7095), through the two open descriptors rather than
|
||||
/// their paths, so nothing here reaches outside the write root to finish a write. The rest
|
||||
/// of what [`photocraft_format::atomic_write`] preserves does not follow: its permission
|
||||
/// step is tracked in #4963, and the Windows `ReplaceFileW` swap is path-based, which this
|
||||
/// path may not use.
|
||||
///
|
||||
/// The parent directory must already exist. `cap-std` performs path
|
||||
/// resolution and file creation relative to the held directory handle, so
|
||||
/// a non-existent final target is supported without ambient path access.
|
||||
@@ -92,20 +85,10 @@ impl AuthorizedWorkspace {
|
||||
let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
let written = root.dir.open_with(&tmp, &options).and_then(|file| {
|
||||
let mut file = file.into_std();
|
||||
let written = root.dir.open_with(&tmp, &options).and_then(|mut file| {
|
||||
file.write_all(bytes)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::fd::AsFd;
|
||||
// Best effort, and only when there is a file being replaced: a save still
|
||||
// succeeds for a document whose Finder tag could not come along.
|
||||
if let Ok(old) = root.dir.open(&relative) {
|
||||
let _ = photocraft_format::atomic::meta::copy_xattrs(old.as_fd(), file.as_fd());
|
||||
}
|
||||
}
|
||||
// Falls back to a plain fsync on shares that refuse a full flush (#1336).
|
||||
photocraft_format::atomic::sync_file(&file)
|
||||
photocraft_format::atomic::sync_file(&file.into_std())
|
||||
});
|
||||
let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative)));
|
||||
if let Err(e) = renamed {
|
||||
@@ -386,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,
|
||||
}
|
||||
@@ -589,32 +568,6 @@ mod tests {
|
||||
assert!(temp_files(&inside).is_empty());
|
||||
}
|
||||
|
||||
/// #7095: replacing a file through the automation path keeps the old file's extended
|
||||
/// attributes, the same as a save through `atomic_write` does.
|
||||
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
|
||||
#[test]
|
||||
fn write_keeps_the_replaced_files_extended_attributes() {
|
||||
#[cfg(target_os = "macos")]
|
||||
const TAG: &str = "com.apple.metadata:_kMDItemUserTags";
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const TAG: &str = "user.photocraft.test";
|
||||
|
||||
let (inside, _, workspace) = roots("xattr");
|
||||
let path = inside.join("doc.psd");
|
||||
workspace.write("doc.psd", b"original").unwrap();
|
||||
if rustix::fs::setxattr(&path, TAG, b"tagged", rustix::fs::XattrFlags::empty()).is_err() {
|
||||
return; // a file system without extended attributes: nothing to assert
|
||||
}
|
||||
|
||||
workspace.write("doc.psd", b"replacement, longer").unwrap();
|
||||
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"replacement, longer");
|
||||
let mut buf = [0u8; 64];
|
||||
let len = rustix::fs::getxattr(&path, TAG, buf.as_mut_slice()).expect("the attribute was dropped");
|
||||
assert_eq!(&buf[..len], b"tagged");
|
||||
assert!(temp_files(&inside).is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn write_into_read_only_folder_keeps_the_original() {
|
||||
@@ -759,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;
|
||||
|
||||
@@ -33,8 +33,6 @@ pub(crate) struct JpegMeta {
|
||||
pub exif: Option<Vec<u8>>,
|
||||
pub xmp: Option<String>,
|
||||
pub dpi: Option<(f32, f32)>,
|
||||
/// The APP13 Photoshop image resource blocks (`8BIM` ...), segments joined, without the header.
|
||||
pub photoshop: Option<Vec<u8>>,
|
||||
pub adobe_transform: Option<u8>,
|
||||
/// (width, height, components) from the first SOFn.
|
||||
pub frame: Option<(u32, u32, u8)>,
|
||||
@@ -110,7 +108,6 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta {
|
||||
.or_else(|| m.xmp.as_deref().and_then(xmp_resolution))
|
||||
.or_else(|| m.exif.as_deref().and_then(exif_resolution))
|
||||
.or(jfif_dpi);
|
||||
m.photoshop = Some(photoshop).filter(|p| !p.is_empty());
|
||||
if !icc_parts.is_empty() {
|
||||
icc_parts.sort_by_key(|p| p.0);
|
||||
let total = icc_parts[0].1 as usize;
|
||||
@@ -229,7 +226,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
}
|
||||
let mut img = Image::from_raw(w, h, layout, SampleType::U8, px)?;
|
||||
img.icc = meta.icc;
|
||||
img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, photoshop_resources: meta.photoshop, ..Default::default() };
|
||||
img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, ..Default::default() };
|
||||
if end == DataEnd::Truncated {
|
||||
img.warnings.push(DecodeWarning::Truncated { format: F });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+24
-244
@@ -7,7 +7,7 @@ use crate::Format;
|
||||
use crate::error::CodecError;
|
||||
use crate::fidelity::Plan;
|
||||
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
|
||||
use crate::options::{EncodeOptions, Limits, PngCompression, PngStereo, alloc_zeroed};
|
||||
use crate::options::{EncodeOptions, Limits, PngCompression, alloc_zeroed};
|
||||
|
||||
const F: Format = Format::Png;
|
||||
pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp";
|
||||
@@ -127,38 +127,6 @@ pub fn palette(bytes: &[u8]) -> Result<Option<PngPalette>, CodecError> {
|
||||
Ok(Some(PngPalette { colors, transparent }))
|
||||
}
|
||||
|
||||
/// The stereo pairing a PNG declares in its `sTER` chunk (PNG Extensions 1.4.0, §3.6), read
|
||||
/// from the headers without decoding pixels. `None` when the file has no CRC-valid `sTER` with a
|
||||
/// defined mode byte. The first valid chunk wins, as a later one would contradict it.
|
||||
pub fn stereo(bytes: &[u8]) -> Option<PngStereo> {
|
||||
let mut rest = bytes.get(8..)?;
|
||||
while let Some((length, tail)) = rest.split_first_chunk::<4>() {
|
||||
let Some((kind, tail)) = tail.split_first_chunk::<4>() else { break };
|
||||
let length = u32::from_be_bytes(*length);
|
||||
if length > i32::MAX as u32 {
|
||||
break;
|
||||
}
|
||||
let Ok(length) = usize::try_from(length) else { break };
|
||||
let Some(data) = tail.get(..length) else { break };
|
||||
let Some((checksum, next)) = tail.get(length..).and_then(|t| t.split_first_chunk::<4>()) else { break };
|
||||
rest = next;
|
||||
if kind == b"IEND" {
|
||||
break;
|
||||
}
|
||||
if kind != b"sTER" || data.len() != 1 {
|
||||
continue;
|
||||
}
|
||||
let mut crc = flate2::Crc::new();
|
||||
crc.update(kind);
|
||||
crc.update(data);
|
||||
if crc.sum() != u32::from_be_bytes(*checksum) {
|
||||
continue;
|
||||
}
|
||||
return PngStereo::from_mode(data[0]);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn map_png_err(e: png::DecodingError) -> CodecError {
|
||||
match e {
|
||||
png::DecodingError::LimitsExceeded => CodecError::LimitExceeded("PNG decoder memory limit".into()),
|
||||
@@ -378,10 +346,6 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
}
|
||||
}
|
||||
let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?;
|
||||
if let Some(stereo) = opts.png_stereo {
|
||||
// Ancillary chunk before IDAT, as the spec recommends for `sTER`.
|
||||
writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?;
|
||||
}
|
||||
if opts.png_interlaced {
|
||||
let bpp = img.layout().channels() * img.sample_type().bytes();
|
||||
let idat = adam7_idat(&data, w as usize, h as usize, bpp, opts.png_compression)?;
|
||||
@@ -541,15 +505,8 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o
|
||||
}
|
||||
};
|
||||
let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::<u64>();
|
||||
let mut best = cost(row);
|
||||
let start = out.len();
|
||||
out.push(0);
|
||||
out.extend_from_slice(row);
|
||||
let mut best = (cost(row), 0u8);
|
||||
for kind in 1u8..=4 {
|
||||
if best == 0 {
|
||||
break;
|
||||
}
|
||||
let mut c = 0u64;
|
||||
for i in 0..n {
|
||||
let x = row[i];
|
||||
scratch[i] = match kind {
|
||||
@@ -558,17 +515,27 @@ fn filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], o
|
||||
3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8),
|
||||
_ => x.wrapping_sub(paeth(left(i), up(i), ul(i))),
|
||||
};
|
||||
c += u64::from((scratch[i] as i8).unsigned_abs());
|
||||
if c >= best {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if c < best {
|
||||
best = c;
|
||||
out[start] = kind;
|
||||
out[start + 1..].copy_from_slice(&scratch[..n]);
|
||||
let c = cost(&scratch[..n]);
|
||||
if c < best.0 {
|
||||
best = (c, kind);
|
||||
}
|
||||
}
|
||||
out.push(best.1);
|
||||
if best.1 == 0 {
|
||||
out.extend_from_slice(row);
|
||||
return;
|
||||
}
|
||||
for i in 0..n {
|
||||
let x = row[i];
|
||||
scratch[i] = match best.1 {
|
||||
1 => x.wrapping_sub(left(i)),
|
||||
2 => x.wrapping_sub(up(i)),
|
||||
3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8),
|
||||
_ => x.wrapping_sub(paeth(left(i), up(i), ul(i))),
|
||||
};
|
||||
}
|
||||
out.extend_from_slice(&scratch[..n]);
|
||||
}
|
||||
|
||||
const ADLER_MOD: u32 = 65_521;
|
||||
@@ -607,7 +574,6 @@ pub fn encode_indexed(
|
||||
palette: &[[u8; 3]],
|
||||
transparent: Option<u8>,
|
||||
dpi: Option<(f32, f32)>,
|
||||
stereo: Option<PngStereo>,
|
||||
) -> Result<Vec<u8>, CodecError> {
|
||||
if width == 0 || height == 0 || indices.len() != width as usize * height as usize {
|
||||
return Err(CodecError::InvalidImage("index data does not match the size".into()));
|
||||
@@ -637,95 +603,12 @@ pub fn encode_indexed(
|
||||
{
|
||||
let encoder = png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?;
|
||||
let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?;
|
||||
if let Some(stereo) = stereo {
|
||||
writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?;
|
||||
}
|
||||
writer.write_image_data(indices).map_err(|e| CodecError::encode(F, e))?;
|
||||
writer.finish().map_err(|e| CodecError::encode(F, e))?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stereo_tests {
|
||||
use super::*;
|
||||
|
||||
/// One CRC-valid chunk as it appears in a file.
|
||||
fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(kind);
|
||||
out.extend_from_slice(data);
|
||||
let mut crc = flate2::Crc::new();
|
||||
crc.update(kind);
|
||||
crc.update(data);
|
||||
out.extend_from_slice(&crc.sum().to_be_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn png_with(stereo: &[u8]) -> Vec<u8> {
|
||||
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
out.extend_from_slice(&chunk(b"IHDR", &[0, 0, 0, 2, 0, 0, 0, 1, 8, 2, 0, 0, 0]));
|
||||
if !stereo.is_empty() {
|
||||
out.extend_from_slice(&chunk(b"sTER", stereo));
|
||||
}
|
||||
out.extend_from_slice(&chunk(b"IDAT", &[0x78, 0x01, 0x01, 0x00, 0xff, 0xff, 0, 3, 1, 0, 0]));
|
||||
out.extend_from_slice(&chunk(b"IEND", &[]));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_both_defined_views() {
|
||||
assert_eq!(stereo(&png_with(&[0])), Some(PngStereo::Horizontal));
|
||||
assert_eq!(stereo(&png_with(&[1])), Some(PngStereo::Vertical));
|
||||
assert_eq!(stereo(&png_with(&[])), None);
|
||||
// Reserved and unknown mode bytes are not a pairing we can keep.
|
||||
assert_eq!(stereo(&png_with(&[2])), None);
|
||||
assert_eq!(stereo(&png_with(&[255])), None);
|
||||
// A two-byte payload is not sTER data.
|
||||
assert_eq!(stereo(&png_with(&[0, 0])), None);
|
||||
assert_eq!(stereo(b"not a png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_crc_is_not_a_stereo_mark() {
|
||||
let mut bytes = png_with(&[0]);
|
||||
let kind = bytes.windows(4).position(|w| w == b"sTER").unwrap();
|
||||
// The CRC trails the type and the one-byte payload of the chunk.
|
||||
bytes[kind + 8] ^= 0xff;
|
||||
assert_eq!(stereo(&bytes), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_writes_the_chunk_before_idat_and_it_reads_back() {
|
||||
let img = Image::from_raw(2, 1, ChannelLayout::Rgb, SampleType::U8, vec![9; 6]).unwrap();
|
||||
for view in [PngStereo::Horizontal, PngStereo::Vertical] {
|
||||
let opts = EncodeOptions { png_stereo: Some(view), ..Default::default() };
|
||||
let bytes = crate::encode(&img, Format::Png, &opts).unwrap();
|
||||
assert_eq!(stereo(&bytes), Some(view));
|
||||
let back = decode(&bytes, &Limits::default()).unwrap();
|
||||
assert_eq!(back.data(), img.data());
|
||||
let ter = bytes.windows(4).position(|w| w == b"sTER").unwrap();
|
||||
let idat = bytes.windows(4).position(|w| w == b"IDAT").unwrap();
|
||||
assert!(ter < idat, "sTER before IDAT");
|
||||
}
|
||||
let plain = crate::encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(stereo(&plain), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interlaced_and_indexed_encoders_keep_the_mark() {
|
||||
let img = Image::from_raw(2, 2, ChannelLayout::Rgb, SampleType::U8, vec![9; 12]).unwrap();
|
||||
let opts = EncodeOptions { png_stereo: Some(PngStereo::Vertical), png_interlaced: true, ..Default::default() };
|
||||
let bytes = crate::encode(&img, Format::Png, &opts).unwrap();
|
||||
assert_eq!(stereo(&bytes), Some(PngStereo::Vertical));
|
||||
let pal = [[255, 0, 0], [0, 0, 255]];
|
||||
let bytes = encode_indexed(2, 1, &[0, 1], &pal, None, None, Some(PngStereo::Horizontal)).unwrap();
|
||||
assert_eq!(stereo(&bytes), Some(PngStereo::Horizontal));
|
||||
assert_eq!(palette(&bytes).unwrap().map(|p| p.colors), Some(pal.to_vec()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod indexed_tests {
|
||||
use super::*;
|
||||
@@ -734,14 +617,14 @@ mod indexed_tests {
|
||||
fn png8_roundtrips_through_the_decoder() {
|
||||
let pal = [[255, 0, 0], [0, 0, 255], [0, 0, 0]];
|
||||
let idx = [0u8, 1, 2, 1, 0, 2];
|
||||
let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None, None).unwrap();
|
||||
let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None).unwrap();
|
||||
let img = decode(&bytes, &Limits::default()).unwrap();
|
||||
assert_eq!(img.dimensions(), (3, 2));
|
||||
let px = img.convert(ChannelLayout::Rgba, SampleType::U8);
|
||||
assert_eq!(&px.data()[..8], &[255, 0, 0, 255, 0, 0, 255, 255]);
|
||||
assert_eq!(px.data()[11], 0, "transparent entry");
|
||||
assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None, None).is_err());
|
||||
assert!(encode_indexed(3, 3, &idx, &pal, None, None, None).is_err());
|
||||
assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None).is_err());
|
||||
assert!(encode_indexed(3, 3, &idx, &pal, None, None).is_err());
|
||||
}
|
||||
|
||||
fn png(color: png::ColorType, trns: Option<&[u8]>) -> Vec<u8> {
|
||||
@@ -760,7 +643,7 @@ mod indexed_tests {
|
||||
|
||||
#[test]
|
||||
fn palette_reads_the_table_and_its_one_transparent_entry() {
|
||||
let bytes = encode_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]], Some(1), None, None).unwrap();
|
||||
let bytes = encode_indexed(2, 1, &[0, 1], &[[1, 2, 3], [4, 5, 6]], Some(1), None).unwrap();
|
||||
let colors = vec![[1, 2, 3], [4, 5, 6]];
|
||||
assert_eq!(palette(&bytes).unwrap(), Some(PngPalette { colors: colors.clone(), transparent: Some(1) }));
|
||||
assert_eq!(palette(&png(png::ColorType::Indexed, None)).unwrap(), Some(PngPalette { colors, transparent: None }));
|
||||
@@ -776,109 +659,6 @@ mod indexed_tests {
|
||||
mod parallel_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual 24 MP release benchmark"]
|
||||
fn benchmark_filter_selection() {
|
||||
use std::{hint::black_box, time::Instant};
|
||||
for pattern in ["zero", "identical", "varied"] {
|
||||
let row: Vec<u8> = (0..6000 * 4).map(|i| if pattern == "zero" { 0 } else { (i * 17 + i / 13) as u8 }).collect();
|
||||
let prev: Vec<_> = row.iter().map(|v| if pattern == "identical" { *v } else { v.wrapping_add(3) }).collect();
|
||||
let mut scratch = vec![0; row.len()];
|
||||
let mut out = Vec::with_capacity(row.len() + 1);
|
||||
for trial in 0..6 {
|
||||
type Filter = fn(&[u8], Option<&[u8]>, usize, &mut [u8], &mut Vec<u8>);
|
||||
let mut paths: [(&str, Filter); 2] = [("main", previous_filter_row), ("split", filter_row)];
|
||||
if trial % 2 == 1 {
|
||||
paths.reverse();
|
||||
}
|
||||
for (name, path) in paths {
|
||||
let t = Instant::now();
|
||||
for _ in 0..4000 {
|
||||
out.clear();
|
||||
black_box(path)(black_box(&row), Some(black_box(&prev)), 4, &mut scratch, &mut out);
|
||||
black_box(&out);
|
||||
}
|
||||
println!("filter,{pattern},{trial},{name},{:.6}", t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The main-branch implementation before this refactor.
|
||||
fn previous_filter_row(row: &[u8], prev: Option<&[u8]>, bpp: usize, scratch: &mut [u8], out: &mut Vec<u8>) {
|
||||
let n = row.len();
|
||||
let up = |i: usize| prev.map_or(0, |p| p[i]);
|
||||
let left = |i: usize| if i >= bpp { row[i - bpp] } else { 0 };
|
||||
let ul = |i: usize| if i >= bpp { prev.map_or(0, |p| p[i - bpp]) } else { 0 };
|
||||
let paeth = |a: u8, b: u8, c: u8| {
|
||||
let p = i16::from(a) + i16::from(b) - i16::from(c);
|
||||
let (pa, pb, pc) = ((p - i16::from(a)).abs(), (p - i16::from(b)).abs(), (p - i16::from(c)).abs());
|
||||
if pa <= pb && pa <= pc {
|
||||
a
|
||||
} else if pb <= pc {
|
||||
b
|
||||
} else {
|
||||
c
|
||||
}
|
||||
};
|
||||
let cost = |v: &[u8]| v.iter().map(|&b| u64::from((b as i8).unsigned_abs())).sum::<u64>();
|
||||
let mut best = (cost(row), 0u8);
|
||||
for kind in 1u8..=4 {
|
||||
for i in 0..n {
|
||||
let x = row[i];
|
||||
scratch[i] = match kind {
|
||||
1 => x.wrapping_sub(left(i)),
|
||||
2 => x.wrapping_sub(up(i)),
|
||||
3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8),
|
||||
_ => x.wrapping_sub(paeth(left(i), up(i), ul(i))),
|
||||
};
|
||||
}
|
||||
let c = cost(&scratch[..n]);
|
||||
if c < best.0 {
|
||||
best = (c, kind);
|
||||
}
|
||||
}
|
||||
out.push(best.1);
|
||||
if best.1 == 0 {
|
||||
out.extend_from_slice(row);
|
||||
return;
|
||||
}
|
||||
for i in 0..n {
|
||||
let x = row[i];
|
||||
scratch[i] = match best.1 {
|
||||
1 => x.wrapping_sub(left(i)),
|
||||
2 => x.wrapping_sub(up(i)),
|
||||
3 => x.wrapping_sub(((u16::from(left(i)) + u16::from(up(i))) / 2) as u8),
|
||||
_ => x.wrapping_sub(paeth(left(i), up(i), ul(i))),
|
||||
};
|
||||
}
|
||||
out.extend_from_slice(&scratch[..n]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_filter_preserves_minimum_cost_and_ties() {
|
||||
for bpp in [1, 2, 3, 4, 6, 8] {
|
||||
for seed in 0..32usize {
|
||||
let row: Vec<u8> = (0..137).map(|i| (i * seed + i / 5) as u8).collect();
|
||||
let shifted: Vec<_> = row.iter().map(|v| v.wrapping_add(seed as u8)).collect();
|
||||
for prev in [None, Some(row.as_slice()), Some(shifted.as_slice())] {
|
||||
let mut expected = vec![99];
|
||||
let mut actual = expected.clone();
|
||||
let mut scratch = vec![0; row.len()];
|
||||
previous_filter_row(&row, prev, bpp, &mut scratch, &mut expected);
|
||||
filter_row(&row, prev, bpp, &mut scratch, &mut actual);
|
||||
assert_eq!(actual, expected, "bpp={bpp}, seed={seed}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
filter_row(&[0; 16], None, 4, &mut [0; 16], &mut out);
|
||||
assert_eq!(out, vec![0; 17]);
|
||||
out.clear();
|
||||
filter_row(&[1; 16], Some(&[0; 16]), 1, &mut [0; 16], &mut out);
|
||||
assert_eq!(out, [vec![1, 1], vec![0; 15]].concat());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adler_combine_matches_one_pass() {
|
||||
let a: Vec<u8> = (0..100_000u32).map(|i| (i * 7 + i / 13) as u8).collect();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,7 @@
|
||||
//! the same reader ([`super::tiff_ifd::File`]): reading a TIFF builds a block from the file's own
|
||||
//! directories, writing one copies the block's directories into the file. Values are copied as
|
||||
//! bytes (re-ordered when the byte orders differ), so tags PhotoCraft doesn't know survive.
|
||||
//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets. What is copied is
|
||||
//! bounded by the stream's own size and a fixed cap, because entries of a hostile file can
|
||||
//! overlap (see [`read`]).
|
||||
//! Classic TIFF only on the reading side: an EXIF block has 4-byte offsets.
|
||||
|
||||
use tiff::encoder::{DirectoryEncoder, TiffEncoder, TiffKind};
|
||||
use tiff::tags::{Tag, Type};
|
||||
@@ -29,11 +27,6 @@ const TAG_INTEROP_IFD: u16 = 40965;
|
||||
const IFD0_TAGS: [u16; 7] = [270, 271, 272, 305, 306, 315, 33432];
|
||||
/// At most this many entries are copied from one directory.
|
||||
const MAX_FIELDS: usize = 1024;
|
||||
/// At most this many value bytes are copied from one stream, over all its directories. Real EXIF
|
||||
/// is far smaller (the largest Nikon maker note seen is under 1 MB); the cap leaves room for
|
||||
/// that and for a few such notes. A stream never yields more than its own length either (see
|
||||
/// [`read`]).
|
||||
const MAX_COPIED_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// One entry with its value bytes in the byte order of the stream it came from.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -60,22 +53,13 @@ impl ExifDirs {
|
||||
}
|
||||
}
|
||||
|
||||
/// The entries of `ifd` that `keep` accepts, with their values copied. An entry's value only has
|
||||
/// to lie inside the file, so entries may overlap: `budget` is the number of value bytes that may
|
||||
/// still be copied (shared by all the directories of one stream), and an entry whose value does
|
||||
/// not fit in what is left is skipped, so later, smaller entries can still be kept.
|
||||
fn fields(f: &File<'_>, ifd: &Ifd, budget: &mut usize, keep: impl Fn(u16) -> bool) -> Vec<Field> {
|
||||
let mut out = Vec::new();
|
||||
for e in ifd.entries.iter().filter(|e| keep(e.tag) && !matches!(e.tag, TAG_EXIF_IFD | TAG_GPS_IFD | TAG_INTEROP_IFD)) {
|
||||
if out.len() >= MAX_FIELDS {
|
||||
break;
|
||||
}
|
||||
let (Ok(count), Some(value)) = (u32::try_from(e.count), f.data(e)) else { continue };
|
||||
let Some(left) = budget.checked_sub(value.len()) else { continue };
|
||||
*budget = left;
|
||||
out.push(Field { tag: e.tag, ty: e.ty, count, value: value.to_vec() });
|
||||
}
|
||||
out
|
||||
fn fields(f: &File<'_>, ifd: &Ifd, keep: impl Fn(u16) -> bool) -> Vec<Field> {
|
||||
ifd.entries
|
||||
.iter()
|
||||
.filter(|e| keep(e.tag) && !matches!(e.tag, TAG_EXIF_IFD | TAG_GPS_IFD | TAG_INTEROP_IFD))
|
||||
.filter_map(|e| Some(Field { tag: e.tag, ty: e.ty, count: u32::try_from(e.count).ok()?, value: f.data(e)?.to_vec() }))
|
||||
.take(MAX_FIELDS)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option<Ifd> {
|
||||
@@ -85,24 +69,14 @@ fn sub(f: &File<'_>, ifd: &Ifd, tag: u16) -> Option<Ifd> {
|
||||
|
||||
/// The EXIF parts of the classic TIFF stream `b` (a TIFF file, or an EXIF block without its
|
||||
/// `Exif\0\0` prefix): IFD0's descriptive tags and the Exif and GPS directories.
|
||||
///
|
||||
/// The values copied add up to at most [`MAX_COPIED_BYTES`] and to at most `b.len()`: the values
|
||||
/// of a well-formed stream are distinct bytes of it, while a hostile one can have many entries
|
||||
/// point at the same bytes and so ask for far more than its size. Entries that do not fit in what
|
||||
/// is left are left out (the small descriptive directories come first, so a huge maker note
|
||||
/// cannot crowd out the GPS position).
|
||||
pub(crate) fn read(b: &[u8]) -> Option<ExifDirs> {
|
||||
let f = File::parse(b)?;
|
||||
if f.big {
|
||||
return None;
|
||||
}
|
||||
let ifd0 = f.ifd(f.first_ifd)?;
|
||||
let mut budget = MAX_COPIED_BYTES.min(b.len());
|
||||
let ifd0_fields = fields(&f, &ifd0, &mut budget, |t| IFD0_TAGS.contains(&t));
|
||||
let mut dir = |tag: u16| sub(&f, &ifd0, tag).map(|d| fields(&f, &d, &mut budget, |_| true)).unwrap_or_default();
|
||||
let gps = dir(TAG_GPS_IFD);
|
||||
let exif = dir(TAG_EXIF_IFD);
|
||||
Some(ExifDirs { le: f.le, ifd0: ifd0_fields, exif, gps })
|
||||
let dir = |tag: u16| sub(&f, &ifd0, tag).map(|d| fields(&f, &d, |_| true)).unwrap_or_default();
|
||||
Some(ExifDirs { le: f.le, ifd0: fields(&f, &ifd0, |t| IFD0_TAGS.contains(&t)), exif: dir(TAG_EXIF_IFD), gps: dir(TAG_GPS_IFD) })
|
||||
}
|
||||
|
||||
/// Bytes per element whose order a byte-order change reverses: a RATIONAL is two LONGs.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -28,7 +28,7 @@ pub mod web;
|
||||
|
||||
pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color};
|
||||
pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES};
|
||||
pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette, stereo as png_stereo};
|
||||
pub use crate::codecs::png::{PngPalette, encode_indexed as encode_png_indexed, palette as png_palette};
|
||||
pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian};
|
||||
pub use crate::codecs::tiff_ifd::{TiffInfo, TiffPage, TiffPageKind};
|
||||
pub use crate::error::CodecError;
|
||||
@@ -38,7 +38,7 @@ pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect,
|
||||
pub use crate::image::{
|
||||
ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType,
|
||||
};
|
||||
pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, PngStereo, TiffCompression};
|
||||
pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
|
||||
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
|
||||
pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution};
|
||||
pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding};
|
||||
|
||||
@@ -100,34 +100,6 @@ pub enum PngCompression {
|
||||
Best,
|
||||
}
|
||||
|
||||
/// How a PNG's `sTER` chunk pairs its two views (PNG Extensions 1.4.0, §3.6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PngStereo {
|
||||
/// Left and right views side by side, left first.
|
||||
Horizontal,
|
||||
/// Left and right views stacked, left on top.
|
||||
Vertical,
|
||||
}
|
||||
|
||||
impl PngStereo {
|
||||
/// The view for an `sTER` mode byte; reserved and unknown values are `None`.
|
||||
pub fn from_mode(mode: u8) -> Option<Self> {
|
||||
match mode {
|
||||
0 => Some(Self::Horizontal),
|
||||
1 => Some(Self::Vertical),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `sTER` mode byte.
|
||||
pub fn mode(self) -> u8 {
|
||||
match self {
|
||||
Self::Horizontal => 0,
|
||||
Self::Vertical => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TIFF strip compression. The default is none, as in Photoshop: uncompressed strips are the
|
||||
/// only kind every TIFF reader opens (Deflate and the predictor are not baseline TIFF, #4484).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
@@ -192,9 +164,6 @@ pub struct EncodeOptions {
|
||||
pub png_compression: PngCompression,
|
||||
/// Write Adam7-interlaced PNG.
|
||||
pub png_interlaced: bool,
|
||||
/// Write an `sTER` chunk marking the image as a stereo pair (PNG Extensions 1.4.0, §3.6),
|
||||
/// so a stereo PNG keeps its mark when saved again (#6063).
|
||||
pub png_stereo: Option<PngStereo>,
|
||||
/// WebP: lossless (VP8L, via `image-webp`) or lossy (our VP8 encoder, see
|
||||
/// `codecs::vp8`) with [`Self::webp_quality`].
|
||||
pub webp_lossless: bool,
|
||||
@@ -220,7 +189,6 @@ impl Default for EncodeOptions {
|
||||
jpeg_chroma_subsampling: true,
|
||||
png_compression: PngCompression::Default,
|
||||
png_interlaced: false,
|
||||
png_stereo: None,
|
||||
webp_lossless: true,
|
||||
webp_quality: 80,
|
||||
tiff_compression: TiffCompression::None,
|
||||
|
||||
@@ -285,8 +285,7 @@ pub fn decode_wbmp(bytes: &[u8]) -> Result<(u32, u32, Vec<bool>), CodecError> {
|
||||
}
|
||||
|
||||
/// JPEG from 8-bit RGB (`rgb.len() == 3·w·h`): quality 1..=100, optional progressive scans and
|
||||
/// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density, EXIF and
|
||||
/// XMP. EXIF that doesn't fit in one segment is left out (see [`jpeg_exif_fits`]).
|
||||
/// optimised Huffman tables (Save for Web's "Optimized"), optional ICC profile, density and XMP.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn encode_jpeg_rgb8(
|
||||
width: u32,
|
||||
@@ -297,7 +296,6 @@ pub fn encode_jpeg_rgb8(
|
||||
optimized: bool,
|
||||
icc: Option<&[u8]>,
|
||||
dpi: Option<f32>,
|
||||
exif: Option<&[u8]>,
|
||||
xmp: Option<&str>,
|
||||
) -> Result<Vec<u8>, CodecError> {
|
||||
let e = |e: jpeg_encoder::EncodingError| CodecError::encode(Format::Jpeg, e);
|
||||
@@ -321,14 +319,9 @@ pub fn encode_jpeg_rgb8(
|
||||
let v = d.round().min(65535.0) as u16;
|
||||
enc.set_density(jpeg_encoder::Density::Inch { x: v, y: v });
|
||||
}
|
||||
let ppi = dpi.filter(|d| *d >= 1.0).map(|d| (d, d));
|
||||
// Upright, without the source's thumbnail, at the JFIF resolution, as Save As writes it.
|
||||
if let Some(seg) = exif.and_then(|x| crate::codecs::jpeg::exif_segment(x, ppi)) {
|
||||
enc.add_app_segment(1, &seg).map_err(e)?;
|
||||
}
|
||||
if let Some(x) = xmp {
|
||||
// Its `tiff:` resolution, if any, says what the JFIF density says (#1691).
|
||||
let x = crate::resolution::export_xmp(x, ppi);
|
||||
let x = crate::resolution::export_xmp(x, dpi.filter(|d| *d >= 1.0).map(|d| (d, d)));
|
||||
let mut seg = b"http://ns.adobe.com/xap/1.0/\0".to_vec();
|
||||
seg.extend_from_slice(x.as_bytes());
|
||||
enc.add_app_segment(1, &seg).map_err(e)?;
|
||||
@@ -340,11 +333,6 @@ pub fn encode_jpeg_rgb8(
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Whether [`encode_jpeg_rgb8`] can write `exif` at `dpi` (it must fit in one APP1 segment).
|
||||
pub fn jpeg_exif_fits(exif: &[u8], dpi: Option<f32>) -> bool {
|
||||
crate::codecs::jpeg::exif_segment(exif, dpi.filter(|d| *d >= 1.0).map(|d| (d, d))).is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -371,7 +359,7 @@ mod tests {
|
||||
let rgb: Vec<u8> = (0..16 * 16 * 3).map(|i| (i * 37 % 256) as u8).collect();
|
||||
for (q, want) in [(1, (2, 2)), (50, (2, 2)), (51, (1, 1)), (60, (1, 1)), (89, (1, 1)), (100, (1, 1))] {
|
||||
for progressive in [false, true] {
|
||||
let bytes = encode_jpeg_rgb8(16, 16, &rgb, q, progressive, false, None, None, None, None).unwrap();
|
||||
let bytes = encode_jpeg_rgb8(16, 16, &rgb, q, progressive, false, None, None, None).unwrap();
|
||||
assert_eq!(luma_sampling(&bytes), Some(want), "quality {q} progressive {progressive}");
|
||||
}
|
||||
}
|
||||
@@ -440,8 +428,8 @@ mod tests {
|
||||
#[test]
|
||||
fn progressive_jpeg_decodes() {
|
||||
let rgb: Vec<u8> = (0..64 * 48 * 3).map(|i| (i % 251) as u8).collect();
|
||||
let base = encode_jpeg_rgb8(64, 48, &rgb, 80, false, false, None, Some(72.0), None, Some("<x:xmpmeta/>")).unwrap();
|
||||
let prog = encode_jpeg_rgb8(64, 48, &rgb, 80, true, true, None, None, None, None).unwrap();
|
||||
let base = encode_jpeg_rgb8(64, 48, &rgb, 80, false, false, None, Some(72.0), Some("<x:xmpmeta/>")).unwrap();
|
||||
let prog = encode_jpeg_rgb8(64, 48, &rgb, 80, true, true, None, None, None).unwrap();
|
||||
assert_ne!(base, prog);
|
||||
assert_eq!(crate::decode(&base).unwrap().meta.xmp.as_deref(), Some("<x:xmpmeta/>"));
|
||||
// SOF2 marks a progressive frame.
|
||||
@@ -454,7 +442,7 @@ mod tests {
|
||||
fn web_jpeg_xmp_resolution_follows_the_density() {
|
||||
let rgb = vec![100u8; 8 * 8 * 3];
|
||||
let xmp = r#"<rdf:Description tiff:XResolution="300/1" tiff:YResolution="300/1"/>"#;
|
||||
let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), None, Some(xmp)).unwrap();
|
||||
let out = encode_jpeg_rgb8(8, 8, &rgb, 80, false, false, None, Some(72.0), Some(xmp)).unwrap();
|
||||
let img = crate::decode(&out).unwrap();
|
||||
assert_eq!(img.meta.dpi, Some((72.0, 72.0)));
|
||||
assert_eq!(img.meta.xmp.as_deref().and_then(crate::xmp_resolution), Some((72.0, 72.0)));
|
||||
@@ -473,7 +461,7 @@ mod subsampling_tests {
|
||||
let rgb: Vec<u8> = (0..w * h).flat_map(|i| [((i % w) * 4) as u8, 76, ((i / w) * 5) as u8]).collect();
|
||||
for q in [30, 60, 95] {
|
||||
for (prog, opt) in [(false, false), (false, true), (true, false), (true, true)] {
|
||||
let bytes = encode_jpeg_rgb8(w, h, &rgb, q, prog, opt, None, None, None, None).unwrap();
|
||||
let bytes = encode_jpeg_rgb8(w, h, &rgb, q, prog, opt, None, None, None).unwrap();
|
||||
let ours = crate::decode(&bytes).unwrap().convert(crate::ChannelLayout::Rgb, crate::SampleType::U8);
|
||||
let err = ours.data().iter().zip(&rgb).map(|(a, b)| i32::from(*a).abs_diff(i32::from(*b))).max().unwrap();
|
||||
assert!(err < 24, "q{q} progressive={prog} optimized={opt}: max error {err}");
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,97 +166,3 @@ fn no_exif_is_written_without_embed_metadata() {
|
||||
let tiff = encode(&img, Format::Tiff, &EncodeOptions { embed_metadata: false, ..Default::default() }).unwrap();
|
||||
assert_eq!(decode(&tiff).unwrap().meta.exif, None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bounded copying: an entry's value only has to lie inside the file, so a hostile file can have
|
||||
// many entries point at the same bytes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A valid 1x1 gray TIFF whose Exif directory has `entries` UNDEFINED entries of `count` bytes
|
||||
/// that all point at the same `count` bytes: the file is about `count` bytes long and asks to
|
||||
/// copy `entries * count`.
|
||||
fn overlapping_tiff(entries: usize, count: u32) -> Vec<u8> {
|
||||
let entry = |tag: u16, ty: u16, count: u32, value: u32| {
|
||||
[tag.to_le_bytes().to_vec(), ty.to_le_bytes().to_vec(), count.to_le_bytes().to_vec(), value.to_le_bytes().to_vec()].concat()
|
||||
};
|
||||
let at_exif = 8 + 2 + 12 * 10 + 4;
|
||||
let data_at = at_exif + 2 + 12 * entries + 4;
|
||||
let mut out = b"II".to_vec();
|
||||
out.extend(42u16.to_le_bytes());
|
||||
out.extend(8u32.to_le_bytes());
|
||||
out.extend(10u16.to_le_bytes());
|
||||
for (tag, ty, value) in
|
||||
[(256, 3, 1), (257, 3, 1), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 0), (277, 3, 1), (278, 3, 1), (279, 4, 1), (34665, 4, at_exif as u32)]
|
||||
{
|
||||
out.extend(entry(tag, ty, 1, value));
|
||||
}
|
||||
out.extend(0u32.to_le_bytes());
|
||||
out.extend((entries as u16).to_le_bytes());
|
||||
for i in 0..entries {
|
||||
out.extend(entry(i as u16 + 1, 7, count, data_at as u32));
|
||||
}
|
||||
out.extend(0u32.to_le_bytes());
|
||||
out.extend(vec![0x5A; count as usize]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_field_values_are_bounded() {
|
||||
// About 62 KB on disk; copied entry by entry it would be 1,000 x 50,000 bytes.
|
||||
let tiff = overlapping_tiff(1000, 50_000);
|
||||
assert!(tiff.len() < 64 * 1024);
|
||||
let img = decode(&tiff).expect("the image itself is fine");
|
||||
assert_eq!(img.dimensions(), (1, 1));
|
||||
let exif = img.meta.exif.expect("the first entries are kept");
|
||||
assert!(exif.len() <= 2 * tiff.len(), "{} bytes of EXIF from a {} byte file", exif.len(), tiff.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_field_values_in_an_exif_block_are_bounded_on_export() {
|
||||
// The same block as an image's EXIF: the writer reads it with the same code.
|
||||
let block = overlapping_tiff(1000, 50_000);
|
||||
let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap();
|
||||
img.meta.exif = Some(block.clone());
|
||||
let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
|
||||
assert!(tiff.len() <= 2 * block.len(), "a {} byte block wrote a {} byte file", block.len(), tiff.len());
|
||||
}
|
||||
|
||||
/// An EXIF block with a maker note of `len` bytes, an exposure time and a GPS latitude reference.
|
||||
fn block_with_maker_note(le: bool, len: usize) -> Vec<u8> {
|
||||
let b = Block { le };
|
||||
b.build(
|
||||
vec![(271, 2, 4, b"Cam\0".to_vec())],
|
||||
vec![(33434, 5, 1, b.rational(1, 125)), (37500, 7, len as u32, (0..len).map(|i| (i * 7) as u8).collect())],
|
||||
vec![(1, 2, 2, b"N\0".to_vec())],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_maker_notes_still_survive() {
|
||||
// Nikon maker notes run from 100 KB to nearly 1 MB.
|
||||
for len in [200 * 1024, 1000 * 1024] {
|
||||
let block = block_with_maker_note(true, len);
|
||||
let mut img = Image::from_raw(4, 3, ChannelLayout::Rgb, SampleType::U8, vec![90; 36]).unwrap();
|
||||
img.meta.exif = Some(block.clone());
|
||||
let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
|
||||
let back = decode(&tiff).unwrap().meta.exif.expect("EXIF read back");
|
||||
let (ble, _, bexif, bgps) = dirs(&back);
|
||||
let (ole, _, oexif, ogps) = dirs(&block);
|
||||
assert_eq!(normalized(&bexif, ble), normalized(&oexif, ole), "{len} byte maker note");
|
||||
assert_eq!(normalized(&bgps, ble), normalized(&ogps, ole));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_beyond_the_cap_is_dropped_and_the_rest_kept() {
|
||||
let block = block_with_maker_note(false, 6 * 1024 * 1024);
|
||||
let mut img = Image::from_raw(2, 2, ChannelLayout::Gray, SampleType::U8, vec![0; 4]).unwrap();
|
||||
img.meta.exif = Some(block);
|
||||
let tiff = encode(&img, Format::Tiff, &EncodeOptions::default()).unwrap();
|
||||
assert!(tiff.len() < 1024 * 1024, "{} bytes", tiff.len());
|
||||
let back = decode(&tiff).unwrap().meta.exif.expect("EXIF read back");
|
||||
let (_, ifd0, exif, gps) = dirs(&back);
|
||||
assert_eq!(exif.iter().map(|e| e.0).collect::<Vec<_>>(), [33434], "the exposure time stays, the 6 MiB maker note goes");
|
||||
assert_eq!(gps.iter().map(|e| e.0).collect::<Vec<_>>(), [1]);
|
||||
assert_eq!(ifd0.iter().map(|e| e.0).collect::<Vec<_>>(), [271]);
|
||||
}
|
||||
+31
-139
@@ -37,25 +37,6 @@ pub fn has_effects(layer: &Layer) -> bool {
|
||||
layer.effects.enabled && layer.effects.items.iter().any(Effect::enabled)
|
||||
}
|
||||
|
||||
/// Whether an enabled effect is laid out over the layer's frame ([`crate::effects_frame`]), so
|
||||
/// growing or shrinking the layer repaints it everywhere, not only where its pixels changed: a
|
||||
/// Gradient Overlay, a gradient stroke (its frame is the shape's extent), and patterns linked with
|
||||
/// the layer without an effects reference point (overlays, pattern strokes and glows, and bevel
|
||||
/// textures tile from the frame's top-left).
|
||||
pub fn spans_frame(layer: &Layer) -> bool {
|
||||
let unanchored = layer.effects.reference.is_none();
|
||||
let pattern = |p: &FxPaint| unanchored && matches!(p, FxPaint::Pattern { .. });
|
||||
has_effects(layer)
|
||||
&& layer.effects.items.iter().filter(|e| e.enabled()).any(|e| match e {
|
||||
Effect::GradientOverlay { .. } => true,
|
||||
Effect::PatternOverlay { link, .. } => *link && unanchored,
|
||||
Effect::Stroke(s) => matches!(s.paint, FxPaint::Gradient(_)) || pattern(&s.paint),
|
||||
Effect::OuterGlow(g) | Effect::InnerGlow(g) => pattern(&g.paint),
|
||||
Effect::BevelEmboss(b) => unanchored && b.texture.as_ref().is_some_and(|t| t.link),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pixels an effect stack can reach beyond the layer's shape.
|
||||
pub fn margin(layer: &Layer) -> i32 {
|
||||
let mut m = 0.0f32;
|
||||
@@ -1528,16 +1509,13 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern
|
||||
tent(&mut h, g.width);
|
||||
h
|
||||
} else {
|
||||
let [din, dout] = bevel_dists(shape, size);
|
||||
let sharp = sharp_edges(shape);
|
||||
let (din, dout) = (bevel_dist_inside(shape, &sharp, size), bevel_dist_outside(shape, &sharp, size));
|
||||
let mut h = Map::new(shape.w, shape.h, 0.0);
|
||||
let soft = g.chisel_soft > 0.0;
|
||||
let w = shape.w;
|
||||
let v = par_fill(w, shape.h, 0.0, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let i = y * w + x;
|
||||
*o = bevel_chisel_h(g.paint, size, din[i], dout[i], soft);
|
||||
}
|
||||
});
|
||||
let mut h = Map { w, h: shape.h, v };
|
||||
for i in 0..h.v.len() {
|
||||
h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i], soft);
|
||||
}
|
||||
// Inner and outer Chisel Soft are not blurred in Photoshop (their facets equal Chisel
|
||||
// Hard's across a straight edge); emboss styles keep the blur until measured.
|
||||
if soft && g.paint == BevelPaint::Both {
|
||||
@@ -1558,23 +1536,14 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern
|
||||
let place = Placement::anchored(tex.anchor, t.link, t.phase, t.scale, 0.0);
|
||||
let k = t.depth / unit;
|
||||
let (x0, y0) = (tex.rect.x0, tex.rect.y0);
|
||||
let w = h.w;
|
||||
par_units(
|
||||
h.v.len(),
|
||||
&mut h.v,
|
||||
w,
|
||||
&mut [] as &mut [()],
|
||||
0,
|
||||
|| (),
|
||||
|_, y, row, _| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5);
|
||||
let p = tile.sample(u, v);
|
||||
let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3];
|
||||
*o += k * if t.invert { 1.0 - l } else { l };
|
||||
}
|
||||
},
|
||||
);
|
||||
for y in 0..h.h {
|
||||
for x in 0..h.w {
|
||||
let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5);
|
||||
let p = tile.sample(u, v);
|
||||
let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3];
|
||||
h.v[y * h.w + x] += k * if t.invert { 1.0 - l } else { l };
|
||||
}
|
||||
}
|
||||
}
|
||||
if b.soften >= 1.0 {
|
||||
tent(&mut h, b.soften);
|
||||
@@ -1656,33 +1625,23 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
|
||||
};
|
||||
let shade_into = |depth: f32, region_kind: u8| -> (Map, Map) {
|
||||
let (mut hi, mut sh) = (Map::new(shape.w, shape.h, 0.0), Map::new(shape.w, shape.h, 0.0));
|
||||
// Rows on threads: every pixel reads only `hmap` and `shape`.
|
||||
par_units(
|
||||
hi.v.len(),
|
||||
&mut hi.v,
|
||||
shape.w,
|
||||
&mut sh.v,
|
||||
shape.w,
|
||||
|| (),
|
||||
|_, y, hi_row, sh_row| {
|
||||
let y = y as i64;
|
||||
for x in 0..shape.w as i64 {
|
||||
let i = y as usize * shape.w + x as usize;
|
||||
let gx = (hmap.get(x + 1, y) - hmap.get(x - 1, y)) * 0.5 * depth;
|
||||
let gy = (hmap.get(x, y + 1) - hmap.get(x, y - 1)) * 0.5 * depth;
|
||||
let n = [-gx, -gy, 1.0];
|
||||
let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt();
|
||||
let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len;
|
||||
let region = if region_kind == 0 { shape.v[i] } else { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && on_bevel(x, y)) };
|
||||
let k = shade - se;
|
||||
if k > 0.0 {
|
||||
hi_row[x as usize] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region;
|
||||
} else {
|
||||
sh_row[x as usize] = (-k / se.max(1e-3)).clamp(0.0, 1.0) * region;
|
||||
}
|
||||
for y in 0..shape.h as i64 {
|
||||
for x in 0..shape.w as i64 {
|
||||
let i = y as usize * shape.w + x as usize;
|
||||
let gx = (hmap.get(x + 1, y) - hmap.get(x - 1, y)) * 0.5 * depth;
|
||||
let gy = (hmap.get(x, y + 1) - hmap.get(x, y - 1)) * 0.5 * depth;
|
||||
let n = [-gx, -gy, 1.0];
|
||||
let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt();
|
||||
let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len;
|
||||
let region = if region_kind == 0 { shape.v[i] } else { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && on_bevel(x, y)) };
|
||||
let k = shade - se;
|
||||
if k > 0.0 {
|
||||
hi.v[i] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region;
|
||||
} else {
|
||||
sh.v[i] = (-k / se.max(1e-3)).clamp(0.0, 1.0) * region;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
(apply_contour(hi, &b.gloss_contour), apply_contour(sh, &b.gloss_contour))
|
||||
};
|
||||
let maps = match g.paint {
|
||||
@@ -1698,13 +1657,7 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
|
||||
let (hi, sh) = shade_into(g.depth, 0);
|
||||
let (ho, so) = shade_into(if g.pillow { -g.depth } else { g.depth }, 1);
|
||||
let mix = |a: Map, b: Map| -> Map {
|
||||
let v = par_fill(a.w, a.h, 0.0, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let i = y * a.w + x;
|
||||
let (x, y, s) = (a.v[i], b.v[i], shape.v[i]);
|
||||
*o = if s > INSIDE_EPS { x } else { 0.0 } + y * (1.0 - s.min(1.0));
|
||||
}
|
||||
});
|
||||
let v = a.v.iter().zip(&b.v).zip(&shape.v).map(|((x, y), s)| if *s > INSIDE_EPS { *x } else { 0.0 } + y * (1.0 - s.min(1.0))).collect();
|
||||
Map { w: a.w, h: a.h, v }
|
||||
};
|
||||
vec![mix(hi, ho), mix(sh, so)]
|
||||
@@ -2337,18 +2290,6 @@ pub fn distance_field(kind: FieldKind, alpha: Vec<f32>, w: usize, h: usize, reac
|
||||
}
|
||||
}
|
||||
|
||||
/// Both chiselled bevel fields of `alpha`, `[BevelInside, BevelOutside]`: each is its
|
||||
/// [`distance_field`], with the sharp edges they both start from found once.
|
||||
pub fn bevel_distance_fields(alpha: Vec<f32>, w: usize, h: usize, reach: f32) -> [Vec<f32>; 2] {
|
||||
bevel_dists(&Map { w, h, v: alpha }, reach)
|
||||
}
|
||||
|
||||
/// The inside and outside bevel distances of `s`, from one set of sharp edges.
|
||||
fn bevel_dists(s: &Map, reach: f32) -> [Vec<f32>; 2] {
|
||||
let sharp = sharp_edges(s);
|
||||
[bevel_dist_inside(s, &sharp, reach), bevel_dist_outside(s, &sharp, reach)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2991,55 +2932,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bevel_maps_do_not_depend_on_the_threads() {
|
||||
// Every style and technique, softened or not, and a texture; one thread (in order) is the reference.
|
||||
let mut patchy = aa_disc(101, 101, 40.0);
|
||||
for (i, v) in patchy.v.iter_mut().enumerate() {
|
||||
if (i % 101 / 13 + i / 101 / 17) % 5 == 0 {
|
||||
*v *= 0.6;
|
||||
}
|
||||
}
|
||||
let shapes = [aa_disc(97, 83, 30.0), aa_round_rect(131, 77, 9.0, 11.0, 120.0, 66.0, 14.0), aa_bar(90, 130, 60.0, 150.0, 25.0), patchy];
|
||||
let mut sf = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8);
|
||||
sf.fill_rect(Rect::new(0, 0, 4, 2), &[0.0, 0.0, 0.0, 1.0]);
|
||||
sf.fill_rect(Rect::new(0, 2, 4, 4), &[1.0, 1.0, 1.0, 1.0]);
|
||||
let pats = [Pattern::new("stripes", sf, 4, 4)];
|
||||
let light = GlobalLight::default();
|
||||
let styles = [BevelStyle::InnerBevel, BevelStyle::OuterBevel, BevelStyle::Emboss, BevelStyle::PillowEmboss, BevelStyle::StrokeEmboss];
|
||||
for (k, shape) in shapes.iter().enumerate() {
|
||||
let tex = TextureCtx { rect: Rect::new(3, 5, 3 + shape.w as i32, 5 + shape.h as i32), patterns: &pats, anchor: (0.0, 0.0) };
|
||||
for style in styles {
|
||||
for technique in [BevelTechnique::Smooth, BevelTechnique::ChiselHard, BevelTechnique::ChiselSoft] {
|
||||
for (size, soften, textured) in [(5.0, 0.0, false), (12.0, 2.0, false), (8.0, 0.0, true)] {
|
||||
let mut b = bevel_of(style, technique);
|
||||
(b.size, b.soften) = (size, soften);
|
||||
if textured {
|
||||
b.texture = Some(photocraft_doc::BevelTexture {
|
||||
name: "stripes".into(),
|
||||
id: String::new(),
|
||||
scale: 1.0,
|
||||
depth: 1.0,
|
||||
invert: false,
|
||||
link: true,
|
||||
phase: (0.0, 0.0),
|
||||
});
|
||||
}
|
||||
let run = || bevel_maps(shape, &b, &light, &tex, &PreparedPatterns::new(&pats, PREPARED_PATTERN_BYTES)).0;
|
||||
let one = with_threads(1, run);
|
||||
for threads in [2, 5, 16] {
|
||||
let many = with_threads(threads, run);
|
||||
assert_eq!(one.len(), many.len());
|
||||
for (j, (a, m)) in one.iter().zip(&many).enumerate() {
|
||||
assert_eq!(bits(&a.v), bits(&m.v), "shape {k}, {style:?} {technique:?} size {size} map {j} on {threads} threads");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The four fields that use the distance transform, of `m`.
|
||||
fn distance_field_of(m: &Map, reach: f32) -> Vec<Vec<f32>> {
|
||||
[FieldKind::Inside, FieldKind::Outside, FieldKind::BevelInside, FieldKind::BevelOutside]
|
||||
|
||||
+19
-60
@@ -349,31 +349,6 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer {
|
||||
buf
|
||||
}
|
||||
|
||||
/// [`render_layer`] in `doc`'s context: the layer alone over `rect`, with the document's patterns,
|
||||
/// Global Light, colour mode, depth and canvas (fill layers and gradients are laid out on it), as
|
||||
/// the canvas composites it. Bakes (Scripts › Flatten All Layer Effects) render through this so
|
||||
/// the pixels match what was on screen (#5719). Rects past [`MAX_RENDER_PIXELS`] give an empty
|
||||
/// buffer.
|
||||
pub fn render_layer_in(doc: &Document, layer: &Layer, rect: Rect) -> Buffer {
|
||||
if rect.is_empty() {
|
||||
return Buffer::transparent(rect);
|
||||
}
|
||||
if u64::from(rect.width()) * u64::from(rect.height()) > MAX_RENDER_PIXELS {
|
||||
return Buffer::transparent(Rect::EMPTY);
|
||||
}
|
||||
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode);
|
||||
// A lone layer: a deep knockout reveals transparency, not the document's Background.
|
||||
let cx = Ctx { doc: None, ..Ctx::for_doc(doc, &patterns) };
|
||||
let lab = doc.mode == photocraft_color::ColorMode::Lab;
|
||||
let hdr = doc.depth == photocraft_color::SampleType::F32;
|
||||
let cmyk = cmyk_space(doc);
|
||||
photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
|
||||
let mut buf = Buffer::transparent(rect);
|
||||
in_blend_space(lab, hdr, || composite_stack_at(std::slice::from_ref(layer), &mut buf, &cx, advanced::Scope::ROOT));
|
||||
buf
|
||||
})
|
||||
}
|
||||
|
||||
/// Documents above this many pixels get thumbnails from a proxy (see [`proxy`]) when that is
|
||||
/// faithful; smaller ones are reduced from the exact composite.
|
||||
pub const PROXY_THUMBNAIL_PIXELS: u64 = 16 << 20;
|
||||
@@ -890,38 +865,6 @@ pub fn paint_bounds(layer: &Layer) -> Option<Rect> {
|
||||
(!r.is_empty()).then_some(r)
|
||||
}
|
||||
|
||||
/// The frame a layer's effects are laid out in: [`paint_bounds`] for a shape, else the layer's
|
||||
/// bounds ([`layer_bounds`]). A Gradient Overlay spans it, linked patterns tile from its corner.
|
||||
pub fn effects_frame(layer: &Layer, canvas: Rect) -> Rect {
|
||||
paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, canvas))
|
||||
}
|
||||
|
||||
/// The pixels a change from `a` to `b` repaints beyond those it changed: where a layer whose
|
||||
/// effects span its frame ([`effects::spans_frame`]) moved that frame, the layer's whole composite
|
||||
/// before and after. Painting on a layer with a Gradient Overlay lays the one gradient out again
|
||||
/// over the grown layer, as Photoshop does after each stroke; damage that covers only the stroke
|
||||
/// leaves the old gradient around earlier strokes (#4053). Empty when no such frame moved.
|
||||
pub fn relayout_damage(a: &Document, b: &Document) -> Rect {
|
||||
let mut out = Rect::EMPTY;
|
||||
let mut add = |r: Rect| {
|
||||
if !r.is_empty() {
|
||||
out = if out.is_empty() { r } else { out.union(&r) };
|
||||
}
|
||||
};
|
||||
for (_, _, y) in b.walk() {
|
||||
if !y.visible || !effects::spans_frame(y) {
|
||||
continue;
|
||||
}
|
||||
let Some(x) = a.layer(y.id) else { continue };
|
||||
if effects_frame(x, a.bounds()) == effects_frame(y, b.bounds()) {
|
||||
continue;
|
||||
}
|
||||
add(change_bounds(x, a.bounds()).unwrap_or_else(|| a.bounds()));
|
||||
add(change_bounds(y, b.bounds()).unwrap_or_else(|| b.bounds()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The bounding box of a shape layer's vector content: its path control bounds plus stroke width.
|
||||
pub fn shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option<Rect> {
|
||||
if sh.fill.is_none() && sh.stroke.as_ref().is_none_or(|s| s.width <= 0.0 || s.opacity <= 0.0) {
|
||||
@@ -1546,7 +1489,15 @@ fn composite_layer_effects(layer: &Layer, clipped: &[Layer], backdrop: &mut Buff
|
||||
advanced::composite_clipped(clipped, &mut content, cx);
|
||||
let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
|
||||
let maps = effect_maps(layer, rect, cx);
|
||||
effects::composite_with_effects_prepared(layer, &content, backdrop, &maps, effects_frame(layer, cx.canvas), cx.patterns, vstroke);
|
||||
effects::composite_with_effects_prepared(
|
||||
layer,
|
||||
&content,
|
||||
backdrop,
|
||||
&maps,
|
||||
paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)),
|
||||
cx.patterns,
|
||||
vstroke,
|
||||
);
|
||||
}
|
||||
|
||||
/// The coverage-mixing gamma of a layer: the text blending gamma
|
||||
@@ -1804,7 +1755,7 @@ fn cached_effect_maps(layer: &Layer, region: Rect, key: u64, cx: &Ctx) -> std::s
|
||||
}
|
||||
|
||||
fn texture_ctx<'a>(layer: &Layer, region: Rect, cx: &Ctx<'a>) -> effects::TextureCtx<'a> {
|
||||
let sb = effects_frame(layer, cx.canvas);
|
||||
let sb = paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas));
|
||||
effects::TextureCtx { rect: region, patterns: cx.patterns.source(), anchor: layer.effects.reference.unwrap_or((f64::from(sb.x0), f64::from(sb.y0))) }
|
||||
}
|
||||
|
||||
@@ -1859,7 +1810,15 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
|
||||
let mut opaque = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() };
|
||||
let maps = effect_maps(layer, rect, cx);
|
||||
effects::composite_with_effects_prepared(layer, &content, &mut opaque, &maps, effects_frame(layer, cx.canvas), cx.patterns, vstroke);
|
||||
effects::composite_with_effects_prepared(
|
||||
layer,
|
||||
&content,
|
||||
&mut opaque,
|
||||
&maps,
|
||||
paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)),
|
||||
cx.patterns,
|
||||
vstroke,
|
||||
);
|
||||
if let Some(m) = &hide {
|
||||
let plain = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() };
|
||||
advanced::apply_hiding_mask(&mut opaque, &plain, m);
|
||||
|
||||
@@ -1082,34 +1082,6 @@ fn glow_gradient_follows_the_shape() {
|
||||
assert!(edge[0] > 0.8 && edge[2] < 0.2, "{edge:?}");
|
||||
}
|
||||
|
||||
// A Gradient Overlay spans the layer's frame: growing the layer repaints all of it, not just the
|
||||
// new pixels (#4053). Effects that don't depend on the frame repaint only what changed.
|
||||
#[test]
|
||||
fn relayout_damage_covers_a_layer_whose_gradient_frame_moved() {
|
||||
let overlay = Effect::GradientOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false };
|
||||
let grown = |a: &Document, rect: Rect, rgba: [f32; 4]| {
|
||||
let mut b = a.clone();
|
||||
let old = b.layers.last().unwrap().clone();
|
||||
let mut l = solid_layer("sq", rect, rgba);
|
||||
(l.id, l.effects) = (old.id, old.effects);
|
||||
*b.layers.last_mut().unwrap() = l;
|
||||
b
|
||||
};
|
||||
let a = fx_doc(vec![overlay.clone()]);
|
||||
let b = grown(&a, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0]);
|
||||
let d = crate::relayout_damage(&a, &b);
|
||||
assert!(d.contains_rect(&Rect::new(10, 10, 36, 34)), "{d:?}");
|
||||
// Same bounds, other pixels: the frame stays, the pixels' own damage is enough.
|
||||
assert_eq!(crate::relayout_damage(&a, &grown(&a, Rect::new(10, 10, 30, 30), [0.0, 1.0, 0.0, 1.0])), Rect::EMPTY);
|
||||
// A Color Overlay doesn't depend on the frame.
|
||||
let c = fx_doc(vec![Effect::ColorOverlay { common: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), color: Color::BLACK }]);
|
||||
assert_eq!(crate::relayout_damage(&c, &grown(&c, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0])), Rect::EMPTY);
|
||||
// Nor does a disabled overlay.
|
||||
let mut off = a.clone();
|
||||
off.layers.last_mut().unwrap().effects.enabled = false;
|
||||
assert_eq!(crate::relayout_damage(&off, &grown(&off, Rect::new(10, 10, 36, 34), [1.0, 0.0, 0.0, 1.0])), Rect::EMPTY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_overlay_follows_angle_and_reverse() {
|
||||
let g = Gradient { stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], angle: 0.0, ..Gradient::default() };
|
||||
|
||||
@@ -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();
|
||||
|
||||
Loaded 100 of 474 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user