mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-21 06:34:12 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16dd4b19ad |
No files matched your search
@@ -1,6 +0,0 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
+2
-11
@@ -1,6 +1,5 @@
|
||||
# cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test
|
||||
# binaries. CI runs it with the `ci` profile below (#6117); locally it is optional and `cargo test`
|
||||
# keeps working (#2204).
|
||||
# binaries. Optional for local runs; `cargo test` keeps working and stays what CI runs (#2204).
|
||||
#
|
||||
# cargo nextest run --workspace # all tests except doctests
|
||||
# cargo test --doc --workspace # nextest does not run doctests
|
||||
@@ -12,13 +11,5 @@
|
||||
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
|
||||
# runner, fails a test that hangs instead of letting it hold the runner, and repeats every failure
|
||||
# at the end of the log. The default profile's GPU override still applies.
|
||||
[profile.ci]
|
||||
fail-fast = true
|
||||
slow-timeout = { period = "60s", terminate-after = 10 }
|
||||
failure-output = "immediate-final"
|
||||
@@ -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)"
|
||||
+46
-189
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
fuzz:
|
||||
@@ -18,13 +17,9 @@ on:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
# No test or fuzz run may print a page: the engine refuses to spool, and if something ever
|
||||
# bypasses that, lp fails loudly on a printer that does not exist.
|
||||
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
|
||||
@@ -40,11 +35,10 @@ jobs:
|
||||
# platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
|
||||
# macOS is tested only by release.yml.
|
||||
platforms:
|
||||
timeout-minutes: 10
|
||||
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,46 +52,34 @@ 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
|
||||
# The browser build (a fat-LTO wasm build) runs only when something it compiles or
|
||||
# packages changed: the web app and its packaging, crate sources and manifests (the web
|
||||
# build compiles nearly every crate), the workspace manifests and this workflow.
|
||||
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web(-boot)?/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then
|
||||
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then
|
||||
echo "web=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
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 +102,49 @@ 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
|
||||
|
||||
- name: Format
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
# Keep formatting, strict Clippy, layering and scorecard checks ahead of tests.
|
||||
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
|
||||
- name: Test
|
||||
run: cargo test --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: runner.os == 'Linux'
|
||||
run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked
|
||||
|
||||
# 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).
|
||||
- name: Check out craft-fonts
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
|
||||
- name: Test with craft-fonts
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: cargo test --workspace --locked --features heif
|
||||
|
||||
- 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,131 +153,7 @@ 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).
|
||||
- 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.
|
||||
- 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)'
|
||||
|
||||
- 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]
|
||||
|
||||
# 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'
|
||||
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).
|
||||
- name: Check out craft-fonts
|
||||
if: matrix.variant == 'with craft-fonts'
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
|
||||
- name: Test with craft-fonts
|
||||
if: matrix.variant == 'with craft-fonts'
|
||||
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
|
||||
-E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)'
|
||||
|
||||
corpus:
|
||||
timeout-minutes: 30
|
||||
# Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are
|
||||
# pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache
|
||||
# restore and a download; a missing corpus fails the tests. The cache key changes only when a
|
||||
@@ -323,7 +186,6 @@ jobs:
|
||||
run: cargo xtask test-corpus
|
||||
|
||||
wasm:
|
||||
timeout-minutes: 30
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -340,7 +202,6 @@ jobs:
|
||||
run: cargo xtask wasm
|
||||
|
||||
web:
|
||||
timeout-minutes: 60
|
||||
name: web build and wasm size gate
|
||||
# PRs run it only when web-relevant files change (see `platforms`); main always does.
|
||||
needs: platforms
|
||||
@@ -364,8 +225,8 @@ jobs:
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
trunk --version
|
||||
# package.sh runs Trunk's release builds, validates relative URLs, cuts the wasm into
|
||||
# parts, checks the 24 MiB per-file limit and exercises the packaging step release.yml uses.
|
||||
# package.sh runs Trunk's release build, validates relative URLs, checks the 24 MiB
|
||||
# wasm limit and exercises the same packaging step used by release.yml.
|
||||
- name: Build and verify browser release
|
||||
run: packaging/web/package.sh
|
||||
- name: Report wasm size
|
||||
@@ -376,19 +237,15 @@ jobs:
|
||||
echo
|
||||
echo "| File | Bytes | MiB | Limit |"
|
||||
echo "| --- | ---: | ---: | ---: |"
|
||||
row() { echo "| $1 | $2 | $(awk -v n="$2" 'BEGIN { printf "%.2f", n / 1048576 }') | $3 |"; }
|
||||
# The app's wasm as built; it ships gzipped in parts, so only the download grows with it.
|
||||
for wasm in dist/web/*.wasm; do
|
||||
[ -f "$wasm" ] || continue
|
||||
row "$(basename "$wasm")" "$(wc -c < "$wasm" | tr -d ' ')" "none"
|
||||
bytes=$(wc -c < "$wasm" | tr -d ' ')
|
||||
mib=$(awk -v n="$bytes" 'BEGIN { printf "%.2f", n / 1048576 }')
|
||||
echo "| $(basename "$wasm") | $bytes | $mib | 24 MiB |"
|
||||
done
|
||||
# What a host sees: the largest file of the packaged site.
|
||||
largest=$(find target/web-package -type f -printf '%s %f\n' | sort -n | tail -1)
|
||||
row "largest file in the site: ${largest#* }" "${largest%% *}" "24 MiB"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
fuzz:
|
||||
timeout-minutes: 60
|
||||
name: fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }})
|
||||
# Placeholder: manual trigger only until a nightly schedule is enabled.
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.fuzz
|
||||
|
||||
@@ -27,7 +27,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
timeout-minutes: 15
|
||||
name: mdBook build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -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,23 +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:
|
||||
nix:
|
||||
# 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/nix.yml@main
|
||||
@@ -23,7 +23,6 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
|
||||
verified-appimage-inputs:
|
||||
timeout-minutes: 10
|
||||
name: Verified AppImage inputs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -35,9 +35,3 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
@@ -27,7 +27,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
|
||||
crates/
|
||||
geom cms color raster L0 foundation (geometry, ICC colour management, pixel formats + blend math, COW tiles)
|
||||
psd codecs L0 standalone format crates (no workspace deps; publishable)
|
||||
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2, Windows barrel button); the one isolated unsafe crate
|
||||
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2); the one isolated unsafe crate
|
||||
doc L1 document model (layers, masks, adjustments, effects, smart objects: pure data)
|
||||
ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes
|
||||
compose gpu format L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format
|
||||
@@ -39,7 +39,6 @@ apps/
|
||||
photocraft desktop app (eframe/wgpu), TCP control server
|
||||
photocraft-cli headless CLI (convert/info/run/batch/commands/mcp)
|
||||
photocraft-web the same app in the browser (trunk + wasm-bindgen)
|
||||
photocraft-web-boot starts the packaged browser app from compressed parts of its wasm (no file over a host's size cap)
|
||||
xtask/ cargo xtask layers | wasm | ci | stats | corpus | test-corpus | parity | perf | scorecard
|
||||
```
|
||||
|
||||
@@ -52,7 +51,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | t
|
||||
People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`.
|
||||
|
||||
- **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`.
|
||||
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop) and on Windows a pointer message hook (`GetPointerPenInfo`). Only its `src/macos.rs` and `src/windows.rs` allow `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s and pointer messages); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
|
||||
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop). Only its `src/macos.rs` allows `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
|
||||
- **Input-derived numbers are hostile.** Use `get()` rather than `[i]`/`[a..b]` for indices from files, params, selections or arithmetic on them; slice strings only at char boundaries; use `checked_*`/`saturating_*` for lengths, offsets and counts; guard division by zero and NaN/inf casts; cap allocations sized by input.
|
||||
- **Bound recursion** with depth limits or seen-sets (documents can be deep or cyclic).
|
||||
- **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s.
|
||||
@@ -132,6 +131,7 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
|
||||
- `../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics.
|
||||
- `plan/` (local, gitignored): research, parity plan, execution plan, estimates.
|
||||
- `log/` (local, gitignored): the dev log.
|
||||
-
|
||||
|
||||
## 8. Keeping the native format complete
|
||||
|
||||
|
||||
+2
-12
@@ -10,10 +10,8 @@ next to them.
|
||||
|
||||
| Path | Title | Author | Source | License |
|
||||
|---|---|---|---|---|
|
||||
| `crates/codecs/src/codecs/xpm/x11_colors.rs` | X11 `rgb.txt` color-name lookup table (normalized X.Org RGB values) | X.Org Foundation and X11 contributors | <https://cgit.freedesktop.org/xorg/app/rgb/tree/rgb.txt> | MIT (X.Org app/rgb) |
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | Greek UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | German UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | Spanish UI translations | @gorevyoneticisi; AI contributor: OpenAI Codex | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Italian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | Korean UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/nl.tsv` | Dutch 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) |
|
||||
@@ -21,13 +19,10 @@ next to them.
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `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`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.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 +33,9 @@ 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/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
@@ -57,8 +49,6 @@ next to them.
|
||||
| `packaging/macos/dmg/` (all files) | PhotoCraft DMG window background and layout | @XusBadia, from the PhotoCraft app icon | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md) | MIT OR Apache-2.0, like the code |
|
||||
| `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)
|
||||
|
||||
@@ -116,6 +106,6 @@ next to the files:
|
||||
| `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause |
|
||||
| `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
|
||||
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
|
||||
| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
|
||||
| `corpus/pixls/` (7 files, 226 MB) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
|
||||
|
||||
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
|
||||
Generated
+42
-1335
File diff suppressed because it is too large.
Load diff
+19
-65
@@ -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.8.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -36,11 +36,8 @@ 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"
|
||||
@@ -53,9 +50,7 @@ zune-core = "0.5"
|
||||
zune-jpeg = "0.5"
|
||||
proptest = "1"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
# Without `default_fonts`, which embeds egui's fonts uncompressed: ui-egui ships them deflated
|
||||
# (egui_fonts.rs; the web build's size gate).
|
||||
egui = { version = "0.36", default-features = false }
|
||||
egui = "0.36"
|
||||
egui_kittest = "0.36"
|
||||
rfd = "0.17"
|
||||
log = "0.4"
|
||||
@@ -77,32 +72,22 @@ 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
|
||||
|
||||
# The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the
|
||||
# size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every
|
||||
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for PhotoCraft's own crates,
|
||||
# "s" for dependencies and for the crates that decode files, and 3 for pixel processing (filters
|
||||
# and compositing). Dependencies stay at "s": at "z" the whole wasm grows (by 0.4 MB, measured).
|
||||
# panic stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"`
|
||||
# is for the native app shell). packaging/web/package.sh fails the build above 24 MiB.
|
||||
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for the UI, command, and I/O
|
||||
# shells, and "s" for other crates except pixel processing, which keeps opt-level 3. panic
|
||||
# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for
|
||||
# the native app shell). packaging/web/package.sh fails the build above 24 MiB.
|
||||
[profile.wasm-release]
|
||||
inherits = "release"
|
||||
opt-level = "z"
|
||||
opt-level = "s"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
strip = "debuginfo"
|
||||
@@ -110,60 +95,29 @@ strip = "debuginfo"
|
||||
[profile.wasm-release.package."*"]
|
||||
opt-level = "s"
|
||||
|
||||
# Decoding and rasterizing keep their speed.
|
||||
[profile.wasm-release.package.photocraft-codecs]
|
||||
opt-level = "s"
|
||||
[profile.wasm-release.package.photocraft-raw]
|
||||
opt-level = "s"
|
||||
[profile.wasm-release.package.photocraft-heif]
|
||||
opt-level = "s"
|
||||
[profile.wasm-release.package.photocraft-vector]
|
||||
opt-level = "s"
|
||||
|
||||
# Large crates off the per-frame and per-pixel paths: the document model, file parsers run once
|
||||
# per open/save, JSON (settings, presets, control channel), SVG parsing, and shader translation
|
||||
# (runs when a pipeline is created). "z" keeps the wasm well under the size gate.
|
||||
[profile.wasm-release.package.photocraft-doc]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-psd]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-format]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-affinity]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.serde_json]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.usvg]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.naga]
|
||||
# Keep the browser shell compact as shared panel controls grow. Pixel-processing crates below
|
||||
# retain their speed-oriented optimization level.
|
||||
[profile.wasm-release.package.photocraft-ui-egui]
|
||||
opt-level = "z"
|
||||
|
||||
# The browser UI toolkit favors size to stay within the WebAssembly upload budget.
|
||||
[profile.wasm-release.package.egui]
|
||||
# Keep browser command dispatch, import/export glue, and the entry point compact. Native
|
||||
# builds and the speed-oriented pixel-processing overrides below retain their settings.
|
||||
[profile.wasm-release.package.photocraft-engine]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.epaint]
|
||||
[profile.wasm-release.package.photocraft-io]
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.eframe]
|
||||
[profile.wasm-release.package.photocraft-web]
|
||||
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.
|
||||
[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
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
<img alt="macOS · Windows · Linux · FreeBSD · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-native-2f7bf5?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
|
||||
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_photocraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/photocraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -190,7 +187,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🗃️ Layers, done properly</h4>
|
||||
Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 Photoshop blend modes plus six Paint.NET variants, opacity and fill, locks, colour labels, a layer kind filter, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into.
|
||||
Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 Photoshop blend modes plus six Paint.NET variants, opacity and fill, locks, colour labels, layer filters, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -202,7 +199,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🗂️ Formats</h4>
|
||||
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
|
||||
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🪄 The everyday essentials</h4>
|
||||
@@ -278,11 +275,9 @@ 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).
|
||||
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.
|
||||
|
||||
On macOS, the command-line tool comes as `photocraft-cli-<version>-macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself:
|
||||
|
||||
@@ -305,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/).
|
||||
@@ -349,6 +331,8 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi
|
||||
| arm64 (Snapdragon and other ARM PCs) | `photocraft-<ver>-windows-arm64.msi` | `photocraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `photocraft-<ver>-windows-x86.msi` | `photocraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Windows 10 or later is required. On Windows 7 and 8 the app can't start (the installer says so).
|
||||
|
||||
Installers and executables are code-signed.
|
||||
|
||||
### macOS
|
||||
|
||||
+3
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from
|
||||
the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md)
|
||||
@@ -40,7 +40,7 @@ the one above. About 80% parallelizes (independent crates and command modules);
|
||||
architecture and the PSD writer are the serial parts.
|
||||
|
||||
**Why alpha.** Core workflows (open, layer, select, paint, adjust, filter, type, save) work end to
|
||||
end, and breadth is high, but 953 issues are open (2026-10-13), 22 of 25 performance budgets
|
||||
end, and breadth is high, but users of 0.5/0.6 have 579 open issues, 22 of 25 performance budgets
|
||||
are missed, and PSDs we write are not yet reliably accepted by Photoshop (#1281, #2469). Beta
|
||||
needs ~75% ready and no blocking PSD gap. The [alpha gate](docs/roadmap.md#alpha-gate) passes: all six core workflows
|
||||
(retouch, composite, cut-out, design, paint, PSD exchange) complete end to end and save and reopen;
|
||||
@@ -110,7 +110,7 @@ Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dut
|
||||
|
||||
| Rank | Milestone | Estimate |
|
||||
|---|---|---:|
|
||||
| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 145 → 200+ of 258 | 150–250 h |
|
||||
| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 133 → 200+ of 256 | 150–250 h |
|
||||
| 2 | Performance budgets: the 22 over-budget scenarios and the 150-layer GPU crash | 200–350 h |
|
||||
| 3 | User bug backlog to under 100 open | 250–400 h |
|
||||
| 4 | Brush and transform feel (brush-parity, ui-parity rows) | 160–290 h |
|
||||
@@ -135,7 +135,6 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
|
||||
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
|
||||
| 2026-10-10 | minor | Readiness by audience with hours per tier |
|
||||
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,14 +12,6 @@ fn main() -> std::io::Result<()> {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return Ok(());
|
||||
}
|
||||
// Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which
|
||||
// stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe
|
||||
// from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use;
|
||||
// the print command checks for it first and reports an error instead.
|
||||
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
|
||||
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll");
|
||||
println!("cargo:rustc-link-arg-bins=delayimp.lib");
|
||||
}
|
||||
let mut res = winresource::WindowsResource::new();
|
||||
// FileVersion / ProductVersion strings and the numeric FILEVERSION / PRODUCTVERSION are left
|
||||
// to `WindowsResource::new`, which reads the Cargo package version. A pre-release such as
|
||||
|
||||
@@ -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,26 +12,22 @@ 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>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
|
||||
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
|
||||
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
|
||||
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
|
||||
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
|
||||
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
|
||||
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
|
||||
--dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16.
|
||||
--dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1
|
||||
(DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain.
|
||||
photocraft-cli info <file> [--compact]
|
||||
Print the document as JSON (size, mode, depth, layer tree).
|
||||
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
|
||||
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
|
||||
Open a file, run engine commands in order, save the result. Each --params
|
||||
applies to the preceding --cmd. Prints each command's JSON result.
|
||||
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
|
||||
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
|
||||
set: its action --action names plays, as below. JSON steps are [id, params] pairs,
|
||||
photocraft-cli batch --actions <actions.json> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>]
|
||||
Apply an action list to every image in a directory. Steps are [id, params] pairs,
|
||||
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
|
||||
(a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An Actions set (the Actions
|
||||
panel's actions.json, {\"actions\": [{\"name\", \"steps\"}…]}) plays the action --action names (needed
|
||||
@@ -71,23 +67,18 @@ struct Subcommand {
|
||||
}
|
||||
|
||||
const SUBCOMMANDS: &[Subcommand] = &[
|
||||
Subcommand {
|
||||
name: "convert",
|
||||
values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
|
||||
bare: &["--tiff-layers", "--dds-mipmaps"],
|
||||
run: convert,
|
||||
},
|
||||
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits"], bare: &["--tiff-layers"], run: convert },
|
||||
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
|
||||
Subcommand {
|
||||
name: "run",
|
||||
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
|
||||
bare: &["--tiff-layers", "--dds-mipmaps"],
|
||||
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits"],
|
||||
bare: &["--tiff-layers"],
|
||||
run: run_cmds,
|
||||
},
|
||||
Subcommand {
|
||||
name: "batch",
|
||||
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
|
||||
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
|
||||
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits"],
|
||||
bare: &["--in-place", "--tiff-layers"],
|
||||
run: batch,
|
||||
},
|
||||
Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet },
|
||||
@@ -222,15 +213,6 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
|
||||
if let Some(b) = a.get("--tga-bits") {
|
||||
o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?);
|
||||
}
|
||||
if let Some(b) = a.get("--dpx-bits") {
|
||||
o.encode.dpx_bits =
|
||||
Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?);
|
||||
}
|
||||
if let Some(c) = a.get("--dds-compression") {
|
||||
o.encode.dds.compression =
|
||||
photocraft_io::DdsCompression::from_name(c).ok_or_else(|| format!("bad --dds-compression `{c}`: expected none, bc1, bc2 or bc3"))?;
|
||||
}
|
||||
o.encode.dds.mipmaps = a.has("--dds-mipmaps");
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
@@ -258,7 +240,7 @@ fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let opts = export_opts(a)?;
|
||||
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
|
||||
warn_all(err, &o.warnings);
|
||||
warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document));
|
||||
warn_all(err, &missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&o.document)));
|
||||
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
|
||||
warn_all(err, &ws);
|
||||
Ok(())
|
||||
@@ -310,16 +292,11 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
|
||||
fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let opts = export_opts(a)?;
|
||||
let mut h = Headless::trusted_local();
|
||||
// Opening, running commands and exporting can all notice the same missing font:
|
||||
// one `run` invocation reports each warning line at most once.
|
||||
let mut reported_fonts = std::collections::HashSet::<String>::new();
|
||||
match (a.positional.as_slice(), a.get("--new")) {
|
||||
([file], None) => {
|
||||
let o = h.open(Path::new(file)).map_err(|e| e.to_string())?;
|
||||
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
|
||||
reported_fonts.extend(ws.iter().cloned());
|
||||
warn_all(err, &ws);
|
||||
reported_fonts.extend(ws);
|
||||
}
|
||||
([], Some(new)) => {
|
||||
let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?;
|
||||
@@ -331,6 +308,7 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
if cmds.is_empty() && a.get("--out").is_none() {
|
||||
return Err("run needs at least one --cmd (or --out)".into());
|
||||
}
|
||||
let mut reported_fonts = std::collections::HashSet::<String>::new();
|
||||
for (id, params) in cmds {
|
||||
let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?;
|
||||
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
|
||||
@@ -364,7 +342,6 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
if let Some(o) = a.get("--out") {
|
||||
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?;
|
||||
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
|
||||
let ws: Vec<String> = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
|
||||
warn_all(err, &ws);
|
||||
}
|
||||
Ok(())
|
||||
@@ -410,38 +387,6 @@ pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec<Action
|
||||
Ok((play, set))
|
||||
}
|
||||
|
||||
/// [`load_actions`] for a file on disk: a Photoshop Actions file (`.atn`, by extension or by
|
||||
/// its version word) is converted first; the played action's steps with no PhotoCraft
|
||||
/// equivalent come back as warnings (they fail at playback, as in Photoshop when a step can't run).
|
||||
pub fn load_actions_file(path: &str, name: Option<&str>) -> Result<(Steps, Vec<Action>, Vec<String>), String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let is_atn = Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]);
|
||||
if !is_atn {
|
||||
let text = String::from_utf8(bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
let (steps, set) = load_actions(&text, name)?;
|
||||
return Ok((steps, set, Vec::new()));
|
||||
}
|
||||
let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
let names = || imp.actions.iter().map(|a| format!("`{}`", a.name)).collect::<Vec<_>>().join(", ");
|
||||
let action = match (name, imp.actions.as_slice()) {
|
||||
(Some(n), _) => {
|
||||
imp.actions.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set `{}` (it holds {})", imp.set, names()))?
|
||||
}
|
||||
(None, [only]) => only,
|
||||
(None, []) => return Err(format!("{path}: the set holds no actions")),
|
||||
(None, _) => return Err(format!("the set `{}` holds {} actions: pick one with --action <name> ({})", imp.set, imp.actions.len(), names())),
|
||||
};
|
||||
let warnings = imp
|
||||
.unsupported
|
||||
.iter()
|
||||
.filter(|u| u.action == action.name)
|
||||
.map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event))
|
||||
.chain(imp.warnings.iter().cloned())
|
||||
.collect();
|
||||
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
|
||||
Ok((play, imp.actions, warnings))
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
|
||||
/// trailing separator, through a symbolic link).
|
||||
fn same_dir(a: &Path, b: &Path) -> bool {
|
||||
@@ -450,7 +395,7 @@ fn same_dir(a: &Path, b: &Path) -> bool {
|
||||
|
||||
fn is_input(p: &Path) -> bool {
|
||||
let known = |e: &str| {
|
||||
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "af" | "afdesign" | "afphoto" | "afpub")
|
||||
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "af" | "afdesign" | "afphoto" | "afpub")
|
||||
|| photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read)
|
||||
};
|
||||
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
|
||||
@@ -460,10 +405,8 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let actions_path = a.get("--actions").ok_or("batch needs --actions <file>")?;
|
||||
let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in <dir>")?);
|
||||
let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out <dir>")?);
|
||||
let (actions, set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
|
||||
for w in &warnings {
|
||||
let _ = writeln!(err, "warning: {w}");
|
||||
}
|
||||
let text = std::fs::read_to_string(actions_path).map_err(|e| format!("{actions_path}: {e}"))?;
|
||||
let (actions, set) = load_actions(&text, a.get("--action"))?;
|
||||
let opts = export_opts(a)?;
|
||||
// Results are saved as `<out>/<stem>.<ext>`, so an `--out` that is the `--in` folder would
|
||||
// replace the originals (#492).
|
||||
@@ -553,7 +496,9 @@ fn commands(a: &Args, out: &mut dyn Write) -> R {
|
||||
.map(|v| v.iter().collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n)))
|
||||
.filter(|c| {
|
||||
needle.as_ref().is_none_or(|n| format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")).to_lowercase().contains(n))
|
||||
})
|
||||
.collect();
|
||||
if a.has("--json") {
|
||||
return print_json(out, &Value::Array(items.into_iter().cloned().collect()), false);
|
||||
@@ -684,11 +629,6 @@ mod missing_font_warning_tests {
|
||||
let (code, _, stderr) = invoke(&["convert", &ora, &png]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
assert!(is_input(std::path::Path::new(&ora)));
|
||||
// Krita documents are batch inputs too (import-only).
|
||||
for name in ["sketch.kra", "old.KRZ"] {
|
||||
std::fs::write(folder.join(name), b"").unwrap();
|
||||
assert!(is_input(&folder.join(name)), "{name}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
|
||||
|
||||
@@ -90,11 +90,6 @@ fn unknown_flags_are_usage_errors() {
|
||||
// Every flag still works in both forms.
|
||||
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
|
||||
assert!(out.contains("filter.blur.gaussianBlur"), "{out}");
|
||||
// The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060).
|
||||
for needle in ["ellipse", "marquee"] {
|
||||
let (out, _) = ok(bin().args(["commands", "--filter", needle]));
|
||||
assert!(out.contains("select.rect"), "{needle}: {out}");
|
||||
}
|
||||
ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([
|
||||
"--format=jpg",
|
||||
"--quality",
|
||||
@@ -163,29 +158,19 @@ fn info_and_convert_report_decode_warnings() {
|
||||
gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
|
||||
}
|
||||
gif.push(0x3B);
|
||||
// A GIF opens in Indexed Color (#7886), so `convert` to PNG also notes the palette PNG it
|
||||
// writes, after the decode warning.
|
||||
let cases = [
|
||||
("whole.jpg", &jpeg[..], None, None),
|
||||
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing"), None),
|
||||
("anim.gif", &gif[..], Some("only the first of 3 frames was imported"), Some("written as an 8-bit palette PNG (2 colours)")),
|
||||
("whole.jpg", &jpeg[..], None),
|
||||
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing")),
|
||||
("anim.gif", &gif[..], Some("only the first of 3 frames was imported")),
|
||||
];
|
||||
for (name, bytes, warning, export) in cases {
|
||||
for (name, bytes, warning) in cases {
|
||||
let file = d.join(name);
|
||||
std::fs::write(&file, bytes).unwrap();
|
||||
let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact"));
|
||||
let v: Value = serde_json::from_str(&out).unwrap();
|
||||
assert_eq!(v["warnings"], json!(warning.into_iter().collect::<Vec<_>>()), "{name}");
|
||||
let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png"))));
|
||||
let expected: Vec<String> = warning.into_iter().chain(export).map(|w| format!("warning: {w}")).collect();
|
||||
assert_eq!(
|
||||
err.trim(),
|
||||
expected.join(
|
||||
"
|
||||
"
|
||||
),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(err.trim(), warning.map(|w| format!("warning: {w}")).unwrap_or_default(), "{name}");
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
@@ -470,73 +455,6 @@ fn quality_outside_1_to_100_is_refused() {
|
||||
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
|
||||
}
|
||||
|
||||
/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the
|
||||
/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049).
|
||||
#[test]
|
||||
fn dpx_converts_both_ways_with_the_bits_asked_for() {
|
||||
let d = tmp("dpx");
|
||||
let a = d.join("a.png");
|
||||
write_png(&a, 8, 4, 3);
|
||||
for (bits, want) in [(None, 8), (Some("10"), 10), (Some("12"), 12), (Some("16"), 16)] {
|
||||
let out = d.join("a.dpx");
|
||||
let mut convert = bin();
|
||||
convert.arg("convert").arg(&a).arg(&out);
|
||||
if let Some(b) = bits {
|
||||
convert.args(["--dpx-bits", b]);
|
||||
}
|
||||
ok(&mut convert);
|
||||
let bytes = std::fs::read(&out).unwrap();
|
||||
assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}");
|
||||
}
|
||||
let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact"));
|
||||
let v: Value = serde_json::from_str(&info).unwrap();
|
||||
assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}");
|
||||
assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}");
|
||||
let back = d.join("back.png");
|
||||
ok(bin().arg("convert").arg(d.join("a.dpx")).arg(&back));
|
||||
assert!(back.exists());
|
||||
for bad in ["24", "0", "abc"] {
|
||||
let out = d.join("bad.dpx");
|
||||
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}");
|
||||
assert!(!out.exists(), "--dpx-bits {bad} wrote a file");
|
||||
}
|
||||
}
|
||||
|
||||
/// DDS converts both ways; `--dds-compression` picks the FourCC (bytes 84–87; none writes
|
||||
/// uncompressed pixels) and `--dds-mipmaps` the mip count (bytes 28–31). A compression that
|
||||
/// isn't none, bc1, bc2 or bc3 is refused and nothing is written (#1327).
|
||||
#[test]
|
||||
fn dds_converts_both_ways_with_the_compression_asked_for() {
|
||||
let d = tmp("dds");
|
||||
let a = d.join("a.png");
|
||||
write_png(&a, 8, 4, 3);
|
||||
for (args, fourcc, mips) in [
|
||||
(&[][..], [0; 4], 1),
|
||||
(&["--dds-compression", "bc1"][..], *b"DXT1", 1),
|
||||
(&["--dds-compression", "DXT3", "--dds-mipmaps"][..], *b"DXT3", 4),
|
||||
(&["--dds-compression", "bc3"][..], *b"DXT5", 1),
|
||||
(&["--dds-mipmaps"][..], [0; 4], 4),
|
||||
] {
|
||||
let out = d.join("a.dds");
|
||||
ok(bin().arg("convert").arg(&a).arg(&out).args(args));
|
||||
let bytes = std::fs::read(&out).unwrap();
|
||||
let mip_count = u32::from_le_bytes(bytes[28..32].try_into().unwrap());
|
||||
assert_eq!((&bytes[..4], &bytes[84..88], mip_count), (&b"DDS "[..], &fourcc[..], mips), "{args:?}");
|
||||
let back = d.join("back.png");
|
||||
ok(bin().arg("convert").arg(&out).arg(&back));
|
||||
assert!(back.exists(), "{args:?}");
|
||||
}
|
||||
for bad in ["bc7", "dxt2", ""] {
|
||||
let out = d.join("bad.dds");
|
||||
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dds-compression", bad]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1), "--dds-compression {bad}");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dds-compression `{bad}`")), "--dds-compression {bad}");
|
||||
assert!(!out.exists(), "--dds-compression {bad} wrote a file");
|
||||
}
|
||||
}
|
||||
|
||||
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
|
||||
/// refused and nothing is written.
|
||||
#[test]
|
||||
@@ -766,33 +684,3 @@ fn a_non_unicode_argument_is_a_usage_error_not_a_panic() {
|
||||
assert!(!err.contains("panicked"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `batch --actions set.atn`: a Photoshop Actions file plays like an Actions set, and its steps
|
||||
/// PhotoCraft can't play are warned about up front.
|
||||
#[test]
|
||||
fn batch_plays_a_photoshop_actions_file() {
|
||||
use photocraft_engine::actions_cmds::Action;
|
||||
let d = tmp("atn");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
let invert = Action::new("Negative", vec![("image.adjustments.invert".into(), json!({})), ("select.all".into(), json!({}))]);
|
||||
let odd = Action::new("Odd", vec![(photocraft_engine::atn_cmds::PHOTOSHOP_STEP.into(), json!({"event": "syntheticFill", "name": "Generative Fill"}))]);
|
||||
let (bytes, skipped) = photocraft_engine::atn_cmds::export("Set", &[&invert, &odd], &[], false).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
let atn = d.join("Set.atn");
|
||||
std::fs::write(&atn, bytes).unwrap();
|
||||
let out_dir = d.join("out");
|
||||
// Two actions: the set needs --action.
|
||||
let o = bin().args(["batch", "--actions"]).arg(&atn).arg("--in").arg(&input).arg("--out").arg(&out_dir).output().unwrap();
|
||||
assert!(!o.status.success());
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("--action"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let (_, err) = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir));
|
||||
assert!(out_dir.join("a.png").exists());
|
||||
assert!(!err.contains("warning"), "{err}");
|
||||
// The unsupported step is warned about, and fails the file as Photoshop would stop on it.
|
||||
let o = bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Odd", "--in"]).arg(&input).arg("--out").arg(d.join("out2")).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(err.contains("Generative Fill"), "{err}");
|
||||
assert!(!o.status.success());
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
[package]
|
||||
name = "photocraft-web-boot"
|
||||
description = "Starts Photocraft in the browser from compressed parts of its WebAssembly"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "photocraft-web-boot"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# Inflates the parts in browsers without `DecompressionStream`.
|
||||
flate2 = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "ReadableStreamDefaultReader", "WritableStream", "WritableStreamDefaultWriter", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
|
||||
@@ -1,6 +0,0 @@
|
||||
# Built by packaging/web/package.sh: trunk build --release (from apps/photocraft-web-boot)
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "../../dist/web-boot"
|
||||
public_url = "./"
|
||||
filehash = true
|
||||
@@ -1,30 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>PhotoCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="photocraft-web-boot" data-cargo-profile="wasm-release" data-wasm-opt="z" data-wasm-opt-params="--converge" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#photocraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
padding: 0 16px; text-align: center; color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
#photocraft_loading p { margin: 0 0 8px; max-width: 420px; line-height: 1.5; }
|
||||
#photocraft_loading a { color: #e0e0e0; pointer-events: auto; }
|
||||
/* The app removes this element once it runs. If the WebAssembly never downloads or doesn't match
|
||||
this page, no Rust runs to say so, so the page itself shows what to do after a while. */
|
||||
#photocraft_stalled { color: #8c8c8c; visibility: hidden; animation: photocraft-stalled 0s 20s forwards; }
|
||||
@keyframes photocraft-stalled { to { visibility: visible; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="photocraft_canvas"></canvas>
|
||||
<div id="photocraft_loading">
|
||||
<p>Loading PhotoCraft…</p>
|
||||
<p id="photocraft_stalled">This is taking longer than usual. If PhotoCraft doesn't start, its files may have failed to
|
||||
download or come from a different version of this page. <a href="">Reload</a> to try again; the browser
|
||||
console lists what failed.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,467 +0,0 @@
|
||||
//! Starts Photocraft in the browser from compressed parts of its WebAssembly.
|
||||
//!
|
||||
//! Static hosts cap the size of one file (Cloudflare Pages: 25 MiB), and the app's wasm is at
|
||||
//! that size. `packaging/web/package.sh` gzips it and cuts it into parts listed in
|
||||
//! `app/parts.txt`. This small module is what the packaged page loads: it fetches the parts in
|
||||
//! order, joins and inflates them as they arrive, and hands the stream to the app's own generated
|
||||
//! loader, so the browser still compiles while downloading. No file on the host is larger than
|
||||
//! one part, however large the app grows.
|
||||
//!
|
||||
//! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80).
|
||||
//! In one that can't, this module inflates them itself, a little slower. Some servers recognise
|
||||
//! the gzip in a part and send it with `Content-Encoding: gzip` (Apache's `mod_mime_magic`
|
||||
//! labels it `x-gzip`), so the browser has inflated it already: the joined download is then
|
||||
//! taken as it is, by its first bytes.
|
||||
//!
|
||||
//! A download that fails is tried again, the later tries past the browser's cache (a bad copy
|
||||
//! there would otherwise fail forever). When every try fails the page says so and offers a reload.
|
||||
//!
|
||||
//! `trunk build` in `apps/photocraft-web` still gives the app as one `.wasm`, for development and
|
||||
//! for hosts without a per-file cap; only the packaged site starts through this module.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
/// First line of `parts.txt`. Some hosts answer a missing file with the home page and HTTP 200;
|
||||
/// without this an HTML page would be read as a list of parts.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
const MARKER: &str = "photocraft-parts 1";
|
||||
|
||||
/// The part names in a `parts.txt`, in order, or `None` when the text isn't one. A name is a
|
||||
/// file next to `parts.txt`: anything that could point elsewhere is refused.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn parts(list: &str) -> Option<Vec<String>> {
|
||||
let mut lines = list.lines().map(str::trim).filter(|l| !l.is_empty());
|
||||
if lines.next() != Some(MARKER) {
|
||||
return None;
|
||||
}
|
||||
let parts: Vec<String> = lines.map(str::to_owned).collect();
|
||||
let plain = |p: &String| !p.starts_with('.') && !p.contains(['/', '\\', '?', '#', ':', '%']);
|
||||
(!parts.is_empty() && parts.iter().all(plain)).then_some(parts)
|
||||
}
|
||||
|
||||
/// What the joined parts hold, told by their first bytes.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Payload {
|
||||
/// The gzip packaging made, still to be inflated.
|
||||
Gzip,
|
||||
/// The app's WebAssembly: the server sent the gzip with a `Content-Encoding` the browser
|
||||
/// already undid.
|
||||
Wasm,
|
||||
}
|
||||
|
||||
/// Enough leading bytes to tell the payloads apart.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
const HEAD: usize = 4;
|
||||
|
||||
/// What a download starting with `head` (at least `HEAD` bytes, or all there were) holds, or
|
||||
/// `None` when it is neither, such as a web page or nothing at all.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn payload(head: &[u8]) -> Option<Payload> {
|
||||
if head.starts_with(&[0x1f, 0x8b]) {
|
||||
Some(Payload::Gzip)
|
||||
} else if head.starts_with(b"\0asm") {
|
||||
Some(Payload::Wasm)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Gzip inflated a chunk at a time, for browsers that can't do it themselves.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
struct Inflater(flate2::write::GzDecoder<Vec<u8>>);
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
impl Inflater {
|
||||
fn new() -> Self {
|
||||
Inflater(flate2::write::GzDecoder::new(Vec::new()))
|
||||
}
|
||||
|
||||
/// What the next `chunk` of the gzip stream inflates to (often nothing: the decoder holds on
|
||||
/// to input until it has a whole block).
|
||||
fn push(&mut self, chunk: &[u8]) -> std::io::Result<Vec<u8>> {
|
||||
std::io::Write::write_all(&mut self.0, chunk)?;
|
||||
Ok(std::mem::take(self.0.get_mut()))
|
||||
}
|
||||
|
||||
/// The rest, once the stream has ended. An error when it ended early or its checksum is
|
||||
/// wrong, the same as the browser's inflater gives.
|
||||
fn finish(&mut self) -> std::io::Result<Vec<u8>> {
|
||||
self.0.try_finish()?;
|
||||
Ok(std::mem::take(self.0.get_mut()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod boot {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::{JsFuture, spawn_local};
|
||||
use web_sys::{
|
||||
Headers, ReadableStream, ReadableStreamDefaultReader, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions,
|
||||
TransformStream, TransformStreamDefaultController, Transformer, WritableStream, WritableStreamDefaultWriter,
|
||||
};
|
||||
|
||||
use super::{HEAD, Inflater, Payload};
|
||||
|
||||
/// Where packaging puts the app, next to this module's own files.
|
||||
const PARTS: &str = "./app/parts.txt";
|
||||
/// Seconds to wait before each further try; one more than this many tries are made.
|
||||
const RETRY_AFTER: [u32; 2] = [1, 3];
|
||||
|
||||
// The app's `wasm-bindgen` loader. Its default export instantiates the module it is given and
|
||||
// runs the app's `main`. The path is fixed at compile time, so packaging gives the file this
|
||||
// name (Trunk's own name for it carries a content hash).
|
||||
#[wasm_bindgen(raw_module = "./app/photocraft-web.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = default, catch)]
|
||||
async fn start_app(module: JsValue) -> Result<JsValue, JsValue>;
|
||||
}
|
||||
|
||||
// The browser's own inflater. `web-sys` has it only behind its unstable-API flag, which would
|
||||
// have to be set for the whole build, so the three members used are declared here.
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
type DecompressionStream;
|
||||
#[wasm_bindgen(constructor, catch)]
|
||||
fn new(format: &str) -> Result<DecompressionStream, JsValue>;
|
||||
#[wasm_bindgen(method, getter)]
|
||||
fn readable(this: &DecompressionStream) -> ReadableStream;
|
||||
#[wasm_bindgen(method, getter)]
|
||||
fn writable(this: &DecompressionStream) -> WritableStream;
|
||||
}
|
||||
|
||||
fn window() -> Result<web_sys::Window, JsValue> {
|
||||
web_sys::window().ok_or_else(|| JsValue::from_str("no window"))
|
||||
}
|
||||
|
||||
/// `fresh` skips the browser's cache.
|
||||
async fn fetch(url: &str, fresh: bool) -> Result<Response, JsValue> {
|
||||
let init = RequestInit::new();
|
||||
if fresh {
|
||||
init.set_cache(RequestCache::Reload);
|
||||
}
|
||||
let response: Response = JsFuture::from(window()?.fetch_with_str_and_init(url, &init)).await?.dyn_into()?;
|
||||
if !response.ok() {
|
||||
return Err(JsValue::from_str(&format!("{url}: HTTP {}", response.status())));
|
||||
}
|
||||
if response.headers().get("Content-Type")?.is_some_and(|t| t.starts_with("text/html")) {
|
||||
return Err(JsValue::from_str(&format!("{url}: the server sent a web page, not the file")));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Copies each part into `sink` in order, then closes it.
|
||||
async fn feed(parts: Vec<String>, sink: WritableStream, fresh: bool) -> Result<(), JsValue> {
|
||||
let keep_open = StreamPipeOptions::new();
|
||||
keep_open.set_prevent_close(true);
|
||||
for part in &parts {
|
||||
let body = fetch(&format!("./app/{part}"), fresh).await?.body().ok_or_else(|| JsValue::from_str("part without a body"))?;
|
||||
JsFuture::from(body.pipe_to_with_options(&sink, &keep_open)).await?;
|
||||
}
|
||||
JsFuture::from(sink.close()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Passes inflated bytes on to the stream's reader.
|
||||
fn emit(inflated: std::io::Result<Vec<u8>>, out: &TransformStreamDefaultController) -> Result<(), JsValue> {
|
||||
let inflated = inflated.map_err(|e| JsValue::from_str(&format!("the download is damaged ({e})")))?;
|
||||
if inflated.is_empty() { Ok(()) } else { out.enqueue_with_chunk(&js_sys::Uint8Array::from(inflated.as_slice())) }
|
||||
}
|
||||
|
||||
/// A gzip inflater with the shape of `DecompressionStream`, running in this module.
|
||||
fn own_inflater() -> Result<TransformStream, JsValue> {
|
||||
let inflater = Rc::new(RefCell::new(Inflater::new()));
|
||||
let last = inflater.clone();
|
||||
let busy = || JsValue::from_str("the inflater is in use");
|
||||
let transform = Closure::<dyn FnMut(JsValue, TransformStreamDefaultController) -> Result<(), JsValue>>::new(
|
||||
move |chunk: JsValue, out: TransformStreamDefaultController| {
|
||||
let chunk = js_sys::Uint8Array::new(&chunk).to_vec();
|
||||
emit(inflater.try_borrow_mut().map_err(|_| busy())?.push(&chunk), &out)
|
||||
},
|
||||
);
|
||||
let flush = Closure::<dyn FnMut(TransformStreamDefaultController) -> Result<(), JsValue>>::new(move |out: TransformStreamDefaultController| {
|
||||
emit(last.try_borrow_mut().map_err(|_| busy())?.finish(), &out)
|
||||
});
|
||||
let transformer = Transformer::new();
|
||||
transformer.set_transform(&transform.into_js_value().unchecked_into());
|
||||
transformer.set_flush(&flush.into_js_value().unchecked_into());
|
||||
TransformStream::new_with_transformer(&transformer)
|
||||
}
|
||||
|
||||
/// `compressed` inflated: by the browser where it can, otherwise here.
|
||||
fn inflated(compressed: &ReadableStream) -> Result<ReadableStream, JsValue> {
|
||||
let pair = if js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("DecompressionStream"))? {
|
||||
let inflater = DecompressionStream::new("gzip")?;
|
||||
ReadableWritablePair::new(&inflater.readable(), &inflater.writable())
|
||||
} else {
|
||||
let inflater = own_inflater()?;
|
||||
ReadableWritablePair::new(&inflater.readable(), &inflater.writable())
|
||||
};
|
||||
Ok(compressed.pipe_through(&pair))
|
||||
}
|
||||
|
||||
/// The next chunk `reader` gives, or `None` at the end of the stream.
|
||||
async fn read(reader: &ReadableStreamDefaultReader) -> Result<Option<js_sys::Uint8Array>, JsValue> {
|
||||
let result = JsFuture::from(reader.read()).await?;
|
||||
if js_sys::Reflect::get(&result, &JsValue::from_str("done"))?.is_truthy() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(js_sys::Reflect::get(&result, &JsValue::from_str("value"))?.dyn_into()?))
|
||||
}
|
||||
|
||||
/// Writes `chunk` into `writer`. When nothing will read it any more (the app's loader gave up
|
||||
/// on its stream), the download from `reader` is cancelled too, so it doesn't stay pending.
|
||||
async fn write(chunk: &js_sys::Uint8Array, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> {
|
||||
if let Err(e) = JsFuture::from(writer.write_with_chunk(chunk)).await {
|
||||
let _ = reader.cancel_with_reason(&e);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes the `held` chunks, then the rest of `reader`'s stream, into `writer`, then closes it.
|
||||
async fn pass_on(held: Vec<js_sys::Uint8Array>, reader: &ReadableStreamDefaultReader, writer: &WritableStreamDefaultWriter) -> Result<(), JsValue> {
|
||||
for chunk in &held {
|
||||
write(chunk, reader, writer).await?;
|
||||
}
|
||||
while let Some(chunk) = read(reader).await? {
|
||||
write(&chunk, reader, writer).await?;
|
||||
}
|
||||
JsFuture::from(writer.close()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The app's WebAssembly from the `joined` parts: inflated, unless its first bytes show the
|
||||
/// browser has already done that.
|
||||
async fn unpacked(joined: &ReadableStream) -> Result<ReadableStream, JsValue> {
|
||||
let reader: ReadableStreamDefaultReader = joined.get_reader().unchecked_into();
|
||||
let (mut head, mut held) = (Vec::with_capacity(HEAD), Vec::new());
|
||||
while head.len() < HEAD {
|
||||
let Some(chunk) = read(&reader).await? else { break };
|
||||
let wanted = u32::try_from(HEAD - head.len()).unwrap_or(u32::MAX).min(chunk.length());
|
||||
head.extend(chunk.subarray(0, wanted).to_vec());
|
||||
held.push(chunk);
|
||||
}
|
||||
let Some(payload) = super::payload(&head) else {
|
||||
let e = JsValue::from_str("the download is not PhotoCraft's WebAssembly");
|
||||
// Stops the rest of the download, which nothing will read.
|
||||
let _ = reader.cancel_with_reason(&e);
|
||||
return Err(e);
|
||||
};
|
||||
let rest = TransformStream::new()?;
|
||||
let writer = rest.writable().get_writer()?;
|
||||
spawn_local(async move {
|
||||
if let Err(e) = pass_on(held, &reader, &writer).await {
|
||||
let _ = writer.abort_with_reason(&e);
|
||||
}
|
||||
});
|
||||
match payload {
|
||||
Payload::Gzip => inflated(&rest.readable()),
|
||||
Payload::Wasm => Ok(rest.readable()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(fresh: bool) -> Result<(), JsValue> {
|
||||
let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default();
|
||||
let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?;
|
||||
let joined = TransformStream::new()?;
|
||||
// Why the download stopped: the app's loader only reports that its stream broke.
|
||||
let cause = Rc::new(RefCell::new(None));
|
||||
let failed = cause.clone();
|
||||
let sink = joined.writable();
|
||||
spawn_local(async move {
|
||||
if let Err(e) = feed(parts, sink.clone(), fresh).await {
|
||||
let _ = sink.abort_with_reason(&e);
|
||||
failed.replace(Some(e));
|
||||
}
|
||||
});
|
||||
let wasm = match unpacked(&joined.readable()).await {
|
||||
Ok(wasm) => wasm,
|
||||
Err(e) => return Err(cause.take().unwrap_or(e)),
|
||||
};
|
||||
let headers = Headers::new()?;
|
||||
headers.set("Content-Type", "application/wasm")?;
|
||||
let init = ResponseInit::new();
|
||||
init.set_headers(&headers);
|
||||
let response = Response::new_with_opt_readable_stream_and_init(Some(&wasm), &init)?;
|
||||
let arg = js_sys::Object::new();
|
||||
js_sys::Reflect::set(&arg, &JsValue::from_str("module_or_path"), &response)?;
|
||||
match start_app(arg.into()).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(cause.take().unwrap_or(e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(seconds: u32) {
|
||||
let wait = js_sys::Promise::new(&mut |done, _| {
|
||||
let ms = i32::try_from(seconds.saturating_mul(1000)).unwrap_or(i32::MAX);
|
||||
if window().and_then(|w| w.set_timeout_with_callback_and_timeout_and_arguments_0(&done, ms)).is_err() {
|
||||
let _ = done.call0(&JsValue::NULL);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(wait).await;
|
||||
}
|
||||
|
||||
/// Replaces the page's "Loading" text. `reload` adds a link that reloads the page.
|
||||
fn say(text: &str, reload: bool) {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Some(loading) = document.get_element_by_id("photocraft_loading") else { return };
|
||||
loading.set_text_content(None);
|
||||
let Ok(line) = document.create_element("p") else { return };
|
||||
line.set_text_content(Some(text));
|
||||
if reload && let Ok(link) = document.create_element("a") {
|
||||
link.set_text_content(Some("Reload"));
|
||||
let _ = link.set_attribute("href", "");
|
||||
let _ = line.append_child(&link);
|
||||
}
|
||||
let _ = loading.append_child(&line);
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
spawn_local(async {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
let error = match run(attempt > 0).await {
|
||||
Ok(()) => return,
|
||||
Err(e) => e,
|
||||
};
|
||||
web_sys::console::error_2(&JsValue::from_str("photocraft-web-boot: could not start PhotoCraft:"), &error);
|
||||
let Some(&wait) = RETRY_AFTER.get(attempt) else {
|
||||
say("PhotoCraft couldn't be downloaded. Check your connection, then try again: ", true);
|
||||
return;
|
||||
};
|
||||
say("Loading PhotoCraft\u{2026} (the download failed, trying again)", false);
|
||||
sleep(wait).await;
|
||||
attempt += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
boot::start();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
eprintln!("photocraft-web-boot only runs in the browser: packaging/web/package.sh builds it with `trunk build --release` in apps/photocraft-web-boot");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{HEAD, Inflater, MARKER, Payload, parts, payload};
|
||||
|
||||
fn gzip(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut z, bytes).expect("gzip");
|
||||
z.finish().expect("gzip")
|
||||
}
|
||||
|
||||
/// Bytes that don't compress to nothing, so the gzip stream spans many chunks.
|
||||
fn sample() -> Vec<u8> {
|
||||
let mut x = 0x2545_f491_4f6c_dd1d_u64;
|
||||
(0..200_000)
|
||||
.map(|i| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
if i % 3 == 0 { x as u8 } else { (i / 7) as u8 }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The parts arrive in whatever pieces the network gives; the inflater must not care where
|
||||
/// they are cut.
|
||||
#[test]
|
||||
fn the_inflater_gives_back_the_bytes_however_the_stream_is_cut() {
|
||||
let (plain, packed) = (sample(), gzip(&sample()));
|
||||
for cut in [1, 7, 4096, 65_536, packed.len()] {
|
||||
let mut inflater = Inflater::new();
|
||||
let mut out = Vec::new();
|
||||
for chunk in packed.chunks(cut) {
|
||||
out.extend(inflater.push(chunk).expect("push"));
|
||||
}
|
||||
out.extend(inflater.finish().expect("finish"));
|
||||
assert_eq!(out, plain, "chunks of {cut}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A download that stops early, or whose bytes changed on the way, must fail (the boot module
|
||||
/// then tries again), never start a damaged app.
|
||||
#[test]
|
||||
fn the_inflater_refuses_a_short_or_damaged_stream() {
|
||||
let packed = gzip(&sample());
|
||||
let run = |bytes: &[u8]| -> std::io::Result<Vec<u8>> {
|
||||
let mut inflater = Inflater::new();
|
||||
let mut out = inflater.push(bytes)?;
|
||||
out.extend(inflater.finish()?);
|
||||
Ok(out)
|
||||
};
|
||||
assert!(run(&packed).is_ok());
|
||||
assert!(run(&packed[..packed.len() / 2]).is_err(), "cut short");
|
||||
assert!(run(&packed[..packed.len() - 4]).is_err(), "trailer cut");
|
||||
let mut damaged = packed.clone();
|
||||
let middle = damaged.len() / 2;
|
||||
damaged[middle] ^= 0x55;
|
||||
assert!(run(&damaged).is_err(), "a changed byte");
|
||||
assert!(run(b"<!DOCTYPE html><html>not gzip</html>").is_err(), "a web page");
|
||||
assert!(run(b"").is_err(), "nothing at all");
|
||||
}
|
||||
|
||||
/// A server that recognises the gzip (Apache's `mod_mime_magic`) sends it with
|
||||
/// `Content-Encoding: x-gzip`, and the browser inflates it on the way (#7639). The joined parts
|
||||
/// are then the app's WebAssembly, which must start as it is, not be inflated a second time.
|
||||
#[test]
|
||||
fn the_first_bytes_tell_gzip_from_wasm_the_browser_already_inflated() {
|
||||
let mut wasm = b"\0asm\x01\0\0\0".to_vec();
|
||||
wasm.extend(sample());
|
||||
let packed = gzip(&wasm);
|
||||
assert_eq!(payload(&packed[..HEAD]), Some(Payload::Gzip), "as packaged");
|
||||
assert_eq!(payload(&wasm[..HEAD]), Some(Payload::Wasm), "inflated by the browser");
|
||||
assert_eq!(payload(&packed[..2]), Some(Payload::Gzip), "a download shorter than HEAD");
|
||||
assert_eq!(payload(b"<!DOCTYPE html>"), None, "a web page");
|
||||
assert_eq!(payload(b"\0as"), None, "a cut-off wasm header");
|
||||
assert_eq!(payload(b""), None, "nothing at all");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parts_list_gives_its_names_in_order() {
|
||||
let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n");
|
||||
assert_eq!(parts(&list), Some(vec!["photocraft-web-0123abcd.part-aa".to_owned(), "photocraft-web-0123abcd.part-ab".to_owned()]));
|
||||
}
|
||||
|
||||
/// A host that answers a missing file with its home page must not have that page read as a
|
||||
/// list of parts.
|
||||
#[test]
|
||||
fn a_web_page_is_not_a_parts_list() {
|
||||
assert_eq!(parts(include_str!("../index.html")), None);
|
||||
assert_eq!(parts(""), None);
|
||||
assert_eq!(parts("photocraft-web.part-aa\n"), None, "no marker");
|
||||
assert_eq!(parts(&format!("{MARKER}\n")), None, "no parts");
|
||||
assert_eq!(parts("photocraft-parts 2\na.part-aa\n"), None, "a later layout this build doesn't know");
|
||||
}
|
||||
|
||||
/// Part names are joined onto `./app/`, so one that could leave that folder, or name another
|
||||
/// origin, refuses the whole list.
|
||||
#[test]
|
||||
fn a_part_name_cannot_point_outside_the_app_folder() {
|
||||
for bad in ["../index.html", "a/b.part-aa", "a\\b", "https://example.com/x", "//example.com/x", "x?y", "x#y", ".hidden", "%2e%2e"] {
|
||||
assert_eq!(parts(&format!("{MARKER}\ngood.part-aa\n{bad}\n")), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The packaged site shows this crate's page, the development build the app's own. They are
|
||||
/// the same page apart from which program the `<link data-trunk>` line builds.
|
||||
#[test]
|
||||
fn the_page_is_the_apps_page() {
|
||||
let trunk = |html: &str| html.lines().filter(|l| !l.contains("data-trunk")).collect::<Vec<_>>().join("\n");
|
||||
let (boot, app) = (include_str!("../index.html"), include_str!("../../photocraft-web/index.html"));
|
||||
assert_eq!(trunk(boot), trunk(app));
|
||||
assert!(boot.contains(r#"data-bin="photocraft-web-boot""#) && boot.contains(r#"data-cargo-profile="wasm-release""#), "{boot}");
|
||||
assert!(boot.contains(r#"id="photocraft_loading""#), "the element the failure message replaces");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -29,12 +29,12 @@ log = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||
eframe = { workspace = true, features = ["wgpu", "accesskit"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
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 }
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>PhotoCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-wasm-opt-params="--converge" data-weak-refs />
|
||||
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -337,7 +337,6 @@ mod tests {
|
||||
let b = Bridge::default();
|
||||
b.unavailable("SecurityError".into());
|
||||
let (mut s, _) = session(b.clone());
|
||||
assert!(!b.unsaved(), "nothing is lost before the first change, so the web shell shows no banner");
|
||||
import(&mut s);
|
||||
assert!(s.tools.presets.iter().any(|p| p.name == "Browser seam brush"));
|
||||
assert!(b.unsaved());
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -16,12 +14,12 @@ use wasm_bindgen::JsCast as _;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
|
||||
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and
|
||||
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
|
||||
"pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef",
|
||||
"orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
|
||||
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
|
||||
"ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
"svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
@@ -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}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -353,12 +305,7 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
// Only while a preset change is unsaved. Storage the browser denies from the start (a
|
||||
// sandboxed iframe, blocked site data) loses nothing until the first change, and that
|
||||
// change brings the banner with the reason.
|
||||
if self.presets.unsaved()
|
||||
&& let Some(error) = self.presets.error()
|
||||
{
|
||||
if let Some(error) = self.presets.error() {
|
||||
egui::Panel::top("browser_preset_storage").show(ui, |ui| {
|
||||
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
|
||||
// Put retry first so floating brush windows cannot cover the control.
|
||||
@@ -381,14 +328,26 @@ 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| {
|
||||
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
|
||||
photocraft_io::export(doc, path, &settings.io_options()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
opts.encode.webp_lossless = settings.webp_lossless;
|
||||
if let Some(q) = settings.webp_quality {
|
||||
opts.encode.webp_quality = q;
|
||||
}
|
||||
opts.tiff_layers = settings.tiff_layers;
|
||||
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
|
||||
opts.tga_bits = settings.tga_bits;
|
||||
opts.image_previews = settings.image_previews;
|
||||
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
file_dialog: Some(Box::new(|request, _parent, reply| match request {
|
||||
// The browser's file picker hands over the files' contents, not paths. File › Open
|
||||
@@ -427,15 +386,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:?}")))),
|
||||
@@ -485,7 +440,6 @@ fn mime_for(name: &str) -> &'static str {
|
||||
Some("gif") => "image/gif",
|
||||
Some("psd" | "psb") => "image/vnd.adobe.photoshop",
|
||||
Some("ora") => "image/openraster",
|
||||
Some("kra" | "krz") => "application/x-krita",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
@@ -29,7 +29,7 @@ photocraft-automation = { workspace = true }
|
||||
photocraft-ui-egui = { workspace = true }
|
||||
# Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate).
|
||||
photocraft-tablet = { workspace = true }
|
||||
eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -65,17 +65,9 @@ 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.
|
||||
wl-clipboard-rs = { version = "=0.9.4", default-features = false }
|
||||
# Wayland desktop color selection through the existing XDG portal transport.
|
||||
zbus = "=5.19.0"
|
||||
# Caption button glyphs from the desktop's icon theme (`caption_style.rs`): SVG paths only, no
|
||||
# text or images. Already in Cargo.lock (photocraft-io); pinned exactly.
|
||||
resvg = { version = "=0.45.1", default-features = false }
|
||||
# Already used by the portal stack; bounded waits let cancelling close the OS request.
|
||||
futures-lite = "=2.6.1"
|
||||
async-io = "=2.6.0"
|
||||
|
||||
@@ -11,14 +11,6 @@ fn main() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return;
|
||||
}
|
||||
// Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which
|
||||
// stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe
|
||||
// from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use;
|
||||
// the print command checks for it first and reports an error instead.
|
||||
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
|
||||
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:windows.data.pdf.dll");
|
||||
println!("cargo:rustc-link-arg-bins=delayimp.lib");
|
||||
}
|
||||
let mut res = winresource::WindowsResource::new();
|
||||
res.set_icon("../../assets/app-icon/photocraft.ico")
|
||||
.set("ProductName", "PhotoCraft")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -26,25 +26,6 @@ pub fn window_icon() -> egui::IconData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the window gets [`window_icon`] at runtime: everywhere except inside a macOS app
|
||||
/// bundle. There the Dock shows the bundle's `PhotoCraft.icns` (`CFBundleIconFile`), which macOS
|
||||
/// renders in its own style (Liquid Glass and its tinted and dark variants); a runtime PNG
|
||||
/// replaced it with a flat picture (#5849). `exe` is the running executable.
|
||||
pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool {
|
||||
!(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle))
|
||||
}
|
||||
|
||||
/// Whether `exe` is an app bundle's executable: `…/Name.app/Contents/MacOS/binary`.
|
||||
fn in_app_bundle(exe: &std::path::Path) -> bool {
|
||||
let name = |p: Option<&std::path::Path>| p.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
|
||||
let macos = exe.parent();
|
||||
let contents = macos.and_then(std::path::Path::parent);
|
||||
let bundle = contents.and_then(std::path::Path::parent);
|
||||
name(macos).as_deref() == Some("MacOS")
|
||||
&& name(contents).as_deref() == Some("Contents")
|
||||
&& name(bundle).is_some_and(|n| n.len() > 4 && n.to_ascii_lowercase().ends_with(".app"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -64,22 +45,6 @@ mod tests {
|
||||
u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize
|
||||
}
|
||||
|
||||
/// #5849: a macOS app bundle keeps its .icns in the Dock; unbundled runs (cargo run, the
|
||||
/// Linux and Windows builds) still set the runtime icon.
|
||||
#[test]
|
||||
fn only_a_macos_app_bundle_skips_the_runtime_icon() {
|
||||
use std::path::Path;
|
||||
assert!(in_app_bundle(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft")));
|
||||
assert!(in_app_bundle(Path::new("/Users/a/Downloads/PhotoCraft Beta.APP/Contents/MacOS/photocraft")));
|
||||
assert!(!in_app_bundle(Path::new("/Users/a/src/photocraft/target/release/photocraft")));
|
||||
assert!(!in_app_bundle(Path::new("/opt/Contents/MacOS/photocraft")), "not inside a .app");
|
||||
assert!(!in_app_bundle(Path::new("/.app/Contents/MacOS/photocraft")), "a bare `.app` is no bundle name");
|
||||
assert!(!in_app_bundle(Path::new("photocraft")));
|
||||
assert!(sets_runtime_icon(None));
|
||||
assert!(sets_runtime_icon(Some(Path::new("/usr/bin/photocraft"))));
|
||||
assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_icon_decodes() {
|
||||
let icon = window_icon();
|
||||
|
||||
@@ -6,7 +6,7 @@ const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
|
||||
const PORTAL_KEY: &str = "color-scheme";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
|
||||
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
|
||||
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
|
||||
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
|
||||
}
|
||||
@@ -14,7 +14,7 @@ pub(crate) fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
|
||||
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
|
||||
portal_code(value).and_then(decode).or_else(gtk_theme)
|
||||
decode(portal_code(value)?)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -39,8 +39,8 @@ fn decode(code: u32) -> Option<egui::Theme> {
|
||||
}
|
||||
}
|
||||
|
||||
/// GNOME fallback when the portal has no preference. Read only on the appearance worker,
|
||||
/// using an absolute path rather than the shell's PATH.
|
||||
/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by
|
||||
/// absolute path, and is never repeated.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn gtk_theme() -> Option<egui::Theme> {
|
||||
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
|
||||
@@ -55,7 +55,7 @@ fn gtk_theme() -> Option<egui::Theme> {
|
||||
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
|
||||
match s.trim().trim_matches('\'') {
|
||||
"prefer-dark" => Some(egui::Theme::Dark),
|
||||
"prefer-light" | "default" => Some(egui::Theme::Light),
|
||||
"prefer-light" => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ pub fn service() -> Option<photocraft_ui_egui::SystemThemeFn> {
|
||||
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
|
||||
continue;
|
||||
}
|
||||
publisher.publish(portal_code(changed).and_then(decode).or_else(gtk_theme));
|
||||
publisher.publish(portal_code(changed).and_then(decode));
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -184,10 +184,7 @@ mod tests {
|
||||
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
|
||||
// GNOME's "default" appearance is light, even though the portal reports no preference.
|
||||
assert_eq!(super::parse_gtk_scheme("'default'\n"), Some(egui::Theme::Light));
|
||||
assert_eq!(super::parse_gtk_scheme("default"), Some(egui::Theme::Light));
|
||||
assert_eq!(super::parse_gtk_scheme("'not-a-scheme'"), None);
|
||||
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
|
||||
}
|
||||
|
||||
/// #2798: a first read that misses the start-up wait still wakes the idle UI.
|
||||
|
||||
@@ -1,399 +0,0 @@
|
||||
//! Linux: which desktop the custom title bar's caption buttons look like, and which buttons go on
|
||||
//! which side (`photocraft_ui_egui::CaptionStyle`). GNOME gets libadwaita's buttons in the order of
|
||||
//! its `button-layout` setting (read through the settings portal, and followed as it changes); KDE
|
||||
//! gets Breeze's in KWin's order (`kwinrc`); other desktops get plain round buttons, or Adwaita's
|
||||
//! or Breeze's when their GTK theme is one of those, in GTK's `gtk-decoration-layout` order.
|
||||
//!
|
||||
//! `PHOTOCRAFT_CAPTION_STYLE` (`windows`, `adwaita`, `breeze`, `generic`) and
|
||||
//! `PHOTOCRAFT_CAPTION_LAYOUT` (a GTK layout such as `close,minimize:`) override what is found.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use photocraft_ui_egui::{CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Desktop {
|
||||
Gnome,
|
||||
Kde,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// The desktop, from the first of its variables that is set: `ubuntu:GNOME`, `Budgie:GNOME` and
|
||||
/// the like count as GNOME.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn desktop(var: &dyn Fn(&str) -> Option<String>) -> Desktop {
|
||||
for key in ["XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"] {
|
||||
let Some(v) = var(key).map(|v| v.to_ascii_lowercase()).filter(|v| !v.trim().is_empty()) else { continue };
|
||||
return if v.contains("gnome") {
|
||||
Desktop::Gnome
|
||||
} else if v.contains("kde") || v.contains("plasma") {
|
||||
Desktop::Kde
|
||||
} else {
|
||||
Desktop::Other
|
||||
};
|
||||
}
|
||||
if var("KDE_FULL_SESSION").is_some() {
|
||||
Desktop::Kde
|
||||
} else if var("GNOME_DESKTOP_SESSION_ID").is_some() {
|
||||
Desktop::Gnome
|
||||
} else {
|
||||
Desktop::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// GNOME's apps are libadwaita's whatever the GTK theme, and KWin draws Breeze; elsewhere the GTK
|
||||
/// theme says (Adwaita, adw-gtk3, Yaru, Breeze), and any other theme gets the plain buttons.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn family(desktop: Desktop, gtk_theme: Option<&str>) -> CaptionFamily {
|
||||
match desktop {
|
||||
Desktop::Gnome => CaptionFamily::Adwaita,
|
||||
Desktop::Kde => CaptionFamily::Breeze,
|
||||
Desktop::Other => {
|
||||
let theme = gtk_theme.unwrap_or_default().trim().to_ascii_lowercase();
|
||||
if ["adwaita", "adw-gtk3", "yaru"].iter().any(|p| theme.starts_with(p)) {
|
||||
CaptionFamily::Adwaita
|
||||
} else if theme.starts_with("breeze") {
|
||||
CaptionFamily::Breeze
|
||||
} else {
|
||||
CaptionFamily::Generic
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of `key` in `[section]` of an INI-style file (GTK's `settings.ini`, KDE's `kwinrc`).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ini_value(text: &str, section: &str, key: &str) -> Option<String> {
|
||||
let mut inside = false;
|
||||
for line in text.lines().map(str::trim) {
|
||||
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
inside = name == section;
|
||||
} else if inside
|
||||
&& let Some((k, v)) = line.split_once('=')
|
||||
&& k.trim() == key
|
||||
{
|
||||
return Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// KWin's button order (`[org.kde.kdecoration2]` `ButtonsOnLeft` / `ButtonsOnRight`, one letter a
|
||||
/// button: `I` minimize, `A` maximize, `X` close), as a GTK layout. Missing keys keep KWin's
|
||||
/// defaults: the window menu on the left, minimize, maximize and close on the right.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn kwin_layout(kwinrc: &str) -> String {
|
||||
let side = |key: &str, default: &str| {
|
||||
let letters = ini_value(kwinrc, "org.kde.kdecoration2", key).unwrap_or_else(|| default.to_string());
|
||||
letters
|
||||
.chars()
|
||||
.filter_map(|c| match c {
|
||||
'I' => Some("minimize"),
|
||||
'A' => Some("maximize"),
|
||||
'X' => Some("close"),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
};
|
||||
format!("{}:{}", side("ButtonsOnLeft", "MS"), side("ButtonsOnRight", "HIAX"))
|
||||
}
|
||||
|
||||
/// What the style is worked out from.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Default)]
|
||||
struct Found {
|
||||
gtk_theme: Option<String>,
|
||||
/// A GTK decoration layout.
|
||||
layout: Option<String>,
|
||||
icon_theme: Option<String>,
|
||||
}
|
||||
|
||||
/// The icon theme the glyphs come from: `PHOTOCRAFT_CAPTION_ICONS` (`none` draws PhotoCraft's own),
|
||||
/// the desktop's setting, or its default theme.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn icon_theme_name(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option<String>) -> Option<String> {
|
||||
match var("PHOTOCRAFT_CAPTION_ICONS") {
|
||||
Some(name) if name.trim().eq_ignore_ascii_case("none") => None,
|
||||
Some(name) => Some(name.trim().to_string()),
|
||||
None => Some(found.icon_theme.clone().unwrap_or_else(|| if desktop == Desktop::Kde { "breeze" } else { "Adwaita" }.into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The style from what was found; `icons` loads a theme's glyphs.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn resolve(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option<String>, icons: &dyn Fn(&str) -> Option<CaptionIcons>) -> CaptionStyle {
|
||||
let family = var("PHOTOCRAFT_CAPTION_STYLE").and_then(|s| CaptionFamily::parse(&s)).unwrap_or_else(|| family(desktop, found.gtk_theme.as_deref()));
|
||||
// GNOME's own default shows Close only; GTK's shows all three.
|
||||
let default = if desktop == Desktop::Gnome { "appmenu:close" } else { "menu:minimize,maximize,close" };
|
||||
let layout = var("PHOTOCRAFT_CAPTION_LAYOUT").or_else(|| found.layout.clone()).unwrap_or_else(|| default.to_string());
|
||||
let mut style = CaptionStyle::from_gtk_layout(family, &layout);
|
||||
style.icons = icon_theme_name(desktop, found, var).and_then(|name| icons(&name));
|
||||
style
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn read_text(path: &std::path::Path) -> Option<String> {
|
||||
crate::icon_theme::read_small(path).map(|b| String::from_utf8_lossy(&b).into_owned())
|
||||
}
|
||||
|
||||
/// The themes and layout from the desktop's files: `kwinrc` and `kdeglobals` on KDE, GTK 3's
|
||||
/// `settings.ini` elsewhere (where GNOME's portal answers, read later, replace them).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn from_files(desktop: Desktop, var: &dyn Fn(&str) -> Option<String>) -> Found {
|
||||
let config = var("XDG_CONFIG_HOME")
|
||||
.filter(|d| std::path::Path::new(d).is_absolute())
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| var("HOME").map(|h| std::path::Path::new(&h).join(".config")));
|
||||
let Some(config) = config else { return Found::default() };
|
||||
let gtk = read_text(&config.join("gtk-3.0").join("settings.ini")).unwrap_or_default();
|
||||
let gtk_theme = ini_value(>k, "Settings", "gtk-theme-name");
|
||||
if desktop == Desktop::Kde {
|
||||
let layout = Some(kwin_layout(&read_text(&config.join("kwinrc")).unwrap_or_default()));
|
||||
let icon_theme = ini_value(&read_text(&config.join("kdeglobals")).unwrap_or_default(), "Icons", "Theme");
|
||||
Found { gtk_theme, layout, icon_theme }
|
||||
} else {
|
||||
let layout = ini_value(>k, "Settings", "gtk-decoration-layout");
|
||||
Found { gtk_theme, layout, icon_theme: ini_value(>k, "Settings", "gtk-icon-theme-name") }
|
||||
}
|
||||
}
|
||||
|
||||
/// The caption glyphs of icon theme `theme` (`window-*-symbolic.svg`), read now and rendered at
|
||||
/// whatever size the bar asks for. `None` when the theme (with its parents) has none of them.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn load_icons(theme: &str) -> Option<CaptionIcons> {
|
||||
use crate::icon_theme::{IconTheme, base_dirs, rasterize_mask, read_small};
|
||||
let found = IconTheme::load(theme, &base_dirs(&|k: &str| std::env::var(k).ok()))?;
|
||||
let svg = |name: &str| found.find_svg(name).and_then(|p| read_small(&p)).map(std::sync::Arc::<[u8]>::from);
|
||||
let files = [svg("window-minimize-symbolic"), svg("window-maximize-symbolic"), svg("window-restore-symbolic"), svg("window-close-symbolic")];
|
||||
if files.iter().all(Option::is_none) {
|
||||
return None;
|
||||
}
|
||||
let render = move |icon: CaptionIcon, px: u32| {
|
||||
let i = match icon {
|
||||
CaptionIcon::Minimize => 0,
|
||||
CaptionIcon::Maximize => 1,
|
||||
CaptionIcon::Restore => 2,
|
||||
CaptionIcon::Close => 3,
|
||||
};
|
||||
files.get(i)?.as_deref().and_then(|svg| rasterize_mask(svg, px))
|
||||
};
|
||||
Some(CaptionIcons { theme: theme.to_string(), render: std::sync::Arc::new(render) })
|
||||
}
|
||||
|
||||
/// [`load_icons`] for the last theme asked for, kept, so a layout change doesn't reread it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Default)]
|
||||
struct IconCache(std::sync::Mutex<Option<(String, Option<CaptionIcons>)>>);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl IconCache {
|
||||
fn get(&self, theme: &str) -> Option<CaptionIcons> {
|
||||
let mut last = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some((name, icons)) = last.as_ref()
|
||||
&& name == theme
|
||||
{
|
||||
return icons.clone();
|
||||
}
|
||||
let icons = load_icons(theme);
|
||||
*last = Some((theme.to_string(), icons.clone()));
|
||||
icons
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const WM_NAMESPACE: &str = "org.gnome.desktop.wm.preferences";
|
||||
#[cfg(target_os = "linux")]
|
||||
const LAYOUT_KEY: &str = "button-layout";
|
||||
#[cfg(target_os = "linux")]
|
||||
const INTERFACE_NAMESPACE: &str = "org.gnome.desktop.interface";
|
||||
#[cfg(target_os = "linux")]
|
||||
const ICONS_KEY: &str = "icon-theme";
|
||||
|
||||
/// A string setting from the portal, unwrapping the variants some portals nest it in.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_string(value: zbus::zvariant::OwnedValue) -> Option<String> {
|
||||
let mut value: zbus::zvariant::Value<'_> = value.into();
|
||||
for _ in 0..4 {
|
||||
match value {
|
||||
zbus::zvariant::Value::Value(inner) => value = *inner,
|
||||
zbus::zvariant::Value::Str(s) => return Some(s.to_string()),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The caption style now, then with the icon theme's glyphs and (GNOME) the portal's settings,
|
||||
/// following the button layout and icon theme as they change. A worker does the file and D-Bus
|
||||
/// work and wakes the UI only when the style changes; the first answer usually lands before the
|
||||
/// first frame.
|
||||
pub fn service() -> Option<photocraft_ui_egui::CaptionStyleFn> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
|
||||
let var = |k: &str| std::env::var(k).ok();
|
||||
let desktop = desktop(&var);
|
||||
let mut found = from_files(desktop, &var);
|
||||
// Icons need the theme read, which takes tens of milliseconds: the worker brings them.
|
||||
let current = Arc::new(Mutex::new(resolve(desktop, &found, &var, &|_: &str| None)));
|
||||
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
|
||||
let (shared, waker) = (Arc::clone(¤t), Arc::clone(&wake));
|
||||
let cache = IconCache::default();
|
||||
let publish = move |found: &Found| {
|
||||
let style = resolve(desktop, found, &|k: &str| std::env::var(k).ok(), &|t: &str| cache.get(t));
|
||||
let mut cur = shared.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if *cur != style {
|
||||
*cur = style;
|
||||
if let Some(ctx) = waker.get() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
};
|
||||
let (ready, wait) = std::sync::mpsc::channel();
|
||||
let spawned = std::thread::Builder::new().name("caption-style".into()).spawn(move || {
|
||||
let proxy = if desktop == Desktop::Gnome { crate::appearance::portal_proxy() } else { None };
|
||||
let Some(proxy) = proxy else {
|
||||
publish(&found);
|
||||
let _ = ready.send(());
|
||||
return;
|
||||
};
|
||||
// Subscribe before the first read, so a change during start-up isn't lost.
|
||||
let signals = proxy.receive_signal("SettingChanged").ok();
|
||||
let read = |namespace: &str, key: &str| {
|
||||
let value: Option<zbus::zvariant::OwnedValue> = proxy.call("Read", &(namespace, key)).ok();
|
||||
value.and_then(portal_string)
|
||||
};
|
||||
found.layout = read(WM_NAMESPACE, LAYOUT_KEY).or(found.layout.take());
|
||||
found.icon_theme = read(INTERFACE_NAMESPACE, ICONS_KEY).or(found.icon_theme.take());
|
||||
publish(&found);
|
||||
let _ = ready.send(());
|
||||
for message in signals.into_iter().flatten() {
|
||||
let Ok((namespace, key, value)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { continue };
|
||||
match (namespace.as_str(), key.as_str()) {
|
||||
(WM_NAMESPACE, LAYOUT_KEY) => found.layout = portal_string(value),
|
||||
(INTERFACE_NAMESPACE, ICONS_KEY) => found.icon_theme = portal_string(value),
|
||||
_ => continue,
|
||||
}
|
||||
publish(&found);
|
||||
}
|
||||
});
|
||||
// A fast desktop answers before the first frame; a slow one updates the bar later.
|
||||
if spawned.is_ok() {
|
||||
let _ = wait.recv_timeout(std::time::Duration::from_millis(150));
|
||||
}
|
||||
Some(Box::new(move |ctx| {
|
||||
let _ = wake.set(ctx.clone());
|
||||
current.lock().unwrap_or_else(PoisonError::into_inner).clone()
|
||||
}))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_ui_egui::Caption::{Close, Maximize, Minimize};
|
||||
|
||||
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
|
||||
let pairs: Vec<(String, String)> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
|
||||
move |k| pairs.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_desktop_comes_from_the_first_variable_set() {
|
||||
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "ubuntu:GNOME")])), Desktop::Gnome);
|
||||
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "KDE")])), Desktop::Kde);
|
||||
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "X-Cinnamon"), ("KDE_FULL_SESSION", "true")])), Desktop::Other);
|
||||
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", " "), ("DESKTOP_SESSION", "plasma")])), Desktop::Kde);
|
||||
assert_eq!(desktop(&env(&[("KDE_FULL_SESSION", "true")])), Desktop::Kde);
|
||||
assert_eq!(desktop(&env(&[("GNOME_DESKTOP_SESSION_ID", "this-is-deprecated")])), Desktop::Gnome);
|
||||
assert_eq!(desktop(&env(&[])), Desktop::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_family_follows_the_desktop_then_the_gtk_theme() {
|
||||
assert_eq!(family(Desktop::Gnome, Some("Arc")), CaptionFamily::Adwaita);
|
||||
assert_eq!(family(Desktop::Kde, None), CaptionFamily::Breeze);
|
||||
assert_eq!(family(Desktop::Other, Some("adw-gtk3-dark")), CaptionFamily::Adwaita);
|
||||
assert_eq!(family(Desktop::Other, Some("Yaru-blue")), CaptionFamily::Adwaita);
|
||||
assert_eq!(family(Desktop::Other, Some("Breeze-Dark")), CaptionFamily::Breeze);
|
||||
assert_eq!(family(Desktop::Other, Some("Mint-Y")), CaptionFamily::Generic);
|
||||
assert_eq!(family(Desktop::Other, None), CaptionFamily::Generic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kwin_button_letters_become_a_gtk_layout() {
|
||||
assert_eq!(kwin_layout(""), ":minimize,maximize,close", "KWin's defaults");
|
||||
let rc = "[Desktops]\nButtonsOnLeft=X\n[org.kde.kdecoration2]\nButtonsOnLeft=XIA\nButtonsOnRight=\nlibrary=org.kde.breeze\n";
|
||||
assert_eq!(kwin_layout(rc), "close,minimize,maximize:");
|
||||
assert_eq!(ini_value(rc, "org.kde.kdecoration2", "library").as_deref(), Some("org.kde.breeze"));
|
||||
assert_eq!(ini_value(rc, "nope", "library"), None);
|
||||
// Garbage is just no buttons on that side, never a panic.
|
||||
assert_eq!(kwin_layout("[org.kde.kdecoration2]\nButtonsOnRight=ÿ€=X\n"), ":close");
|
||||
}
|
||||
|
||||
fn no_icons(_: &str) -> Option<CaptionIcons> {
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_icon_theme_comes_from_the_override_the_setting_or_the_desktop() {
|
||||
let found = Found { icon_theme: Some("Papirus".into()), ..Found::default() };
|
||||
assert_eq!(icon_theme_name(Desktop::Gnome, &found, &env(&[])).as_deref(), Some("Papirus"));
|
||||
assert_eq!(icon_theme_name(Desktop::Gnome, &Found::default(), &env(&[])).as_deref(), Some("Adwaita"));
|
||||
assert_eq!(icon_theme_name(Desktop::Kde, &Found::default(), &env(&[])).as_deref(), Some("breeze"));
|
||||
assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", " None ")])), None);
|
||||
assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", "Tela")])).as_deref(), Some("Tela"));
|
||||
// The style carries what the loader returns for that name.
|
||||
let s = resolve(Desktop::Gnome, &found, &env(&[]), &|t| {
|
||||
(t == "Papirus").then(|| CaptionIcons { theme: t.into(), render: std::sync::Arc::new(|_, _| None) })
|
||||
});
|
||||
assert_eq!(s.icons.map(|i| i.theme).as_deref(), Some("Papirus"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_and_overrides_resolve_to_a_style() {
|
||||
let none = env(&[]);
|
||||
let gnome = resolve(Desktop::Gnome, &Found::default(), &none, &no_icons);
|
||||
assert_eq!((gnome.family, gnome.left, gnome.right), (CaptionFamily::Adwaita, vec![], vec![Close]), "GNOME's default layout");
|
||||
let kde = resolve(Desktop::Kde, &Found { layout: Some(kwin_layout("")), ..Found::default() }, &none, &no_icons);
|
||||
assert_eq!((kde.family, kde.right), (CaptionFamily::Breeze, vec![Minimize, Maximize, Close]));
|
||||
let found = Found { gtk_theme: Some("Mint-Y".into()), layout: Some("close:".into()), icon_theme: None };
|
||||
let other = resolve(Desktop::Other, &found, &none, &no_icons);
|
||||
assert_eq!((other.family, other.left, other.right), (CaptionFamily::Generic, vec![Close], vec![]));
|
||||
let forced = env(&[("PHOTOCRAFT_CAPTION_STYLE", "Breeze"), ("PHOTOCRAFT_CAPTION_LAYOUT", "minimize,close:maximize")]);
|
||||
let s = resolve(Desktop::Gnome, &found, &forced, &no_icons);
|
||||
assert_eq!((s.family, s.left, s.right), (CaptionFamily::Breeze, vec![Minimize, Close], vec![Maximize]));
|
||||
// An unknown override name falls back to what was found.
|
||||
assert_eq!(resolve(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_STYLE", "aqua")]), &no_icons).family, CaptionFamily::Breeze);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portal_strings_unwrap_nested_variants() {
|
||||
use zbus::zvariant::{OwnedValue, Value};
|
||||
let nested = Value::Value(Box::new(Value::Value(Box::new(Value::from("appmenu:close")))));
|
||||
assert_eq!(portal_string(OwnedValue::try_from(nested).unwrap()).as_deref(), Some("appmenu:close"));
|
||||
assert_eq!(portal_string(OwnedValue::try_from(Value::U32(7)).unwrap()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_files_are_read_from_the_config_dir() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-caption-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("gtk-3.0")).unwrap();
|
||||
std::fs::write(dir.join("gtk-3.0/settings.ini"), "[Settings]\ngtk-theme-name=Yaru\ngtk-decoration-layout=close,maximize:\n").unwrap();
|
||||
std::fs::write(dir.join("kwinrc"), "[org.kde.kdecoration2]\nButtonsOnRight=AX\n").unwrap();
|
||||
std::fs::write(dir.join("kdeglobals"), "[Icons]\nTheme=breeze-dark\n").unwrap();
|
||||
let var = env(&[("XDG_CONFIG_HOME", dir.to_str().unwrap())]);
|
||||
let other = from_files(Desktop::Other, &var);
|
||||
assert_eq!((other.gtk_theme.as_deref(), other.layout.as_deref()), (Some("Yaru"), Some("close,maximize:")));
|
||||
let kde = from_files(Desktop::Kde, &var);
|
||||
assert_eq!((kde.layout.as_deref(), kde.icon_theme.as_deref()), (Some(":maximize,close"), Some("breeze-dark")));
|
||||
// A relative XDG_CONFIG_HOME is ignored, as the XDG spec says; no HOME: nothing found.
|
||||
assert_eq!(from_files(Desktop::Other, &env(&[("XDG_CONFIG_HOME", "rel")])).layout, None);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,14 @@ use std::io::{BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_automation::budgets::write_reply;
|
||||
use photocraft_automation::security::{
|
||||
AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream,
|
||||
read_bounded_line, repeat_auth_reply,
|
||||
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line,
|
||||
};
|
||||
use photocraft_ui_egui::ControlRequest;
|
||||
use photocraft_ui_egui::control::REPLY_DEADLINE;
|
||||
use photocraft_ui_egui::control::{REPLY_DEADLINE, timeout_error};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
@@ -48,17 +47,11 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRe
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE);
|
||||
}
|
||||
|
||||
/// [`serve`] for a connection that must complete `auth` within `auth_deadline` of connecting,
|
||||
/// however much it sends meanwhile (#4965); after that only the idle timeout applies.
|
||||
fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, auth_deadline: Duration) {
|
||||
if configure_stream(&stream).is_err() {
|
||||
return;
|
||||
}
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut reader = BufReader::new(AuthDeadlineStream::new(read, auth_deadline));
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut out = stream;
|
||||
let mut line = String::new();
|
||||
let mut authenticated = false;
|
||||
@@ -81,20 +74,11 @@ fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender<ControlRe
|
||||
if !authenticated {
|
||||
let (reply, ok) = authentication_reply(&line, token);
|
||||
authenticated = ok;
|
||||
if ok && reader.get_mut().authenticated().is_err() {
|
||||
break;
|
||||
}
|
||||
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() || !authenticated {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(reply) = repeat_auth_reply(&line) {
|
||||
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
@@ -103,13 +87,12 @@ fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender<ControlRe
|
||||
let deadline = Instant::now() + REPLY_DEADLINE;
|
||||
let (mut req, rrx) = ControlRequest::new(method, params);
|
||||
req.deadline = Some(deadline);
|
||||
let dispatch = req.dispatch.clone();
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r =
|
||||
rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": dispatch.give_up()}));
|
||||
rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": timeout_error()}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
@@ -130,34 +113,7 @@ fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender<ControlRe
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::BufRead;
|
||||
|
||||
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
|
||||
/// (releasing its connection permit), and nothing it sent is dispatched.
|
||||
#[test]
|
||||
fn an_unauthenticated_connection_is_closed_at_the_deadline_whatever_it_sends() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let started = Instant::now();
|
||||
serve_with_auth_deadline(stream, "unused", tx, egui::Context::default(), Duration::from_millis(300));
|
||||
started.elapsed()
|
||||
});
|
||||
// Blank lines and partial frames, one byte every 20 ms: each read succeeds long before
|
||||
// the idle timeout.
|
||||
let mut stream = TcpStream::connect(addr).unwrap();
|
||||
for i in 0..150 {
|
||||
let byte: &[u8] = if i % 5 == 0 { b"\n" } else { b" " };
|
||||
if stream.write_all(byte).is_err() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let served = server.join().unwrap();
|
||||
assert!(served < Duration::from_secs(3), "the trickle did not extend the deadline: {served:?}");
|
||||
assert!(rx.try_recv().is_err(), "nothing was dispatched");
|
||||
}
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn oversized_reply_preserves_framing_id_and_the_next_control_request() {
|
||||
@@ -246,44 +202,4 @@ mod tests {
|
||||
handler.join().unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_auth_reports_already_authenticated_without_dispatching() {
|
||||
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
serve(stream, TOKEN, tx, egui::Context::default());
|
||||
});
|
||||
let handler = std::thread::spawn(move || {
|
||||
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(req.method, "test.after", "a repeat auth must not reach the UI dispatcher");
|
||||
req.reply.send(json!({"ok": true, "result": "still serving"})).unwrap();
|
||||
});
|
||||
let mut stream = TcpStream::connect(addr).unwrap();
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut line = String::new();
|
||||
for id in ["auth", "again"] {
|
||||
writeln!(stream, "{}", json!({"id": id, "method": "auth", "params": {"token": TOKEN}})).unwrap();
|
||||
line.clear();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
}
|
||||
let again: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(again["id"], "again");
|
||||
assert_eq!(again["ok"], false);
|
||||
assert_eq!(again["error"], "already authenticated");
|
||||
writeln!(stream, "{}", json!({"id": 9, "method": "test.after"})).unwrap();
|
||||
line.clear();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
let after: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(after["id"], 9);
|
||||
assert_eq!(after["result"], "still serving");
|
||||
drop(reader);
|
||||
drop(stream);
|
||||
handler.join().unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,8 @@
|
||||
//! - macOS: Metal → CPU
|
||||
//!
|
||||
//! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance,
|
||||
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12, and when DXGI lists an
|
||||
//! Intel adapter the instance leaves Vulkan out, so the first start doesn't load the Intel Vulkan
|
||||
//! driver either. `WGPU_BACKEND` still overrides everything, and `--safe-gpu` forces the CPU path
|
||||
//! for one launch.
|
||||
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12. `WGPU_BACKEND` still
|
||||
//! overrides everything, and `--safe-gpu` forces the CPU path for one launch.
|
||||
//!
|
||||
//! "CPU" composites on the CPU and draws the window with a software adapter where the platform
|
||||
//! has one (WARP on Windows, llvmpipe over GL elsewhere). On Linux a GL adapter that can't present
|
||||
@@ -179,12 +177,6 @@ pub fn plan_with_mode(pref: GpuBackend, mode: RenderingMode, crashed: Option<&Ma
|
||||
plan(backend, crashed, env, safe_gpu, os)
|
||||
}
|
||||
|
||||
/// Persist successful hardware fallbacks, but never silently pin CPU mode.
|
||||
/// The recovery dialog explicitly stores CPU mode when the user chooses it.
|
||||
pub fn backend_to_remember(plan: &Plan) -> Option<GpuBackend> {
|
||||
(plan.remember && plan.backend != GpuBackend::Cpu).then_some(plan.backend)
|
||||
}
|
||||
|
||||
/// Instance backends for `plan` (`None`: egui's default, which honours `WGPU_BACKEND`).
|
||||
pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
|
||||
if plan.env.is_some() {
|
||||
@@ -206,28 +198,6 @@ pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Instance backends for `auto` on Windows (no `WGPU_BACKEND`) when `dx12_vendors`, the PCI vendor
|
||||
/// ids of the DX12 adapters, includes Intel: DX12, with GL as the last resort (see [`rank`]). An
|
||||
/// instance with Vulkan loads the Intel Vulkan driver before the adapter selector runs, and #4's
|
||||
/// crash is in that driver. `dx12_vendors` is called only for that plan; `None` keeps [`backends`]'
|
||||
/// choice.
|
||||
pub fn windows_auto_backends(plan: &Plan, os: Os, dx12_vendors: impl FnOnce() -> Vec<u32>) -> Option<wgpu::Backends> {
|
||||
let auto = plan.env.is_none() && plan.backend == GpuBackend::Auto && os == Os::Windows;
|
||||
(auto && dx12_vendors().contains(&INTEL)).then_some(wgpu::Backends::DX12 | wgpu::Backends::GL)
|
||||
}
|
||||
|
||||
/// The PCI vendor ids of the DX12 adapters, from a DX12-only instance (DXGI, no Vulkan driver)
|
||||
/// with `desc`'s flags and backend options (the shader compiler from [`dx12_compiler`]).
|
||||
fn dx12_vendors(desc: &wgpu::InstanceDescriptor) -> Vec<u32> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends: wgpu::Backends::DX12,
|
||||
flags: desc.flags,
|
||||
backend_options: desc.backend_options.clone(),
|
||||
..wgpu::InstanceDescriptor::new_without_display_handle()
|
||||
});
|
||||
crate::services::block_on(instance.enumerate_adapters(wgpu::Backends::DX12)).iter().map(|a| a.get_info().vendor).collect()
|
||||
}
|
||||
|
||||
/// The DX12 shader compiler (#712). wgpu's default loads `dxcompiler.dll` by name, and the
|
||||
/// Windows DLL search falls through to the current directory and `PATH`, so another program's DXC
|
||||
/// build (a browser's, an SDK's) got loaded and failed device creation or crashed the first shader
|
||||
@@ -252,17 +222,14 @@ pub struct Candidate {
|
||||
pub backend: wgpu::Backend,
|
||||
/// Can present to the window.
|
||||
pub surface_ok: bool,
|
||||
/// Its name matches the adapter the user asked for ([`adapter_matches`]).
|
||||
pub wanted: bool,
|
||||
}
|
||||
|
||||
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
|
||||
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
|
||||
/// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback
|
||||
/// for presenting the window (see [`backends`]). On Windows GL comes after every other adapter,
|
||||
/// WARP included: GL there (VMware's SVGA3D, GL-only old GPUs) drew a black window (#5191, #1788).
|
||||
/// for presenting the window (see [`backends`]).
|
||||
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
|
||||
let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl));
|
||||
let fallback = u8::from(backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan);
|
||||
let ty = match a.device_type {
|
||||
wgpu::DeviceType::DiscreteGpu => 0,
|
||||
wgpu::DeviceType::IntegratedGpu => 1,
|
||||
@@ -285,59 +252,19 @@ fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
|
||||
(fallback, ty, be)
|
||||
}
|
||||
|
||||
/// The adapter to use among `adapters` (`None` when there is none). Adapters the user asked for
|
||||
/// win over the rest when any of them can present; otherwise the ranking decides (see
|
||||
/// [`wanted_missed`]).
|
||||
/// The adapter to use among `adapters` (`None` when there is none).
|
||||
pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option<usize> {
|
||||
let usable = |a: &&Candidate| a.surface_ok;
|
||||
let any_wanted = adapters.iter().filter(usable).any(|a| a.wanted);
|
||||
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok && (a.wanted || !any_wanted)).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Whether an adapter called `name` is the one the user asked for: `wanted` is a case-insensitive
|
||||
/// piece of its name ("nvidia"), or several words that each appear in it ("arc a370m" finds
|
||||
/// "Intel(R) Arc(tm) A370M Graphics"). An empty `wanted` matches nothing.
|
||||
pub fn adapter_matches(name: &str, wanted: &str) -> bool {
|
||||
photocraft_engine::prefs::gpu_adapter_matches(name, wanted)
|
||||
}
|
||||
|
||||
/// The user asked for an adapter and none of `adapters` that can present is it.
|
||||
pub fn wanted_missed(adapters: &[Candidate], wanted: Option<&str>) -> bool {
|
||||
wanted.is_some() && !adapters.iter().any(|a| a.surface_ok && a.wanted)
|
||||
}
|
||||
|
||||
/// Choosing an adapter is Linux-only for now: Windows has its own Intel/DX12 selection and macOS a
|
||||
/// single Metal path, and neither is tested with a requested adapter.
|
||||
pub const ADAPTER_CHOICE: bool = cfg!(target_os = "linux");
|
||||
|
||||
/// The adapter request that applies to this launch: none off Linux (`linux`), and none in CPU mode,
|
||||
/// which wants a software adapter whatever the preference says.
|
||||
pub fn applicable_adapter(wanted: Option<String>, backend: GpuBackend, linux: bool) -> Option<String> {
|
||||
wanted.filter(|_| linux && backend != GpuBackend::Cpu)
|
||||
}
|
||||
|
||||
/// Whether `PHOTOCRAFT_GPU_ADAPTER` makes this launch its own process: only when startup will act
|
||||
/// on it (a non-empty value, on Linux, outside CPU mode). Otherwise it changes nothing, and the
|
||||
/// launch may still be forwarded to the running window.
|
||||
pub fn adapter_override_is_process_specific(env: Option<&str>, mode: RenderingMode, linux: bool) -> bool {
|
||||
env.is_some_and(|v| !v.trim().is_empty()) && linux && mode != RenderingMode::Cpu
|
||||
}
|
||||
|
||||
/// The adapter to ask for: `PHOTOCRAFT_GPU_ADAPTER` when set (for one launch), else
|
||||
/// `performance.gpuAdapter`; `None` is automatic.
|
||||
pub fn wanted_adapter(env: Option<&str>, pref: &str) -> Option<String> {
|
||||
[env.unwrap_or(""), pref].iter().map(|s| s.trim()).find(|s| !s.is_empty()).map(|s| s.chars().take(256).collect())
|
||||
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
|
||||
/// same vendor was available, or `vulkan_left_out`: [`windows_auto_backends`] left Vulkan out of
|
||||
/// the instance).
|
||||
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os, vulkan_left_out: bool) -> bool {
|
||||
/// same vendor was available).
|
||||
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool {
|
||||
let Some(c) = adapters.get(chosen) else { return false };
|
||||
os == Os::Windows
|
||||
&& c.vendor == INTEL
|
||||
&& c.backend == wgpu::Backend::Dx12
|
||||
&& (vulkan_left_out || adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan))
|
||||
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
|
||||
}
|
||||
|
||||
/// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler
|
||||
@@ -351,7 +278,7 @@ fn skips_indirect_validation(os: Os, intel: bool) -> bool {
|
||||
/// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter
|
||||
/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the
|
||||
/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
|
||||
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted: Option<String>, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
|
||||
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
|
||||
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
|
||||
if let Some(b) = backends(plan, os) {
|
||||
create.instance_descriptor.backends = b;
|
||||
@@ -364,13 +291,7 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted:
|
||||
let env = std::env::var("WGPU_DX12_COMPILER").ok();
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(env.as_deref(), exe.as_deref().and_then(Path::parent));
|
||||
}
|
||||
let without_vulkan = windows_auto_backends(plan, os, || dx12_vendors(&create.instance_descriptor));
|
||||
if let Some(b) = without_vulkan {
|
||||
create.instance_descriptor.backends = b;
|
||||
}
|
||||
let vulkan_left_out = without_vulkan.is_some();
|
||||
let wanted = applicable_adapter(wanted, plan.backend, ADAPTER_CHOICE);
|
||||
let select = wanted.is_some() || (plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows)));
|
||||
let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows));
|
||||
if select {
|
||||
let backend = plan.backend;
|
||||
create.native_adapter_selector = Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| {
|
||||
@@ -383,20 +304,15 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, wanted:
|
||||
device_type: i.device_type,
|
||||
backend: i.backend,
|
||||
surface_ok: surface.is_none_or(|s| a.is_surface_supported(s)),
|
||||
wanted: wanted.as_deref().is_some_and(|w| adapter_matches(&i.name, w)),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let i = pick(&cands, backend, os).ok_or_else(|| "no graphics adapter found".to_string())?;
|
||||
if let Some(w) = wanted.as_deref().filter(|_| wanted_missed(&cands, wanted.as_deref())) {
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some(format!("No graphics adapter named \"{w}\" can draw the window; using the automatic choice"));
|
||||
}
|
||||
if backend == GpuBackend::Cpu && cands.get(i).is_some_and(|c| c.device_type != wgpu::DeviceType::Cpu) {
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into());
|
||||
}
|
||||
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os, vulkan_left_out) {
|
||||
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os) {
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
|
||||
}
|
||||
@@ -520,21 +436,6 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Preferences › Performance › Graphics Adapter (`performance.gpuAdapter`), read before the window
|
||||
/// opens; empty (automatic) when the file or value is missing.
|
||||
pub fn read_gpu_adapter(path: Option<&Path>) -> String {
|
||||
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);
|
||||
v.get("performance").and_then(|p| p.get("gpuAdapter")).and_then(Value::as_str).unwrap_or("").chars().take(256).collect()
|
||||
}
|
||||
|
||||
/// 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);
|
||||
@@ -555,23 +456,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;
|
||||
@@ -657,27 +541,6 @@ mod tests {
|
||||
assert_eq!(plan(Auto, None, Some("dx12"), true, Os::Windows).backend, Cpu);
|
||||
}
|
||||
|
||||
/// #3483: A temporary CPU recovery must not change the next launch.
|
||||
#[test]
|
||||
fn cpu_recovery_requires_explicit_confirmation_to_persist() {
|
||||
let gl_crash = crashed("gl", "gl");
|
||||
let cpu = plan_with_mode(Auto, RenderingMode::Auto, Some(&gl_crash), None, false, Os::Other);
|
||||
assert_eq!((cpu.backend, cpu.remember), (Cpu, true));
|
||||
assert_eq!(backend_to_remember(&cpu), None);
|
||||
|
||||
let safe = plan_with_mode(Auto, RenderingMode::Auto, None, None, true, Os::Other);
|
||||
assert_eq!(safe.backend, Cpu);
|
||||
assert_eq!(backend_to_remember(&safe), None);
|
||||
// An unconfirmed fallback leaves the original automatic selection intact.
|
||||
assert_eq!(plan_with_mode(Auto, RenderingMode::Auto, None, None, false, Os::Other).backend, Auto);
|
||||
// A working hardware fallback can still be remembered.
|
||||
let vk_crash = crashed("auto", "vulkan");
|
||||
let gl = plan_with_mode(Auto, RenderingMode::Auto, Some(&vk_crash), None, false, Os::Other);
|
||||
assert_eq!(backend_to_remember(&gl), Some(Gl));
|
||||
// CPU explicitly selected in preferences still works.
|
||||
assert_eq!(plan_with_mode(Auto, RenderingMode::Cpu, None, None, false, Os::Other).backend, Cpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_gpu_forces_the_cpu_for_one_launch() {
|
||||
let p = plan(Vulkan, Some(&crashed("vulkan", "vulkan")), None, true, Os::Other);
|
||||
@@ -688,82 +551,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn cand(vendor: u32, device_type: wgpu::DeviceType, backend: wgpu::Backend) -> Candidate {
|
||||
Candidate { vendor, device_type, backend, surface_ok: true, wanted: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_requested_adapter_beats_the_automatic_ranking() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
// The order Vulkan lists this machine's GPUs in: iGPU, dGPU, eGPU.
|
||||
let mut a = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::DiscreteGpu, B::Vulkan), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
|
||||
assert_eq!(pick(&a, Auto, Os::Other), Some(1), "automatic takes the first discrete adapter, the Arc");
|
||||
a[2].wanted = true;
|
||||
assert_eq!(pick(&a, Auto, Os::Other), Some(2));
|
||||
a[2].wanted = false;
|
||||
a[0].wanted = true;
|
||||
assert_eq!(pick(&a, Auto, Os::Other), Some(0), "even the iGPU, when asked for");
|
||||
// A wanted adapter that can't present is skipped, not an error.
|
||||
a[0].surface_ok = false;
|
||||
assert_eq!(pick(&a, Auto, Os::Other), Some(1));
|
||||
assert!(wanted_missed(&a, Some("iris")));
|
||||
assert!(!wanted_missed(&a, None));
|
||||
a[0].surface_ok = true;
|
||||
assert!(!wanted_missed(&a, Some("iris")));
|
||||
// Among several wanted ones (same GPU on Vulkan and GL) the usual ranking decides.
|
||||
let mut gl = [cand(0x10de, T::DiscreteGpu, B::Gl), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
|
||||
gl.iter_mut().for_each(|c| c.wanted = true);
|
||||
assert_eq!(pick(&gl, Auto, Os::Other), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adapter_request_applies_on_linux_outside_cpu_mode() {
|
||||
let w = || Some("nvidia".to_string());
|
||||
assert_eq!(applicable_adapter(w(), Auto, true), w());
|
||||
assert_eq!(applicable_adapter(w(), Vulkan, true), w());
|
||||
assert_eq!(applicable_adapter(w(), Cpu, true), None);
|
||||
assert_eq!(applicable_adapter(w(), Auto, false), None, "Windows and macOS keep their own selection");
|
||||
assert_eq!(applicable_adapter(None, Auto, true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adapter_override_keeps_its_own_process_only_when_it_applies() {
|
||||
let on = |env, mode, linux| adapter_override_is_process_specific(env, mode, linux);
|
||||
assert!(on(Some("nvidia"), RenderingMode::Auto, true));
|
||||
assert!(on(Some("nvidia"), RenderingMode::Gpu, true));
|
||||
assert!(!on(Some("nvidia"), RenderingMode::Cpu, true), "ignored in CPU mode");
|
||||
assert!(!on(Some("nvidia"), RenderingMode::Auto, false), "ignored off Linux");
|
||||
assert!(!on(Some(" "), RenderingMode::Auto, true));
|
||||
assert!(!on(None, RenderingMode::Auto, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_names_match_loosely_and_the_environment_wins() {
|
||||
assert!(adapter_matches("NVIDIA GeForce RTX 5060 Ti", "nvidia"));
|
||||
assert!(adapter_matches("Intel(R) Arc(tm) A370M Graphics (DG2)", " ARC A370M "));
|
||||
assert!(!adapter_matches("Intel(R) Iris(R) Xe Graphics (ADL GT2)", "arc"));
|
||||
assert!(!adapter_matches("anything", ""));
|
||||
assert!(!adapter_matches("anything", " "));
|
||||
assert_eq!(wanted_adapter(None, ""), None);
|
||||
assert_eq!(wanted_adapter(Some(" "), "iris"), Some("iris".into()));
|
||||
assert_eq!(wanted_adapter(Some("nvidia"), "iris"), Some("nvidia".into()));
|
||||
assert_eq!(wanted_adapter(Some(&"x".repeat(1000)), "").map(|w| w.len()), Some(256));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_adapter_is_read_before_the_window_opens() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-gpu-adapter-{}", 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_gpu_adapter(Some(&file))
|
||||
};
|
||||
assert_eq!(read(r#"{"performance":{"gpuAdapter":"nvidia"}}"#), "nvidia");
|
||||
assert_eq!(read(r#"{"performance":{"gpuAdapter":7}}"#), "");
|
||||
assert_eq!(read(r#"{"performance":{}}"#), "");
|
||||
assert_eq!(read("not json"), "");
|
||||
assert_eq!(read_gpu_adapter(None), "");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
Candidate { vendor, device_type, backend, surface_ok: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -772,13 +560,13 @@ mod tests {
|
||||
let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
|
||||
let i = pick(&intel, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(intel[i].backend, B::Dx12);
|
||||
assert!(intel_dx12_applied(&intel, i, Os::Windows, false));
|
||||
assert!(intel_dx12_applied(&intel, i, Os::Windows));
|
||||
// Elsewhere (and for other vendors) Vulkan stays first.
|
||||
assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan);
|
||||
let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
|
||||
let i = pick(&nv, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(nv[i].backend, B::Vulkan);
|
||||
assert!(!intel_dx12_applied(&nv, i, Os::Windows, false));
|
||||
assert!(!intel_dx12_applied(&nv, i, Os::Windows));
|
||||
// Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan.
|
||||
let hybrid = [intel[0], intel[1], nv[1]];
|
||||
assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2));
|
||||
@@ -789,45 +577,6 @@ mod tests {
|
||||
assert_eq!(pick(&[], Auto, Os::Windows), None);
|
||||
}
|
||||
|
||||
/// #4: an instance with Vulkan loads the Intel Vulkan driver, which crashed at startup, before
|
||||
/// the selector picks Intel's DX12 adapter. With an Intel adapter in the DX12 list, `auto` on
|
||||
/// Windows creates the instance without Vulkan.
|
||||
#[test]
|
||||
fn auto_on_windows_with_an_intel_adapter_leaves_vulkan_out_of_the_instance() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
const NVIDIA: u32 = 0x10de;
|
||||
const AMD: u32 = 0x1002;
|
||||
const WARP: u32 = 0x1414;
|
||||
let auto = plan(Auto, None, None, false, Os::Windows);
|
||||
let dx12_gl = Some(wgpu::Backends::DX12 | wgpu::Backends::GL);
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![INTEL, WARP]), dx12_gl);
|
||||
// Intel iGPU + discrete GPU: the discrete GPU is then used on DX12.
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, INTEL, WARP]), dx12_gl);
|
||||
// No Intel adapter: egui's default backends, Vulkan included.
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![NVIDIA, WARP]), None);
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, || vec![AMD]), None);
|
||||
assert_eq!(windows_auto_backends(&auto, Os::Windows, Vec::new), None);
|
||||
// `WGPU_BACKEND`, `--safe-gpu`, a chosen backend, a crash fallback and other platforms
|
||||
// don't list the DX12 adapters at all.
|
||||
let not_listed = || -> Vec<u32> { panic!("DX12 adapters listed") };
|
||||
for (p, os) in [
|
||||
(plan(Auto, None, Some("vulkan"), false, Os::Windows), Os::Windows),
|
||||
(plan(Auto, None, None, true, Os::Windows), Os::Windows),
|
||||
(plan(Vulkan, None, None, false, Os::Windows), Os::Windows),
|
||||
(plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Windows), Os::Windows),
|
||||
(plan(Auto, None, None, false, Os::Other), Os::Other),
|
||||
(plan(Auto, None, None, false, Os::Mac), Os::Mac),
|
||||
] {
|
||||
assert_eq!(windows_auto_backends(&p, os, not_listed), None, "{p:?}");
|
||||
}
|
||||
// Without Vulkan adapters to compare with, System Info still names the Intel default.
|
||||
let listed = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(WARP, T::Cpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
|
||||
let i = pick(&listed, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(i, 0);
|
||||
assert!(intel_dx12_applied(&listed, i, Os::Windows, true));
|
||||
assert!(!intel_dx12_applied(&listed, i, Os::Windows, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_prefers_software_adapters() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
@@ -859,20 +608,6 @@ mod tests {
|
||||
assert_eq!(pick(&[nvidia_gl, nvidia_vk], Auto, Os::Other), Some(1));
|
||||
}
|
||||
|
||||
/// #5191, #1788: a Windows VM offers VMware's SVGA3D over GL (`Other`) and WARP (`Cpu`, DX12).
|
||||
/// GL drew a black window; WARP works. GL stays the last resort.
|
||||
#[test]
|
||||
fn windows_auto_prefers_warp_over_gl() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
let svga_gl = cand(0x15ad, T::Other, B::Gl);
|
||||
let warp = cand(0x1414, T::Cpu, B::Dx12);
|
||||
assert_eq!(pick(&[svga_gl, warp], Auto, Os::Windows), Some(1));
|
||||
assert_eq!(pick(&[cand(INTEL, T::IntegratedGpu, B::Gl), warp], Auto, Os::Windows), Some(1));
|
||||
assert_eq!(pick(&[svga_gl], Auto, Os::Windows), Some(0));
|
||||
// Elsewhere GL keeps its rank.
|
||||
assert_eq!(pick(&[svga_gl, warp], Auto, Os::Other), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapters_must_present_even_when_none_support_the_surface() {
|
||||
let mut a = cand(INTEL, wgpu::DeviceType::IntegratedGpu, wgpu::Backend::Dx12);
|
||||
@@ -937,7 +672,7 @@ mod tests {
|
||||
} else {
|
||||
s.finish();
|
||||
}
|
||||
let (prev, _s) = begin_when_unlocked(&dir);
|
||||
let (prev, _s) = Sentinel::begin(&dir);
|
||||
assert_eq!(prev, Previous::Clean);
|
||||
assert_eq!(plan(Auto, prev.crashed(), None, false, Os::Other).backend, Auto);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
@@ -949,22 +684,6 @@ mod tests {
|
||||
d
|
||||
}
|
||||
|
||||
/// `Sentinel::begin`, called again up to 50 times, 10 ms apart, while it returns
|
||||
/// `Previous::Busy`. Other tests in this binary spawn processes: between fork and exec a child
|
||||
/// briefly shares the marker's flock, so `begin` can see the lock as busy just after a test
|
||||
/// drops its sentinel.
|
||||
fn begin_when_unlocked(dir: &Path) -> (Previous, Option<Sentinel>) {
|
||||
let (mut prev, mut s) = Sentinel::begin(dir);
|
||||
for _ in 0..50 {
|
||||
if prev != Previous::Busy {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
(prev, s) = Sentinel::begin(dir);
|
||||
}
|
||||
(prev, s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentinel_detects_a_crash_and_clears_on_success() {
|
||||
let dir = temp_dir("cycle");
|
||||
@@ -980,7 +699,16 @@ mod tests {
|
||||
assert!(none.is_none());
|
||||
// The process dies in the driver: the lock goes, the marker stays.
|
||||
drop(s);
|
||||
let (prev, s) = begin_when_unlocked(&dir);
|
||||
// Another test in this binary spawns a process: between its fork and exec the child briefly
|
||||
// shares this file's flock, so `begin` can see the lock as busy. Retry for a moment.
|
||||
let (mut prev, mut s) = Sentinel::begin(&dir);
|
||||
for _ in 0..50 {
|
||||
if prev != Previous::Busy {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
(prev, s) = Sentinel::begin(&dir);
|
||||
}
|
||||
let m = prev.crashed().cloned().expect("crash detected");
|
||||
assert_eq!((m.adapter.as_str(), m.tried()), ("GPU", Some(Vulkan)));
|
||||
// This start succeeds: the next one finds nothing.
|
||||
@@ -993,29 +721,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn begin_when_unlocked_waits_for_a_briefly_held_lock() {
|
||||
let dir = temp_dir("shared-lock");
|
||||
let (_, s) = Sentinel::begin(&dir);
|
||||
let mut s = s.expect("sentinel");
|
||||
s.write(Marker { backend: "vulkan".into(), ..Default::default() }).unwrap();
|
||||
// After the start dies, a second handle on the marker holds the lock for 50 ms, as a
|
||||
// spawned child's copy of the descriptor does until the child calls exec.
|
||||
let held = File::open(dir.join(MARKER_FILE)).unwrap();
|
||||
drop(s);
|
||||
held.lock().unwrap();
|
||||
assert_eq!(Sentinel::begin(&dir).0, Previous::Busy);
|
||||
let release = std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
held.unlock().unwrap();
|
||||
});
|
||||
let (prev, s) = begin_when_unlocked(&dir);
|
||||
release.join().unwrap();
|
||||
assert_eq!(prev.crashed().map(|m| m.backend.as_str()), Some("vulkan"));
|
||||
s.expect("sentinel").finish();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returned_non_graphics_errors_do_not_trigger_backend_recovery() {
|
||||
let cases = [
|
||||
@@ -1033,7 +738,7 @@ mod tests {
|
||||
} else {
|
||||
s.finish();
|
||||
}
|
||||
let (previous, s) = begin_when_unlocked(&dir);
|
||||
let (previous, s) = Sentinel::begin(&dir);
|
||||
assert_eq!(previous.crashed().is_some(), keep, "{name}: {result:?}");
|
||||
if !keep {
|
||||
assert_eq!(plan(Vulkan, previous.crashed(), None, false, Os::Other).backend, Vulkan);
|
||||
@@ -1092,7 +797,7 @@ mod tests {
|
||||
// The app's setup replaces wgpu's default (`Auto`: DXC by name from the search path).
|
||||
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
|
||||
let plan = plan(Auto, None, None, false, Os::Windows);
|
||||
configure(&mut setup, &plan, Os::Windows, None, Arc::new(Mutex::new(None)), Arc::default());
|
||||
configure(&mut setup, &plan, Os::Windows, Arc::new(Mutex::new(None)), Arc::default());
|
||||
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
|
||||
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
|
||||
let safe = match &create.instance_descriptor.backend_options.dx12.shader_compiler {
|
||||
@@ -1116,13 +821,13 @@ mod tests {
|
||||
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
|
||||
let egui_wgpu::WgpuSetup::CreateNew(before) = &setup else { panic!("a new instance") };
|
||||
let before = before.instance_descriptor.flags;
|
||||
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, None, Arc::new(Mutex::new(None)), Arc::default());
|
||||
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, Arc::new(Mutex::new(None)), Arc::default());
|
||||
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
|
||||
assert_eq!(create.instance_descriptor.flags, before, "{os:?}: flags untouched");
|
||||
}
|
||||
if cfg!(target_arch = "x86_64") && std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
|
||||
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
|
||||
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, None, Arc::new(Mutex::new(None)), Arc::default());
|
||||
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, Arc::new(Mutex::new(None)), Arc::default());
|
||||
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
|
||||
assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
|
||||
}
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
//! Linux: icons from the desktop's freedesktop icon theme (the Icon Theme Specification), for the
|
||||
//! caption buttons' glyphs (`caption_style.rs`). A theme is searched, then the themes it inherits,
|
||||
//! then `hicolor`; within each, its directories from the best fit for a 16 px icon down. Only SVG
|
||||
//! files are used: symbolic icons are drawn as a coverage mask in the button's ink colour.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Bounds on what a theme's files can make us do: themes in an inheritance chain, directories a
|
||||
/// theme lists, and the bytes read from one file.
|
||||
const MAX_THEMES: usize = 32;
|
||||
const MAX_DIRS: usize = 4096;
|
||||
/// How deep an index's unlisted directories are looked for (`symbolic/ui`, `16x16@2/actions`).
|
||||
const SCAN_DEPTH: usize = 3;
|
||||
const MAX_FILE: u64 = 1 << 20;
|
||||
/// The size the caption glyphs are drawn at, in the spec's units.
|
||||
const ICON_SIZE: i64 = 16;
|
||||
|
||||
/// A theme or icon name that stays inside its directory.
|
||||
pub fn valid_name(name: &str) -> bool {
|
||||
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
|
||||
}
|
||||
|
||||
/// At most [`MAX_FILE`] bytes of a file (settings, theme indexes and icons are small).
|
||||
pub fn read_small(path: &Path) -> Option<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let mut bytes = vec![];
|
||||
std::fs::File::open(path).ok()?.take(MAX_FILE).read_to_end(&mut bytes).ok()?;
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// Where themes live, in the spec's order: `~/.icons`, `$XDG_DATA_HOME/icons`, then each
|
||||
/// `$XDG_DATA_DIRS/icons`. Relative paths in the variables are ignored, as the XDG spec says.
|
||||
pub fn base_dirs(var: &dyn Fn(&str) -> Option<String>) -> Vec<PathBuf> {
|
||||
let absolute = |p: &String| Path::new(p).is_absolute();
|
||||
let home = var("HOME").filter(absolute).map(PathBuf::from);
|
||||
let mut dirs = vec![];
|
||||
if let Some(home) = &home {
|
||||
dirs.push(home.join(".icons"));
|
||||
}
|
||||
if let Some(data) = var("XDG_DATA_HOME").filter(absolute).map(PathBuf::from).or_else(|| home.map(|h| h.join(".local").join("share"))) {
|
||||
dirs.push(data.join("icons"));
|
||||
}
|
||||
let data_dirs = var("XDG_DATA_DIRS").filter(|d| !d.trim().is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
|
||||
dirs.extend(data_dirs.split(':').filter(|d| Path::new(d).is_absolute()).map(|d| Path::new(d).join("icons")));
|
||||
dirs
|
||||
}
|
||||
|
||||
/// One `[section]` of an `index.theme`, as (key, value) pairs.
|
||||
fn section<'a>(index: &'a str, name: &str) -> Vec<(&'a str, &'a str)> {
|
||||
let mut inside = false;
|
||||
let mut pairs = vec![];
|
||||
for line in index.lines().map(str::trim) {
|
||||
if let Some(s) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
if inside {
|
||||
break;
|
||||
}
|
||||
inside = s == name;
|
||||
} else if inside && let Some((k, v)) = line.split_once('=') {
|
||||
pairs.push((k.trim(), v.trim()));
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
fn list(value: Option<&str>) -> impl Iterator<Item = &str> {
|
||||
value.unwrap_or_default().split(',').map(str::trim).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// How far a theme directory is from a 16 px icon at scale 1 (the spec's
|
||||
/// `DirectorySizeDistance`), then its scale: 0 is an exact fit.
|
||||
fn distance(keys: &[(&str, &str)]) -> (i64, i64) {
|
||||
let get = |k: &str| keys.iter().find(|(key, _)| *key == k).map(|(_, v)| *v);
|
||||
let num = |k: &str, default: i64| get(k).and_then(|v| v.parse::<i64>().ok()).unwrap_or(default).clamp(0, 1 << 16);
|
||||
let size = num("Size", 0);
|
||||
let scale = num("Scale", 1).max(1);
|
||||
let d = match get("Type").unwrap_or("Threshold") {
|
||||
"Fixed" => (size * scale - ICON_SIZE).abs(),
|
||||
"Scalable" => {
|
||||
let (min, max) = (num("MinSize", size) * scale, num("MaxSize", size) * scale);
|
||||
(min - ICON_SIZE).max(ICON_SIZE - max).max(0)
|
||||
}
|
||||
_ => ((size * scale - ICON_SIZE).abs() - num("Threshold", 2) * scale).max(0),
|
||||
};
|
||||
(d, scale)
|
||||
}
|
||||
|
||||
/// Every directory under `root`, at most [`SCAN_DEPTH`] levels down and [`MAX_DIRS`] in all, in
|
||||
/// path order: scalable and symbolic ones first, as icons there fit any size.
|
||||
fn subdirectories(root: &Path) -> Vec<PathBuf> {
|
||||
let mut found = vec![];
|
||||
let mut level = vec![root.to_path_buf()];
|
||||
for _ in 0..SCAN_DEPTH {
|
||||
let mut next = vec![];
|
||||
for dir in &level {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { continue };
|
||||
for entry in entries.flatten().take(MAX_DIRS) {
|
||||
let path = entry.path();
|
||||
if found.len() + next.len() < MAX_DIRS && path.is_dir() {
|
||||
next.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
found.extend(next.iter().cloned());
|
||||
level = next;
|
||||
}
|
||||
let vector = |p: &PathBuf| !p.components().any(|c| matches!(c.as_os_str().to_str(), Some("scalable" | "symbolic")));
|
||||
found.sort_by_key(|p| (vector(p), p.clone()));
|
||||
found
|
||||
}
|
||||
|
||||
/// One theme of a chain: its listed directories, best fit first, in every base directory that has
|
||||
/// the theme, and (looked for only when an icon isn't in those) the directories its index forgot.
|
||||
struct Theme {
|
||||
roots: Vec<PathBuf>,
|
||||
listed: Vec<PathBuf>,
|
||||
unlisted: std::cell::OnceCell<Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// The directories the index forgot to list: GTK still finds icons there, and custom themes
|
||||
/// often add `symbolic/ui` without listing it.
|
||||
fn unlisted(&self) -> &[PathBuf] {
|
||||
self.unlisted.get_or_init(|| {
|
||||
let listed: std::collections::HashSet<&PathBuf> = self.listed.iter().collect();
|
||||
self.roots.iter().flat_map(|r| subdirectories(r)).filter(|d| !listed.contains(d)).collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A theme and the themes it inherits, in search order.
|
||||
pub struct IconTheme {
|
||||
themes: Vec<Theme>,
|
||||
}
|
||||
|
||||
impl IconTheme {
|
||||
/// `name` and the themes it inherits, then `hicolor`. `None` when no theme in the chain has an
|
||||
/// `index.theme`.
|
||||
pub fn load(name: &str, bases: &[PathBuf]) -> Option<Self> {
|
||||
let mut chain: Vec<String> = vec![name.to_string()];
|
||||
let mut themes = vec![];
|
||||
let mut i = 0;
|
||||
loop {
|
||||
let Some(theme) = chain.get(i).cloned() else {
|
||||
// Every chain ends in hicolor, whether or not the themes before it exist.
|
||||
if chain.iter().any(|t| t == "hicolor") {
|
||||
break;
|
||||
}
|
||||
chain.push("hicolor".into());
|
||||
continue;
|
||||
};
|
||||
i += 1;
|
||||
if !valid_name(&theme) {
|
||||
continue;
|
||||
}
|
||||
let roots: Vec<PathBuf> = bases.iter().map(|b| b.join(&theme)).filter(|r| r.is_dir()).collect();
|
||||
// The first index wins; a theme can still be spread over several base directories.
|
||||
let Some(index) = roots.iter().find_map(|r| read_small(&r.join("index.theme"))) else { continue };
|
||||
let index = String::from_utf8_lossy(&index);
|
||||
let head = section(&index, "Icon Theme");
|
||||
let get = |k: &str| head.iter().find(|(key, _)| *key == k).map(|(_, v)| *v);
|
||||
let mut subdirs: Vec<((i64, i64), &str)> = list(get("Directories"))
|
||||
.chain(list(get("ScaledDirectories")))
|
||||
.filter(|d| !d.split('/').any(|part| part == ".." || part.is_empty()))
|
||||
.take(MAX_DIRS)
|
||||
.map(|d| (distance(§ion(&index, d)), d))
|
||||
.collect();
|
||||
subdirs.sort_by_key(|(key, _)| *key);
|
||||
// Not checked for existence: a theme lists hundreds, and the search stops at a hit.
|
||||
let listed = subdirs.iter().flat_map(|(_, sub)| roots.iter().map(move |r| r.join(sub))).collect();
|
||||
for parent in list(get("Inherits")) {
|
||||
if chain.len() < MAX_THEMES && !chain.iter().any(|t| t == parent) {
|
||||
chain.push(parent.to_string());
|
||||
}
|
||||
}
|
||||
themes.push(Theme { roots, listed, unlisted: std::cell::OnceCell::new() });
|
||||
}
|
||||
(!themes.is_empty()).then_some(Self { themes })
|
||||
}
|
||||
|
||||
/// The first `<icon>.svg` in search order: each theme's listed directories, then its unlisted
|
||||
/// ones, before its parent.
|
||||
pub fn find_svg(&self, icon: &str) -> Option<PathBuf> {
|
||||
if !valid_name(icon) {
|
||||
return None;
|
||||
}
|
||||
let file = format!("{icon}.svg");
|
||||
let hit = |dirs: &[PathBuf]| dirs.iter().map(|d| d.join(&file)).find(|p| p.is_file());
|
||||
self.themes.iter().find_map(|t| hit(&t.listed).or_else(|| hit(t.unlisted())))
|
||||
}
|
||||
}
|
||||
|
||||
/// An SVG's coverage (alpha) as a `px` × `px` mask, the drawing fitted and centred. `None` for an
|
||||
/// unreadable SVG or an absurd size.
|
||||
pub fn rasterize_mask(svg: &[u8], px: u32) -> Option<Vec<u8>> {
|
||||
use resvg::{tiny_skia, usvg};
|
||||
if !(1..=256).contains(&px) {
|
||||
return None;
|
||||
}
|
||||
// Glyphs are paths: no `<image>` is loaded, so an icon can't make us read another file
|
||||
// (`/dev/zero`, a huge picture) or nest an SVG.
|
||||
let opts = usvg::Options {
|
||||
image_href_resolver: usvg::ImageHrefResolver { resolve_data: Box::new(|_, _, _| None), resolve_string: Box::new(|_, _| None) },
|
||||
..usvg::Options::default()
|
||||
};
|
||||
let tree = usvg::Tree::from_data(svg, &opts).ok()?;
|
||||
let size = tree.size();
|
||||
let side = size.width().max(size.height());
|
||||
if !side.is_finite() || side <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let s = px as f32 / side;
|
||||
let (dx, dy) = ((px as f32 - size.width() * s) / 2.0, (px as f32 - size.height() * s) / 2.0);
|
||||
let mut pixmap = tiny_skia::Pixmap::new(px, px)?;
|
||||
resvg::render(&tree, tiny_skia::Transform::from_scale(s, s).post_translate(dx, dy), &mut pixmap.as_mut());
|
||||
Some(pixmap.data().as_chunks::<4>().0.iter().map(|[_, _, _, a]| *a).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A throwaway icon tree: `files` are (path under the base, contents).
|
||||
fn tree(tag: &str, files: &[(&str, &str)]) -> PathBuf {
|
||||
let base = std::env::temp_dir().join(format!("photocraft-icons-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for (path, text) in files {
|
||||
let p = base.join(path);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(p, text).unwrap();
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
const SQUARE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M4 4h8v8H4z" fill="#2e3436"/></svg>"##;
|
||||
|
||||
#[test]
|
||||
fn icons_come_from_the_theme_then_its_parents_then_hicolor_best_size_first() {
|
||||
let base = tree(
|
||||
"chain",
|
||||
&[
|
||||
("Child/index.theme", "[Icon Theme]\nInherits=Parent, Child\nDirectories=48,16\n[48]\nSize=48\nType=Fixed\n[16]\nSize=16\nType=Fixed\n"),
|
||||
("Child/48/a-symbolic.svg", "big"),
|
||||
// Not in the index: still found, and before the parent's copy.
|
||||
("Child/symbolic/ui/d-symbolic.svg", SQUARE),
|
||||
("Parent/ui/d-symbolic.svg", SQUARE),
|
||||
("Child/16/a-symbolic.svg", SQUARE),
|
||||
("Parent/index.theme", "[Icon Theme]\nDirectories=ui\n[ui]\nSize=16\nMinSize=8\nMaxSize=512\nType=Scalable\n"),
|
||||
("Parent/ui/b-symbolic.svg", SQUARE),
|
||||
("hicolor/index.theme", "[Icon Theme]\nDirectories=s\n[s]\nSize=16\nType=Scalable\n"),
|
||||
("hicolor/s/c-symbolic.svg", SQUARE),
|
||||
],
|
||||
);
|
||||
let theme = IconTheme::load("Child", std::slice::from_ref(&base)).expect("the theme loads");
|
||||
assert_eq!(theme.find_svg("a-symbolic"), Some(base.join("Child/16/a-symbolic.svg")), "16 px before 48 px");
|
||||
assert_eq!(theme.find_svg("b-symbolic"), Some(base.join("Parent/ui/b-symbolic.svg")));
|
||||
assert_eq!(theme.find_svg("c-symbolic"), Some(base.join("hicolor/s/c-symbolic.svg")));
|
||||
assert_eq!(theme.find_svg("d-symbolic"), Some(base.join("Child/symbolic/ui/d-symbolic.svg")), "unlisted, but the theme's own");
|
||||
assert_eq!(theme.find_svg("missing"), None);
|
||||
assert_eq!(theme.find_svg("../Parent/ui/b-symbolic"), None);
|
||||
assert!(IconTheme::load("Nowhere", std::slice::from_ref(&base)).is_some(), "hicolor still answers");
|
||||
assert!(IconTheme::load("Nowhere", &[base.join("empty")]).is_none());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// Hostile theme files: cycles, escapes and junk sizes end the search, never hang or panic.
|
||||
#[test]
|
||||
fn cyclic_or_hostile_indexes_are_harmless() {
|
||||
let base = tree(
|
||||
"hostile",
|
||||
&[
|
||||
(
|
||||
"A/index.theme",
|
||||
"[Icon Theme]\nInherits=B,../etc,\nDirectories=../../x,,/abs,ok\n[ok]\nSize=99999999999999999999\nScale=-3\nType=Threshold\n",
|
||||
),
|
||||
("A/ok/x.svg", SQUARE),
|
||||
("B/index.theme", "[Icon Theme]\nInherits=A\n"),
|
||||
],
|
||||
);
|
||||
let theme = IconTheme::load("A", std::slice::from_ref(&base)).expect("loads");
|
||||
assert_eq!(theme.find_svg("x"), Some(base.join("A/ok/x.svg")));
|
||||
assert!(IconTheme::load("..", std::slice::from_ref(&base)).is_none());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distance_follows_the_spec() {
|
||||
assert_eq!(distance(&[("Size", "16"), ("Type", "Fixed")]), (0, 1));
|
||||
assert_eq!(distance(&[("Size", "22"), ("Type", "Fixed")]), (6, 1));
|
||||
assert_eq!(distance(&[("Size", "16"), ("Scale", "2"), ("Type", "Fixed")]), (16, 2));
|
||||
assert_eq!(distance(&[("Size", "32"), ("MinSize", "8"), ("MaxSize", "512"), ("Type", "Scalable")]), (0, 1));
|
||||
assert_eq!(distance(&[("Size", "48"), ("Type", "Scalable")]), (32, 1));
|
||||
assert_eq!(distance(&[("Size", "18")]), (0, 1), "Threshold 2 by default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_are_square_coverage() {
|
||||
let mask = rasterize_mask(SQUARE.as_bytes(), 32).expect("renders");
|
||||
assert_eq!(mask.len(), 32 * 32);
|
||||
assert_eq!(mask[16 * 32 + 16], 255, "inside the square");
|
||||
assert_eq!(mask[0], 0, "outside it");
|
||||
assert!(rasterize_mask(b"not svg", 16).is_none());
|
||||
assert!(rasterize_mask(SQUARE.as_bytes(), 0).is_none());
|
||||
assert!(rasterize_mask(SQUARE.as_bytes(), 100_000).is_none());
|
||||
}
|
||||
|
||||
/// An icon's `<image>` is never loaded: not a device file, not an embedded picture.
|
||||
#[test]
|
||||
fn masks_load_no_images() {
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16">
|
||||
<image href="/dev/zero" width="16" height="16"/>
|
||||
<image xlink:href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16'/></svg>" width="16" height="16"/>
|
||||
</svg>"##;
|
||||
let mask = rasterize_mask(svg.as_bytes(), 16).expect("renders");
|
||||
assert!(mask.iter().all(|a| *a == 0), "nothing drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_dirs_follow_xdg() {
|
||||
let var = |k: &str| match k {
|
||||
"HOME" => Some("/home/u".to_string()),
|
||||
"XDG_DATA_DIRS" => Some("/a:rel:/b".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let dirs = base_dirs(&var);
|
||||
let want: Vec<PathBuf> = ["/home/u/.icons", "/home/u/.local/share/icons", "/a/icons", "/b/icons"].iter().map(PathBuf::from).collect();
|
||||
assert_eq!(dirs, want);
|
||||
assert_eq!(base_dirs(&|_| None), vec![PathBuf::from("/usr/local/share/icons"), PathBuf::from("/usr/share/icons")]);
|
||||
}
|
||||
}
|
||||
+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 {
|
||||
|
||||
+80
-499
@@ -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
|
||||
@@ -41,14 +26,11 @@ mod appearance;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod caps_lock;
|
||||
mod caption_style;
|
||||
mod control_server;
|
||||
mod crash_guard;
|
||||
mod cursor;
|
||||
mod file_dialog_probe;
|
||||
mod gpu_startup;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod icon_theme;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -59,19 +41,10 @@ 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;
|
||||
// Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button).
|
||||
// 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;
|
||||
mod ui_state;
|
||||
mod window_frame;
|
||||
@@ -99,14 +72,11 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
|
||||
/// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame
|
||||
/// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without
|
||||
/// `centered`, Windows cascades each new window from the top-left corner, so it opened at a
|
||||
/// different offset every launch (#419). A later launch restores the saved position instead
|
||||
/// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves.
|
||||
/// different offset every launch (#419). Wayland compositors place windows themselves.
|
||||
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
let viewport = egui::ViewportBuilder::default();
|
||||
// Not inside a macOS app bundle: its Dock icon is the bundle's .icns (`app_icon`, #5849).
|
||||
let viewport = if app_icon::sets_runtime_icon(std::env::current_exe().ok().as_deref()) { viewport.with_icon(app_icon::window_icon()) } else { viewport };
|
||||
eframe::NativeOptions {
|
||||
viewport: viewport
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_icon(app_icon::window_icon())
|
||||
.with_app_id(APP_ID)
|
||||
.with_title("PhotoCraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
@@ -120,21 +90,11 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
// eframe saves native window geometry and egui panel/window sizes on exit.
|
||||
// Keep that state beside preferences, including config overrides and portable mode.
|
||||
persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")),
|
||||
window_builder: Some(Box::new(restored_window)),
|
||||
window_builder: Some(Box::new(at_least_min_size)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The window eframe restores from `ui.ron`, corrected before it is created: at least the
|
||||
/// minimum size, and never full screen. PhotoCraft always starts in Standard Screen Mode, but
|
||||
/// eframe also saves and restores full screen, and a window closed in a full-screen mode is
|
||||
/// saved full screen and maximized at once: the overlap `view_cmds::screen_mode_commands` keeps
|
||||
/// apart at run time. [OBSERVED 2026-10-12, Windows 11] Restored that way, View › Screen Mode ›
|
||||
/// Full Screen did nothing until `F` had cycled through Standard.
|
||||
fn restored_window(builder: egui::ViewportBuilder) -> egui::ViewportBuilder {
|
||||
at_least_min_size(builder).with_fullscreen(false)
|
||||
}
|
||||
|
||||
/// Grow a restored window that is smaller than the minimum size, keeping its centre. On
|
||||
/// Windows, eframe saves a minimized window's size (160 pt wide, 64 pt tall once eframe has
|
||||
/// clamped it), and the minimum size isn't applied to a restored one: PhotoCraft then reopened
|
||||
@@ -156,188 +116,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]
|
||||
@@ -358,152 +151,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,
|
||||
mut 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.
|
||||
@@ -515,53 +227,6 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
|
||||
// An adapter named in the environment is chosen at startup, which a forwarded launch never reaches.
|
||||
let rendering_mode = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref()).1;
|
||||
if gpu_startup::adapter_override_is_process_specific(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), rendering_mode, gpu_startup::ADAPTER_CHOICE) {
|
||||
launch_options.push("PHOTOCRAFT_GPU_ADAPTER");
|
||||
}
|
||||
// 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")]
|
||||
@@ -631,11 +296,6 @@ fn main() -> eframe::Result {
|
||||
let tablet_monitor = std::cell::OnceCell::new();
|
||||
#[cfg(target_os = "macos")]
|
||||
let tablet_monitor = &tablet_monitor;
|
||||
// The Windows pointer message hook; dropping it unhooks when the event loop returns.
|
||||
#[cfg(target_os = "windows")]
|
||||
let tablet_monitor_win = std::cell::OnceCell::new();
|
||||
#[cfg(target_os = "windows")]
|
||||
let tablet_monitor_win = &tablet_monitor_win;
|
||||
|
||||
// Read the displays' ICC profiles while the window opens (colour-managed canvas; `None`
|
||||
// where the platform has no reader).
|
||||
@@ -657,9 +317,6 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
// eframe restores the saved window layout before our code runs; drop values that would crash it.
|
||||
ui_state::sanitize(options.persistence_path.as_deref());
|
||||
// Centre only the first window: eframe centres after restoring the saved position, so a
|
||||
// window closed on another monitor reopened in the middle of the main one (#4826).
|
||||
options.centered = !ui_state::has_saved_position(options.persistence_path.as_deref());
|
||||
// Crash-safe GPU startup (#4): pick the backend (a marker left by a start that died in the
|
||||
// driver moves to a safer one), and lock this start's marker until the first frames render.
|
||||
let t_sentinel = std::time::Instant::now();
|
||||
@@ -687,24 +344,7 @@ 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.
|
||||
let wanted_adapter =
|
||||
gpu_startup::wanted_adapter(std::env::var("PHOTOCRAFT_GPU_ADAPTER").ok().as_deref(), &gpu_startup::read_gpu_adapter(services::prefs_file().as_deref()));
|
||||
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, wanted_adapter, 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}");
|
||||
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
|
||||
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;
|
||||
@@ -732,16 +372,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).
|
||||
@@ -773,10 +404,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}")),
|
||||
@@ -785,16 +412,9 @@ fn main() -> eframe::Result {
|
||||
info.canvas = "cpu".into();
|
||||
if let Some(rs) = cc.wgpu_render_state.clone() {
|
||||
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
|
||||
app.perf.gpu_info.set_available(&rs.available_adapters);
|
||||
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
|
||||
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
|
||||
// Keep an earlier note, such as a requested adapter that couldn't be used.
|
||||
let software = "No compatible hardware graphics adapter; using software graphics.";
|
||||
let fallback = &mut app.perf.gpu_info.fallback;
|
||||
*fallback = Some(match fallback.take() {
|
||||
Some(earlier) => format!("{earlier}. {software}"),
|
||||
None => software.into(),
|
||||
});
|
||||
app.perf.gpu_info.fallback = Some("No compatible hardware graphics adapter; using software graphics.".into());
|
||||
}
|
||||
if !software_window
|
||||
&& plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
|
||||
@@ -816,13 +436,24 @@ fn main() -> eframe::Result {
|
||||
photocraft_ui_egui::gpu_status::queue_fallback_notice(&mut app, &reason);
|
||||
}
|
||||
app.perf.span("gpuSentinel", sentinel_ms);
|
||||
// CPU rendering after a failed GPU start is temporary. Only the
|
||||
// recovery-dialog choice may persist it (#3483).
|
||||
let remember = gpu_startup::backend_to_remember(&plan);
|
||||
// Once the first frames rendered: clear the marker, and keep a crash fallback.
|
||||
let remember_cpu =
|
||||
std::env::var_os("PHOTOCRAFT_GPU_STARTUP_FAILURE").is_some() || (plan.remember && plan.backend == photocraft_engine::prefs::GpuBackend::Cpu);
|
||||
let remember = plan.remember.then_some(plan.backend);
|
||||
app.on_started(move |app| {
|
||||
if let Some(s) = started_sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
|
||||
s.finish();
|
||||
}
|
||||
if remember_cpu
|
||||
&& let Err(error) = app.run(
|
||||
"prefs.set",
|
||||
serde_json::json!({"values": {
|
||||
"performance.renderingMode": "cpu", "performance.useGpu": false
|
||||
}}),
|
||||
)
|
||||
{
|
||||
log::warn!("couldn't remember CPU recovery: {error}");
|
||||
}
|
||||
if let Some(b) = remember
|
||||
&& app.session.prefs().performance.gpu_backend != b
|
||||
&& let Err(e) = app.run("prefs.set", serde_json::json!({"path": "performance.gpuBackend", "value": b.name()}))
|
||||
@@ -846,20 +477,11 @@ 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
|
||||
// border offset the content and the pointer on some Windows systems (#2246).
|
||||
app.services.window_frame = window_frame::service(cc, custom_titlebar);
|
||||
// Linux: the desktop's caption buttons (GNOME's, KDE's) on the sides its layout puts them.
|
||||
if custom_titlebar {
|
||||
app.services.caption_style = caption_style::service();
|
||||
}
|
||||
// Caps Lock state (X11/Windows/macOS; `None` on native Wayland): the canvas shows the
|
||||
// precise crosshair for painting tools while it is toggled on (#1758).
|
||||
app.services.caps_lock = caps_lock::service(cc);
|
||||
@@ -871,13 +493,8 @@ fn main() -> eframe::Result {
|
||||
if let Some(monitor) = tablet::install_macos(&app.stylus.feed) {
|
||||
let _ = tablet_monitor.set(monitor);
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(monitor) = tablet::install_windows() {
|
||||
let _ = tablet_monitor_win.set(monitor);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
tablet::spawn_x11(&app.stylus.feed, display);
|
||||
app.services.right_button_down = tablet::right_button_service();
|
||||
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
|
||||
app.open_paths(&files);
|
||||
if !unreadable_paths.is_empty() {
|
||||
@@ -893,13 +510,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.
|
||||
@@ -943,19 +553,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]
|
||||
@@ -1027,22 +624,6 @@ mod tests {
|
||||
assert_eq!(super::at_least_min_size(builder).inner_size, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_saved_in_full_screen_reopens_maximized_not_full_screen() {
|
||||
let hook = super::native_options(super::CUSTOM_TITLEBAR).window_builder.expect("a window builder hook");
|
||||
// What eframe restores from a ui.ron saved in a full-screen mode on Windows.
|
||||
let saved = super::native_options(super::CUSTOM_TITLEBAR)
|
||||
.viewport
|
||||
.with_inner_size([2560.0, 1392.0])
|
||||
.with_position([0.0, 0.0])
|
||||
.with_fullscreen(true)
|
||||
.with_maximized(true);
|
||||
let opened = hook(saved);
|
||||
assert_eq!(opened.fullscreen, Some(false), "Standard Screen Mode is where PhotoCraft starts");
|
||||
assert_eq!(opened.maximized, Some(true), "the rest of the saved geometry is kept");
|
||||
assert_eq!(opened.inner_size, Some(egui::vec2(2560.0, 1392.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_system_title_bar_preference_keeps_the_window_decorations() {
|
||||
// #1271, #1316: Preferences › Interface › System Title Bar gives the window back its
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
+51
-446
@@ -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;
|
||||
@@ -16,13 +16,13 @@ use std::sync::Arc;
|
||||
#[cfg(test)]
|
||||
mod recovery_tests;
|
||||
|
||||
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
|
||||
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and
|
||||
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
|
||||
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
|
||||
const OPEN_EXTS: &[&str] = &[
|
||||
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
|
||||
"pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw",
|
||||
"arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
|
||||
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
|
||||
"ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
|
||||
"svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
|
||||
@@ -36,47 +36,15 @@ 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"])),
|
||||
("SVG", open_filter_extensions(SVG_EXTS)),
|
||||
]
|
||||
}
|
||||
|
||||
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
|
||||
@@ -84,7 +52,6 @@ fn open_filters() -> Vec<(&'static str, Vec<String>)> {
|
||||
/// keeps defaulting to PSD.
|
||||
const SAVE_FILTERS: &[(&str, &[&str])] = &[
|
||||
("PSD Document", &["psd", "psb"]),
|
||||
("PDF", &["pdf"]),
|
||||
("PhotoCraft", &["pcraft"]),
|
||||
("OpenRaster", &["ora"]),
|
||||
("PNG", &["png"]),
|
||||
@@ -92,15 +59,8 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
|
||||
("WebP", &["webp"]),
|
||||
("TIFF", &["tif"]),
|
||||
("Targa", &["tga"]),
|
||||
// Also in Photoshop's Save As list; ICO is PhotoCraft's own (#4357): up to 256×256, with alpha.
|
||||
("BMP", &["bmp"]),
|
||||
("GIF", &["gif"]),
|
||||
("ICO", &["ico"]),
|
||||
("OpenEXR", &["exr"]),
|
||||
("FITS", &["fits", "fit"]),
|
||||
("DPX", &["dpx"]),
|
||||
// Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327).
|
||||
("DDS", &["dds"]),
|
||||
];
|
||||
|
||||
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
|
||||
@@ -116,11 +76,8 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
|
||||
let mut v: Vec<(String, Vec<String>)> = SAVE_FILTERS.iter().map(|(name, exts)| (name.to_string(), exts.iter().map(|e| e.to_string()).collect())).collect();
|
||||
match v.iter().position(|(_, exts)| exts.contains(&ext)) {
|
||||
Some(i) => {
|
||||
let (name, mut exts) = v.remove(i);
|
||||
// Windows adds the first type's first extension to a bare name: `.psb`, not `.psd`.
|
||||
exts.retain(|e| *e != ext);
|
||||
exts.insert(0, ext);
|
||||
v.insert(0, (name, exts));
|
||||
let f = v.remove(i);
|
||||
v.insert(0, f);
|
||||
}
|
||||
None if !ext.is_empty() => v.insert(0, (ext.to_ascii_uppercase(), vec![ext])),
|
||||
None => {}
|
||||
@@ -128,54 +85,6 @@ 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
|
||||
/// 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> {
|
||||
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();
|
||||
Some(if bare { stem.to_string_lossy().into_owned() } else { name })
|
||||
}
|
||||
|
||||
/// Shows an Open or Save dialog without blocking the event loop (#673, #574): rfd's async dialog
|
||||
/// is created here, on the main thread, with the window as its parent (a sheet on macOS, an owned
|
||||
/// modal window elsewhere), and a helper thread waits for it and hands the answer to the app.
|
||||
@@ -193,7 +102,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();
|
||||
@@ -211,52 +123,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 {
|
||||
dialog = dialog.set_file_name(name);
|
||||
if let Some(name) = Path::new(&suggested).file_name() {
|
||||
dialog = dialog.set_file_name(name.to_string_lossy());
|
||||
}
|
||||
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.
|
||||
@@ -274,7 +151,7 @@ fn path_of(file: &rfd::FileHandle) -> String {
|
||||
}
|
||||
|
||||
/// Runs `future` to completion on this thread, sleeping until it is woken.
|
||||
pub(crate) fn block_on<T>(future: impl Future<Output = T>) -> T {
|
||||
fn block_on<T>(future: impl Future<Output = T>) -> T {
|
||||
struct Unpark(std::thread::Thread);
|
||||
impl std::task::Wake for Unpark {
|
||||
fn wake(self: Arc<Self>) {
|
||||
@@ -294,8 +171,8 @@ pub(crate) 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()
|
||||
}
|
||||
@@ -310,11 +187,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
|
||||
@@ -330,7 +204,19 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R
|
||||
}
|
||||
|
||||
fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions {
|
||||
settings.io_options()
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
if let Some(q) = settings.jpeg_quality {
|
||||
opts.encode.jpeg_quality = q;
|
||||
}
|
||||
opts.encode.webp_lossless = settings.webp_lossless;
|
||||
if let Some(q) = settings.webp_quality {
|
||||
opts.encode.webp_quality = q;
|
||||
}
|
||||
opts.tiff_layers = settings.tiff_layers;
|
||||
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
|
||||
opts.tga_bits = settings.tga_bits;
|
||||
opts.image_previews = settings.image_previews;
|
||||
opts
|
||||
}
|
||||
|
||||
/// The share of a save's progress bar that encoding fills; writing the file fills the rest. A
|
||||
@@ -437,43 +323,6 @@ fn image_from_files(paths: &[PathBuf]) -> Option<(u32, u32, Vec<u8>)> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Largest clipboard image payload read on Wayland (1 GiB, a 16K × 16K RGBA BMP), so no real
|
||||
/// screenshot is turned away, while a broken or hostile source can't make us allocate without end.
|
||||
#[cfg(target_os = "linux")]
|
||||
const MAX_CLIPBOARD_IMAGE_BYTES: u64 = 4 * 16384 * 16384;
|
||||
|
||||
/// The clipboard image arboard can't read on Wayland (#2546): arboard 3.6 asks for `image/png`
|
||||
/// only, so a screenshot tool or clipboard manager that offers just `image/jpeg`, `image/bmp` or
|
||||
/// `image/webp` pasted nothing. Reads the other `image/*` types through the same data-control
|
||||
/// protocol arboard uses, and decodes them with our own codecs. `None` off Wayland.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn wayland_clipboard_image() -> Option<(u32, u32, Vec<u8>)> {
|
||||
use std::io::Read;
|
||||
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
|
||||
let offers = get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).ok()?;
|
||||
image_from_offers(&offers, |mime| {
|
||||
let (pipe, _) = get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Specific(mime)).ok()?;
|
||||
let mut bytes = Vec::new();
|
||||
pipe.take(MAX_CLIPBOARD_IMAGE_BYTES + 1).read_to_end(&mut bytes).ok()?;
|
||||
(bytes.len() as u64 <= MAX_CLIPBOARD_IMAGE_BYTES).then_some(bytes)
|
||||
})
|
||||
}
|
||||
|
||||
/// The first clipboard offer, in the source's order, that `fetch` returns as a decodable image,
|
||||
/// upright as RGBA8. Only `image/*` types are fetched, so text offers are never transferred.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn image_from_offers(offers: &[String], mut fetch: impl FnMut(&str) -> Option<Vec<u8>>) -> Option<(u32, u32, Vec<u8>)> {
|
||||
offers.iter().filter(|mime| mime.starts_with("image/")).find_map(|mime| {
|
||||
let bytes = fetch(mime)?;
|
||||
// TGA has no magic number (see `image_from_files`), so trust it only when the type says so.
|
||||
if photocraft_codecs::detect(&bytes)? == photocraft_codecs::Format::Tga && !mime.contains("tga") {
|
||||
return None;
|
||||
}
|
||||
let img = photocraft_codecs::decode(&bytes).ok()?;
|
||||
Some((img.width(), img.height(), img.to_rgba8()))
|
||||
})
|
||||
}
|
||||
|
||||
/// The shell command that starts this install under XWayland, where winit delivers file drops
|
||||
/// (winit 0.30 has none on Wayland, #386). A Flatpak gets the X11 socket only without the Wayland
|
||||
/// one, an AppImage relaunches by its own path, and anything else is `photocraft` on the PATH.
|
||||
@@ -495,7 +344,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())?;
|
||||
@@ -538,11 +386,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,
|
||||
@@ -569,47 +412,21 @@ 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().map_err(|_| "clipboard is busy".to_string())?;
|
||||
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.map_err(|e| e.to_string())?)
|
||||
}
|
||||
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.
|
||||
match cb.get().file_list() {
|
||||
Ok(paths) => {
|
||||
if let Some(img) = image_from_files(&paths) {
|
||||
return Ok(Some(img));
|
||||
}
|
||||
}
|
||||
Err(error @ arboard::Error::ClipboardOccupied) => return Err(error.to_string()),
|
||||
Err(_) => {}
|
||||
}
|
||||
match cb.get_image() {
|
||||
Ok(img) => Ok(Some((img.width as u32, img.height as u32, img.bytes.into_owned()))),
|
||||
Err(error) => {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(image) = wayland_clipboard_image() {
|
||||
return Ok(Some(image));
|
||||
}
|
||||
match error {
|
||||
arboard::Error::ContentNotAvailable => Ok(None),
|
||||
_ => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
|
||||
return Some(img);
|
||||
}
|
||||
let img = cb.get_image().ok()?;
|
||||
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
|
||||
})
|
||||
}),
|
||||
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()))),
|
||||
@@ -713,92 +530,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"));
|
||||
}
|
||||
|
||||
/// Regression using the real clipboard on an isolated Windows CI runner.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
#[ignore = "requires exclusive use of the Windows clipboard"]
|
||||
fn busy_native_clipboard_preserves_copied_shape() {
|
||||
for id in ["edit.paste", "edit.pasteSpecial.pasteInPlace"] {
|
||||
let mut native = native(None);
|
||||
let services = Services {
|
||||
clipboard_set_image: native.clipboard_set_image.take(),
|
||||
clipboard_get_image: native.clipboard_get_image.take(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({"width": 80, "height": 60})).unwrap();
|
||||
app.run("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap();
|
||||
app.run("edit.copy", json!({})).unwrap();
|
||||
assert!(app.session.clipboard.as_ref().unwrap().layers.is_some());
|
||||
assert!(app.services.clipboard_get_image.as_mut().unwrap()().unwrap().is_some(), "native export succeeded");
|
||||
app.run("file.new", json!({"width": 80, "height": 60})).unwrap();
|
||||
|
||||
// Another thread holds the OS clipboard open beyond arboard's bounded retry window.
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel();
|
||||
let holder = std::thread::spawn(move || {
|
||||
// A non-null window distinguishes this owner from arboard's null-window opens.
|
||||
let guard = winsafe::HWND::GetDesktopWindow().OpenClipboard().unwrap();
|
||||
ready_tx.send(()).unwrap();
|
||||
release_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
|
||||
drop(guard);
|
||||
});
|
||||
ready_rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap();
|
||||
let contention = arboard::Clipboard::new().unwrap().get_image().err();
|
||||
let first = app.run(id, json!({}));
|
||||
release_tx.send(()).unwrap();
|
||||
holder.join().unwrap();
|
||||
assert!(matches!(contention, Some(arboard::Error::ClipboardOccupied)), "diagnostic must actually occupy the clipboard: {contention:?}");
|
||||
eprintln!("paste while clipboard occupied: {first:?}");
|
||||
assert!(first.is_err(), "report contention rather than a successful no-op");
|
||||
assert!(
|
||||
app.session.clipboard.as_ref().is_some_and(|clip| clip.layers.is_some()),
|
||||
"a temporary OS read failure must not discard the copied editable shape"
|
||||
);
|
||||
app.run(id, json!({})).unwrap();
|
||||
let state = app.session.active().unwrap();
|
||||
assert!(
|
||||
matches!(state.doc.layer(state.active_layer.unwrap()).unwrap().content, photocraft_doc::LayerContent::Shape(_)),
|
||||
"retry keeps the shape editable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn open_filters_cover_uppercase_and_mixed_case_extensions() {
|
||||
@@ -833,26 +564,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() {
|
||||
@@ -951,67 +662,11 @@ mod tests {
|
||||
.join(" ");
|
||||
assert_eq!(exts, want.split(' ').collect::<Vec<_>>());
|
||||
for (name, ext) in asked.iter().zip(exts) {
|
||||
// The first type's first extension is the one Windows adds to the bare name.
|
||||
assert_eq!(save_filters(name)[0].1.first().map(String::as_str), Some(ext), "{name}: {:?}", save_filters(name)[0]);
|
||||
assert!(save_filters(name)[0].1.iter().any(|e| e == ext), "{name}: {:?}", save_filters(name)[0]);
|
||||
}
|
||||
assert_eq!(save_filters("Untitled")[0].0, "PSD Document", "no extension keeps the default");
|
||||
}
|
||||
|
||||
/// Every flat format Save As offers is one PhotoCraft writes, and ICO is among them (#4357).
|
||||
#[test]
|
||||
fn save_as_offers_only_writable_formats_including_ico() {
|
||||
// Layered formats, and PDF, are written by `photocraft-io`, not a codec.
|
||||
let layered = ["psd", "psb", "pcraft", "ora", "pdf"];
|
||||
for (name, exts) in SAVE_FILTERS {
|
||||
for ext in exts.iter().filter(|e| !layered.contains(e)) {
|
||||
let format = photocraft_codecs::from_extension(ext).unwrap_or_else(|| panic!("{name}: .{ext} is no known format"));
|
||||
assert!(format.caps().write, "{name}: .{ext} can't be written");
|
||||
}
|
||||
}
|
||||
for ext in ["ico", "gif", "bmp", "dds"] {
|
||||
assert!(save_filters("Untitled-1.psd").iter().any(|(_, exts)| exts.iter().any(|e| e == ext)), ".{ext} is offered");
|
||||
}
|
||||
assert_eq!(save_filters("icon.ico")[0].0, "ICO", "an .ico name leads with its own type");
|
||||
// What Save As writes there: an icon up to 256×256, and a clear error past that.
|
||||
let doc = |side: u32| {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": side, "height": side})).unwrap();
|
||||
s.active().unwrap().doc.clone()
|
||||
};
|
||||
let opts = export_options(&photocraft_ui_egui::ExportSettings::default());
|
||||
let icon = photocraft_io::export(&doc(256), "icon.ico", &opts).unwrap();
|
||||
assert_eq!(icon.bytes[..4], [0, 0, 1, 0], "an ICO header");
|
||||
let err = photocraft_io::export(&doc(300), "icon.ico", &opts).map(|_| ()).unwrap_err().to_string();
|
||||
assert!(err.contains("256"), "{err}");
|
||||
}
|
||||
|
||||
/// #4394: with File name extensions shown, Windows kept `image.psd` when the type was changed to
|
||||
/// PhotoCraft. Given the bare `image`, it adds the extension of whichever type is picked
|
||||
/// (checked by hand with rfd 0.17.2 on Windows 11), defaulting to the first type's.
|
||||
#[test]
|
||||
fn the_windows_save_dialog_gets_a_bare_name_so_the_type_picks_the_extension() {
|
||||
for (suggested, name, first) in [
|
||||
("/pics/image.psd", "image", "psd"),
|
||||
("/pics/image.psb", "image", "psb"),
|
||||
("/pics/image.pcraft", "image", "pcraft"),
|
||||
("/pics/sky.fit", "sky", "fit"),
|
||||
("cat.jpeg", "cat", "jpeg"),
|
||||
("swatches.aco", "swatches", "aco"),
|
||||
] {
|
||||
assert_eq!(dialog_file_name(suggested, true).as_deref(), Some(name), "{suggested}");
|
||||
assert_eq!(save_filters(suggested)[0].1[0], first, "{suggested}");
|
||||
// Elsewhere the dialogs keep the whole name (see `dialog_file_name`).
|
||||
let whole = Path::new(suggested).file_name().unwrap().to_string_lossy().into_owned();
|
||||
assert_eq!(dialog_file_name(suggested, false), Some(whole), "{suggested}");
|
||||
}
|
||||
// Kept whole: a second dot (Windows could keep `scan.jpg`), the user's upper case, no
|
||||
// extension, and a dotfile.
|
||||
for name in ["scan.jpg.psd", "my.photo.psd", "transparent.PNG", "Untitled", ".psd"] {
|
||||
assert_eq!(dialog_file_name(name, true).as_deref(), Some(name));
|
||||
}
|
||||
assert_eq!(dialog_file_name("", true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_on_waits_for_a_wake_from_another_thread() {
|
||||
let (tx, rx) = std::sync::mpsc::channel::<std::task::Waker>();
|
||||
@@ -1130,7 +785,6 @@ mod tests {
|
||||
let mut app = launch(&recovery);
|
||||
new_doc(&mut app, "#ff0000");
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0");
|
||||
autosave(&mut app, &ctx);
|
||||
|
||||
let mut failed = false;
|
||||
@@ -1151,16 +805,12 @@ mod tests {
|
||||
std::fs::remove_file(&recovery).unwrap();
|
||||
std::fs::create_dir(&recovery).unwrap();
|
||||
autosave(&mut app, &ctx);
|
||||
// The worker commits the bundle's manifest before it publishes the sidecar, and
|
||||
// `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so
|
||||
// the first non-empty listing can come from the middle of the write. Wait for the write
|
||||
// to finish: the entry then carries the sidecar's revision.
|
||||
let mut recovered = Vec::new();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while std::time::Instant::now() < deadline {
|
||||
prefs_ui::tick(&mut app, &ctx);
|
||||
recovered = list_recovery(&recovery);
|
||||
if recovered.iter().any(|e| e.info.revision == revision) {
|
||||
if !recovered.is_empty() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
@@ -1235,51 +885,6 @@ mod tests {
|
||||
assert!(image_from_files(&[bad]).is_none());
|
||||
}
|
||||
|
||||
fn encoded(format: photocraft_codecs::Format, w: u32, h: u32) -> Vec<u8> {
|
||||
let img = Image::from_u8(w, h, ChannelLayout::Rgba, [255u8, 0, 0, 255].repeat((w * h) as usize)).unwrap();
|
||||
photocraft_codecs::encode(&img, format, &EncodeOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_png_clipboard_images_paste() {
|
||||
// #2546: a screenshot tool that offers only image/bmp or image/jpeg pasted nothing.
|
||||
let bmp = encoded(photocraft_codecs::Format::Bmp, 3, 2);
|
||||
let offers = vec!["text/plain".to_string(), "image/bmp".to_string()];
|
||||
let mut asked = Vec::new();
|
||||
let got = image_from_offers(&offers, |mime| {
|
||||
asked.push(mime.to_string());
|
||||
Some(bmp.clone())
|
||||
});
|
||||
assert_eq!(got, Some((3, 2, [255u8, 0, 0, 255].repeat(6))));
|
||||
assert_eq!(asked, ["image/bmp"], "text offers are never fetched");
|
||||
|
||||
let jpeg = encoded(photocraft_codecs::Format::Jpeg, 16, 8);
|
||||
let offers = vec!["image/jpeg".to_string()];
|
||||
assert_eq!(image_from_offers(&offers, |_| Some(jpeg.clone())).map(|(w, h, _)| (w, h)), Some((16, 8)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undecodable_clipboard_offers_fall_through_to_the_next() {
|
||||
let bmp = encoded(photocraft_codecs::Format::Bmp, 2, 2);
|
||||
let cut = encoded(photocraft_codecs::Format::Png, 8, 8)[..20].to_vec();
|
||||
let offers: Vec<String> = ["image/webp", "image/png", "image/x-tga", "image/bmp"].map(String::from).to_vec();
|
||||
let got = image_from_offers(&offers, |mime| match mime {
|
||||
"image/webp" => None,
|
||||
"image/png" => Some(cut.clone()),
|
||||
"image/x-tga" => Some(b"not an image".to_vec()),
|
||||
_ => Some(bmp.clone()),
|
||||
});
|
||||
assert_eq!(got.map(|(w, h, _)| (w, h)), Some((2, 2)));
|
||||
assert!(image_from_offers(&offers[..3], |_| Some(cut.clone())).is_none());
|
||||
assert!(image_from_offers(&[], |_| Some(bmp.clone())).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_tga_is_trusted_only_by_its_type() {
|
||||
assert_eq!(image_from_offers(&["image/x-tga".to_string()], |_| Some(tga_1x1())), Some((1, 1, vec![255, 0, 0, 255])));
|
||||
assert!(image_from_offers(&["image/x-unknown".to_string()], |_| Some(tga_1x1())).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tga_is_trusted_only_with_its_extension() {
|
||||
let dir = temp("tga");
|
||||
|
||||
@@ -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,12 +1,10 @@
|
||||
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79), and
|
||||
//! barrel button on Windows (#5905).
|
||||
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79).
|
||||
//!
|
||||
//! winit 0.30 drops tablet data on both macOS and X11, so `photocraft-tablet` reads it beside winit
|
||||
//! (an AppKit local event monitor; XInput2 raw events on a second X connection) and this module writes
|
||||
//! each sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
|
||||
//! Pointer Events and automation's simulated pen. On Windows, winit forwards `WM_POINTER` pressure
|
||||
//! as touch force, but drops `PEN_FLAG_BARREL` (`0x0001`); `photocraft-tablet` recovers the barrel
|
||||
//! button through a thread-local message hook beside winit.
|
||||
//! winit 0.30 drops tablet data on both, so `photocraft-tablet` reads it beside winit (an AppKit
|
||||
//! local event monitor; XInput2 raw events on a second X connection) and this module writes each
|
||||
//! sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
|
||||
//! Pointer Events and automation's simulated pen. Windows needs nothing here: winit forwards
|
||||
//! `WM_POINTER` pressure as touch force.
|
||||
//!
|
||||
//! Wayland compositors give a pen only to clients that bind the tablet protocol
|
||||
//! (`zwp_tablet_v2`), which winit 0.30 doesn't (and binding it on winit's connection needs
|
||||
@@ -21,36 +19,15 @@ use photocraft_tablet::Sample;
|
||||
use photocraft_ui_egui::stylus::{PenSample, StylusFeed};
|
||||
|
||||
/// A tablet sample as the UI's pen sample.
|
||||
#[allow(dead_code)]
|
||||
pub fn pen_sample(s: Sample) -> PenSample {
|
||||
PenSample { pressure: s.pressure, tilt_x: s.tilt_x, tilt_y: s.tilt_y, rotation: s.rotation, eraser: s.eraser }.sanitized()
|
||||
}
|
||||
|
||||
/// Writes tablet samples into `feed` (`None` = a mouse).
|
||||
#[allow(dead_code)]
|
||||
pub fn sink(feed: StylusFeed) -> impl Fn(Option<Sample>) + Send + 'static {
|
||||
move |s| feed.set(s.map(pen_sample))
|
||||
}
|
||||
|
||||
/// Install the Windows pointer message hook (main thread; keep the result until the event loop
|
||||
/// returns). On failure this logs why: the pen barrel button is then missed.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn install_windows() -> Option<photocraft_tablet::windows::Monitor> {
|
||||
photocraft_tablet::windows::Monitor::install().map_err(|e| log::warn!("{e}")).ok()
|
||||
}
|
||||
|
||||
/// Stylus barrel button down check (Windows). The right mouse button is left to egui, which
|
||||
/// reports the logical secondary button (swapped buttons included) for the app's own window only.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn right_button_service() -> Option<photocraft_ui_egui::RightButtonDownFn> {
|
||||
Some(Box::new(photocraft_tablet::windows::is_barrel_button_down))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn right_button_service() -> Option<photocraft_ui_egui::RightButtonDownFn> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Install the AppKit tablet monitor (main thread, once winit created the shared application:
|
||||
/// from eframe's app creation, not before the event loop). Keep the result until the event loop
|
||||
/// returns. On failure this logs why: pens then paint like a mouse.
|
||||
@@ -87,33 +64,15 @@ pub fn pen_needs_xwayland(session: DisplaySession, var: impl Fn(&str) -> Option<
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const BTN_TOOL_PEN: u32 = 0x140;
|
||||
|
||||
/// `ABS_X` and `ABS_Y` (linux/input-event-codes.h): the absolute axes every pen reports.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const ABS_X: u32 = 0x00;
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const ABS_Y: u32 = 0x01;
|
||||
|
||||
/// Is a pen among the input devices `/proc/bus/input/devices` lists? A pen is a device whose
|
||||
/// `B: KEY=` bitmap has `BTN_TOOL_PEN` *and* whose `B: ABS=` bitmap has `ABS_X` and `ABS_Y`
|
||||
/// (libinput asks the same of a tablet tool). The key alone is not enough: a virtual keyboard
|
||||
/// made with uinput (RustDesk's, for one) claims every key code, `BTN_TOOL_PEN` included, but
|
||||
/// has no absolute axes. Taking it for a pen sent the window through Xwayland, where a
|
||||
/// fractionally scaled Wayland desktop shows it upscaled and blurred (#1072, #639).
|
||||
/// Is a pen among the input devices `/proc/bus/input/devices` lists? Each device's `B: KEY=` line
|
||||
/// is its key bitmap as hex words of the kernel's `long`, most significant first.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
pub fn has_pen(devices: &str) -> bool {
|
||||
devices.split("\n\n").any(|device| {
|
||||
let bitmap = |name: &str| device.lines().find_map(|l| l.strip_prefix(name));
|
||||
bitmap("B: KEY=").is_some_and(|keys| bit_set(keys, BTN_TOOL_PEN)) && bitmap("B: ABS=").is_some_and(|axes| bit_set(axes, ABS_X) && bit_set(axes, ABS_Y))
|
||||
})
|
||||
}
|
||||
|
||||
/// Bit `bit` of a `/proc/bus/input/devices` bitmap: hex words of the kernel's `long`, most
|
||||
/// significant first. Short or malformed bitmaps have no bits set.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn bit_set(bitmap: &str, bit: u32) -> bool {
|
||||
let bits = usize::BITS;
|
||||
let word = usize::try_from(bit / bits).unwrap_or(usize::MAX);
|
||||
bitmap.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (bit % bits)) & 1 == 1)
|
||||
let word = usize::try_from(BTN_TOOL_PEN / bits).unwrap_or(usize::MAX);
|
||||
devices.lines().filter_map(|l| l.strip_prefix("B: KEY=")).any(|keys| {
|
||||
keys.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (BTN_TOOL_PEN % bits)) & 1 == 1)
|
||||
})
|
||||
}
|
||||
|
||||
/// Start the XInput2 reader when eframe runs on X11 (Xwayland included). On native Wayland,
|
||||
@@ -279,49 +238,6 @@ mod tests {
|
||||
for keys in ["", "B: KEY=", "B: KEY=1", "B: KEY=zz 0 0 0 0 0", "B: KEY=ffffffffffffffffff 0 0 0 0 0", "B: KEY=0 0 0 0 0 0 0 0"] {
|
||||
assert!(!has_pen(keys), "{keys:?}");
|
||||
}
|
||||
for abs in ["B: ABS=", "B: ABS=zz", "B: ABS=ffffffffffffffffff", "B: ABS=0 0"] {
|
||||
assert!(!has_pen(&format!("B: KEY=1c03 0 0 0 0 0\n{abs}")), "{abs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1072: RustDesk's uinput keyboard claims every key code, `BTN_TOOL_PEN` included, and has
|
||||
/// no absolute axes (`EV=20013`: no `EV_ABS`, no `B: ABS=` line). It is no pen, so the window
|
||||
/// stays on native Wayland, where the fractional display scale renders sharp. The bitmaps are
|
||||
/// the device's own, from `/proc/bus/input/devices`.
|
||||
const VIRTUAL_KEYBOARD: &str = "I: Bus=0003 Vendor=1234 Product=5678 Version=0111\n\
|
||||
N: Name=\"RustDesk UInput Keyboard\"\n\
|
||||
P: Phys=\n\
|
||||
S: Sysfs=/devices/virtual/input/input54\n\
|
||||
U: Uniq=\n\
|
||||
H: Handlers=sysrq rfkill kbd event256 leds \n\
|
||||
B: PROP=0\n\
|
||||
B: EV=20013\n\
|
||||
B: KEY=ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe\n\
|
||||
B: MSC=10\n\
|
||||
B: LED=7\n";
|
||||
|
||||
#[test]
|
||||
fn a_virtual_keyboard_claiming_btn_tool_pen_is_no_pen() {
|
||||
assert!(
|
||||
bit_set(
|
||||
"ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe",
|
||||
BTN_TOOL_PEN
|
||||
),
|
||||
"the fixture claims the key"
|
||||
);
|
||||
assert!(!has_pen(VIRTUAL_KEYBOARD));
|
||||
// Beside a real pen, the pen is still found; the keyboard's key bits never leak into the
|
||||
// pen's device and the pen's axes never into the keyboard's.
|
||||
let both = format!("{VIRTUAL_KEYBOARD}\n{WITH_PEN}");
|
||||
assert!(has_pen(&both));
|
||||
let pen_without_axes = WITH_PEN.lines().filter(|l| !l.starts_with("B: ABS=1000f")).collect::<Vec<_>>().join("\n");
|
||||
assert!(!has_pen(&pen_without_axes), "a pen reports absolute positions");
|
||||
// A pen display or pen-enabled touchscreen reports the pen with its own axes.
|
||||
let pen_display = "N: Name=\"Wacom One Pen\"\nB: KEY=1c03 0 0 0 0 0\nB: ABS=3\n";
|
||||
assert!(has_pen(pen_display));
|
||||
// Axes without `ABS_X` or `ABS_Y` don't count.
|
||||
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000002\n"));
|
||||
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000001\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -27,23 +27,6 @@ pub fn sanitize(path: Option<&Path>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` holds a window position from an earlier session, which eframe restores. Then
|
||||
/// `NativeOptions::centered` must be off: eframe applies it after restoring the saved geometry,
|
||||
/// so it moved the window back to the centre of the main monitor on every launch (#4826).
|
||||
pub fn has_saved_position(path: Option<&Path>) -> bool {
|
||||
let Some(text) = path.and_then(|p| std::fs::read_to_string(p).ok()) else { return false };
|
||||
saved_position(&text)
|
||||
}
|
||||
|
||||
fn saved_position(text: &str) -> bool {
|
||||
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return false };
|
||||
let Some(window) = kv.get("window").and_then(|w| ron::from_str::<ron::Value>(w).ok()) else { return false };
|
||||
// eframe restores the inner position on macOS and the outer position elsewhere
|
||||
// (`egui_winit::WindowSettings::initialize_viewport_builder`).
|
||||
let key = if cfg!(target_os = "macos") { "inner_position_pixels" } else { "outer_position_pixels" };
|
||||
matches!(field(&window, key), Some(ron::Value::Option(Some(_))))
|
||||
}
|
||||
|
||||
/// `Err` names the first value that would crash startup.
|
||||
fn check(text: &str) -> Result<(), String> {
|
||||
// eframe stores a map of key → RON string; a file that doesn't parse is ignored by eframe.
|
||||
@@ -95,7 +78,7 @@ fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{check, saved_position};
|
||||
use super::check;
|
||||
|
||||
fn file(window: &str, zoom: &str) -> String {
|
||||
let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))");
|
||||
@@ -122,21 +105,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_window_position_is_detected() {
|
||||
// #4826: the position from the report (Linux Mint, window moved to the third monitor).
|
||||
let window = "(inner_position_pixels:Some((x:2048.0,y:1265.0)),outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
|
||||
assert!(saved_position(&file(window, "1.0")));
|
||||
let no_position = "(inner_position_pixels:None,outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
|
||||
assert!(!saved_position(&file(no_position, "1.0")));
|
||||
assert!(!saved_position(&format!("{{\"egui\":{:?}}}", "()")), "no window entry");
|
||||
for text in ["", "not ron {{{", "{\"window\":\"(((\"}"] {
|
||||
assert!(!saved_position(text), "{text:?}");
|
||||
}
|
||||
assert!(!super::has_saved_position(None));
|
||||
assert!(!super::has_saved_position(Some(&std::env::temp_dir().join("photocraft-missing-ui.ron"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_files_are_left_to_eframe() {
|
||||
// eframe ignores files and entries it can't parse, so they can't crash it.
|
||||
|
||||
@@ -20,11 +20,6 @@
|
||||
//! the work area plus its own frame, clear of the taskbar. winit pins the client area to the work
|
||||
//! area either way (`WM_NCCALCSIZE`), so the app's pixels do not move.
|
||||
//!
|
||||
//! An auto-hidden taskbar reserves no work area, so that trim leaves the maximize covering the
|
||||
//! whole monitor, and the shell, taking it for a full-screen app, no longer reveals the taskbar
|
||||
//! when the pointer reaches its edge (#4845). On each edge with an auto-hidden taskbar the guard
|
||||
//! instead stops the window one pixel short of the monitor, as other custom-frame apps do.
|
||||
//!
|
||||
//! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and
|
||||
//! winsafe can't wrap a raw handle without it, so the window is found by its handle instead.
|
||||
|
||||
@@ -34,20 +29,7 @@ pub use platform::service;
|
||||
mod platform {
|
||||
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
|
||||
use photocraft_ui_egui::WindowFrameFn;
|
||||
use winsafe::prelude::Handle;
|
||||
use winsafe::{AtomStr, HWND, HwndPlace, POINT, SIZE, co};
|
||||
|
||||
/// A rect in desktop coordinates: `(left, top, right, bottom)`, right and bottom exclusive.
|
||||
type Rect = (i32, i32, i32, i32);
|
||||
|
||||
/// A monitor edge.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Edge {
|
||||
Left,
|
||||
Top,
|
||||
Right,
|
||||
Bottom,
|
||||
}
|
||||
use winsafe::{HWND, HwndPlace, POINT, SIZE, co};
|
||||
|
||||
/// A guard that keeps the window borderless each frame, or `None` when the app uses the
|
||||
/// system title bar (the window is decorated) or its handle can't be found.
|
||||
@@ -99,16 +81,17 @@ mod platform {
|
||||
HwndPlace::None,
|
||||
POINT::new(),
|
||||
SIZE::new(),
|
||||
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
);
|
||||
}
|
||||
|
||||
/// How far a parked maximize reaches past a monitor/work-area edge: the resize
|
||||
/// How far a parked maximize reaches past its monitor on the left, top and right: the resize
|
||||
/// border (about 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not
|
||||
/// parked but being dragged or snapped, and is left alone.
|
||||
const FRAME: i32 = 32;
|
||||
|
||||
/// Keep a parked maximize inside all four work-area edges, including a top taskbar.
|
||||
/// [`clamped_height`] for this frame's window: trim a maximized window whose rect runs under
|
||||
/// the taskbar back to the work area plus its frame (#3144). No-op otherwise.
|
||||
fn keep_clear_of_the_taskbar(hwnd: &HWND) {
|
||||
if !hwnd.IsZoomed() {
|
||||
return;
|
||||
@@ -117,124 +100,34 @@ mod platform {
|
||||
return;
|
||||
};
|
||||
let (m, w) = (mi.rcMonitor, mi.rcWork);
|
||||
let (rect, monitor, work) = ((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom));
|
||||
// The taskbars (a couple of `FindWindowEx` calls) are only looked up while the window
|
||||
// covers the whole monitor, which a trimmed maximize no longer does.
|
||||
let Some((left, top, right, bottom)) = clamped_rect(rect, monitor, work)
|
||||
.or_else(|| covers(rect, monitor).then(|| short_of_autohide_taskbars(rect, monitor, work, &taskbar_edges(monitor))).flatten())
|
||||
else {
|
||||
let Some(height) = clamped_height((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)) else {
|
||||
return;
|
||||
};
|
||||
let (Some(width), Some(height)) = (right.checked_sub(left), bottom.checked_sub(top)) else {
|
||||
return;
|
||||
};
|
||||
// Moving the outer frame while winit pins the client rect to the work area must
|
||||
// not copy the old frame's pixels into the custom title bar.
|
||||
let _ = hwnd.SetWindowPos(
|
||||
HwndPlace::None,
|
||||
POINT { x: left, y: top },
|
||||
SIZE { cx: width, cy: height },
|
||||
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
POINT::new(),
|
||||
SIZE { cx: r.right - r.left, cy: height },
|
||||
co::SWP::NOMOVE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
);
|
||||
let _ = hwnd.InvalidateRect(None, false);
|
||||
}
|
||||
|
||||
/// Return the corrected outer rect in desktop coordinates, preserving the resize border.
|
||||
/// Each edge must be parked just outside either the monitor or work-area edge. Accepting
|
||||
/// both makes the guard idempotent and handles Windows already correcting some edges.
|
||||
/// Dragged/snapped windows are left alone; no monitor-origin or DPI assumptions are needed.
|
||||
fn clamped_rect(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<(i32, i32, i32, i32)> {
|
||||
/// The height to trim a maximize to so its rect stops at the taskbar, or `None` when it
|
||||
/// already does or `rect` (`left, top, right, bottom`, desktop coordinates) is not parked at
|
||||
/// its maximize place. `monitor` is the monitor's full rect and `work` its work area (the
|
||||
/// monitor less the taskbar) in the same coordinates. The parked check is against the full
|
||||
/// monitor, because that is where Windows puts a maximize; the trim is against the work area.
|
||||
fn clamped_height(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<i32> {
|
||||
let (l, t, r, b) = rect;
|
||||
let (ml, mt, mr, mb) = monitor;
|
||||
let (wl, wt, wr, wb) = work;
|
||||
if ml >= mr || mt >= mb || wl < ml || wt < mt || wr > mr || wb > mb || wl >= wr || wt >= wb {
|
||||
let (ml, mt, mr, _) = monitor;
|
||||
let (_, _, _, wb) = work;
|
||||
let side = ml - l;
|
||||
if !(0..=FRAME).contains(&side) || !(0..=FRAME).contains(&(mt - t)) || !(0..=FRAME).contains(&(r - mr)) {
|
||||
return None;
|
||||
}
|
||||
// Infer the frame from an edge without a taskbar. On an obstructed edge, the
|
||||
// monitor and work-area offsets can both look like valid borders (e.g. auto-hide).
|
||||
let border = [
|
||||
(ml == wl).then(|| ml.checked_sub(l)).flatten(),
|
||||
(mt == wt).then(|| mt.checked_sub(t)).flatten(),
|
||||
(mr == wr).then(|| r.checked_sub(mr)).flatten(),
|
||||
(mb == wb).then(|| b.checked_sub(mb)).flatten(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|n| (0..=FRAME).contains(n))
|
||||
.min()?;
|
||||
let parked = |monitor: Option<i32>, work: Option<i32>| monitor == Some(border) || work == Some(border);
|
||||
if !parked(ml.checked_sub(l), wl.checked_sub(l))
|
||||
|| !parked(mt.checked_sub(t), wt.checked_sub(t))
|
||||
|| !parked(r.checked_sub(mr), r.checked_sub(wr))
|
||||
|| !parked(b.checked_sub(mb), b.checked_sub(wb))
|
||||
{
|
||||
if b <= wb + side {
|
||||
return None;
|
||||
}
|
||||
let corrected = (wl.checked_sub(border)?, wt.checked_sub(border)?, wr.checked_add(border)?, wb.checked_add(border)?);
|
||||
(corrected != rect).then_some(corrected)
|
||||
}
|
||||
|
||||
/// Whether `rect` reaches every edge of `monitor`: what the shell takes for a full-screen app.
|
||||
fn covers(rect: Rect, monitor: Rect) -> bool {
|
||||
rect.0 <= monitor.0 && rect.1 <= monitor.1 && rect.2 >= monitor.2 && rect.3 >= monitor.3
|
||||
}
|
||||
|
||||
/// The edges of `monitor` that have a taskbar (the primary `Shell_TrayWnd` or a secondary
|
||||
/// monitor's `Shell_SecondaryTrayWnd`), shown or hidden.
|
||||
fn taskbar_edges(monitor: Rect) -> Vec<Edge> {
|
||||
let mut edges = Vec::new();
|
||||
for class in ["Shell_TrayWnd", "Shell_SecondaryTrayWnd"] {
|
||||
let mut after: Option<HWND> = None;
|
||||
// One taskbar per monitor; the bound only guards against a misbehaving shell.
|
||||
for _ in 0..64 {
|
||||
let Ok(Some(tray)) = HWND::NULL.FindWindowEx(after.as_ref(), AtomStr::from_str(class), None) else {
|
||||
break;
|
||||
};
|
||||
if let Ok(r) = tray.GetWindowRect() {
|
||||
edges.extend(taskbar_edge((r.left, r.top, r.right, r.bottom), monitor));
|
||||
}
|
||||
after = Some(tray);
|
||||
}
|
||||
}
|
||||
edges
|
||||
}
|
||||
|
||||
/// The edge of `monitor` a taskbar with rect `taskbar` sits on: the one nearest its centre,
|
||||
/// or `None` when it is on another monitor. An auto-hidden taskbar is parked mostly past its
|
||||
/// edge with a sliver left on screen, so its centre may lie outside the monitor.
|
||||
fn taskbar_edge(taskbar: Rect, monitor: Rect) -> Option<Edge> {
|
||||
let (tl, tt, tr, tb) = (i64::from(taskbar.0), i64::from(taskbar.1), i64::from(taskbar.2), i64::from(taskbar.3));
|
||||
let (ml, mt, mr, mb) = (i64::from(monitor.0), i64::from(monitor.1), i64::from(monitor.2), i64::from(monitor.3));
|
||||
if tl >= tr || tt >= tb || ml >= mr || mt >= mb || tr <= ml || tl >= mr || tb <= mt || tt >= mb {
|
||||
return None;
|
||||
}
|
||||
// Doubled centre and distances, so nothing rounds.
|
||||
let (cx, cy) = (tl + tr, tt + tb);
|
||||
[(Edge::Left, cx - 2 * ml), (Edge::Top, cy - 2 * mt), (Edge::Right, 2 * mr - cx), (Edge::Bottom, 2 * mb - cy)]
|
||||
.into_iter()
|
||||
.min_by_key(|&(_, distance)| distance)
|
||||
.map(|(edge, _)| edge)
|
||||
}
|
||||
|
||||
/// A maximize covering its monitor, trimmed to stop one pixel short of each edge where a
|
||||
/// taskbar reserves no work area (it auto-hides), so the shell still reveals it there.
|
||||
/// `None` when there is nothing to trim.
|
||||
fn short_of_autohide_taskbars(rect: Rect, monitor: Rect, work: Rect, taskbars: &[Edge]) -> Option<Rect> {
|
||||
if !covers(rect, monitor) {
|
||||
return None;
|
||||
}
|
||||
let (mut l, mut t, mut r, mut b) = rect;
|
||||
for edge in taskbars {
|
||||
match edge {
|
||||
Edge::Left if work.0 == monitor.0 => l = monitor.0.checked_add(1)?,
|
||||
Edge::Top if work.1 == monitor.1 => t = monitor.1.checked_add(1)?,
|
||||
Edge::Right if work.2 == monitor.2 => r = monitor.2.checked_sub(1)?,
|
||||
Edge::Bottom if work.3 == monitor.3 => b = monitor.3.checked_sub(1)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let trimmed = (l, t, r, b);
|
||||
(l < r && t < b && trimmed != rect).then_some(trimmed)
|
||||
Some(wb + side - t)
|
||||
}
|
||||
|
||||
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
|
||||
@@ -246,7 +139,7 @@ mod platform {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Edge, borderless, clamped_rect, short_of_autohide_taskbars, taskbar_edge};
|
||||
use super::{borderless, clamped_height};
|
||||
use winsafe::co;
|
||||
|
||||
#[test]
|
||||
@@ -263,76 +156,34 @@ mod platform {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_respects_taskbars_on_every_edge() {
|
||||
fn a_maximize_covering_the_taskbar_is_trimmed_to_the_work_area() {
|
||||
// 1920 × 1080 with a 34 pt taskbar: a caption-less maximize covers the whole
|
||||
// monitor, taskbar included (#3144). Trimmed so the rect ends at the work area
|
||||
// plus the frame the window was parked with.
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let rect = (-8, -8, 1928, 1088);
|
||||
for (work, expected) in [
|
||||
((0, 0, 1920, 1046), (-8, -8, 1928, 1054)),
|
||||
((0, 48, 1920, 1080), (-8, 40, 1928, 1088)),
|
||||
((0, 2, 1920, 1080), (-8, -6, 1928, 1088)),
|
||||
((0, 0, 1920, 1078), (-8, -8, 1928, 1086)),
|
||||
((48, 0, 1920, 1080), (40, -8, 1928, 1088)),
|
||||
((0, 0, 1872, 1080), (-8, -8, 1880, 1088)),
|
||||
] {
|
||||
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
|
||||
assert_eq!(clamped_rect(expected, monitor, work), None, "already corrected");
|
||||
}
|
||||
let work = (0, 0, 1920, 1046);
|
||||
assert_eq!(clamped_height((-7, -7, 1927, 1087), monitor, work), Some(1046 + 7 - (-7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_taskbar_keeps_its_offset_at_other_origins_and_frame_sizes() {
|
||||
let monitor = (-1920, -1080, 0, 0);
|
||||
let work = (-1920, -1032, 0, 0);
|
||||
for border in [0, 8, 16, 32] {
|
||||
let rect = (-1920 - border, -1080 - border, border, border);
|
||||
let expected = (-1920 - border, -1032 - border, border, border);
|
||||
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
|
||||
assert_eq!(clamped_rect(expected, monitor, work), None);
|
||||
}
|
||||
fn a_maximize_already_clear_of_the_taskbar_is_left_alone() {
|
||||
// What Windows sizes a framed maximize to: the work area plus the frame.
|
||||
assert_eq!(clamped_height((-8, -8, 1928, 1054), (0, 0, 1920, 1080), (0, 0, 1920, 1046)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_without_a_reserved_taskbar_is_left_alone() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
assert_eq!(clamped_rect((-8, -8, 1928, 1088), monitor, monitor), None);
|
||||
fn a_maximize_beside_a_side_taskbar_is_left_alone() {
|
||||
// Taskbar on the left: the work area starts further right, but the monitor's bottom
|
||||
// is still clear, so the rect reaches nothing.
|
||||
assert_eq!(clamped_height((-8, -8, 1928, 1088), (0, 0, 1920, 1080), (48, 0, 1920, 1080)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_being_dragged_or_snapped_is_left_alone() {
|
||||
fn a_maximize_being_dragged_away_is_left_alone() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let work = (0, 48, 1920, 1080);
|
||||
assert_eq!(clamped_rect((100, 200, 1100, 900), monitor, work), None, "moved");
|
||||
assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged");
|
||||
assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_maximize_leaves_a_pixel_strip_on_an_auto_hidden_taskbar_edge() {
|
||||
// #4845: an auto-hidden taskbar reserves no work area.
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
let rect = (-8, -8, 1928, 1088);
|
||||
for (edge, expected) in
|
||||
[(Edge::Bottom, (-8, -8, 1928, 1079)), (Edge::Top, (-8, 1, 1928, 1088)), (Edge::Left, (1, -8, 1928, 1088)), (Edge::Right, (-8, -8, 1919, 1088))]
|
||||
{
|
||||
assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[edge]), Some(expected));
|
||||
assert_eq!(short_of_autohide_taskbars(expected, monitor, monitor, &[edge]), None, "already trimmed");
|
||||
}
|
||||
assert_eq!(short_of_autohide_taskbars(rect, monitor, monitor, &[]), None, "no taskbar");
|
||||
// A taskbar that reserves its work area is the clamp's job.
|
||||
assert_eq!(short_of_autohide_taskbars(rect, monitor, (0, 0, 1920, 1032), &[Edge::Bottom]), None);
|
||||
assert_eq!(short_of_autohide_taskbars((0, 0, 960, 1080), monitor, monitor, &[Edge::Bottom]), None, "half-screen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_taskbar_sits_on_the_monitor_edge_nearest_its_centre() {
|
||||
let monitor = (0, 0, 1920, 1080);
|
||||
assert_eq!(taskbar_edge((0, 1032, 1920, 1080), monitor), Some(Edge::Bottom), "shown");
|
||||
assert_eq!(taskbar_edge((0, 1078, 1920, 1126), monitor), Some(Edge::Bottom), "auto-hidden");
|
||||
assert_eq!(taskbar_edge((0, -46, 1920, 2), monitor), Some(Edge::Top));
|
||||
assert_eq!(taskbar_edge((-60, 0, 2, 1080), monitor), Some(Edge::Left));
|
||||
assert_eq!(taskbar_edge((1918, 0, 1980, 1080), monitor), Some(Edge::Right));
|
||||
assert_eq!(taskbar_edge((1920, 1032, 3840, 1080), monitor), None, "other monitor");
|
||||
assert!(taskbar_edge((i32::MIN, i32::MIN, i32::MAX, i32::MAX), monitor).is_some(), "no overflow");
|
||||
let work = (0, 0, 1920, 1046);
|
||||
assert_eq!(clamped_height((100, 200, 1100, 900), monitor, work), None, "moved");
|
||||
assert_eq!(clamped_height((-500, -7, 1427, 1087), monitor, work), None, "resized");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a10 10 0 0 0 0 20z" fill="currentColor" stroke="none" />
|
||||
<path d="M12 18a6 6 0 0 0 0-12v12z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 317 B After Width: | Height: | Size: 285 B |
@@ -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 |
@@ -1,19 +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"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="g" x1="3" y1="0" x2="20" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="currentColor" stop-opacity="0.9" />
|
||||
<stop offset="100%" stop-color="currentColor" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="18" height="16" x="3" y="4" rx="2" fill="url(#g)" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 549 B |
@@ -1,16 +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"
|
||||
>
|
||||
<path d="M14 3H7a4 4 0 0 0-4 4v10a4 4 0 0 0 4 4h7" stroke-dasharray="2 3" />
|
||||
<path d="m14 10 2.1 2.1" />
|
||||
<path d="M10.85 17.7A2.45 2.45 0 1 0 8.4 15.25a1.834 1.834 0 0 1-.496 1.254A.7 .7 0 0 0 8.4 17.7z" />
|
||||
<path d="M13.278 14.922 21.265 6.937a.7 .7 0 0 0-2.101-2.101L11.177 12.822" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 502 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
|
||||
|
||||
@@ -19,16 +19,6 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_
|
||||
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
|
||||
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened.
|
||||
- TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit.
|
||||
- DDS, for game and 3D textures: Save As and Save a Copy ask (DDS Options) for the compression (None, uncompressed and lossless; BC1/DXT1, whose alpha is on or off; BC2/DXT3; BC3/DXT5) and whether to generate mipmaps (the full chain down to 1×1, each level a box filter of the one above). Scripts pass `ddsCompression` (`none`, `bc1`, `bc2`, `bc3`) and `ddsMipmaps` (`--dds-compression`, `--dds-mipmaps` on the CLI). Opening one reads its top mip level.
|
||||
|
||||
Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through
|
||||
the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed
|
||||
that exact profile. Untagged composites use and embed PhotoCraft's synthetic coated CMYK profile;
|
||||
invalid, non-CMYK or unusable embedded profiles are rejected before compositing. TIFF retains
|
||||
8/16/32f depth and straight alpha; JPEG reduces to 8-bit with a warning and mattes transparency
|
||||
over zero-ink white. A plain single raster layer retains its native samples. RGB-only formats use
|
||||
the RGB composite directly. This RGB composite roundtrip does **not** preserve original ink
|
||||
separations, black generation or native CMYK blending, and is not a claim of Photoshop parity.
|
||||
|
||||
## Implemented decode limits
|
||||
|
||||
|
||||
@@ -17,10 +17,6 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.jonathanwxh-cell]
|
||||
display_name = "jonathnwxh"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Skyfuller1303]
|
||||
display_name = "Skyfuller"
|
||||
sync_github_name = true
|
||||
@@ -121,73 +117,3 @@ sync_github_name = true
|
||||
|
||||
[people.FredApps]
|
||||
sync_github_name = true
|
||||
|
||||
[people.arthurdeka]
|
||||
sync_github_name = true
|
||||
|
||||
[people.aweussom]
|
||||
real_name = "Tommy Leonhardsen"
|
||||
|
||||
[people.Furash]
|
||||
sync_github_name = true
|
||||
|
||||
[people.lucAmbr0]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Hevarh1]
|
||||
sync_github_name = true
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.deadmeu]
|
||||
sync_github_name = true
|
||||
|
||||
[people.megaDon101]
|
||||
display_name = "megadon"
|
||||
sync_github_name = false
|
||||
|
||||
[people.sssehnsuchttt]
|
||||
sync_github_name = true
|
||||
|
||||
[people.VanTan666]
|
||||
display_name = "VanTan"
|
||||
sync_github_name = false
|
||||
|
||||
[people.yeniufuklar-com]
|
||||
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
|
||||
|
||||
[people.guillehr2]
|
||||
real_name = "Guillem Hermida"
|
||||
sync_github_name = true
|
||||
|
||||
[people.fjamesprice]
|
||||
sync_github_name = true
|
||||
|
||||
[people.ha1des]
|
||||
sync_github_name = true
|
||||
@@ -16,7 +16,7 @@ synth = []
|
||||
[dependencies]
|
||||
crc32fast = "1.5"
|
||||
flate2 = { workspace = true }
|
||||
ruzstd = "0.9.1"
|
||||
ruzstd = "0.9"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -16,7 +16,7 @@ Affinity files.
|
||||
| Artboards (legacy `ABEn`, or current `.af` `phrp`/`aprp` properties, including converted curves), with name, solid background, any transform | artboards (nested ones as groups with a vector mask; rotated/curved boards use their bounding rectangle) |
|
||||
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | groups (pass-through or Normal) |
|
||||
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | shape layers (multi-subpath curves fill even-odd) |
|
||||
| Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers |
|
||||
| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers |
|
||||
| Compound shapes (`Comp`, add and subtract) | one even-odd shape |
|
||||
| A shape or curve with children clips them | group with the outline as vector mask, the shape at its bottom |
|
||||
| Vector masks (`AdCh` curves and shapes) | vector masks |
|
||||
@@ -34,7 +34,7 @@ Affinity files.
|
||||
Everything else is reported in the import warnings, one line per kind with a count, never
|
||||
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
|
||||
transparency gradients, fill opacity, bitmap fills, master pages, unrecognized
|
||||
parametric shapes (imported as their bounding ellipses), corner types other than round or straight, stars with
|
||||
parametric shapes (imported as their bounding ellipses), corner types other than round, stars with
|
||||
rounded points, several fills or strokes on one object (one of each is used), strokes behind the
|
||||
fill, outlined or scaled text, text fields such as page numbers, frames with columns or a curved
|
||||
outline, grey/Lab/32-bit pixels, CMYK and Lab colour (converted to RGB without the document's
|
||||
@@ -132,25 +132,16 @@ test fixtures, not an exporter.
|
||||
|
||||
## Safety limits
|
||||
|
||||
Every size is checked before it is allocated: 256 MiB per archive entry, 1 GiB extracted per import
|
||||
and 1 GiB of decoded pixels per import by default (`Limits`: extracted bytes and materialized pixels
|
||||
are separate budgets, since a bitmap's declared size costs memory whatever its tiles hold), a 64 MiB
|
||||
zstd window, 4096 saved revisions, array lengths no longer than the
|
||||
Every size is checked before it is allocated: 256 MiB per archive entry and 1 GiB per import by
|
||||
default (`Limits`), a 64 MiB 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,5 +6,5 @@ use libfuzzer_sys::fuzz_target;
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let _ = photocraft_affinity::container::header(data);
|
||||
let _ = photocraft_affinity::preview(data);
|
||||
let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 });
|
||||
let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
|
||||
});
|
||||
@@ -7,5 +7,5 @@ use libfuzzer_sys::fuzz_target;
|
||||
fuzz_target!(|stream: &[u8]| {
|
||||
let _ = photocraft_affinity::stream::parse(stream);
|
||||
let file = photocraft_affinity::synth::container(&[("doc.dat", stream, photocraft_affinity::synth::Method::Stored)], None);
|
||||
let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 });
|
||||
let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
|
||||
});
|
||||
@@ -26,16 +26,11 @@ pub struct Limits {
|
||||
pub max_entry: usize,
|
||||
/// Total bytes extracted over the whole import.
|
||||
pub max_total: usize,
|
||||
/// Bytes of decoded pixels one import may hold: every layer's RGBA together, plus the
|
||||
/// temporary planes the layer being read decodes through. The entry limits bound what is
|
||||
/// extracted, not what the layers materialized out of it cost (#4960).
|
||||
pub max_pixel_bytes: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
// The gigabyte the other importers allow decoded pixels (`io`'s `MAX_PIXEL_BYTES`).
|
||||
Self { max_entry: 256 << 20, max_total: 1 << 30, max_pixel_bytes: 1 << 30 }
|
||||
Self { max_entry: 256 << 20, max_total: 1 << 30 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -288,7 +288,7 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
|
||||
let mut archive = Archive::open(bytes, limits)?;
|
||||
let doc = archive.read("doc.dat")?;
|
||||
let s = stream::parse(&doc)?;
|
||||
Reader::new(&s, &mut archive, limits).document()
|
||||
Reader::new(&s, &mut archive).document()
|
||||
}
|
||||
|
||||
pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
@@ -298,37 +298,11 @@ 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,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's
|
||||
/// output, which stays with the document; `planes` is the working set it decodes through and
|
||||
/// releases. Every layer read so far is still live while this one decodes, so the peak is all
|
||||
/// three together.
|
||||
pub(crate) fn admit_pixels(&mut self, rgba: usize, planes: usize) -> Result<(), Error> {
|
||||
let peak = rgba.saturating_add(planes);
|
||||
if peak > self.pixels_left {
|
||||
return Err(Error::Limit("decoded pixel layers exceed the pixel budget"));
|
||||
}
|
||||
self.pixels_left -= rgba;
|
||||
Ok(())
|
||||
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
|
||||
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() }
|
||||
}
|
||||
|
||||
pub(crate) fn warn(&mut self, what: impl Into<String>) {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -117,11 +117,6 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
// A layer is materialized from its declared size, whatever the tiles hold: admit the output
|
||||
// and the planes it decodes through before either is allocated (#4960).
|
||||
let pixels = (w as usize).checked_mul(h as usize).ok_or(Error::Limit("pixel layer size"))?;
|
||||
let scratch = pixels.checked_mul(channels * bps).ok_or(Error::Limit("pixel layer size"))?;
|
||||
r.admit_pixels(pixels.checked_mul(4).ok_or(Error::Limit("pixel layer size"))?, scratch)?;
|
||||
let mut planes = Vec::with_capacity(channels);
|
||||
let mut cache = HashMap::new();
|
||||
for c in 1..=channels {
|
||||
|
||||
@@ -51,44 +51,28 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
|
||||
let class = r.s.class(shape)?;
|
||||
let sub = match &class.0.to_be_bytes() {
|
||||
b"ShNR" | b"ShRR" => {
|
||||
let types = match last(r, shape, b"CTyp") {
|
||||
Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(),
|
||||
_ => Vec::new(),
|
||||
let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty());
|
||||
let radius = match last(r, shape, b"ShCR") {
|
||||
Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0),
|
||||
_ => None,
|
||||
};
|
||||
let radii = match last(r, shape, b"ShCR") {
|
||||
Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
// Corners follow the first one (top left) unless they were unlocked; then each has
|
||||
// its own radius and type, clockwise from the top left.
|
||||
let locked = !matches!(last(r, shape, b"Lock"), Some(Value::Bool(false)));
|
||||
let corners: [(f64, Option<u16>); 4] = std::array::from_fn(|i| {
|
||||
let i = if locked { 0 } else { i };
|
||||
(radii.get(i).copied().unwrap_or(0.0), types.get(i).copied().flatten())
|
||||
});
|
||||
if types.is_empty() || corners.iter().all(|(f, _)| *f == 0.0) {
|
||||
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
|
||||
} else {
|
||||
if corners.iter().any(|(f, t)| *f > 0.0 && !matches!(t, Some(0 | 1 | 4))) {
|
||||
r.warn("rectangles with concave or other corner types (imported with round corners)");
|
||||
if let (true, Some(f)) = (rounded, radius) {
|
||||
if let Some(Value::Array(types)) = last(r, shape, b"CTyp")
|
||||
&& types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. }))
|
||||
{
|
||||
r.warn("rectangles with straight, concave or other corner types (imported with round corners)");
|
||||
}
|
||||
let absolute = r.s.bool(shape, b"AbSz") == Some(true);
|
||||
let [a, b2, c, d, ..] = world.0;
|
||||
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
|
||||
cornered_rect(
|
||||
b,
|
||||
corners.map(|(f, t)| {
|
||||
let (crx, cry) = if !absolute {
|
||||
(f * w.min(h), f * w.min(h))
|
||||
} else if sx > 0.0 && sy > 0.0 {
|
||||
// An absolute radius is in document pixels, whatever the node's scale.
|
||||
(f / sx, f / sy)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
};
|
||||
(crx, cry, t == Some(1))
|
||||
}),
|
||||
)
|
||||
let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) {
|
||||
// An absolute radius is in document pixels, whatever the node's scale.
|
||||
let [a, b2, c, d, ..] = world.0;
|
||||
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
|
||||
if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) }
|
||||
} else {
|
||||
(f * w.min(h), f * w.min(h))
|
||||
};
|
||||
rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0))
|
||||
} else {
|
||||
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
|
||||
}
|
||||
}
|
||||
b"ShpE" => ellipse(cx, cy, rx, ry),
|
||||
@@ -131,21 +115,13 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
|
||||
}))?
|
||||
}
|
||||
b"ShPi" => {
|
||||
let raw_start = num(r, shape, b"AngS").unwrap_or(0.0);
|
||||
let raw_end = num(r, shape, b"AngE").unwrap_or(TAU);
|
||||
// Normalize in constant time. Repeatedly adding TAU can hang on finite values
|
||||
// large enough that floating-point addition no longer changes the endpoint.
|
||||
let start = raw_start.rem_euclid(TAU);
|
||||
let delta = raw_end - raw_start;
|
||||
let sweep = if raw_end >= raw_start {
|
||||
delta.min(TAU)
|
||||
} else if delta.is_finite() {
|
||||
delta.rem_euclid(TAU)
|
||||
} else {
|
||||
(raw_end.rem_euclid(TAU) - start).rem_euclid(TAU)
|
||||
};
|
||||
let start = num(r, shape, b"AngS").unwrap_or(0.0);
|
||||
let mut end = num(r, shape, b"AngE").unwrap_or(TAU);
|
||||
while end < start {
|
||||
end += TAU;
|
||||
}
|
||||
let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
pie(cx, cy, rx, ry, start, start + sweep, inner)
|
||||
pie(cx, cy, rx, ry, start, end.min(start + TAU), inner)
|
||||
}
|
||||
b"ShpT" => {
|
||||
let p = num(r, shape, b"Pos ").unwrap_or(0.5);
|
||||
@@ -370,31 +346,22 @@ fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
|
||||
}
|
||||
}
|
||||
|
||||
/// A rectangle whose corners (top left, top right, bottom right, bottom left) are each rounded,
|
||||
/// or cut straight across, by their (horizontal, vertical) radii. Two radii that don't fit along
|
||||
/// one side shrink in proportion until they do.
|
||||
fn cornered_rect([x0, y0, x1, y1]: [f64; 4], corners: [(f64, f64, bool); 4]) -> SubPath {
|
||||
let fit = |r: f64, pair: f64, side: f64| if pair > side { side * (r / pair) } else { r };
|
||||
let [tl, tr, br, bl] = corners;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let rx = [fit(tl.0, tl.0 + tr.0, w), fit(tr.0, tl.0 + tr.0, w), fit(br.0, bl.0 + br.0, w), fit(bl.0, bl.0 + br.0, w)];
|
||||
let ry = [fit(tl.1, tl.1 + bl.1, h), fit(tr.1, tr.1 + br.1, h), fit(br.1, tr.1 + br.1, h), fit(bl.1, tl.1 + bl.1, h)];
|
||||
let (kx, ky) = (rx.map(|r| (1.0 - KAPPA) * r), ry.map(|r| (1.0 - KAPPA) * r));
|
||||
// (where the corner leaves the previous side, its two handles, where it joins the next side)
|
||||
let arcs = [
|
||||
[pt(x1 - rx[1], y0), pt(x1 - kx[1], y0), pt(x1, y0 + ky[1]), pt(x1, y0 + ry[1])],
|
||||
[pt(x1, y1 - ry[2]), pt(x1, y1 - ky[2]), pt(x1 - kx[2], y1), pt(x1 - rx[2], y1)],
|
||||
[pt(x0 + rx[3], y1), pt(x0 + kx[3], y1), pt(x0, y1 - ky[3]), pt(x0, y1 - ry[3])],
|
||||
[pt(x0, y0 + ry[0]), pt(x0, y0 + ky[0]), pt(x0 + kx[0], y0), pt(x0 + rx[0], y0)],
|
||||
];
|
||||
let mut segments = Vec::with_capacity(8);
|
||||
for ([s, c1, c2, e], straight) in arcs.into_iter().zip([tr.2, br.2, bl.2, tl.2]) {
|
||||
segments.push([s, s, s]);
|
||||
if s != e {
|
||||
segments.push(if straight { [e, e, e] } else { [c1, c2, e] });
|
||||
}
|
||||
fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath {
|
||||
let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry);
|
||||
SubPath {
|
||||
start: pt(x0 + rx, y0),
|
||||
segments: vec![
|
||||
[pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)],
|
||||
[pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)],
|
||||
[pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)],
|
||||
[pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)],
|
||||
[pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)],
|
||||
[pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)],
|
||||
[pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)],
|
||||
[pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)],
|
||||
],
|
||||
closed: true,
|
||||
}
|
||||
SubPath { start: pt(x0 + rx[0], y0), segments, closed: true }
|
||||
}
|
||||
|
||||
/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are),
|
||||
|
||||
@@ -251,9 +251,9 @@ mod archive {
|
||||
broken[90] ^= 0x55;
|
||||
let mut a = Archive::open(&broken, Limits::default()).unwrap();
|
||||
assert!(a.read("doc.dat").is_err());
|
||||
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX, ..Limits::default() }).unwrap();
|
||||
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap();
|
||||
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
|
||||
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1, ..Limits::default() }).unwrap();
|
||||
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap();
|
||||
a.read("doc.dat").unwrap();
|
||||
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
|
||||
}
|
||||
@@ -349,363 +349,3 @@ mod archive {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod shapes {
|
||||
use crate::container::Limits;
|
||||
use crate::model::{Document, Kind, Path, Point, SubPath};
|
||||
use crate::stream::Tag;
|
||||
use crate::synth::{self, F, Method, tag};
|
||||
|
||||
fn shape(id: u32, class: &[u8; 4], parameters: Vec<(Tag, F)>) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"ShpN")],
|
||||
vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(id + 10_000, vec![tag(class)], parameters))],
|
||||
)
|
||||
}
|
||||
|
||||
/// Reads a document whose one page holds the object `top` from `definitions`, on a thread of
|
||||
/// its own, and fails the test when reading takes more than 20 s.
|
||||
fn read(definitions: Vec<F>, top: u32) -> Document {
|
||||
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(vec![F::Ref(top)]))],
|
||||
)]),
|
||||
)],
|
||||
),
|
||||
),
|
||||
]);
|
||||
let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || tx.send(crate::model::read(&bytes, Limits::default())).ok());
|
||||
rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading the document did not finish")).unwrap()
|
||||
}
|
||||
|
||||
fn path(document: &Document) -> &Path {
|
||||
match &document.spreads[0].nodes[0].kind {
|
||||
Kind::Shape { path, .. } => path,
|
||||
other => panic!("expected shape, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pie_angles_with_huge_finite_values_do_not_loop_or_emit_nonfinite_points() {
|
||||
let document = read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(1.0e308)), (tag(b"AngE"), F::F64(-1.0e308))])], 100);
|
||||
for subpath in &path(&document).subpaths {
|
||||
assert!(subpath.start.x.is_finite() && subpath.start.y.is_finite());
|
||||
assert!(subpath.segments.len() <= 20);
|
||||
assert!(subpath.segments.iter().flatten().all(|point| point.x.is_finite() && point.y.is_finite()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_pie_angles_keep_the_same_geometry_as_the_positive_span() {
|
||||
let make = |end| read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(0.0)), (tag(b"AngE"), F::F64(end))])], 100);
|
||||
assert_eq!(path(&make(-std::f64::consts::FRAC_PI_2)), path(&make(3.0 * std::f64::consts::FRAC_PI_2)));
|
||||
}
|
||||
|
||||
/// A 10×20 rectangle (`ShNR`) with the given parameters, read back as its outline.
|
||||
fn rectangle(parameters: Vec<(Tag, F)>) -> (SubPath, Vec<String>) {
|
||||
let node =
|
||||
F::Def(3, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(4, vec![tag(b"ShNR")], parameters))]);
|
||||
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(vec![node]))]);
|
||||
let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
|
||||
let doc = crate::read(&synth::container(&[("doc.dat", &data, Method::Zlib)], None), Limits::default()).unwrap();
|
||||
let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected a shape") };
|
||||
(path.subpaths[0].clone(), doc.warnings)
|
||||
}
|
||||
|
||||
/// `CTyp`: an array of corner-type enums (top left, top right, bottom right, bottom left).
|
||||
fn corner_types(ids: [u16; 4]) -> F {
|
||||
let mut raw = vec![0xaa];
|
||||
raw.extend(tag(b"CTyp").0.to_le_bytes());
|
||||
raw.extend(4u32.to_le_bytes());
|
||||
raw.extend(0u16.to_le_bytes());
|
||||
raw.extend(ids.iter().flat_map(|id| id.to_le_bytes()));
|
||||
F::Raw(raw)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlocked_rectangle_corners_keep_their_own_radius_and_type() {
|
||||
let p = |x: f64, y: f64| Point { x, y };
|
||||
// Relative radii scale by 10. Round 5, round 1, straight 3, none.
|
||||
let corners = |lock: Option<bool>| {
|
||||
let mut fields = vec![(tag(b"ShCR"), F::F64s(vec![0.5, 0.1, 0.3, 0.0])), (tag(b"CTyp"), corner_types([0, 0, 1, 4]))];
|
||||
fields.extend(lock.map(|l| (tag(b"Lock"), F::Bool(l))));
|
||||
rectangle(fields)
|
||||
};
|
||||
|
||||
let (sub, warnings) = corners(Some(false));
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(sub.start, p(5.0, 0.0));
|
||||
let ends: Vec<Point> = sub.segments.iter().map(|s| s[2]).collect();
|
||||
assert_eq!(ends, [p(9.0, 0.0), p(10.0, 1.0), p(10.0, 17.0), p(7.0, 20.0), p(0.0, 20.0), p(0.0, 5.0), p(5.0, 0.0)]);
|
||||
assert_eq!(sub.segments[3], [p(7.0, 20.0); 3], "the straight corner is a line");
|
||||
assert_ne!(sub.segments[1][0], p(9.0, 0.0), "the round corner is a curve");
|
||||
|
||||
// Locked (the default): every corner follows the top left one.
|
||||
let (sub, _) = corners(None);
|
||||
assert_eq!(sub.segments.len(), 8);
|
||||
assert_eq!(sub.segments[3][2], p(5.0, 20.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregate materialized-pixel admission (#4960). The entry limits bound what the archive
|
||||
/// extracts; these cover what the decoded layers and their working planes cost, which is declared
|
||||
/// by each bitmap's `BmpW`/`BmpH` and needs no tile data at all.
|
||||
mod pixels {
|
||||
use crate::Error;
|
||||
use crate::container::Limits;
|
||||
use crate::model::{Document, Kind};
|
||||
use crate::synth::{self, F, Method, tag};
|
||||
|
||||
/// A `Rstr` pixel layer whose bitmap declares `w`×`h` pixels and holds no tiles.
|
||||
fn layer(id: u32, w: i32, h: i32) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"Rstr")],
|
||||
vec![
|
||||
(tag(b"Desc"), F::Str(format!("Layer {id}"))),
|
||||
(tag(b"Bitm"), F::Def(id + 1, vec![tag(b"DyBm")], vec![(tag(b"BmpW"), F::I32(w)), (tag(b"BmpH"), F::I32(h)), (tag(b"Frmt"), F::Enum(0, 0))])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// A document whose one page holds a pixel layer per `sizes` entry.
|
||||
fn document(sizes: &[(i32, i32)]) -> Vec<u8> {
|
||||
let children: Vec<F> = sizes.iter().enumerate().map(|(i, &(w, h))| layer(10 + 2 * i as u32, w, h)).collect();
|
||||
let data = synth::stream(&[(
|
||||
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, 256.0, 256.0])), (tag(b"Chld"), F::Shared(children))],
|
||||
)]),
|
||||
)],
|
||||
),
|
||||
)]);
|
||||
synth::container(&[("doc.dat", &data, Method::Zlib)], None)
|
||||
}
|
||||
|
||||
fn imported_layers(d: &Document) -> usize {
|
||||
d.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count()
|
||||
}
|
||||
|
||||
/// A 64×64 layer costs 16 KiB of RGBA and 16 KiB of planes, so 40 KiB admits one and not two.
|
||||
const ONE_LAYER: usize = 40 << 10;
|
||||
|
||||
#[test]
|
||||
fn a_pixel_layer_within_the_budget_imports() {
|
||||
let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() };
|
||||
let d = crate::model::read(&document(&[(64, 64)]), limits).unwrap();
|
||||
assert_eq!(imported_layers(&d), 1, "{:?}", d.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_past_the_pixel_budget_are_rejected_before_they_are_allocated() {
|
||||
let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() };
|
||||
// The document is a few hundred bytes: the sizes are in the headers, not the entries.
|
||||
let bytes = document(&[(64, 64), (64, 64)]);
|
||||
assert!(bytes.len() < 4096, "the fixture is {} bytes", bytes.len());
|
||||
assert!(matches!(crate::model::read(&bytes, limits), Err(Error::Limit(_))), "the second layer materialized");
|
||||
// Two layers fit once the budget covers their pixels together.
|
||||
let room = Limits { max_pixel_bytes: ONE_LAYER + (32 << 10), ..Limits::default() };
|
||||
assert_eq!(imported_layers(&crate::model::read(&bytes, room).unwrap()), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_declared_size_larger_than_the_budget_is_rejected_on_its_own() {
|
||||
// 2048×2048 is 16 MiB of RGBA and 16 MiB of planes, past a 1 MiB budget.
|
||||
let limits = Limits { max_pixel_bytes: 1 << 20, ..Limits::default() };
|
||||
assert!(
|
||||
matches!(crate::model::read(&document(&[(2048, 2048)]), limits), Err(Error::Limit(_))),
|
||||
"16 MiB of declared pixels materialized under a 1 MiB budget"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
@@ -427,7 +427,7 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
|
||||
F::Crosshatch => (g("strokeLength") / 3.0) as i32 + gs(1.0) + 2,
|
||||
F::DarkStrokes => 9,
|
||||
F::InkOutlines => (g("strokeLength") / 2.0) as i32 + 3,
|
||||
F::Spatter => (g("sprayRadius") * 1.5).ceil() as i32 + 2,
|
||||
F::Spatter => g("sprayRadius").ceil() as i32 + 2,
|
||||
F::SprayedStrokes => g("sprayRadius").ceil() as i32 + (g("strokeLength") / 2.0) as i32 + 3,
|
||||
F::SumiE => gs(g("strokeWidth") * 0.2) + (g("strokeWidth") / 2.0) as i32 + 2,
|
||||
F::Glass => (g("distortion") * 2.0).ceil() as i32 + 2,
|
||||
@@ -768,26 +768,9 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
||||
win.c[i] = map(win.c[i], |v| (v + (1.0 - v) * lt) * (1.0 - dk) * (1.0 - clamp01(ink)));
|
||||
}
|
||||
}
|
||||
F::Spatter => {
|
||||
// Smoothness smooths the random displacement: a larger feature size and, as with any
|
||||
// averaged noise, a smaller reach. Low smoothness sprays specks out to the full
|
||||
// radius; high smoothness leaves a torn but attached, hard edge (what Photoshop
|
||||
// shows at Smoothness 15).
|
||||
F::Spatter | F::SprayedStrokes => {
|
||||
let rad = g("sprayRadius");
|
||||
let feature = 1.0 + g("smoothness") * 0.35;
|
||||
let reach = (rad * 1.2 / feature).min(rad);
|
||||
let src = Win { r: win.r, w, h, c: win.c.clone() };
|
||||
for i in 0..win.c.len() {
|
||||
let (x, y) = win.xy(i);
|
||||
let (fx, fy) = (x as f32 / feature, y as f32 / feature);
|
||||
let dx = (fbm(fx, fy, 91) - 0.5) * 3.0 * reach;
|
||||
let dy = (fbm(fx, fy, 92) - 0.5) * 3.0 * reach;
|
||||
win.c[i] = src.sample(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy);
|
||||
}
|
||||
}
|
||||
F::SprayedStrokes => {
|
||||
let rad = g("sprayRadius");
|
||||
let sc = 1.2;
|
||||
let sc = if e.filter == F::Spatter { 0.6 + g("smoothness") * 0.25 } else { 1.2 };
|
||||
let src = Win { r: win.r, w, h, c: win.c.clone() };
|
||||
for i in 0..win.c.len() {
|
||||
let (x, y) = win.xy(i);
|
||||
@@ -796,7 +779,9 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
|
||||
let dy = (vnoise(fx, fy, 92) - 0.5) * 2.0 * rad;
|
||||
win.c[i] = src.sample(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy);
|
||||
}
|
||||
line_blur3(&mut win.c, w, h, direction(e.choice("strokeDirection")), (g("strokeLength") / 2.0) as usize);
|
||||
if e.filter == F::SprayedStrokes {
|
||||
line_blur3(&mut win.c, w, h, direction(e.choice("strokeDirection")), (g("strokeLength") / 2.0) as usize);
|
||||
}
|
||||
}
|
||||
F::SumiE => {
|
||||
let sw = g("strokeWidth");
|
||||
|
||||
@@ -992,9 +992,8 @@ mod tests {
|
||||
let mixed = crate::apply_in(&s, &p, canvas, canvas, Some(&half), canvas);
|
||||
for (x, y) in [(0, 0), (18, 11), (36, 22)] {
|
||||
let (o, f, m) = (s.pixel(x, y), full.pixel(x, y), mixed.pixel(x, y));
|
||||
assert!((m[3] - (o[3] + f[3]) / 2.0).abs() < 1e-5, "({x},{y}) alpha");
|
||||
for c in 0..3 {
|
||||
assert!((m[c] * m[3] - (o[c] * o[3] + f[c] * f[3]) / 2.0).abs() < 1e-5, "({x},{y}) premultiplied channel {c}");
|
||||
for c in 0..4 {
|
||||
assert!((m[c] - (o[c] + f[c]) / 2.0).abs() < 1e-5, "({x},{y}) channel {c}");
|
||||
}
|
||||
}
|
||||
let cancel = || true;
|
||||
|
||||
@@ -169,7 +169,7 @@ fn apply_inner(
|
||||
return None;
|
||||
}
|
||||
if let Some(selection) = selection {
|
||||
crate::mix_selection(&mut data, output, selection, &src, fmt.alpha);
|
||||
crate::mix_selection(&mut data, output, selection, &src);
|
||||
}
|
||||
if ctl.cancelled() {
|
||||
return None;
|
||||
|
||||
@@ -280,7 +280,7 @@ fn motion_fft_pipeline_supports_depths_models_edges_selections_and_tile_seams()
|
||||
for mask in [None, Some(&selection)] {
|
||||
let mut expected_data = filtered.clone();
|
||||
if let Some(mask) = mask {
|
||||
crate::mix_selection(&mut expected_data, rect, mask, &src, alpha);
|
||||
crate::mix_selection(&mut expected_data, rect, mask, &src);
|
||||
}
|
||||
let mut expected = source.clone();
|
||||
expected.write_region(rect, &expected_data);
|
||||
@@ -362,7 +362,7 @@ fn motion_fft_production_crosses_real_tile_seams_and_leaves_other_pixels_untouch
|
||||
let mut expected = source.clone();
|
||||
let mut data = filtered.clone();
|
||||
if let Some(mask) = mask {
|
||||
crate::mix_selection(&mut data, area, mask, &src, true);
|
||||
crate::mix_selection(&mut data, area, mask, &src);
|
||||
}
|
||||
expected.write_region(area, &data);
|
||||
let expected = Image::read(&expected, check);
|
||||
|
||||
@@ -69,7 +69,7 @@ fn row_surface(surface: &Surface, params: &FilterParams, area: Rect, bounds: Rec
|
||||
let ctx = Ctx { bounds, mode: format.mode, alpha: format.alpha };
|
||||
let mut data = motion_rows(&src, area, &ctx, *angle, *distance);
|
||||
if let Some(selection) = selection {
|
||||
crate::mix_selection(&mut data, area, selection, &src, format.alpha);
|
||||
crate::mix_selection(&mut data, area, selection, &src);
|
||||
}
|
||||
let mut result = surface.clone();
|
||||
result.write_region(area, &data);
|
||||
|
||||
@@ -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 })
|
||||
};
|
||||
|
||||
@@ -9,10 +9,9 @@
|
||||
//! with the guided filter); negative amounts add haze toward the atmospheric light.
|
||||
//! 3. **Tone** on luminance with colour ratios kept: Highlights / Shadows as exposure changes
|
||||
//! weighted by an edge-preserving base layer (guided filter: K. He, J. Sun, X. Tang, *Guided
|
||||
//! Image Filtering*, ECCV 2010), so local contrast survives; Contrast is an S-curve about
|
||||
//! middle grey; Clarity (large radius, midtone weighted) and Texture (small radius) add
|
||||
//! band-pass local contrast. Whites / Blacks follow as Photoshop's Camera Raw applies them:
|
||||
//! image-adaptive global curves measured on Photoshop 25.4 (see `whites_blacks`).
|
||||
//! Image Filtering*, ECCV 2010), so local contrast survives; Whites / Blacks move the ends of
|
||||
//! the curve; Contrast is an S-curve about middle grey; Clarity (large radius, midtone
|
||||
//! weighted) and Texture (small radius) add band-pass local contrast.
|
||||
//! 4. **Presence**: Vibrance (weighted toward less saturated colours) and Saturation.
|
||||
//! 5. **Tone Curve**: parametric regions (Highlights, Lights, Darks, Shadows with movable splits)
|
||||
//! then point curves (master and per channel), monotone cubic interpolation (F. Fritsch,
|
||||
@@ -55,32 +54,6 @@ pub fn validate_curve(points: &[[f32; 2]]) -> Result<(), String> {
|
||||
|
||||
use crate::photo_util::{hash01, linear_to_srgb, par_rows, srgb_to_linear};
|
||||
|
||||
mod local_tone;
|
||||
mod whites_blacks;
|
||||
|
||||
/// How the pixels encode light: the sRGB curve (gamma 0) or a pure power law (ProPhoto's 1.8,
|
||||
/// Adobe RGB's 2.2). The tone controls work on light, so the document's own curve decodes it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct Encoding(f32);
|
||||
|
||||
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 luma_linear(c: [f32; 3]) -> f32 {
|
||||
luma(c)
|
||||
}
|
||||
|
||||
/// One colour grading wheel.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
@@ -163,9 +136,6 @@ pub struct CameraRaw {
|
||||
pub seed: u32,
|
||||
/// Pixel-size multiplier for previews on downsampled proxies (radii in px scale by it).
|
||||
pub pixel_scale: f32,
|
||||
/// The document's transfer curve: 0 for sRGB's, else the gamma of a power-law space (1.8
|
||||
/// for ProPhoto). Set from the document's profile; Whites and Blacks act on light.
|
||||
pub encoding_gamma: f32,
|
||||
}
|
||||
|
||||
impl Default for CameraRaw {
|
||||
@@ -221,7 +191,6 @@ impl Default for CameraRaw {
|
||||
vignette_style: "highlightPriority".into(),
|
||||
seed: 0,
|
||||
pixel_scale: 1.0,
|
||||
encoding_gamma: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -522,11 +491,7 @@ impl CameraRaw {
|
||||
self.temperature == 0.0 && self.tint == 0.0 && self.exposure == 0.0
|
||||
}
|
||||
fn tone_neutral(&self) -> bool {
|
||||
self.local_tone_neutral() && self.whites == 0.0 && self.blacks == 0.0
|
||||
}
|
||||
/// Contrast, Highlights, Shadows, Texture and Clarity (the luminance stage).
|
||||
fn local_tone_neutral(&self) -> bool {
|
||||
[self.contrast, self.highlights, self.shadows, self.texture, self.clarity].iter().all(|v| *v == 0.0)
|
||||
[self.contrast, self.highlights, self.shadows, self.whites, self.blacks, self.texture, self.clarity].iter().all(|v| *v == 0.0)
|
||||
}
|
||||
fn hsl_neutral(&self) -> bool {
|
||||
self.hsl_hue.iter().chain(&self.hsl_sat).chain(&self.hsl_lum).all(|v| *v == 0.0)
|
||||
@@ -596,13 +561,16 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
||||
if p.dehaze != 0.0 {
|
||||
dehaze(px, w, h, p.dehaze / 100.0);
|
||||
}
|
||||
// 3. Tone. Highlights and Shadows: a local Laplacian filter (see `local_tone`).
|
||||
if p.highlights != 0.0 || p.shadows != 0.0 {
|
||||
local_tone::apply(px, w, h, p.shadows, p.highlights, Encoding::of(p.encoding_gamma));
|
||||
}
|
||||
if !p.local_tone_neutral() && [p.contrast, p.texture, p.clarity].iter().any(|v| *v != 0.0) {
|
||||
// 3. Tone on luminance.
|
||||
if !p.tone_neutral() {
|
||||
let l: Vec<f32> = px.iter().map(|q| luma([q[0], q[1], q[2]]).max(0.0)).collect();
|
||||
let lc: Vec<f32> = l.iter().map(|v| v.min(1.0)).collect();
|
||||
let base = if p.highlights != 0.0 || p.shadows != 0.0 {
|
||||
let r = ((long * 0.015) as usize).max(2);
|
||||
Some(guided_fast(&lc, &lc, w, h, r, 0.02))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let clar = (p.clarity != 0.0).then(|| guided_fast(&lc, &lc, w, h, ((long * 0.02) as usize).max(3), 0.005));
|
||||
let tex = (p.texture != 0.0).then(|| gauss(&lc, w, h, (long * 0.002).max(1.5 * ps)));
|
||||
let k_con = p.contrast / 100.0 * 0.6;
|
||||
@@ -612,6 +580,12 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
||||
let i = y * w + x;
|
||||
let l0 = l[i];
|
||||
let mut lv = lc[i];
|
||||
if let Some(b) = &base {
|
||||
let bv = b[i].clamp(0.0, 1.0);
|
||||
let hm = smoothstep(0.45, 0.95, bv);
|
||||
let sm = 1.0 - smoothstep(0.05, 0.5, bv);
|
||||
lv *= 2f32.powf(p.highlights / 100.0 * 1.2 * hm + p.shadows / 100.0 * 1.4 * sm);
|
||||
}
|
||||
if let Some(c) = &clar {
|
||||
let mw = 1.0 - (2.0 * lv.clamp(0.0, 1.0) - 1.0).powi(2);
|
||||
lv += p.clarity / 100.0 * 0.8 * (lc[i] - c[i]) * mw;
|
||||
@@ -619,6 +593,12 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
||||
if let Some(t) = &tex {
|
||||
lv += p.texture / 100.0 * 0.9 * (lc[i] - t[i]).clamp(-0.1, 0.1);
|
||||
}
|
||||
if p.whites != 0.0 {
|
||||
lv += p.whites / 100.0 * 0.25 * smoothstep(0.4, 1.0, lv);
|
||||
}
|
||||
if p.blacks != 0.0 {
|
||||
lv += p.blacks / 100.0 * 0.15 * (1.0 - smoothstep(0.0, 0.45, lv));
|
||||
}
|
||||
if k_con != 0.0 {
|
||||
let xx = lv.clamp(0.0, 1.0);
|
||||
lv += -k_con * (tau * xx).sin() / tau;
|
||||
@@ -631,13 +611,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
||||
}
|
||||
});
|
||||
}
|
||||
// 3b. Whites and Blacks: global curves adapted to the image's black and white points.
|
||||
if p.whites != 0.0 || p.blacks != 0.0 {
|
||||
let enc = Encoding::of(p.encoding_gamma);
|
||||
let (bp, wp) = luminance_points(px, w, h, enc);
|
||||
let (xw, xb) = whites_blacks::amounts(p.whites.clamp(-100.0, 100.0), p.blacks.clamp(-100.0, 100.0), bp, wp);
|
||||
whites_blacks::apply(px, w, xw, xb, enc);
|
||||
}
|
||||
// 4–7: per-pixel colour stages.
|
||||
let need_color = p.vibrance != 0.0 || p.saturation != 0.0 || !p.curves_neutral() || !p.hsl_neutral() || !p.grade_neutral();
|
||||
if need_color {
|
||||
@@ -759,36 +732,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
|
||||
}
|
||||
}
|
||||
|
||||
/// The image's black and white points as sRGB-encoded grey levels: the 3rd percentile of the
|
||||
/// luminance and the 99.5th of the brightest channel, on a copy at most 256 pixels across (so a
|
||||
/// preview proxy and the full image agree).
|
||||
fn luminance_points(px: &[[f32; 4]], w: usize, h: usize, enc: Encoding) -> (f32, f32) {
|
||||
let f = w.max(h).div_ceil(256).max(1);
|
||||
let (sw, sh) = (w.div_ceil(f), h.div_ceil(f));
|
||||
let mut sum = vec![[0.0f64; 4]; sw * sh];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let q = px[y * w + x];
|
||||
let s = &mut sum[(y / f) * sw + x / f];
|
||||
for c in 0..3 {
|
||||
s[c] += f64::from(enc.decode(q[c]).clamp(0.0, 1.0));
|
||||
}
|
||||
s[3] += 1.0;
|
||||
}
|
||||
}
|
||||
let cells: Vec<[f32; 3]> = sum.iter().filter(|s| s[3] > 0.0).map(|s| [0, 1, 2].map(|c| (s[c] / s[3]) as f32)).collect();
|
||||
if cells.is_empty() {
|
||||
return (0.0, 1.0);
|
||||
}
|
||||
let pct = |mut v: Vec<f32>, q: f32| {
|
||||
v.sort_by(f32::total_cmp);
|
||||
v[((v.len() - 1) as f32 * q).round() as usize]
|
||||
};
|
||||
let bp = pct(cells.iter().map(|c| luma(*c)).collect(), 0.03);
|
||||
let wp = pct(cells.iter().map(|c| c[0].max(c[1]).max(c[2])).collect(), 0.995);
|
||||
(linear_to_srgb(bp), linear_to_srgb(wp))
|
||||
}
|
||||
|
||||
fn value_noise(x: f32, y: f32, seed: u64) -> f32 {
|
||||
let (xi, yi) = (x.floor(), y.floor());
|
||||
let (tx, ty) = (x - xi, y - yi);
|
||||
|
||||
@@ -1,597 +0,0 @@
|
||||
//! Shadows and Highlights as a local Laplacian filter.
|
||||
//!
|
||||
//! Adobe has said Camera Raw's process version 2012 rests on local Laplacian filters (S. Paris,
|
||||
//! S. W. Hasinoff, J. Kautz, *Local Laplacian Filters: Edge-aware Image Processing with a
|
||||
//! Laplacian Pyramid*, SIGGRAPH 2011). This follows that paper, in its fast form (M. Aubry et
|
||||
//! al., *Fast Local Laplacian Filters*, ACM TOG 2014: the remapping sampled at a few reference
|
||||
//! values, each pyramid coefficient interpolated between them), on the log2 luminance:
|
||||
//!
|
||||
//! - **Shadows** raises pixels darker than their surroundings, by an amount set by their own and
|
||||
//! their surroundings' place in the image's range (1st to 99th percentile); **Highlights**
|
||||
//! pulls pixels brighter than their surroundings toward them. Differences under a threshold
|
||||
//! are left alone, so fine detail stays.
|
||||
//! - Shadows then keeps the image's 99th percentile, Highlights its 1st (Photoshop keeps the
|
||||
//! brightest content under Shadows and the darkest under Highlights).
|
||||
//! - A uniform image is unchanged, edges keep no halo, and the result does not depend on the
|
||||
//! image size.
|
||||
//!
|
||||
//! The strengths were fitted to Photoshop 25.4's Camera Raw Filter on ten public-domain photos
|
||||
//! (black-box measurements, no Adobe code or data). It is an approximation: on those photos the
|
||||
//! mean error against Photoshop is 2.8–9.4 levels (of 255) where the previous heuristic's was
|
||||
//! 3.7–14.3.
|
||||
|
||||
use super::{Encoding, luma};
|
||||
use crate::photo_util::par_rows;
|
||||
|
||||
/// Spacing of the fast filter's reference values in stops. Its remapping changes within about
|
||||
/// half a stop, and coefficients interpolated across wider gaps show as contour lines on smooth
|
||||
/// gradients (12 references over a whole raw's range left visible steps; at an eighth of a stop a
|
||||
/// ramp's slope varies by under a third, as the remapping itself makes it).
|
||||
const REF_STEP: f32 = 0.125;
|
||||
/// The filter runs on a copy at most this many pixels across; the change it makes is brought back
|
||||
/// to full size by a guided upsampling, so a preview and the full image agree.
|
||||
const WORK_SIDE: usize = 1600;
|
||||
/// Knots of the strength curves over the surroundings' place in the image range (0 … 1).
|
||||
const KNOTS: usize = 5;
|
||||
|
||||
/// Shadows: how far a pixel darker than its surroundings rises, in stops per unit slider, as
|
||||
/// a function of its own place in the image range (`own`) times a factor of its surroundings'
|
||||
/// place (`around`); it rises fully once it is `sigma` stops below them. The lift depends on
|
||||
/// the pixel's tone, not on the size of the difference, so it is the same at every pyramid
|
||||
/// level and a big edge keeps no halo. Lowering (negative values) is `down` times as strong.
|
||||
const SHADOWS: Lift = Lift { own: [-0.017, 0.466, 0.886, 1.221, -0.081], around: [4.566, 4.067, 1.088, 0.149, 0.878], sigma: 0.571, down: 0.808 };
|
||||
const HIGHLIGHTS: Side = Side { pull: [0.179, 0.141, 0.196, 0.629, 2.07], push: [0.156, 0.08, 0.175, 0.049, 1.261], sigma: 0.05 };
|
||||
|
||||
/// See [`SHADOWS`].
|
||||
struct Lift {
|
||||
own: [f32; KNOTS],
|
||||
around: [f32; KNOTS],
|
||||
sigma: f32,
|
||||
down: f32,
|
||||
}
|
||||
|
||||
/// Highlights: strengths per unit slider over the surroundings' place in the image range, for
|
||||
/// lowering (`pull`, toward the surroundings) and raising (`push`), and the detail threshold in
|
||||
/// stops.
|
||||
struct Side {
|
||||
pull: [f32; KNOTS],
|
||||
push: [f32; KNOTS],
|
||||
sigma: f32,
|
||||
}
|
||||
|
||||
/// One channel, `w × h`.
|
||||
struct Plane {
|
||||
w: usize,
|
||||
h: usize,
|
||||
v: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Mirrors `i` into `0..n` (the edge sample is not repeated).
|
||||
fn mirror(i: isize, n: usize) -> usize {
|
||||
let n = n as isize;
|
||||
if n == 1 {
|
||||
return 0;
|
||||
}
|
||||
let p = 2 * (n - 1);
|
||||
let m = i.rem_euclid(p);
|
||||
(if m >= n { p - m } else { m }) as 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.
|
||||
let mut tmp = vec![0.0f32; p.w * h2];
|
||||
rows(&mut tmp, p.w, |y2, row| {
|
||||
let y = (y2 * step) as isize;
|
||||
let src: [&[f32]; 5] = std::array::from_fn(|k| {
|
||||
let r = mirror(y + k as isize - 2, p.h);
|
||||
&p.v[r * p.w..(r + 1) * p.w]
|
||||
});
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
*o = (0..5).map(|k| TAPS[k] * src[k][x]).sum();
|
||||
}
|
||||
});
|
||||
let mut out = vec![0.0f32; w2 * h2];
|
||||
rows(&mut out, w2, |y2, row| {
|
||||
let src = &tmp[y2 * p.w..(y2 + 1) * p.w];
|
||||
for (x2, o) in row.iter_mut().enumerate() {
|
||||
let x = x2 * step;
|
||||
*o = if x >= 2 && x + 2 < p.w {
|
||||
let s = &src[x - 2..x + 3];
|
||||
(0..5).map(|k| TAPS[k] * s[k]).sum()
|
||||
} else {
|
||||
(0..5).map(|k| TAPS[k] * src[mirror(x as isize + k as isize - 2, p.w)]).sum()
|
||||
};
|
||||
}
|
||||
});
|
||||
Plane { w: w2, h: h2, v: out }
|
||||
}
|
||||
|
||||
/// The next coarser level.
|
||||
fn down(p: &Plane) -> Plane {
|
||||
blur_step(p, 2)
|
||||
}
|
||||
|
||||
/// `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()
|
||||
};
|
||||
}
|
||||
});
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/// Levels down to about 2 pixels on the short side.
|
||||
fn depth(w: usize, h: usize) -> usize {
|
||||
let mut s = w.min(h);
|
||||
let mut n = 1;
|
||||
while s > 2 {
|
||||
s = s.div_ceil(2);
|
||||
n += 1;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn gaussian(p: Plane, n: usize) -> Vec<Plane> {
|
||||
let mut g = vec![p];
|
||||
for _ in 1..n {
|
||||
let next = down(&g[g.len() - 1]);
|
||||
g.push(next);
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// The local Laplacian filter of `u` with `remap(value, reference)`; the coarsest level is kept.
|
||||
fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
|
||||
let n = depth(u.w, u.h);
|
||||
let g = gaussian(Plane { w: u.w, h: u.h, v: u.v.clone() }, n);
|
||||
// References span the image's tones; the deepest 0.1 % (noise down to the floor) share the
|
||||
// lowest reference instead of stretching the spacing.
|
||||
let hi = u.v.iter().fold(f32::NEG_INFINITY, |a, &v| a.max(v));
|
||||
let lo = quantile(&u.v, 0.001).min(hi);
|
||||
if hi - lo <= 1e-6 || (hi - lo).is_nan() {
|
||||
return Plane { w: u.w, h: u.h, v: u.v.clone() };
|
||||
}
|
||||
let refs = (((hi - lo) / REF_STEP).ceil() as usize + 1).clamp(2, 96);
|
||||
let step = (hi - lo) / (refs - 1) as f32;
|
||||
let mut lap: Vec<Vec<f32>> = g[..n - 1].iter().map(|p| vec![0.0; p.v.len()]).collect();
|
||||
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| {
|
||||
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);
|
||||
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));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut o = Plane { w: g[n - 1].w, h: g[n - 1].h, v: g[n - 1].v.clone() };
|
||||
for l in (0..n - 1).rev() {
|
||||
let mut next = up(&o, g[l].w, g[l].h);
|
||||
for (a, b) in next.v.iter_mut().zip(&lap[l]) {
|
||||
*a += b;
|
||||
}
|
||||
o = next;
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across.
|
||||
fn range(u: &Plane) -> (f32, f32) {
|
||||
let f = u.w.max(u.h).div_ceil(256).max(1);
|
||||
let (sw, sh) = (u.w.div_ceil(f), u.h.div_ceil(f));
|
||||
let mut sum = vec![(0.0f64, 0u32); sw * sh];
|
||||
for y in 0..u.h {
|
||||
for x in 0..u.w {
|
||||
let c = &mut sum[(y / f) * sw + x / f];
|
||||
c.0 += f64::from(u.v[y * u.w + x]);
|
||||
c.1 += 1;
|
||||
}
|
||||
}
|
||||
let cells: Vec<f32> = sum.iter().filter(|c| c.1 > 0).map(|c| (c.0 / f64::from(c.1)) as f32).collect();
|
||||
(quantile(&cells, 0.01), quantile(&cells, 0.99))
|
||||
}
|
||||
|
||||
/// `d` past the detail threshold: about 0 below `sigma`, `d − sigma` well above it.
|
||||
fn excess(d: f32, sigma: f32) -> f32 {
|
||||
d - sigma * (d / sigma).tanh()
|
||||
}
|
||||
|
||||
fn knot(t: f32, k: &[f32; KNOTS]) -> f32 {
|
||||
let x = t.clamp(0.0, 1.0) * (KNOTS - 1) as f32;
|
||||
let i = (x as usize).min(KNOTS - 2);
|
||||
k[i] + (k[i + 1] - k[i]) * (x - i as f32)
|
||||
}
|
||||
|
||||
/// The Highlights pass on the log luminance `u` (`amount` −1 … 1): pixels brighter than their
|
||||
/// surroundings move toward them (or away, for positive values) by the part of the difference
|
||||
/// past `sigma`, then the darkest content is put back where it was.
|
||||
fn highlights_pass(u: &Plane, amount: f32, side: &Side) -> Plane {
|
||||
let (lo, hi) = range(u);
|
||||
let span = (hi - lo).max(1.0);
|
||||
let strengths = if amount < 0.0 { &side.pull } else { &side.push };
|
||||
let o = llf(u, |x, g| x + knot((g - lo) / span, strengths) * amount * excess((x - g).max(0.0), side.sigma));
|
||||
let shift = quantile(&u.v, 0.01) - quantile(&o.v, 0.01);
|
||||
Plane { w: o.w, h: o.h, v: o.v.iter().map(|v| v + shift).collect() }
|
||||
}
|
||||
|
||||
/// The Shadows pass on the log luminance `u` (`amount` −1 … 1).
|
||||
fn shadows_pass(u: &Plane, amount: f32) -> Plane {
|
||||
let (lo, hi) = range(u);
|
||||
let span = (hi - lo).max(1.0);
|
||||
let k = if amount > 0.0 { amount } else { amount * SHADOWS.down };
|
||||
let o = llf(u, |x, g| {
|
||||
let below = ((g - x) / SHADOWS.sigma).clamp(0.0, 1.0);
|
||||
if below == 0.0 {
|
||||
return x;
|
||||
}
|
||||
let w = below * below * (3.0 - 2.0 * below);
|
||||
x + k * knot((x - lo) / span, &SHADOWS.own) * knot((g - lo) / span, &SHADOWS.around) * w
|
||||
});
|
||||
// Keep the brightest content.
|
||||
let shift = quantile(&u.v, 0.99) - quantile(&o.v, 0.99);
|
||||
Plane { w: o.w, h: o.h, v: o.v.iter().map(|v| v + shift).collect() }
|
||||
}
|
||||
|
||||
/// Applies Shadows and Highlights (−100 … 100) to straight RGBA pixels encoded with `enc`.
|
||||
pub(crate) fn apply(px: &mut [[f32; 4]], w: usize, h: usize, shadows: f32, highlights: f32, enc: Encoding) {
|
||||
if (shadows == 0.0 && highlights == 0.0) || w < 2 || h < 2 {
|
||||
return;
|
||||
}
|
||||
const FLOOR: f32 = 1.0 / 65536.0;
|
||||
let u = Plane { w, h, v: px.iter().map(|q| (luma([0, 1, 2].map(|c| enc.decode(q[c]))).max(0.0) + FLOOR).log2()).collect() };
|
||||
// The filter on a copy at most WORK_SIDE across (box-averaged in log).
|
||||
let f = w.max(h).div_ceil(WORK_SIDE).max(1);
|
||||
let small = if f == 1 { Plane { w, h, v: u.v.clone() } } else { shrink(&u, f) };
|
||||
let mut o = Plane { w: small.w, h: small.h, v: small.v.clone() };
|
||||
if highlights != 0.0 {
|
||||
o = highlights_pass(&o, highlights.clamp(-100.0, 100.0) / 100.0, &HIGHLIGHTS);
|
||||
}
|
||||
if shadows != 0.0 {
|
||||
o = shadows_pass(&o, shadows.clamp(-100.0, 100.0) / 100.0);
|
||||
}
|
||||
// The change in log luminance, at full size.
|
||||
let change: Vec<f32> = if f == 1 {
|
||||
o.v.iter().zip(&u.v).map(|(a, b)| a - b).collect()
|
||||
} else {
|
||||
let d: Vec<f32> = o.v.iter().zip(&small.v).map(|(a, b)| a - b).collect();
|
||||
guided_upsample(&small, &d, &u)
|
||||
};
|
||||
par_rows(px, w, 1, |y, row| {
|
||||
for (x, q) in row.iter_mut().enumerate() {
|
||||
let gain = change[y * w + x].exp2();
|
||||
for c in 0..3 {
|
||||
q[c] = enc.encode(enc.decode(q[c]) * gain);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `u` averaged over `f × f` blocks.
|
||||
fn shrink(u: &Plane, f: usize) -> Plane {
|
||||
let (sw, sh) = (u.w.div_ceil(f), u.h.div_ceil(f));
|
||||
let mut v = vec![0.0f32; sw * sh];
|
||||
par_rows(&mut v, sw, 1, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let (x0, y0) = (x * f, y * f);
|
||||
let (x1, y1) = ((x0 + f).min(u.w), (y0 + f).min(u.h));
|
||||
let mut s = 0.0f64;
|
||||
for yy in y0..y1 {
|
||||
for xx in x0..x1 {
|
||||
s += f64::from(u.v[yy * u.w + xx]);
|
||||
}
|
||||
}
|
||||
*o = (s / ((x1 - x0) * (y1 - y0)) as f64) as f32;
|
||||
}
|
||||
});
|
||||
Plane { w: sw, h: sh, v }
|
||||
}
|
||||
|
||||
/// The change `d` computed on `small` brought to the size of `full`, following its edges: a local
|
||||
/// linear model of the change against the log luminance (the guided filter of K. He, J. Sun,
|
||||
/// *Fast Guided Filter*, 2015), fitted on the small copy and evaluated on the full one.
|
||||
fn guided_upsample(small: &Plane, d: &[f32], full: &Plane) -> Vec<f32> {
|
||||
let (sw, sh) = (small.w, small.h);
|
||||
let r = 2;
|
||||
let eps = 0.01;
|
||||
let mean = |v: &[f32]| super::box_mean(v, sw, sh, r);
|
||||
let ud: Vec<f32> = small.v.iter().zip(d).map(|(a, b)| a * b).collect();
|
||||
let uu: Vec<f32> = small.v.iter().map(|a| a * a).collect();
|
||||
let (mu, md, mud, muu) = (mean(&small.v), mean(d), mean(&ud), mean(&uu));
|
||||
let a: Vec<f32> = (0..sw * sh).map(|i| (mud[i] - mu[i] * md[i]) / (muu[i] - mu[i] * mu[i] + eps)).collect();
|
||||
let b: Vec<f32> = (0..sw * sh).map(|i| md[i] - a[i] * mu[i]).collect();
|
||||
let (ma, mb) = (mean(&a), mean(&b));
|
||||
let (fx, fy) = (sw as f32 / full.w as f32, sh as f32 / full.h as f32);
|
||||
let mut out = vec![0.0f32; full.w * full.h];
|
||||
par_rows(&mut out, full.w, 1, |y, row| {
|
||||
let sy = (y as f32 + 0.5) * fy - 0.5;
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let sx = (x as f32 + 0.5) * fx - 0.5;
|
||||
let av = crate::photo_util::bilinear(&ma, sw, sh, 1, 0, sx, sy);
|
||||
let bv = crate::photo_util::bilinear(&mb, sw, sh, 1, 0, sx, sy);
|
||||
*o = av * full.v[y * full.w + x] + bv;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_image_is_unchanged() {
|
||||
let mut px = grey(64, 48, |_, _| 0.4);
|
||||
apply(&mut px, 64, 48, 100.0, -100.0, SRGB);
|
||||
assert!(px.iter().all(|q| (q[0] - 0.4).abs() < 1e-5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadows_raise_a_dark_area_without_a_halo() {
|
||||
// A 0.1 | 0.6 step (the dark half is the image's black point, which Photoshop barely
|
||||
// moves either): the bright half stays, and no halo forms on either side of the edge.
|
||||
let (w, h) = (256, 128);
|
||||
let mut px = grey(w, h, |x, _| if x < w / 2 { 0.1 } else { 0.6 });
|
||||
apply(&mut px, w, h, 100.0, 0.0, SRGB);
|
||||
let row = &px[64 * w..65 * w];
|
||||
assert!((row[w / 2 + 1][0] - 0.6).abs() < 0.002 && (row[w - 5][0] - 0.6).abs() < 0.002);
|
||||
let (far, near) = (row[10][0], row[w / 2 - 1][0]);
|
||||
assert!((far - 0.1).abs() < 0.004 && (far - near).abs() < 0.004, "{far} {near}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grey_moves_as_in_photoshop() {
|
||||
// Mid-grey with 1 % black (0.015) and white (0.97) corners, Photoshop 25.4: Shadows +100
|
||||
// takes the grey from 127.5 to 165.3, Highlights −100 to 89.6 (of 255).
|
||||
let n = 256;
|
||||
let image = |c: f32| {
|
||||
grey(n, n, |x, y| {
|
||||
if x >= n - 26 && y >= n - 26 {
|
||||
0.97
|
||||
} else if x >= n - 26 && y < 26 {
|
||||
0.015
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
};
|
||||
for (s, hl, want) in [(100.0, 0.0, 165.3), (0.0, -100.0, 89.6)] {
|
||||
let mut px = image(0.5);
|
||||
apply(&mut px, n, n, s, hl, SRGB);
|
||||
let got = px[175 * n + 100][0] * 255.0;
|
||||
assert!((got - want).abs() < 5.0, "{s} {hl}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlights_lower_a_bright_area_and_keep_the_darkest() {
|
||||
let (w, h) = (192, 192);
|
||||
let mut px = grey(w, h, |x, y| if (64..128).contains(&x) && (64..128).contains(&y) { 0.8 } else { 0.2 });
|
||||
apply(&mut px, w, h, 0.0, -100.0, SRGB);
|
||||
assert!(px[96 * w + 96][0] < 0.75, "{}", px[96 * w + 96][0]);
|
||||
assert!((px[5 * w + 5][0] - 0.2).abs() < 0.01, "{}", px[5 * w + 5][0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_gradients_stay_smooth() {
|
||||
// A soft ramp over eight stops, as in an out-of-focus background. Coefficients interpolated
|
||||
// between references too far apart drew contour lines across such ramps (Shadows +100 on
|
||||
// a D4 raw): the output must rise steadily, without ripples.
|
||||
let (w, h) = (640, 48);
|
||||
let mut px = grey(w, h, |x, _| crate::photo_util::linear_to_srgb((x as f32 / (w - 1) as f32 * 8.0 - 9.0).exp2()));
|
||||
for (s, hl) in [(100.0, 0.0), (0.0, -100.0), (-100.0, 100.0)] {
|
||||
let mut p = px.clone();
|
||||
apply(&mut p, w, h, s, hl, SRGB);
|
||||
let row: Vec<f32> = (0..w).map(|x| crate::photo_util::srgb_to_linear(p[24 * w + x][0]).max(1e-6).log2()).collect();
|
||||
let steps: Vec<f32> = row.windows(2).map(|d| d[1] - d[0]).collect();
|
||||
assert!(steps.iter().all(|d| *d > 0.0), "{s} {hl}: the ramp reverses");
|
||||
let jumps: Vec<f32> = steps.windows(2).map(|d| (d[1] - d[0]).abs()).collect();
|
||||
let ripple = jumps[16..jumps.len() - 16].iter().fold(0.0f32, |a, b| a.max(*b));
|
||||
// The ramp rises 0.0125 stops per pixel; its slope may change by a third at most.
|
||||
assert!(ripple < 0.004, "{s} {hl}: slope jumps by {ripple} stops per pixel");
|
||||
}
|
||||
px.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_result_does_not_depend_on_the_image_size() {
|
||||
let at = |n: usize| {
|
||||
let mut px = grey(n, n, |x, y| if x < n / 3 || y < n / 4 { 0.08 } else { 0.7 });
|
||||
apply(&mut px, n, n, 70.0, -60.0, SRGB);
|
||||
[px[(n / 8) * n + n / 8][0], px[(3 * n / 4) * n + 3 * n / 4][0]]
|
||||
};
|
||||
let (a, b) = (at(96), at(384));
|
||||
assert!((a[0] - b[0]).abs() < 0.02 && (a[1] - b[1]).abs() < 0.02, "{a:?} {b:?}");
|
||||
}
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
//! Whites and Blacks as Adobe Camera Raw (process version 2012) applies them.
|
||||
//!
|
||||
//! Measured as a black box on the Camera Raw Filter of Photoshop 25.4: grey blocks with chosen
|
||||
//! histograms went through the filter by script and came back exactly; no Adobe code or data
|
||||
//! was used.
|
||||
//!
|
||||
//! - Both are global curves of the grey level, applied after Shadows and Highlights, Whites
|
||||
//! first. On an image whose darkest and lightest percent reach black and white the curve of a
|
||||
//! slider value `x` is the measured row at `x` of [`WHITES`] or [`BLACKS`].
|
||||
//! - The direction that only compresses (Whites down, Blacks up) is that fixed curve on every
|
||||
//! image. The direction that clips (Whites up, Blacks down) adapts: the row used is
|
||||
//! `x · A(BP, WP)`. On the block images BP and WP are their darkest and lightest levels; on
|
||||
//! photographs Photoshop's amounts follow best with BP the 3rd percentile of the luminance and
|
||||
//! WP the 99.5th of the brightest channel, and Blacks at least 1.2 times. A low
|
||||
//! white point makes Whites +100 stronger (up to 2.7 times), a raised black point Blacks
|
||||
//! (up to 1.45 times at −40). Whites rows past +100 come from images where `A > 1`.
|
||||
//! - Not reproduced: past −40 Photoshop's Blacks grows faster on images with a raised black
|
||||
//! point (on one spanning 0.6–1 its −100 blacks out everything below 0.74, here below about
|
||||
//! 0.15); its curves there are not rows of the full-range family.
|
||||
//! - Colour: the clipping directions act on each channel, the compressing ones scale the pixel
|
||||
//! by the curve's change of its luminance (closest to Photoshop on saturated colours).
|
||||
|
||||
use super::Encoding;
|
||||
use crate::photo_util::par_rows;
|
||||
|
||||
/// Grey levels the curves are sampled at: 0, 1/32, …, 1 (sRGB-encoded).
|
||||
const STEPS: usize = 33;
|
||||
/// The first row's internal amount; rows are 10 apart.
|
||||
const WHITES_FROM: f32 = -100.0;
|
||||
const BLACKS_FROM: f32 = -100.0;
|
||||
// Measured levels; some happen to lie near π/8 and π/4.
|
||||
/// Whites curves at internal amounts -100, -90, …, 270 (rows) over grey levels 0, 1/32, …, 1.
|
||||
#[allow(clippy::approx_constant)]
|
||||
static WHITES: [[f32; 33]; 38] = [
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1523, 0.1817, 0.2107, 0.2391, 0.2671, 0.2946, 0.3215, 0.3480, 0.3738, 0.3995, 0.4256, 0.4517, 0.4774, 0.5028,
|
||||
0.5278, 0.5524, 0.5768, 0.6008, 0.6245, 0.6482, 0.6717, 0.6957, 0.7207, 0.7483, 0.7814, 0.8263, 0.8949, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1523, 0.1817, 0.2107, 0.2392, 0.2672, 0.2947, 0.3217, 0.3482, 0.3742, 0.4000, 0.4263, 0.4526, 0.4785, 0.5042,
|
||||
0.5296, 0.5548, 0.5797, 0.6044, 0.6291, 0.6539, 0.6789, 0.7046, 0.7317, 0.7617, 0.7969, 0.8425, 0.9074, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1524, 0.1818, 0.2108, 0.2393, 0.2673, 0.2950, 0.3221, 0.3487, 0.3748, 0.4008, 0.4274, 0.4540, 0.4803, 0.5064,
|
||||
0.5323, 0.5580, 0.5837, 0.6093, 0.6350, 0.6610, 0.6875, 0.7149, 0.7438, 0.7756, 0.8122, 0.8576, 0.9184, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1524, 0.1818, 0.2109, 0.2395, 0.2677, 0.2954, 0.3227, 0.3495, 0.3759, 0.4022, 0.4291, 0.4561, 0.4828, 0.5094,
|
||||
0.5360, 0.5624, 0.5888, 0.6154, 0.6423, 0.6695, 0.6973, 0.7261, 0.7566, 0.7897, 0.8271, 0.8716, 0.9280, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1525, 0.1820, 0.2111, 0.2398, 0.2682, 0.2961, 0.3237, 0.3508, 0.3775, 0.4042, 0.4317, 0.4591, 0.4864, 0.5136,
|
||||
0.5408, 0.5680, 0.5953, 0.6229, 0.6508, 0.6791, 0.7082, 0.7382, 0.7698, 0.8037, 0.8412, 0.8843, 0.9365, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0927, 0.1227, 0.1527, 0.1822, 0.2116, 0.2404, 0.2690, 0.2972, 0.3251, 0.3527, 0.3799, 0.4071, 0.4351, 0.4630, 0.4910, 0.5189,
|
||||
0.5469, 0.5749, 0.6031, 0.6316, 0.6605, 0.6899, 0.7199, 0.7509, 0.7832, 0.8174, 0.8544, 0.8958, 0.9439, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0621, 0.0927, 0.1229, 0.1529, 0.1827, 0.2122, 0.2414, 0.2702, 0.2989, 0.3272, 0.3553, 0.3831, 0.4110, 0.4396, 0.4682, 0.4968, 0.5254,
|
||||
0.5542, 0.5830, 0.6121, 0.6414, 0.6712, 0.7014, 0.7322, 0.7637, 0.7964, 0.8305, 0.8668, 0.9062, 0.9503, 0.9999,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0622, 0.0928, 0.1231, 0.1534, 0.1834, 0.2132, 0.2427, 0.2720, 0.3011, 0.3300, 0.3587, 0.3872, 0.4160, 0.4452, 0.4745, 0.5038, 0.5332,
|
||||
0.5627, 0.5923, 0.6221, 0.6522, 0.6826, 0.7134, 0.7447, 0.7765, 0.8092, 0.8429, 0.8781, 0.9155, 0.9559, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0623, 0.0930, 0.1236, 0.1541, 0.1844, 0.2145, 0.2445, 0.2744, 0.3041, 0.3337, 0.3632, 0.3925, 0.4221, 0.4520, 0.4820, 0.5120, 0.5421,
|
||||
0.5723, 0.6026, 0.6330, 0.6636, 0.6946, 0.7257, 0.7572, 0.7890, 0.8214, 0.8545, 0.8885, 0.9237, 0.9608, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0298, 0.0624, 0.0934, 0.1242, 0.1551, 0.1858, 0.2164, 0.2470, 0.2775, 0.3080, 0.3383, 0.3687, 0.3989, 0.4293, 0.4600, 0.4906, 0.5213, 0.5520,
|
||||
0.5827, 0.6136, 0.6445, 0.6755, 0.7067, 0.7380, 0.7694, 0.8011, 0.8330, 0.8652, 0.8978, 0.9310, 0.9651, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0312, 0.0625, 0.0938, 0.1250, 0.1562, 0.1875, 0.2188, 0.2500, 0.2812, 0.3125, 0.3438, 0.3750, 0.4062, 0.4375, 0.4688, 0.5000, 0.5312, 0.5625,
|
||||
0.5938, 0.6250, 0.6562, 0.6875, 0.7188, 0.7500, 0.7812, 0.8125, 0.8438, 0.8750, 0.9062, 0.9375, 0.9688, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0299, 0.0626, 0.0940, 0.1253, 0.1567, 0.1881, 0.2195, 0.2510, 0.2825, 0.3140, 0.3456, 0.3772, 0.4089, 0.4405, 0.4720, 0.5036, 0.5351, 0.5667,
|
||||
0.5983, 0.6298, 0.6613, 0.6929, 0.7244, 0.7559, 0.7874, 0.8189, 0.8503, 0.8816, 0.9128, 0.9438, 0.9742, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0299, 0.0627, 0.0941, 0.1257, 0.1573, 0.1890, 0.2208, 0.2526, 0.2846, 0.3167, 0.3488, 0.3811, 0.4134, 0.4456, 0.4777, 0.5098, 0.5419, 0.5741,
|
||||
0.6062, 0.6383, 0.6704, 0.7025, 0.7345, 0.7664, 0.7983, 0.8301, 0.8617, 0.8931, 0.9241, 0.9543, 0.9829, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0299, 0.0628, 0.0944, 0.1262, 0.1582, 0.1903, 0.2225, 0.2550, 0.2876, 0.3204, 0.3534, 0.3867, 0.4200, 0.4529, 0.4858, 0.5186, 0.5516, 0.5845,
|
||||
0.6174, 0.6502, 0.6830, 0.7157, 0.7482, 0.7807, 0.8131, 0.8451, 0.8769, 0.9082, 0.9385, 0.9673, 0.9928, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0299, 0.0629, 0.0948, 0.1269, 0.1594, 0.1920, 0.2249, 0.2582, 0.2917, 0.3256, 0.3597, 0.3942, 0.4289, 0.4628, 0.4966, 0.5305, 0.5644, 0.5983,
|
||||
0.6321, 0.6658, 0.6994, 0.7327, 0.7660, 0.7989, 0.8316, 0.8639, 0.8956, 0.9263, 0.9553, 0.9815, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0299, 0.0631, 0.0953, 0.1280, 0.1610, 0.1944, 0.2282, 0.2625, 0.2973, 0.3325, 0.3683, 0.4045, 0.4407, 0.4758, 0.5109, 0.5460, 0.5811, 0.6161,
|
||||
0.6510, 0.6857, 0.7201, 0.7541, 0.7879, 0.8213, 0.8543, 0.8864, 0.9175, 0.9468, 0.9733, 0.9950, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0299, 0.0633, 0.0961, 0.1293, 0.1632, 0.1976, 0.2327, 0.2684, 0.3048, 0.3419, 0.3798, 0.4183, 0.4562, 0.4928, 0.5294, 0.5660, 0.6024, 0.6387,
|
||||
0.6748, 0.7104, 0.7456, 0.7803, 0.8145, 0.8480, 0.8807, 0.9121, 0.9417, 0.9684, 0.9907, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0300, 0.0637, 0.0971, 0.1313, 0.1663, 0.2020, 0.2387, 0.2763, 0.3149, 0.3546, 0.3952, 0.4365, 0.4763, 0.5149, 0.5532, 0.5914, 0.6294, 0.6670,
|
||||
0.7041, 0.7406, 0.7763, 0.8113, 0.8456, 0.8788, 0.9104, 0.9401, 0.9667, 0.9888, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0300, 0.0641, 0.0984, 0.1338, 0.1703, 0.2079, 0.2468, 0.2870, 0.3286, 0.3717, 0.4161, 0.4602, 0.5024, 0.5430, 0.5833, 0.6232, 0.6627, 0.7015,
|
||||
0.7394, 0.7764, 0.8123, 0.8472, 0.8808, 0.9126, 0.9420, 0.9681, 0.9896, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0302, 0.0647, 0.1003, 0.1374, 0.1760, 0.2160, 0.2579, 0.3016, 0.3472, 0.3949, 0.4436, 0.4908, 0.5355, 0.5784, 0.6207, 0.6622, 0.7029, 0.7424,
|
||||
0.7806, 0.8176, 0.8530, 0.8868, 0.9185, 0.9474, 0.9726, 0.9926, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0304, 0.0656, 0.1029, 0.1423, 0.1837, 0.2271, 0.2731, 0.3215, 0.3726, 0.4262, 0.4791, 0.5294, 0.5769, 0.6220, 0.6660, 0.7086, 0.7498, 0.7893,
|
||||
0.8272, 0.8631, 0.8969, 0.9281, 0.9560, 0.9795, 0.9967, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0305, 0.0679, 0.1094, 0.1509, 0.1968, 0.2459, 0.2956, 0.3510, 0.4094, 0.4680, 0.5248, 0.5781, 0.6278, 0.6742, 0.7195, 0.7624, 0.8031, 0.8417,
|
||||
0.8779, 0.9113, 0.9414, 0.9675, 0.9886, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0306, 0.0702, 0.1160, 0.1623, 0.2144, 0.2709, 0.3243, 0.3885, 0.4556, 0.5199, 0.5807, 0.6369, 0.6884, 0.7355, 0.7799, 0.8217, 0.8606, 0.8964,
|
||||
0.9288, 0.9570, 0.9803, 0.9958, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0308, 0.0725, 0.1225, 0.1737, 0.2320, 0.2960, 0.3641, 0.4393, 0.5133, 0.5825, 0.6460, 0.7032, 0.7544, 0.8003, 0.8426, 0.8817, 0.9168, 0.9475,
|
||||
0.9733, 0.9924, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0322, 0.0751, 0.1303, 0.1918, 0.2594, 0.3348, 0.4189, 0.5044, 0.5841, 0.6565, 0.7208, 0.7771, 0.8262, 0.8692, 0.9074, 0.9408, 0.9686, 0.9900,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0331, 0.0818, 0.1482, 0.2171, 0.2977, 0.3892, 0.4865, 0.5779, 0.6602, 0.7322, 0.7943, 0.8470, 0.8915, 0.9286, 0.9600, 0.9844, 0.9996, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0340, 0.0884, 0.1661, 0.2544, 0.3540, 0.4658, 0.5729, 0.6681, 0.7496, 0.8183, 0.8748, 0.9208, 0.9568, 0.9832, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.3356, 0.5799, 0.6166, 0.6166, 0.6166, 0.6166, 0.6624, 0.7556, 0.8321, 0.8935, 0.9411, 0.9755, 0.9970, 0.9998, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.5007, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.1655, 0.1655, 0.2972, 0.4728, 0.6327, 0.7616, 0.8598, 0.9317, 0.9790, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0457, 0.1830, 0.3836, 0.5790, 0.7379, 0.8549, 0.9360, 0.9869, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0520, 0.2329, 0.4864, 0.6989, 0.8449, 0.9403, 0.9884, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0773, 0.3027, 0.6040, 0.8058, 0.9302, 0.9930, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.1454, 0.4738, 0.7615, 0.9074, 0.9908, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0964, 0.5416, 0.8589, 0.9897, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.1538, 0.1538, 0.7080, 0.9596, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.8439, 0.8439, 0.8439, 0.9993, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
[
|
||||
0.9228, 0.9228, 0.9228, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
|
||||
],
|
||||
];
|
||||
/// Blacks curves at internal amounts -100, …, 100 in steps of 10.
|
||||
#[allow(clippy::approx_constant)]
|
||||
static BLACKS: [[f32; 33]; 21] = [
|
||||
[
|
||||
0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0290, 0.0572, 0.0879, 0.1222, 0.1580, 0.1935, 0.2287, 0.2636, 0.2988, 0.3355, 0.3741, 0.4151, 0.4571,
|
||||
0.4990, 0.5406, 0.5818, 0.6226, 0.6631, 0.7031, 0.7424, 0.7811, 0.8192, 0.8568, 0.8937, 0.9300, 0.9655, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0000, 0.0000, 0.0015, 0.0363, 0.0640, 0.0956, 0.1300, 0.1651, 0.2000, 0.2345, 0.2686, 0.3026, 0.3369, 0.3727, 0.4105, 0.4495, 0.4885,
|
||||
0.5272, 0.5657, 0.6039, 0.6419, 0.6796, 0.7169, 0.7538, 0.7903, 0.8264, 0.8621, 0.8974, 0.9322, 0.9664, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0000, 0.0000, 0.0346, 0.0625, 0.0934, 0.1271, 0.1616, 0.1959, 0.2298, 0.2635, 0.2969, 0.3301, 0.3638, 0.3990, 0.4357, 0.4727, 0.5094,
|
||||
0.5460, 0.5824, 0.6187, 0.6547, 0.6906, 0.7262, 0.7615, 0.7965, 0.8313, 0.8657, 0.8998, 0.9336, 0.9671, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0000, 0.0235, 0.0554, 0.0850, 0.1175, 0.1514, 0.1852, 0.2187, 0.2519, 0.2849, 0.3178, 0.3505, 0.3837, 0.4183, 0.4537, 0.4891, 0.5243,
|
||||
0.5594, 0.5944, 0.6292, 0.6639, 0.6985, 0.7329, 0.7671, 0.8010, 0.8347, 0.8683, 0.9016, 0.9347, 0.9676, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0020, 0.0438, 0.0724, 0.1035, 0.1365, 0.1699, 0.2030, 0.2359, 0.2686, 0.3011, 0.3336, 0.3659, 0.3986, 0.4327, 0.4669, 0.5011, 0.5352,
|
||||
0.5692, 0.6031, 0.6369, 0.6706, 0.7043, 0.7378, 0.7711, 0.8043, 0.8373, 0.8702, 0.9029, 0.9355, 0.9679, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0230, 0.0567, 0.0865, 0.1182, 0.1510, 0.1839, 0.2164, 0.2489, 0.2812, 0.3133, 0.3455, 0.3775, 0.4100, 0.4433, 0.4767, 0.5100, 0.5433,
|
||||
0.5765, 0.6096, 0.6426, 0.6756, 0.7086, 0.7414, 0.7741, 0.8067, 0.8392, 0.8716, 0.9039, 0.9361, 0.9682, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0368, 0.0670, 0.0976, 0.1294, 0.1619, 0.1943, 0.2265, 0.2586, 0.2906, 0.3225, 0.3544, 0.3862, 0.4184, 0.4512, 0.4839, 0.5166, 0.5492,
|
||||
0.5818, 0.6144, 0.6469, 0.6793, 0.7117, 0.7441, 0.7763, 0.8085, 0.8406, 0.8727, 0.9046, 0.9365, 0.9683, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0000, 0.0444, 0.0749, 0.1058, 0.1377, 0.1699, 0.2020, 0.2339, 0.2657, 0.2975, 0.3292, 0.3609, 0.3926, 0.4245, 0.4568, 0.4891, 0.5213, 0.5535,
|
||||
0.5857, 0.6179, 0.6499, 0.6820, 0.7140, 0.7460, 0.7779, 0.8098, 0.8416, 0.8734, 0.9052, 0.9368, 0.9685, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0086, 0.0511, 0.0817, 0.1128, 0.1445, 0.1763, 0.2080, 0.2397, 0.2713, 0.3029, 0.3344, 0.3660, 0.3975, 0.4292, 0.4611, 0.4930, 0.5249, 0.5567,
|
||||
0.5886, 0.6204, 0.6522, 0.6839, 0.7157, 0.7475, 0.7791, 0.8108, 0.8424, 0.8740, 0.9056, 0.9371, 0.9686, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0198, 0.0564, 0.0874, 0.1186, 0.1501, 0.1817, 0.2133, 0.2447, 0.2762, 0.3076, 0.3390, 0.3705, 0.4018, 0.4333, 0.4649, 0.4965, 0.5281, 0.5597,
|
||||
0.5912, 0.6227, 0.6543, 0.6858, 0.7173, 0.7488, 0.7802, 0.8117, 0.8431, 0.8745, 0.9059, 0.9373, 0.9686, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0312, 0.0625, 0.0938, 0.1250, 0.1562, 0.1875, 0.2188, 0.2500, 0.2812, 0.3125, 0.3438, 0.3750, 0.4062, 0.4375, 0.4688, 0.5000, 0.5312, 0.5625,
|
||||
0.5938, 0.6250, 0.6562, 0.6875, 0.7188, 0.7500, 0.7812, 0.8125, 0.8438, 0.8750, 0.9062, 0.9375, 0.9688, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0313, 0.0650, 0.0979, 0.1303, 0.1624, 0.1942, 0.2260, 0.2576, 0.2891, 0.3206, 0.3520, 0.3833, 0.4146, 0.4455, 0.4763, 0.5070, 0.5378, 0.5684,
|
||||
0.5991, 0.6298, 0.6605, 0.6912, 0.7219, 0.7526, 0.7833, 0.8141, 0.8450, 0.8758, 0.9067, 0.9377, 0.9688, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0326, 0.0677, 0.1022, 0.1357, 0.1686, 0.2010, 0.2332, 0.2652, 0.2969, 0.3286, 0.3601, 0.3915, 0.4227, 0.4532, 0.4835, 0.5138, 0.5440, 0.5741,
|
||||
0.6042, 0.6344, 0.6645, 0.6946, 0.7248, 0.7550, 0.7853, 0.8156, 0.8461, 0.8766, 0.9072, 0.9380, 0.9689, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0341, 0.0706, 0.1066, 0.1413, 0.1749, 0.2079, 0.2405, 0.2728, 0.3048, 0.3366, 0.3682, 0.3996, 0.4307, 0.4608, 0.4906, 0.5203, 0.5500, 0.5796,
|
||||
0.6092, 0.6387, 0.6683, 0.6979, 0.7276, 0.7573, 0.7871, 0.8170, 0.8472, 0.8773, 0.9077, 0.9382, 0.9690, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0354, 0.0736, 0.1113, 0.1472, 0.1814, 0.2149, 0.2479, 0.2805, 0.3127, 0.3446, 0.3763, 0.4077, 0.4385, 0.4681, 0.4974, 0.5267, 0.5558, 0.5849,
|
||||
0.6139, 0.6429, 0.6720, 0.7011, 0.7303, 0.7595, 0.7889, 0.8184, 0.8481, 0.8780, 0.9081, 0.9384, 0.9690, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0368, 0.0768, 0.1162, 0.1531, 0.1881, 0.2221, 0.2554, 0.2882, 0.3206, 0.3527, 0.3843, 0.4158, 0.4461, 0.4753, 0.5041, 0.5328, 0.5615, 0.5900,
|
||||
0.6185, 0.6470, 0.6755, 0.7041, 0.7328, 0.7616, 0.7906, 0.8197, 0.8491, 0.8787, 0.9085, 0.9386, 0.9691, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0381, 0.0802, 0.1214, 0.1593, 0.1949, 0.2293, 0.2630, 0.2961, 0.3286, 0.3607, 0.3924, 0.4236, 0.4536, 0.4823, 0.5106, 0.5388, 0.5669, 0.5949,
|
||||
0.6229, 0.6509, 0.6789, 0.7070, 0.7352, 0.7636, 0.7922, 0.8209, 0.8500, 0.8793, 0.9089, 0.9388, 0.9691, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0394, 0.0837, 0.1267, 0.1656, 0.2018, 0.2367, 0.2707, 0.3039, 0.3365, 0.3686, 0.4003, 0.4314, 0.4608, 0.4890, 0.5168, 0.5445, 0.5721, 0.5996,
|
||||
0.6271, 0.6546, 0.6822, 0.7098, 0.7375, 0.7655, 0.7937, 0.8221, 0.8509, 0.8799, 0.9092, 0.9390, 0.9692, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0407, 0.0875, 0.1322, 0.1720, 0.2088, 0.2441, 0.2784, 0.3118, 0.3444, 0.3765, 0.4082, 0.4389, 0.4679, 0.4955, 0.5229, 0.5501, 0.5771, 0.6042,
|
||||
0.6312, 0.6582, 0.6853, 0.7124, 0.7398, 0.7673, 0.7952, 0.8233, 0.8517, 0.8804, 0.9096, 0.9391, 0.9692, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0421, 0.0915, 0.1379, 0.1786, 0.2160, 0.2516, 0.2861, 0.3196, 0.3523, 0.3844, 0.4159, 0.4462, 0.4747, 0.5019, 0.5287, 0.5554, 0.5820, 0.6085,
|
||||
0.6351, 0.6616, 0.6882, 0.7149, 0.7419, 0.7690, 0.7965, 0.8243, 0.8524, 0.8810, 0.9099, 0.9393, 0.9693, 1.0000,
|
||||
],
|
||||
[
|
||||
0.0000, 0.0436, 0.0957, 0.1438, 0.1853, 0.2232, 0.2592, 0.2938, 0.3274, 0.3601, 0.3922, 0.4235, 0.4533, 0.4813, 0.5080, 0.5343, 0.5605, 0.5866, 0.6127,
|
||||
0.6387, 0.6648, 0.6910, 0.7173, 0.7439, 0.7707, 0.7978, 0.8253, 0.8532, 0.8815, 0.9102, 0.9394, 0.9693, 1.0000,
|
||||
],
|
||||
];
|
||||
const BPS: [f32; 9] = [0.0000, 0.0200, 0.0500, 0.1000, 0.2000, 0.3000, 0.4500, 0.6000, 0.8000];
|
||||
const WPS: [f32; 8] = [0.1000, 0.2000, 0.3000, 0.4500, 0.6000, 0.8000, 0.9000, 1.0000];
|
||||
static AW: [[f32; 8]; 9] = [
|
||||
[1.7750, 2.3875, 2.4000, 2.1625, 1.9438, 1.6125, 1.3875, 1.0000],
|
||||
[1.8125, 2.5250, 2.3937, 2.1688, 1.9375, 1.6062, 1.3938, 0.9875],
|
||||
[2.7000, 2.4688, 2.2750, 2.0688, 1.8875, 1.6000, 1.3875, 0.9938],
|
||||
[2.3250, 2.3250, 2.1875, 1.9750, 1.7937, 1.5188, 1.3250, 1.0437],
|
||||
[2.0187, 2.0187, 2.0187, 1.8625, 1.7063, 1.4062, 1.2250, 0.9250],
|
||||
[1.7500, 1.7500, 1.7500, 1.7500, 1.6438, 1.3562, 1.1687, 0.8625],
|
||||
[1.4937, 1.4937, 1.4937, 1.4937, 1.4937, 1.2875, 1.1000, 0.7875],
|
||||
[1.1687, 1.1687, 1.1687, 1.1687, 1.1687, 1.1687, 1.0375, 0.7562],
|
||||
[0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.7562],
|
||||
];
|
||||
static AB: [[f32; 8]; 9] = [
|
||||
[0.9938, 0.9812, 1.0000, 1.0125, 0.9563, 0.9688, 1.0437, 1.0000],
|
||||
[1.0437, 1.0250, 1.0125, 1.0000, 1.0000, 1.0375, 1.0000, 0.9938],
|
||||
[1.2312, 1.2063, 1.1875, 1.1625, 1.1438, 1.1438, 1.1375, 1.1250],
|
||||
[1.2500, 1.2500, 1.2312, 1.2125, 1.2125, 1.2375, 1.2375, 1.2375],
|
||||
[1.1750, 1.1750, 1.1750, 1.1750, 1.2063, 1.2688, 1.3125, 1.3375],
|
||||
[1.1812, 1.1812, 1.1812, 1.1812, 1.2125, 1.2812, 1.3313, 1.4125],
|
||||
[1.2375, 1.2375, 1.2375, 1.2375, 1.2375, 1.3062, 1.3562, 1.4375],
|
||||
[1.3250, 1.3250, 1.3250, 1.3250, 1.3250, 1.3250, 1.3687, 1.4500],
|
||||
[1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.4312],
|
||||
];
|
||||
|
||||
/// A curve row at a fractional internal amount `x`, at grey level `v` (both interpolated).
|
||||
fn curve(rows: &[[f32; STEPS]], from: f32, x: f32, v: f32) -> f32 {
|
||||
if v >= 1.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let r = ((x - from) / 10.0).clamp(0.0, (rows.len() - 1) as f32);
|
||||
let i = (r as usize).min(rows.len() - 2);
|
||||
let t = r - i as f32;
|
||||
let at = |row: &[f32; STEPS]| {
|
||||
let p = v.max(0.0) * (STEPS - 1) as f32;
|
||||
let j = (p as usize).min(STEPS - 2);
|
||||
let s = p - j as f32;
|
||||
row[j] + (row[j + 1] - row[j]) * s
|
||||
};
|
||||
at(&rows[i]) * (1.0 - t) + at(&rows[i + 1]) * t
|
||||
}
|
||||
|
||||
/// Whites at internal amount `x` (−100 … 270) of grey level `v`.
|
||||
pub(crate) fn whites(v: f32, x: f32) -> f32 {
|
||||
curve(&WHITES, WHITES_FROM, x, v)
|
||||
}
|
||||
|
||||
/// Blacks at internal amount `x` (−100 … 100) of grey level `v`.
|
||||
pub(crate) fn blacks(v: f32, x: f32) -> f32 {
|
||||
curve(&BLACKS, BLACKS_FROM, x, v)
|
||||
}
|
||||
|
||||
/// The least Blacks adaptation on photographs.
|
||||
const PHOTO_BLACKS: f32 = 1.2;
|
||||
|
||||
/// Bilinear lookup of an adaptation table at black point `bp` and white point `wp`.
|
||||
fn adapt(table: &[[f32; 8]; 9], bp: f32, wp: f32) -> f32 {
|
||||
let idx = |grid: &[f32], v: f32| {
|
||||
let v = v.clamp(grid[0], grid[grid.len() - 1]);
|
||||
let i = grid.windows(2).position(|w| v <= w[1]).unwrap_or(grid.len() - 2);
|
||||
(i, (v - grid[i]) / (grid[i + 1] - grid[i]))
|
||||
};
|
||||
let (i, s) = idx(&BPS, bp);
|
||||
let (j, t) = idx(&WPS, wp);
|
||||
let row = |i: usize| table[i][j] * (1.0 - t) + table[i][j + 1] * t;
|
||||
row(i) * (1.0 - s) + row(i + 1) * s
|
||||
}
|
||||
|
||||
/// The internal amounts of the Whites and Blacks sliders (−100 … 100) for an image whose 1st
|
||||
/// and 99th luminance percentiles are the grey levels `bp` and `wp` (sRGB-encoded).
|
||||
pub(crate) fn amounts(whites: f32, blacks: f32, bp: f32, wp: f32) -> (f32, f32) {
|
||||
let w = if whites > 0.0 { whites * adapt(&AW, bp, wp) } else { whites };
|
||||
// On photographs Photoshop's Blacks is about 1.2 times the block images' amount even where
|
||||
// their black point is 0 (ten public-domain photos: 1.08–1.36).
|
||||
let b = if blacks < 0.0 { blacks * adapt(&AB, bp, wp).max(PHOTO_BLACKS) } else { blacks };
|
||||
(w.clamp(-100.0, 270.0), b.clamp(-100.0, 100.0))
|
||||
}
|
||||
|
||||
/// Applies Whites and Blacks (internal amounts `w`, `b`) to straight RGBA pixels (`width` per
|
||||
/// row) encoded with `enc`. Values above 1 (float documents) keep their excess.
|
||||
pub(crate) fn apply(px: &mut [[f32; 4]], width: usize, w: f32, b: f32, enc: Encoding) {
|
||||
if w == 0.0 && b == 0.0 {
|
||||
return;
|
||||
}
|
||||
// A grey level's new level, both sRGB-encoded.
|
||||
let grey = |v: f32| {
|
||||
let v = if w != 0.0 { whites(v, w) } else { v };
|
||||
if b != 0.0 { blacks(v, b) } else { v }
|
||||
};
|
||||
let per_channel = w > 0.0 || b < 0.0;
|
||||
par_rows(px, width, 1, |_, row| {
|
||||
for q in row.iter_mut() {
|
||||
let lin = [0, 1, 2].map(|c| enc.decode(q[c]));
|
||||
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);
|
||||
}
|
||||
} 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 k = y2 / y;
|
||||
for c in 0..3 {
|
||||
q[c] = enc.encode(lin[c] * k);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn full_range_rows_match_photoshop() {
|
||||
// Photoshop 25.4 on a 64-level block image spanning black to white (sRGB levels).
|
||||
assert!((whites(97.1 / 255.0, 100.0) * 255.0 - 137.4).abs() < 0.6);
|
||||
assert!((whites(194.3 / 255.0, -100.0) * 255.0 - 167.6).abs() < 0.6);
|
||||
assert!((blacks(97.1 / 255.0, -100.0) * 255.0 - 60.0).abs() < 0.6);
|
||||
assert!((blacks(48.6 / 255.0, 100.0) * 255.0 - 67.0).abs() < 0.6);
|
||||
// The compressing directions keep black and white.
|
||||
assert_eq!(whites(1.0, -100.0), 1.0);
|
||||
assert_eq!(blacks(0.0, 100.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clipping_directions_adapt_to_the_image() {
|
||||
// A dark, low-contrast image needs more: Whites +50 acts like +94.5 on [0.05, 0.6].
|
||||
let (w, _) = amounts(50.0, 0.0, 0.05, 0.6);
|
||||
assert!((w - 94.5).abs() < 2.0, "{w}");
|
||||
// A raised black point strengthens Blacks −40 to −58 on [0.6, 1].
|
||||
let (_, b) = amounts(0.0, -40.0, 0.6, 1.0);
|
||||
assert!((b + 58.0).abs() < 1.0, "{b}");
|
||||
// Never below the photographs' 1.2.
|
||||
assert!((amounts(0.0, -50.0, 0.0, 1.0).1 + 60.0).abs() < 1e-3);
|
||||
// The compressing directions never adapt.
|
||||
assert_eq!(amounts(-50.0, 50.0, 0.3, 0.6), (-50.0, 50.0));
|
||||
}
|
||||
}
|
||||
@@ -474,10 +474,8 @@ pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bo
|
||||
let mut cost_fg = vec![0.0f32; sw * sh];
|
||||
let mut cost_bg = vec![0.0f32; sw * sh];
|
||||
let mut fixed = vec![FREE; sw * sh];
|
||||
// Always the hole's own edge, and around a thin selection each part's surroundings.
|
||||
let keep = thin_band(sw, sh, &shole);
|
||||
for i in 0..sw * sh {
|
||||
if shole[i] || dist[i] <= keep {
|
||||
if shole[i] || dist[i] <= 1.5 {
|
||||
fixed[i] = HARD_FG;
|
||||
continue;
|
||||
}
|
||||
@@ -510,22 +508,6 @@ pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bo
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// How far around a thin selection (strings, wires, and any small parts selected with them, all
|
||||
/// at most a quarter as thick as the selection is long) the Auto area always reaches, in cells of
|
||||
/// a `w × h` grid: as far as the selection is thick, so that each part, however far from the
|
||||
/// rest, keeps its own surroundings to copy from. 1.5 cells for any other selection.
|
||||
fn thin_band(w: usize, h: usize, hole: &[bool]) -> f32 {
|
||||
let inside = crate::selection::edt(&hole.iter().map(|h| !h).collect::<Vec<_>>(), w, h);
|
||||
let thickness = 2.0 * inside.iter().zip(hole).filter(|(_, h)| **h).map(|(d, _)| *d).fold(0.0f32, f32::max);
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
|
||||
for (i, _) in hole.iter().enumerate().filter(|(_, h)| **h) {
|
||||
let (x, y) = (i % w, i / w);
|
||||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
|
||||
}
|
||||
let extent = x1.saturating_sub(x0).max(y1.saturating_sub(y0)) as f32;
|
||||
if 4.0 * thickness <= extent { thickness.max(1.5) } else { 1.5 }
|
||||
}
|
||||
|
||||
/// The Patch Tool's and the Content-Aware Move Tool's Color option (0–10) as the colour adaptation
|
||||
/// of the copied content. Measured on Photoshop 25.4 with content brighter than its new place: 0
|
||||
/// keeps it as it is; 3 moves it about 0.12 (32 of 42 levels, and 31 of 102: a limit, not a
|
||||
@@ -566,133 +548,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 +559,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};
|
||||
@@ -844,26 +662,6 @@ mod tests {
|
||||
assert!(err < 0.12, "mean error {err}");
|
||||
}
|
||||
|
||||
/// A vertical sky gradient (each row one colour) filled at every adaptation level stays the
|
||||
/// gradient: no tinted blotch where the copied rows' colours were shifted per channel.
|
||||
#[test]
|
||||
fn color_adaptation_leaves_a_smooth_gradient_untinted() {
|
||||
let (w, h) = (120, 60);
|
||||
let row = |y: usize| {
|
||||
let t = y as f32 / (h - 1) as f32;
|
||||
[0.27 + 0.45 * t, 0.53 + 0.32 * t, 0.86 + 0.1 * t]
|
||||
};
|
||||
let img: Vec<f32> = (0..w * h).flat_map(|i| row(i / w)).collect();
|
||||
let hole: Vec<bool> = (0..w * h).map(|i| (50..70).contains(&(i % w)) && (20..40).contains(&(i / w))).collect();
|
||||
let src = vec![true; w * h];
|
||||
for level in ["none", "default", "high", "veryHigh"] {
|
||||
let out = fill(w, h, 3, &img, &hole, &src, &FillOptions { color_adaptation: color_level(level), ..Default::default() });
|
||||
let (out, img) = (&out, &img);
|
||||
let worst = (0..w * h).filter(|i| hole[*i]).flat_map(|i| (0..3).map(move |c| (out[i * 3 + c] - img[i * 3 + c]).abs())).fold(0.0f32, f32::max);
|
||||
assert!(worst < 3.0 / 255.0, "{level}: off by {}", worst * 255.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// I.i.d. noise: every value occurs once, so a filled pixel equal to an image pixel was copied.
|
||||
fn noise(w: usize, h: usize, seed: u64) -> Vec<f32> {
|
||||
let mut z = seed;
|
||||
@@ -933,31 +731,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_sampling_keeps_the_surroundings_of_every_part_of_a_thin_selection() {
|
||||
// A long thin selection across a grey ramp, and a small knob selected with it in a red
|
||||
// corner. Each part keeps its own surroundings; a compact selection is unchanged.
|
||||
let (w, h) = (900, 600);
|
||||
let tex = noise(w, h, 4);
|
||||
let img: Vec<f32> = (0..w * h * 3)
|
||||
.map(|i| {
|
||||
let (x, y, c) = ((i / 3) % w, (i / 3) / w, i % 3);
|
||||
let base = if x > 690 && y > 420 { [0.7, 0.25, 0.2][c] } else { 0.2 + 0.5 * x as f32 / w as f32 };
|
||||
base + (tex[i] - 0.5) * 0.04
|
||||
})
|
||||
.collect();
|
||||
let line = |i: usize| (30..870).contains(&(i % w)) && (180..192).contains(&(i / w));
|
||||
let knob = |i: usize| ((i % w) as f32 - 795.0).hypot((i / w) as f32 - 510.0) < 10.0;
|
||||
let hole: Vec<bool> = (0..w * h).map(|i| line(i) || knob(i)).collect();
|
||||
let area = auto_sampling(w, h, 3, &img, &hole);
|
||||
let around_knob = (0..w * h).filter(|&i| !hole[i] && ((i % w) as f32 - 795.0).hypot((i / w) as f32 - 510.0) < 24.0);
|
||||
let (n, kept) = around_knob.fold((0, 0), |(n, k), i| (n + 1, k + usize::from(area[i])));
|
||||
assert!(kept as f32 > 0.9 * n as f32, "{kept} of {n} pixels around the knob");
|
||||
assert!(thin_band(w, h, &hole) >= 20.0, "{}", thin_band(w, h, &hole));
|
||||
let disc: Vec<bool> = (0..w * h).map(|i| ((i % w) as f32 - 450.0).hypot((i / w) as f32 - 300.0) < 90.0).collect();
|
||||
assert_eq!(thin_band(w, h, &disc), 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_colour_adaptation_the_fill_copies_pixels_exactly() {
|
||||
// A smooth texture with fine noise, so that every value is unique: a filled pixel equal to
|
||||
@@ -1005,116 +778,6 @@ mod tests {
|
||||
assert!((shift(10) - 0.4).abs() < 0.01, "{}", shift(10));
|
||||
}
|
||||
|
||||
/// A soft background (gradients, broad out-of-focus blobs of colour, fine noise) with thin, long bright
|
||||
/// objects over it: three coiled strings 3 to 7 px wide and two straighter ones, each with an
|
||||
/// anti-aliased edge and a faint halo. Returns the image; what removing the coils should give
|
||||
/// (the background and the straighter strings, which stay); and the selection a user would draw
|
||||
/// around the coils, `margin` px wide.
|
||||
fn strings_scene(w: usize, h: usize, margin: f32) -> (Vec<f32>, Vec<f32>, Vec<bool>) {
|
||||
let fine = noise(w, h, 11);
|
||||
// Out-of-focus colour: broad blobs over two gradients.
|
||||
let blob = noise(3, 3, 5);
|
||||
let bg: Vec<f32> = (0..w * h * 3)
|
||||
.map(|i| {
|
||||
let (x, y, c) = (((i / 3) % w) as f32 / w as f32, ((i / 3) / w) as f32 / h as f32, i % 3);
|
||||
let mut v = [0.18 + 0.25 * x, 0.22 + 0.1 * y, 0.3 - 0.1 * x][c];
|
||||
for b in blob.as_chunks::<3>().0 {
|
||||
let (bx, by, s) = (b[0] * 1.6 - 0.3, b[1] * 1.6 - 0.3, 0.1 + 0.25 * b[2]);
|
||||
v += (b[(c + 1) % 3] - 0.2) * 0.5 * (-((x - bx).powi(2) + (y - by).powi(2) * 0.56) / (2.0 * s * s)).exp();
|
||||
}
|
||||
v + (fine[i] - 0.5) * 0.03
|
||||
})
|
||||
.collect();
|
||||
// Signed distance to each object's surface, stamped from points along it.
|
||||
let stamp = |sd: &mut [f32], pts: &mut dyn Iterator<Item = (f32, f32)>, half: f32| {
|
||||
for (px, py) in pts {
|
||||
let reach = (half + 4.0) as i32 + 1;
|
||||
for dy in -reach..=reach {
|
||||
for dx in -reach..=reach {
|
||||
let (x, y) = (px as i32 + dx, py as i32 + dy);
|
||||
if x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h {
|
||||
let i = y as usize * w + x as usize;
|
||||
sd[i] = sd[i].min((x as f32 - px).hypot(y as f32 - py) - half);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut coils = vec![f32::INFINITY; w * h];
|
||||
for (k, half) in [1.5f32, 2.5, 3.5].into_iter().enumerate() {
|
||||
let (y0, r) = ((0.22 + 0.28 * k as f32) * h as f32, 10.0 + 2.0 * k as f32);
|
||||
let mut pts = (0..4000).map(|j| {
|
||||
let t = j as f32 / 4000.0;
|
||||
let a = 8.0 * std::f32::consts::TAU * t;
|
||||
(18.0 + t * (w as f32 - 60.0) + r * a.cos(), y0 + 6.0 * (6.0 * t).sin() + 0.7 * r * a.sin())
|
||||
});
|
||||
stamp(&mut coils, &mut pts, half);
|
||||
}
|
||||
let mut straight = vec![f32::INFINITY; w * h];
|
||||
for y in [0.07, 0.93] {
|
||||
stamp(&mut straight, &mut (0..2000).map(|j| (j as f32 / 2000.0 * w as f32, y * h as f32 + 3.0 * (j as f32 / 300.0).sin())), 2.0);
|
||||
}
|
||||
let draw = |base: &[f32], sd: &[f32]| -> Vec<f32> {
|
||||
let mut img = base.to_vec();
|
||||
for i in 0..w * h {
|
||||
let cover = (0.5 - sd[i]).clamp(0.0, 1.0);
|
||||
let halo = if sd[i] > 0.0 { 0.08 * (-sd[i] * sd[i] / 2.9).exp() } else { 0.0 };
|
||||
for (c, metal) in [0.9f32, 0.85, 0.7].into_iter().enumerate() {
|
||||
let v = &mut img[i * 3 + c];
|
||||
*v = *v * (1.0 - cover) + metal * cover + halo * (1.0 - cover);
|
||||
}
|
||||
}
|
||||
img
|
||||
};
|
||||
let clean = draw(&bg, &straight);
|
||||
let both: Vec<f32> = coils.iter().zip(&straight).map(|(a, b)| a.min(*b)).collect();
|
||||
let img = draw(&bg, &both);
|
||||
let hole = coils.iter().map(|d| *d <= margin).collect();
|
||||
(img, clean, hole)
|
||||
}
|
||||
|
||||
/// RMS over the hole of the error averaged over 7 × 7 pixels (in levels of 255): the noise
|
||||
/// cancels out, a ghost of the removed outline or a blotch of another shade does not.
|
||||
fn blotch_error(w: usize, h: usize, out: &[f32], clean: &[f32], hole: &[bool]) -> f32 {
|
||||
let (mut sum, mut n) = (0.0f64, 0usize);
|
||||
for y in 3..h - 3 {
|
||||
for x in 3..w - 3 {
|
||||
if !hole[y * w + x] {
|
||||
continue;
|
||||
}
|
||||
let mut e = [0.0f32; 3];
|
||||
for j in (y - 3..=y + 3).flat_map(|yy| (x - 3..=x + 3).map(move |xx| yy * w + xx)) {
|
||||
for (c, ec) in e.iter_mut().enumerate() {
|
||||
*ec += out[j * 3 + c] - clean[j * 3 + c];
|
||||
}
|
||||
}
|
||||
sum += f64::from(e.iter().map(|v| (v / 49.0).powi(2)).sum::<f32>() / 3.0);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
((sum / n.max(1) as f64).sqrt() * 255.0) as f32
|
||||
}
|
||||
|
||||
/// Removing thin, long objects (guitar strings, wires) from a soft background (#1366): the
|
||||
/// fill is the background, with no ghost of their outline and nothing of their colour copied
|
||||
/// back in. Their selection is long but only a few pixels thick, so the fill is worked out at
|
||||
/// a scale where it is still a few pixels thick rather than one where the strings' surroundings
|
||||
/// have merged into a blur.
|
||||
#[test]
|
||||
fn thin_objects_leave_no_ghost_and_none_of_their_colour() {
|
||||
let (w, h) = (360, 270);
|
||||
let (img, clean, hole) = strings_scene(w, h, 2.0);
|
||||
let source = auto_sampling(w, h, 3, &img, &hole);
|
||||
let out = fill(w, h, 3, &img, &hole, &source, &FillOptions::default());
|
||||
let blotch = blotch_error(w, h, &out, &clean, &hole);
|
||||
let luma = |v: &[f32], i: usize| (v[i * 3] + v[i * 3 + 1] + v[i * 3 + 2]) / 3.0;
|
||||
let bright = (0..w * h).filter(|&i| hole[i] && luma(&out, i) > luma(&clean, i) + 0.06).count();
|
||||
// Before #1366 the outlines stayed as darker and lighter blotches (13 levels of 255) and
|
||||
// 1121 pixels took the strings' colour.
|
||||
assert!(blotch < 2.5, "ghosts and blotches: {blotch:.2} levels of 255");
|
||||
assert_eq!(bright, 0, "filled pixels with the strings' colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_geometry() {
|
||||
let img = vec![0.5f32; 10 * 6];
|
||||
|
||||
@@ -1,990 +0,0 @@
|
||||
//! Depth from focus for Edit › Auto-Blend Layers › Stack Images: a dense, sub-layer depth map
|
||||
//! from the aligned stack, the index of the layer each pixel is sharpest in.
|
||||
//!
|
||||
//! The pipeline is the non-learned "focus volume" recipe, written from the papers:
|
||||
//!
|
||||
//! 1. **Focus measure** per layer on luminance at full resolution: the *ring difference filter*
|
||||
//! of Jeon, Surh, Im & Kweon, *Ring Difference Filter for Fast and Noise Robust Depth From
|
||||
//! Focus*, IEEE TIP 29 (2019), the magnitude of the difference between the mean of a small
|
||||
//! disk and the mean of the ring around it, a band-pass whose support averages sensor noise
|
||||
//! away while staying local. Block-averaged to a working grid (half resolution).
|
||||
//! 2. **Cost aggregation**: each slice of the focus volume is filtered with the *guided filter*
|
||||
//! (He, Sun & Tang, PAMI 2013) using the all-in-focus luminance as guide, edge-aware
|
||||
//! aggregation as in fast cost-volume filtering (Hosni et al., PAMI 2013).
|
||||
//! 3. **Peak search** per pixel over the layer axis, one slice at a time: the global peak and its
|
||||
//! two neighbours for the *Gaussian interpolation* of Nayar & Nakagawa, *Shape from Focus*,
|
||||
//! PAMI 1994 (sub-layer depth), the second-best local maximum for a peak-ratio confidence,
|
||||
//! the profile mean for a prominence term and the profile minimum for a noise gate.
|
||||
//! 4. **Regularisation**: the sub-layer depth is smoothed and holes filled by an edge-aware
|
||||
//! weighted least squares energy (Farbman, Fattal, Lischinski & Szeliski, SIGGRAPH 2008)
|
||||
//! whose data term is the confidence, solved by conjugate gradients preconditioned by a
|
||||
//! multigrid V-cycle from the separable sweeps of the *fast global smoother* (Min, Choi, Lu,
|
||||
//! Ham, Sohn & Do, IEEE TIP 2014) as the start, with one Huber reweighting so isolated
|
||||
//! outliers stop pulling.
|
||||
//! 5. **Upsampling** to full resolution with the guided filter's linear coefficients.
|
||||
//!
|
||||
//! Everything is deterministic; the slices are folded one at a time, so memory is one working
|
||||
//! grid per layer plus a few planes.
|
||||
|
||||
use crate::photo_util::{par_map, par_rows, par_rows2};
|
||||
|
||||
/// Depth-from-focus settings.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct DepthParams {
|
||||
/// The working grid is `1 / 2^scale` of full resolution (1 = half).
|
||||
pub scale: usize,
|
||||
/// Ring difference filter radii: the disk `r ≤ r_in`, the ring `r_in < r ≤ r_out`.
|
||||
pub r_in: usize,
|
||||
pub r_out: usize,
|
||||
/// Guided-filter aggregation radius (working-grid pixels) and regulariser.
|
||||
pub agg_radius: usize,
|
||||
pub agg_eps: f32,
|
||||
/// Smoothness weight and guide-edge sensitivity (luminance units) of the regularisation.
|
||||
pub lambda: f32,
|
||||
pub sigma_c: f32,
|
||||
/// Noise gate: full confidence needs a peak ≥ `(1 + gate) ×` the noise floor (the median
|
||||
/// over pixels of the profile minimum). 0 = off.
|
||||
pub gate: f32,
|
||||
/// Robust data term: after the first solve the data weights are scaled by the Huber factor
|
||||
/// `min(1, robust / |d − u|)` (in layers) and the system is solved once more. 0 = off.
|
||||
pub robust: f32,
|
||||
}
|
||||
|
||||
impl Default for DepthParams {
|
||||
fn default() -> Self {
|
||||
DepthParams { scale: 1, r_in: 1, r_out: 3, agg_radius: 3, agg_eps: 1e-4, lambda: 3.0, sigma_c: 0.04, gate: 1.0, robust: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of [`depth_from_focus`].
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DepthMap {
|
||||
/// Full-resolution fractional layer index per pixel, in `0 ..= n − 1`.
|
||||
pub depth: Vec<f32>,
|
||||
/// Full-resolution confidence in `0 ..= 1`.
|
||||
pub conf: Vec<f32>,
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
}
|
||||
|
||||
/// Mean over `k × k` blocks (partial blocks at the far edges use their own count).
|
||||
pub fn block_mean(src: &[f32], w: usize, h: usize, k: usize) -> (Vec<f32>, usize, usize) {
|
||||
let k = k.max(1);
|
||||
let (dw, dh) = (w.div_ceil(k), h.div_ceil(k));
|
||||
if k == 1 {
|
||||
return (src.to_vec(), dw, dh);
|
||||
}
|
||||
let mut out = vec![0.0f32; dw * dh];
|
||||
par_rows(&mut out, dw, 1, |oy, row| {
|
||||
let (y0, y1) = (oy * k, (oy * k + k).min(h));
|
||||
for y in y0..y1 {
|
||||
let s = &src[y * w..y * w + w];
|
||||
for (ox, o) in row.iter_mut().enumerate() {
|
||||
*o += s[ox * k..(ox * k + k).min(w)].iter().sum::<f32>();
|
||||
}
|
||||
}
|
||||
for (ox, o) in row.iter_mut().enumerate() {
|
||||
*o /= ((y1 - y0) * ((ox * k + k).min(w) - ox * k)) as f32;
|
||||
}
|
||||
});
|
||||
(out, dw, dh)
|
||||
}
|
||||
|
||||
/// Bilinear resampling of a working-grid plane (block size `k`, samples at block centres) to
|
||||
/// `w × h`.
|
||||
pub fn upsample_bilinear(g: &[f32], dw: usize, dh: usize, w: usize, h: usize, k: usize) -> Vec<f32> {
|
||||
let mut out = vec![0.0f32; w * h];
|
||||
if dw == 0 || dh == 0 {
|
||||
return out;
|
||||
}
|
||||
let inv = 1.0 / k.max(1) as f32;
|
||||
par_rows(&mut out, w, 1, |y, row| {
|
||||
let fy = ((y as f32 + 0.5) * inv - 0.5).clamp(0.0, (dh - 1) as f32);
|
||||
let y0 = fy as usize;
|
||||
let y1 = (y0 + 1).min(dh - 1);
|
||||
let ty = fy - y0 as f32;
|
||||
let (r0, r1) = (&g[y0 * dw..y0 * dw + dw], &g[y1 * dw..y1 * dw + dw]);
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let fx = ((x as f32 + 0.5) * inv - 0.5).clamp(0.0, (dw - 1) as f32);
|
||||
let x0 = fx as usize;
|
||||
let x1 = (x0 + 1).min(dw - 1);
|
||||
let tx = fx - x0 as f32;
|
||||
let top = r0[x0] + (r0[x1] - r0[x0]) * tx;
|
||||
let bot = r1[x0] + (r1[x1] - r1[x0]) * tx;
|
||||
*o = top + (bot - top) * ty;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Box means over a `(2r + 1)²` window clamped to the plane (partial windows at the borders use
|
||||
/// their own count): a horizontal moving sum per row in parallel, then a vertical moving sum
|
||||
/// carried down the rows as one accumulator row.
|
||||
struct BoxFilter {
|
||||
w: usize,
|
||||
h: usize,
|
||||
r: usize,
|
||||
inv_count: Vec<f32>,
|
||||
}
|
||||
|
||||
impl BoxFilter {
|
||||
fn new(w: usize, h: usize, r: usize) -> BoxFilter {
|
||||
let cnt = |n: usize, i: usize| ((i + r + 1).min(n) - i.saturating_sub(r)) as f32;
|
||||
let mut inv_count = vec![0.0f32; w * h];
|
||||
par_rows(&mut inv_count, w, 1, |y, row| {
|
||||
let cy = cnt(h, y);
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
*o = 1.0 / (cy * cnt(w, x));
|
||||
}
|
||||
});
|
||||
BoxFilter { w, h, r, inv_count }
|
||||
}
|
||||
|
||||
fn mean(&self, src: &[f32]) -> Vec<f32> {
|
||||
let (w, h, r) = (self.w, self.h, self.r);
|
||||
let mut tmp = vec![0.0f32; w * h];
|
||||
par_rows(&mut tmp, w, 1, |y, row| {
|
||||
let s = &src[y * w..y * w + w];
|
||||
let mut acc: f64 = s[..(r + 1).min(w)].iter().map(|v| f64::from(*v)).sum();
|
||||
for x in 0..w {
|
||||
row[x] = acc as f32;
|
||||
if x + r + 1 < w {
|
||||
acc += f64::from(s[x + r + 1]);
|
||||
}
|
||||
if x >= r {
|
||||
acc -= f64::from(s[x - r]);
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut out = vec![0.0f32; w * h];
|
||||
let mut acc = vec![0.0f64; w];
|
||||
for y in 0..(r + 1).min(h) {
|
||||
for (a, v) in acc.iter_mut().zip(&tmp[y * w..y * w + w]) {
|
||||
*a += f64::from(*v);
|
||||
}
|
||||
}
|
||||
for y in 0..h {
|
||||
for ((o, a), c) in out[y * w..y * w + w].iter_mut().zip(&acc).zip(&self.inv_count[y * w..y * w + w]) {
|
||||
*o = *a as f32 * c;
|
||||
}
|
||||
if y + r + 1 < h {
|
||||
for (a, v) in acc.iter_mut().zip(&tmp[(y + r + 1) * w..(y + r + 2) * w]) {
|
||||
*a += f64::from(*v);
|
||||
}
|
||||
}
|
||||
if y >= r {
|
||||
for (a, v) in acc.iter_mut().zip(&tmp[(y - r) * w..(y - r + 1) * w]) {
|
||||
*a -= f64::from(*v);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Guided filter (He, Sun & Tang 2013) with a fixed grayscale guide: made once per guide and
|
||||
/// radius, it keeps the guide's statistics so a slice costs only the arithmetic.
|
||||
struct GuidedFilter {
|
||||
guide: Vec<f32>,
|
||||
mean_i: Vec<f32>,
|
||||
var_i: Vec<f32>,
|
||||
bf: BoxFilter,
|
||||
eps: f32,
|
||||
}
|
||||
|
||||
impl GuidedFilter {
|
||||
fn new(guide: Vec<f32>, w: usize, h: usize, r: usize, eps: f32) -> GuidedFilter {
|
||||
let bf = BoxFilter::new(w, h, r);
|
||||
let mean_i = bf.mean(&guide);
|
||||
let ii: Vec<f32> = guide.iter().map(|v| v * v).collect();
|
||||
let var_i: Vec<f32> = bf.mean(&ii).iter().zip(&mean_i).map(|(v, m)| (v - m * m).max(0.0)).collect();
|
||||
GuidedFilter { guide, mean_i, var_i, bf, eps }
|
||||
}
|
||||
|
||||
/// The box-averaged linear coefficients `(ā, b̄)` such that `q ≈ ā·I + b̄`.
|
||||
fn coeffs(&self, p: &[f32]) -> (Vec<f32>, Vec<f32>) {
|
||||
let mean_p = self.bf.mean(p);
|
||||
let ip: Vec<f32> = self.guide.iter().zip(p).map(|(i, p)| i * p).collect();
|
||||
let corr_ip = self.bf.mean(&ip);
|
||||
let (mut a, mut b) = (vec![0.0f32; p.len()], vec![0.0f32; p.len()]);
|
||||
for k in 0..p.len() {
|
||||
let cov = corr_ip[k] - self.mean_i[k] * mean_p[k];
|
||||
a[k] = cov / (self.var_i[k] + self.eps);
|
||||
b[k] = mean_p[k] - a[k] * self.mean_i[k];
|
||||
}
|
||||
(self.bf.mean(&a), self.bf.mean(&b))
|
||||
}
|
||||
|
||||
fn filter(&self, p: &[f32]) -> Vec<f32> {
|
||||
let (a, b) = self.coeffs(p);
|
||||
(0..p.len()).map(|k| a[k] * self.guide[k] + b[k]).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Ring difference filter taps `(dy, dx, weight)`: `+1/|disk|` for `r ≤ r_in`, `−1/|ring|` for
|
||||
/// `r_in < r ≤ r_out`.
|
||||
fn rdf_taps(r_in: usize, r_out: usize) -> Vec<(i64, i64, f32)> {
|
||||
let (ri2, ro2) = ((r_in * r_in) as i64, (r_out * r_out) as i64);
|
||||
let (mut disk, mut ring) = (Vec::new(), Vec::new());
|
||||
let r = r_out as i64;
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if d2 <= ri2 {
|
||||
disk.push((dy, dx));
|
||||
} else if d2 <= ro2 {
|
||||
ring.push((dy, dx));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (wd, wr) = (1.0 / disk.len().max(1) as f32, -1.0 / ring.len().max(1) as f32);
|
||||
disk.into_iter().map(|(y, x)| (y, x, wd)).chain(ring.into_iter().map(|(y, x)| (y, x, wr))).collect()
|
||||
}
|
||||
|
||||
/// Per-pixel focus measure of a luminance plane: the magnitude of the ring difference filter,
|
||||
/// clamped borders.
|
||||
pub fn focus_measure(y: &[f32], w: usize, h: usize, r_in: usize, r_out: usize) -> Vec<f32> {
|
||||
let taps = rdf_taps(r_in, r_out.max(r_in + 1));
|
||||
let mut out = vec![0.0f32; w * h];
|
||||
if w == 0 || h == 0 {
|
||||
return out;
|
||||
}
|
||||
par_rows(&mut out, w, 1, |yy, row| {
|
||||
for &(dy, dx, wt) in &taps {
|
||||
let sy = (yy as i64 + dy).clamp(0, h as i64 - 1) as usize;
|
||||
let s = &y[sy * w..sy * w + w];
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
*o += wt * s[(x as i64 + dx).clamp(0, w as i64 - 1) as usize];
|
||||
}
|
||||
}
|
||||
row.iter_mut().for_each(|v| *v = v.abs());
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Per-pixel state of the focus profile, updated one slice at a time.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Px {
|
||||
/// Best local maximum (value, left neighbour, right neighbour; −1 = none) and its index.
|
||||
c1: f32,
|
||||
l1: f32,
|
||||
r1: f32,
|
||||
i1: u32,
|
||||
/// Second-best local maximum (−1 = none).
|
||||
c2: f32,
|
||||
/// The last two slice values.
|
||||
prev: f32,
|
||||
prev2: f32,
|
||||
sum: f32,
|
||||
cmin: f32,
|
||||
}
|
||||
|
||||
impl Px {
|
||||
fn register(&mut self, val: f32, idx: u32, l: f32, r: f32) {
|
||||
if val > self.c1 {
|
||||
if self.c1 >= 0.0 {
|
||||
self.c2 = self.c1;
|
||||
}
|
||||
(self.c1, self.i1, self.l1, self.r1) = (val, idx, l, r);
|
||||
} else if val > self.c2 {
|
||||
self.c2 = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The streaming peak search over the layer axis.
|
||||
struct PeakTracker {
|
||||
px: Vec<Px>,
|
||||
m: usize,
|
||||
}
|
||||
|
||||
impl PeakTracker {
|
||||
fn new(n: usize) -> PeakTracker {
|
||||
PeakTracker { px: vec![Px { c1: -1.0, l1: -1.0, r1: -1.0, i1: 0, c2: -1.0, prev: 0.0, prev2: 0.0, sum: 0.0, cmin: f32::INFINITY }; n], m: 0 }
|
||||
}
|
||||
|
||||
/// Fold the next slice (aggregated focus values ≥ 0).
|
||||
fn push(&mut self, c: &[f32]) {
|
||||
let m = self.m;
|
||||
for (p, &v) in self.px.iter_mut().zip(c) {
|
||||
if m >= 1 && (m == 1 || p.prev >= p.prev2) && p.prev > v {
|
||||
let l = if m >= 2 { p.prev2 } else { -1.0 };
|
||||
p.register(p.prev, (m - 1) as u32, l, v);
|
||||
}
|
||||
p.sum += v;
|
||||
p.cmin = p.cmin.min(v);
|
||||
(p.prev2, p.prev) = (p.prev, v);
|
||||
}
|
||||
self.m += 1;
|
||||
}
|
||||
|
||||
/// Close the profiles: the sub-layer depth and the raw confidence per pixel.
|
||||
fn finish(mut self, gate: f32) -> (Vec<f32>, Vec<f32>) {
|
||||
let n = self.m;
|
||||
if n == 0 {
|
||||
return (vec![0.0; self.px.len()], vec![0.0; self.px.len()]);
|
||||
}
|
||||
for p in self.px.iter_mut() {
|
||||
if n == 1 || p.prev >= p.prev2 {
|
||||
let l = if n >= 2 { p.prev2 } else { -1.0 };
|
||||
p.register(p.prev, (n - 1) as u32, l, -1.0);
|
||||
}
|
||||
}
|
||||
// The noise floor: the median of the per-pixel profile minimum (subsampled).
|
||||
let mut mins: Vec<f32> = self.px.iter().step_by(7).map(|p| if p.cmin.is_finite() { p.cmin } else { 0.0 }).collect();
|
||||
let floor = if mins.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
let mid = mins.len() / 2;
|
||||
*mins.select_nth_unstable_by(mid, |a, b| a.total_cmp(b)).1
|
||||
};
|
||||
let inv_n = 1.0 / n as f32;
|
||||
let out: Vec<(f32, f32)> = par_map(self.px.len(), |i| {
|
||||
let p = &self.px[i];
|
||||
let (c1, l, r) = (p.c1, p.l1, p.r1);
|
||||
let mut delta = 0.0f32;
|
||||
if l >= 0.0 && r >= 0.0 && c1 > 0.0 {
|
||||
// Gaussian interpolation (Nayar & Nakagawa 1994): a parabola in the log domain.
|
||||
let (ll, lc, lr) = (l.max(1e-12).ln(), c1.ln(), r.max(1e-12).ln());
|
||||
let den = ll - 2.0 * lc + lr;
|
||||
if den < 0.0 {
|
||||
delta = (0.5 * (ll - lr) / den).clamp(-0.5, 0.5);
|
||||
}
|
||||
}
|
||||
let conf = if c1 > 0.0 {
|
||||
let prom = (1.0 - p.sum * inv_n / c1).clamp(0.0, 1.0);
|
||||
// A rival local maximum of similar height means an ambiguous profile.
|
||||
let pkr = if p.c2 >= 0.0 { (1.0 - p.c2 / c1).clamp(0.0, 1.0) } else { 1.0 };
|
||||
let g = if gate > 0.0 && floor > 0.0 { ((c1 - floor) / (gate * floor)).clamp(0.0, 1.0) } else { 1.0 };
|
||||
prom * pkr * g
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
(p.i1 as f32 + delta, conf)
|
||||
});
|
||||
out.into_iter().unzip()
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale the confidence so its 90th percentile maps to 1: `lambda` then means the same on every
|
||||
/// stack.
|
||||
fn normalize_conf(conf: &mut [f32]) {
|
||||
let mut sample: Vec<f32> = conf.iter().step_by(7).copied().collect();
|
||||
if sample.is_empty() {
|
||||
return;
|
||||
}
|
||||
let k = (sample.len() * 9 / 10).min(sample.len() - 1);
|
||||
let p90 = *sample.select_nth_unstable_by(k, |a, b| a.total_cmp(b)).1;
|
||||
if p90 > 1e-6 {
|
||||
conf.iter_mut().for_each(|c| *c = (*c / p90).min(1.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// 3×3 median, clamped borders.
|
||||
fn median3(src: &[f32], w: usize, h: usize) -> Vec<f32> {
|
||||
let mut out = vec![0.0f32; w * h];
|
||||
par_rows(&mut out, w, 1, |y, row| {
|
||||
let ys = [y.saturating_sub(1), y, (y + 1).min(h - 1)];
|
||||
let mut v = [0.0f32; 9];
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let xs = [x.saturating_sub(1), x, (x + 1).min(w - 1)];
|
||||
for (k, (yy, xx)) in ys.iter().flat_map(|yy| xs.iter().map(move |xx| (*yy, *xx))).enumerate() {
|
||||
v[k] = src[yy * w + xx];
|
||||
}
|
||||
v.sort_unstable_by(|a, b| a.total_cmp(b));
|
||||
*o = v[4];
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Edge weights between horizontal and vertical neighbours: `exp(−|ΔI| / σ)`; `ax[i]` sits
|
||||
/// between `(x, y)` and `(x + 1, y)`, `ay[i]` between `(x, y)` and `(x, y + 1)`.
|
||||
fn edge_weights(guide: &[f32], w: usize, h: usize, sigma: f32) -> (Vec<f32>, Vec<f32>) {
|
||||
let inv = -1.0 / sigma.max(1e-6);
|
||||
let (mut ax, mut ay) = (vec![0.0f32; w * h], vec![0.0f32; w * h]);
|
||||
par_rows2(&mut ax, &mut ay, w, |y, rx, ry| {
|
||||
let g = &guide[y * w..y * w + w];
|
||||
for x in 0..w.saturating_sub(1) {
|
||||
rx[x] = ((g[x] - g[x + 1]).abs() * inv).exp();
|
||||
}
|
||||
if y + 1 < h {
|
||||
let g1 = &guide[(y + 1) * w..(y + 2) * w];
|
||||
for x in 0..w {
|
||||
ry[x] = ((g[x] - g1[x]).abs() * inv).exp();
|
||||
}
|
||||
}
|
||||
});
|
||||
(ax, ay)
|
||||
}
|
||||
|
||||
/// One 1-D weighted-least-squares line `(wd_i + λ(a_{i−1} + a_i)) u_i − λ a_{i−1} u_{i−1} −
|
||||
/// λ a_i u_{i+1} = wd_i f_i` by the Thomas algorithm; `a[i]` is the weight between `i` and
|
||||
/// `i + 1`; `scratch` holds the sweep's two coefficient lines.
|
||||
fn solve_line(u: &mut [f32], f: &[f32], wd: &[f32], a: &[f32], lam: f32, scratch: &mut [f32]) {
|
||||
let n = u.len();
|
||||
if n == 0 || scratch.len() < 2 * n {
|
||||
return;
|
||||
}
|
||||
let (cp, dp) = scratch.split_at_mut(n);
|
||||
let mut prev_a = 0.0f32;
|
||||
for i in 0..n {
|
||||
let ai = if i + 1 < n { a[i] } else { 0.0 };
|
||||
let diag = wd[i] + lam * (prev_a + ai);
|
||||
let lower = -lam * prev_a;
|
||||
let upper = -lam * ai;
|
||||
let (c0, d0) = if i > 0 { (cp[i - 1], dp[i - 1]) } else { (0.0, 0.0) };
|
||||
let m = diag - lower * c0;
|
||||
let m = if m.abs() < 1e-12 { 1e-12 } else { m };
|
||||
cp[i] = upper / m;
|
||||
dp[i] = (wd[i] * f[i] - lower * d0) / m;
|
||||
prev_a = ai;
|
||||
}
|
||||
u[n - 1] = dp[n - 1];
|
||||
for i in (0..n - 1).rev() {
|
||||
u[i] = dp[i] - cp[i] * u[i + 1];
|
||||
}
|
||||
}
|
||||
|
||||
/// The row sweeps of the fast global smoother, in parallel over rows.
|
||||
fn solve_rows(u: &mut [f32], f: &[f32], wd: &[f32], ax: &[f32], w: usize, lam: f32) {
|
||||
par_rows(u, w, 1, |y, row| {
|
||||
let o = y * w;
|
||||
let mut scratch = vec![0.0f32; 2 * w];
|
||||
solve_line(row, &f[o..o + w], &wd[o..o + w], &ax[o..o + w], lam, &mut scratch);
|
||||
});
|
||||
}
|
||||
|
||||
/// The column sweeps: the planes are transposed, swept as rows, and transposed back.
|
||||
fn solve_cols(u: &mut [f32], f: &[f32], wd: &[f32], ay: &[f32], w: usize, h: usize, lam: f32) {
|
||||
let t = |p: &[f32]| -> Vec<f32> {
|
||||
let mut o = vec![0.0f32; w * h];
|
||||
par_rows(&mut o, h, 1, |x, col| {
|
||||
for (y, v) in col.iter_mut().enumerate() {
|
||||
*v = p[y * w + x];
|
||||
}
|
||||
});
|
||||
o
|
||||
};
|
||||
let (ft, wdt, ayt) = (t(f), t(wd), t(ay));
|
||||
let mut ut = vec![0.0f32; w * h];
|
||||
solve_rows(&mut ut, &ft, &wdt, &ayt, h, lam);
|
||||
par_rows(u, w, 1, |y, row| {
|
||||
for (x, v) in row.iter_mut().enumerate() {
|
||||
*v = ut[x * h + y];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The fast global smoother's three separable passes with the `λ_t` schedule of Min et al.
|
||||
/// 2014, the first pass carrying the data weights so holes are filled by interpolation, the
|
||||
/// later ones the previous output: the initial guess of [`wls_solve`].
|
||||
fn sweeps(d: &[f32], wd: &[f32], ex: &[f32], ey: &[f32], w: usize, h: usize, lambda: f32) -> Vec<f32> {
|
||||
const T: i32 = 3;
|
||||
let ones = vec![1.0f32; w * h];
|
||||
let mut u = vec![0.0f32; w * h];
|
||||
let mut f = d.to_vec();
|
||||
for t in 1..=T {
|
||||
let lam_t = 1.5 * lambda * 4f32.powi(T - t) / (4f32.powi(T) - 1.0);
|
||||
let wt: &[f32] = if t == 1 { wd } else { &ones };
|
||||
solve_rows(&mut u, &f, wt, ex, w, lam_t);
|
||||
f.copy_from_slice(&u);
|
||||
solve_cols(&mut u, &f, &ones, ey, w, h, lam_t);
|
||||
f.copy_from_slice(&u);
|
||||
}
|
||||
u
|
||||
}
|
||||
|
||||
/// The WLS operator `A = W + Σ λ a_pq` on one grid of the multigrid hierarchy, with the planes
|
||||
/// a V-cycle needs there.
|
||||
struct MgLevel {
|
||||
w: usize,
|
||||
h: usize,
|
||||
/// Data weight per cell; on the coarse grids the sum over the block.
|
||||
wd: Vec<f32>,
|
||||
/// `λ·a` between `(x, y)` and `(x + 1, y)`, and between `(x, y)` and `(x, y + 1)`; on the
|
||||
/// coarse grids the sum of the fine edges the block boundary cuts.
|
||||
ax: Vec<f32>,
|
||||
ay: Vec<f32>,
|
||||
/// `1 / diag(A)`.
|
||||
dinv: Vec<f32>,
|
||||
x: Vec<f32>,
|
||||
b: Vec<f32>,
|
||||
r: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Damped-Jacobi weight (4/5 is the smoothing optimum of the 5-point stencil).
|
||||
const MG_OMEGA: f32 = 0.8;
|
||||
/// Smoothing sweeps before and after the coarse correction, and on the coarsest grid (where
|
||||
/// they are the solve).
|
||||
const MG_PRE: usize = 1;
|
||||
const MG_POST: usize = 1;
|
||||
const MG_COARSE: usize = 32;
|
||||
/// Coarsen until the grid is this small on its longer side.
|
||||
const MG_MIN: usize = 16;
|
||||
|
||||
impl MgLevel {
|
||||
fn new(w: usize, h: usize) -> MgLevel {
|
||||
let n = w * h;
|
||||
MgLevel { w, h, wd: vec![0.0; n], ax: vec![0.0; n], ay: vec![0.0; n], dinv: vec![0.0; n], x: vec![0.0; n], b: vec![0.0; n], r: vec![0.0; n] }
|
||||
}
|
||||
|
||||
/// `out_i = f(i, (A x)_i)` over the grid.
|
||||
fn apply<F: Fn(usize, f32) -> f32 + Sync + Send>(&self, x: &[f32], out: &mut [f32], f: F) {
|
||||
let (w, h) = (self.w, self.h);
|
||||
let (wd, ax, ay) = (&self.wd, &self.ax, &self.ay);
|
||||
par_rows(out, w, 1, |y, row| {
|
||||
let o = y * w;
|
||||
for (i, r) in row.iter_mut().enumerate() {
|
||||
let k = o + i;
|
||||
let xk = x[k];
|
||||
let mut v = wd[k] * xk;
|
||||
if i > 0 {
|
||||
v += ax[k - 1] * (xk - x[k - 1]);
|
||||
}
|
||||
if i + 1 < w {
|
||||
v += ax[k] * (xk - x[k + 1]);
|
||||
}
|
||||
if y > 0 {
|
||||
v += ay[k - w] * (xk - x[k - w]);
|
||||
}
|
||||
if y + 1 < h {
|
||||
v += ay[k] * (xk - x[k + w]);
|
||||
}
|
||||
*r = f(k, v);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn set_dinv(&mut self) {
|
||||
let (w, h) = (self.w, self.h);
|
||||
let (wd, ax, ay) = (&self.wd, &self.ax, &self.ay);
|
||||
par_rows(&mut self.dinv, w, 1, |y, row| {
|
||||
for (i, r) in row.iter_mut().enumerate() {
|
||||
let k = y * w + i;
|
||||
let mut v = wd[k];
|
||||
if i > 0 {
|
||||
v += ax[k - 1];
|
||||
}
|
||||
if i + 1 < w {
|
||||
v += ax[k];
|
||||
}
|
||||
if y > 0 {
|
||||
v += ay[k - w];
|
||||
}
|
||||
if y + 1 < h {
|
||||
v += ay[k];
|
||||
}
|
||||
*r = 1.0 / v.max(1e-12);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One damped-Jacobi sweep on `x` for `b`: `x += ω D⁻¹ (b − A x)`.
|
||||
fn jacobi(&mut self, zero_start: bool) {
|
||||
if zero_start {
|
||||
for ((x, b), d) in self.x.iter_mut().zip(&self.b).zip(&self.dinv) {
|
||||
*x = MG_OMEGA * d * b;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut r = std::mem::take(&mut self.r);
|
||||
{
|
||||
let (x, b, dinv) = (&self.x, &self.b, &self.dinv);
|
||||
self.apply(x, &mut r, |k, ax| x[k] + MG_OMEGA * dinv[k] * (b[k] - ax));
|
||||
}
|
||||
self.r = std::mem::replace(&mut self.x, r);
|
||||
}
|
||||
|
||||
/// The 2×2 aggregation of this grid: the Galerkin operator of a piecewise-constant
|
||||
/// prolongation (data weights summed over the block, the fine edges cut by a block boundary
|
||||
/// summed into the coarse edge).
|
||||
fn coarsen(&self) -> MgLevel {
|
||||
let (w, h) = (self.w, self.h);
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let mut c = MgLevel::new(cw, ch);
|
||||
self.coarsen_wd_into(&mut c);
|
||||
let (ax, ay) = (&self.ax, &self.ay);
|
||||
par_rows(&mut c.ax, cw, 1, |yy, row| {
|
||||
for y in 2 * yy..(2 * yy + 2).min(h) {
|
||||
for (xx, o) in row.iter_mut().enumerate() {
|
||||
if xx + 1 < cw {
|
||||
*o += ax[y * w + 2 * xx + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
par_rows(&mut c.ay, cw, 1, |yy, row| {
|
||||
if yy + 1 < ch {
|
||||
let y = 2 * yy + 1;
|
||||
for (xx, o) in row.iter_mut().enumerate() {
|
||||
*o = ay[y * w..y * w + w][2 * xx..(2 * xx + 2).min(w)].iter().sum::<f32>();
|
||||
}
|
||||
}
|
||||
});
|
||||
c
|
||||
}
|
||||
|
||||
/// The data weights of the coarse grid `c` from this grid's (after a reweighting; the edges
|
||||
/// do not change).
|
||||
fn coarsen_wd_into(&self, c: &mut MgLevel) {
|
||||
let (w, h, cw) = (self.w, self.h, c.w);
|
||||
let wd = &self.wd;
|
||||
par_rows(&mut c.wd, cw, 1, |yy, row| {
|
||||
row.fill(0.0);
|
||||
for y in 2 * yy..(2 * yy + 2).min(h) {
|
||||
for (xx, o) in row.iter_mut().enumerate() {
|
||||
*o += wd[y * w..y * w + w][2 * xx..(2 * xx + 2).min(w)].iter().sum::<f32>();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `x += P x_c`: the coarse correction injected (piecewise constant).
|
||||
fn prolong_add(x: &mut [f32], w: usize, xc: &[f32], cw: usize) {
|
||||
par_rows(x, w, 1, |y, row| {
|
||||
let c = &xc[(y / 2) * cw..(y / 2) * cw + cw];
|
||||
for (i, o) in row.iter_mut().enumerate() {
|
||||
*o += c[i / 2];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `b_c = Pᵀ r`: the residual summed over each block.
|
||||
fn restrict(r: &[f32], w: usize, h: usize, bc: &mut [f32], cw: usize) {
|
||||
par_rows(bc, cw, 1, |yy, row| {
|
||||
row.fill(0.0);
|
||||
for y in 2 * yy..(2 * yy + 2).min(h) {
|
||||
let s = &r[y * w..y * w + w];
|
||||
for (xx, o) in row.iter_mut().enumerate() {
|
||||
*o += s[2 * xx..(2 * xx + 2).min(w)].iter().sum::<f32>();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One V-cycle from `levels[0]` (whose `b` is set) into its `x`.
|
||||
fn vcycle(levels: &mut [MgLevel]) {
|
||||
let Some((top, rest)) = levels.split_first_mut() else { return };
|
||||
let Some(next) = rest.first_mut() else {
|
||||
for k in 0..MG_COARSE {
|
||||
top.jacobi(k == 0);
|
||||
}
|
||||
return;
|
||||
};
|
||||
for k in 0..MG_PRE {
|
||||
top.jacobi(k == 0);
|
||||
}
|
||||
{
|
||||
let mut r = std::mem::take(&mut top.r);
|
||||
let (x, b) = (&top.x, &top.b);
|
||||
top.apply(x, &mut r, |k, ax| b[k] - ax);
|
||||
top.r = r;
|
||||
}
|
||||
restrict(&top.r, top.w, top.h, &mut next.b, next.w);
|
||||
vcycle(rest);
|
||||
let Some(next) = rest.first() else { return };
|
||||
prolong_add(&mut top.x, top.w, &next.x, next.w);
|
||||
for _ in 0..MG_POST {
|
||||
top.jacobi(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Edge-aware WLS: `argmin_u Σ w_p (u_p − d_p)² + λ Σ a_pq (u_p − u_q)²`, the 2-D system
|
||||
/// `(W + λL) u = W d` solved by conjugate gradients from the separable sweeps' guess,
|
||||
/// preconditioned by one multigrid V-cycle (2×2 aggregation, damped Jacobi), so the low modes
|
||||
/// converge where the confidence is low over a large area (a flat wall). The solver keeps its
|
||||
/// hierarchy between solves, so a reweighting costs no rebuild.
|
||||
struct WlsSolver {
|
||||
w: usize,
|
||||
h: usize,
|
||||
lambda: f32,
|
||||
/// Edge weights `exp(−|ΔI| / σ)` (without λ), for the separable sweeps.
|
||||
ex: Vec<f32>,
|
||||
ey: Vec<f32>,
|
||||
levels: Vec<MgLevel>,
|
||||
}
|
||||
|
||||
impl WlsSolver {
|
||||
fn new(guide: &[f32], w: usize, h: usize, lambda: f32, sigma_c: f32) -> WlsSolver {
|
||||
let (ex, ey) = edge_weights(guide, w, h, sigma_c);
|
||||
let mut top = MgLevel::new(w, h);
|
||||
for (a, e) in top.ax.iter_mut().zip(&ex) {
|
||||
*a = lambda * e;
|
||||
}
|
||||
for (a, e) in top.ay.iter_mut().zip(&ey) {
|
||||
*a = lambda * e;
|
||||
}
|
||||
let mut levels = vec![top];
|
||||
while levels.last().is_some_and(|l| l.w.max(l.h) > MG_MIN) {
|
||||
let Some(last) = levels.last() else { break };
|
||||
let c = last.coarsen();
|
||||
levels.push(c);
|
||||
}
|
||||
WlsSolver { w, h, lambda, ex, ey, levels }
|
||||
}
|
||||
|
||||
/// The data weights: the confidence (plus a floor so the system is definite where nothing
|
||||
/// is known), aggregated down the hierarchy.
|
||||
fn set_weights(&mut self, conf: &[f32]) {
|
||||
const EPS_DATA: f32 = 1e-4;
|
||||
if let Some(top) = self.levels.first_mut() {
|
||||
for (w, c) in top.wd.iter_mut().zip(conf) {
|
||||
*w = c.max(0.0) + EPS_DATA;
|
||||
}
|
||||
}
|
||||
for l in 0..self.levels.len() {
|
||||
if l > 0 {
|
||||
let (fine, coarse) = self.levels.split_at_mut(l);
|
||||
if let (Some(f), Some(c)) = (fine.last(), coarse.first_mut()) {
|
||||
f.coarsen_wd_into(c);
|
||||
}
|
||||
}
|
||||
self.levels[l].set_dinv();
|
||||
}
|
||||
}
|
||||
|
||||
/// Solve for the data `d`: the solution and its final relative residual.
|
||||
fn solve(&mut self, d: &[f32], max_iters: usize) -> (Vec<f32>, f32) {
|
||||
let (w, h, lambda) = (self.w, self.h, self.lambda);
|
||||
let n = w * h;
|
||||
let Some(top) = self.levels.first() else { return (vec![0.0; n], 0.0) };
|
||||
let mut u = sweeps(d, &top.wd, &self.ex, &self.ey, w, h, lambda);
|
||||
if w == 1 || h == 1 || n == 0 {
|
||||
return (u, 0.0);
|
||||
}
|
||||
let dot = |a: &[f32], b: &[f32]| -> f64 { a.iter().zip(b).map(|(x, y)| f64::from(*x) * f64::from(*y)).sum() };
|
||||
let b: Vec<f32> = top.wd.iter().zip(d).map(|(w, d)| w * d).collect();
|
||||
let bnorm = dot(&b, &b).sqrt().max(1e-30);
|
||||
let mut r = vec![0.0f32; n];
|
||||
top.apply(&u, &mut r, |k, au| b[k] - au);
|
||||
let precond = |levels: &mut [MgLevel], r: &[f32], z: &mut [f32]| {
|
||||
if let Some(top) = levels.first_mut() {
|
||||
top.b.copy_from_slice(r);
|
||||
}
|
||||
vcycle(levels);
|
||||
if let Some(top) = levels.first() {
|
||||
z.copy_from_slice(&top.x);
|
||||
}
|
||||
};
|
||||
let mut z = vec![0.0f32; n];
|
||||
precond(&mut self.levels, &r, &mut z);
|
||||
let mut p = z.clone();
|
||||
let mut ap = vec![0.0f32; n];
|
||||
let mut rz = dot(&r, &z);
|
||||
let mut rel = (dot(&r, &r).sqrt() / bnorm) as f32;
|
||||
for _ in 0..max_iters {
|
||||
if rel < 1e-5 {
|
||||
break;
|
||||
}
|
||||
let Some(top) = self.levels.first() else { break };
|
||||
top.apply(&p, &mut ap, |_, v| v);
|
||||
let pap = dot(&p, &ap);
|
||||
if pap <= 0.0 {
|
||||
break;
|
||||
}
|
||||
let alpha = (rz / pap) as f32;
|
||||
for (u, p) in u.iter_mut().zip(&p) {
|
||||
*u += alpha * p;
|
||||
}
|
||||
for (r, ap) in r.iter_mut().zip(&ap) {
|
||||
*r -= alpha * ap;
|
||||
}
|
||||
rel = (dot(&r, &r).sqrt() / bnorm) as f32;
|
||||
if rel < 1e-5 {
|
||||
break;
|
||||
}
|
||||
precond(&mut self.levels, &r, &mut z);
|
||||
let rz_new = dot(&r, &z);
|
||||
let beta = (rz_new / rz) as f32;
|
||||
rz = rz_new;
|
||||
for (p, z) in p.iter_mut().zip(&z) {
|
||||
*p = z + beta * *p;
|
||||
}
|
||||
}
|
||||
(u, rel)
|
||||
}
|
||||
}
|
||||
|
||||
/// Conjugate-gradient iteration cap of the regularisation.
|
||||
const CG_ITERS: usize = 200;
|
||||
|
||||
/// Depth from focus over the `n` layers' luminance planes `luma` (`w × h`, aligned), guided by
|
||||
/// the all-in-focus luminance `guide` of the same size. Without layers, or when `guide` or a
|
||||
/// plane is not `w × h` long, the map is empty: depth 0 and confidence 0 everywhere.
|
||||
pub fn depth_from_focus(luma: &[&[f32]], guide: &[f32], w: usize, h: usize, p: &DepthParams) -> DepthMap {
|
||||
let n = luma.len();
|
||||
let empty = n == 0 || w == 0 || h == 0 || guide.len() != w * h || luma.iter().any(|l| l.len() != w * h);
|
||||
if empty {
|
||||
return DepthMap { depth: vec![0.0; w * h], conf: vec![0.0; w * h], w, h };
|
||||
}
|
||||
let k = 1usize << p.scale.min(8);
|
||||
let (g, dw, dh) = block_mean(guide, w, h, k);
|
||||
let gf = GuidedFilter::new(g.clone(), dw, dh, p.agg_radius, p.agg_eps);
|
||||
let mut tracker = PeakTracker::new(dw * dh);
|
||||
for l in luma {
|
||||
let slice = block_mean(&focus_measure(l, w, h, p.r_in, p.r_out), w, h, k).0;
|
||||
if p.agg_radius > 0 {
|
||||
// The guided filter can undershoot; the profile statistics assume ≥ 0.
|
||||
let agg: Vec<f32> = gf.filter(&slice).into_iter().map(|v| v.max(0.0)).collect();
|
||||
tracker.push(&agg);
|
||||
} else {
|
||||
tracker.push(&slice);
|
||||
}
|
||||
}
|
||||
let (depth_w, mut conf) = tracker.finish(p.gate);
|
||||
let depth_w = median3(&depth_w, dw, dh);
|
||||
normalize_conf(&mut conf);
|
||||
let depth_w = if p.lambda > 0.0 {
|
||||
let mut solver = WlsSolver::new(&g, dw, dh, p.lambda, p.sigma_c);
|
||||
solver.set_weights(&conf);
|
||||
let (u, _) = solver.solve(&depth_w, CG_ITERS);
|
||||
if p.robust > 0.0 {
|
||||
// One reweighted step with a Huber loss on the data residual.
|
||||
let wd2: Vec<f32> = conf.iter().zip(&depth_w).zip(&u).map(|((c, d), u)| c * (p.robust / (d - u).abs().max(1e-6)).min(1.0)).collect();
|
||||
solver.set_weights(&wd2);
|
||||
solver.solve(&depth_w, CG_ITERS).0
|
||||
} else {
|
||||
u
|
||||
}
|
||||
} else {
|
||||
depth_w
|
||||
};
|
||||
let max_d = (n - 1) as f32;
|
||||
let up = GuidedFilter::new(g, dw, dh, 2, 1e-2);
|
||||
let depth: Vec<f32> = if k == 1 {
|
||||
up.filter(&depth_w)
|
||||
} else {
|
||||
let (a, b) = up.coeffs(&depth_w);
|
||||
let a = upsample_bilinear(&a, dw, dh, w, h, k);
|
||||
let b = upsample_bilinear(&b, dw, dh, w, h, k);
|
||||
(0..w * h).map(|i| a[i] * guide[i] + b[i]).collect()
|
||||
};
|
||||
let depth = depth.into_iter().map(|v| v.clamp(0.0, max_d)).collect();
|
||||
let conf = if k == 1 { conf } else { upsample_bilinear(&conf, dw, dh, w, h, k) };
|
||||
DepthMap { depth, conf, w, h }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn lcg(seed: &mut u64) -> f32 {
|
||||
*seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
((*seed >> 33) % 10000) as f32 / 10000.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_filter_matches_the_naive_window() {
|
||||
let (w, h, r) = (23, 17, 3);
|
||||
let mut s = 5u64;
|
||||
let src: Vec<f32> = (0..w * h).map(|_| lcg(&mut s)).collect();
|
||||
let m = BoxFilter::new(w, h, r).mean(&src);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (mut acc, mut cnt) = (0.0, 0.0);
|
||||
for yy in y.saturating_sub(r)..(y + r + 1).min(h) {
|
||||
for xx in x.saturating_sub(r)..(x + r + 1).min(w) {
|
||||
acc += src[yy * w + xx];
|
||||
cnt += 1.0;
|
||||
}
|
||||
}
|
||||
assert!((m[y * w + x] - acc / cnt).abs() < 1e-4, "({x},{y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guided_filter_with_a_flat_guide_is_a_double_box_mean() {
|
||||
let (w, h) = (40, 30);
|
||||
let mut s = 9u64;
|
||||
let p: Vec<f32> = (0..w * h).map(|_| lcg(&mut s)).collect();
|
||||
let q = GuidedFilter::new(vec![0.5; w * h], w, h, 2, 1e-3).filter(&p);
|
||||
let bf = BoxFilter::new(w, h, 2);
|
||||
let m = bf.mean(&bf.mean(&p));
|
||||
assert!(q.iter().zip(&m).all(|(a, b)| (a - b).abs() < 1e-4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_difference_is_zero_mean_and_sees_texture() {
|
||||
let taps = rdf_taps(1, 3);
|
||||
assert!(taps.iter().map(|t| t.2).sum::<f32>().abs() < 1e-6);
|
||||
let (w, h) = (32, 32);
|
||||
assert!(focus_measure(&vec![0.4; w * h], w, h, 1, 3).iter().all(|v| v.abs() < 1e-6));
|
||||
let check: Vec<f32> = (0..w * h).map(|i| if ((i % w) / 2 + (i / w) / 2).is_multiple_of(2) { 0.2 } else { 0.8 }).collect();
|
||||
assert!(focus_measure(&check, w, h, 1, 3).iter().cloned().fold(0.0, f32::max) > 0.1);
|
||||
let (m, dw, dh) = block_mean(&check, w, h, 2);
|
||||
assert_eq!((dw, dh, m.len()), (16, 16, 256));
|
||||
assert!(m.iter().all(|v| (v - 0.2).abs() < 1e-6 || (v - 0.8).abs() < 1e-6));
|
||||
let up = upsample_bilinear(&m, dw, dh, w, h, 2);
|
||||
assert_eq!(up.len(), w * h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peak_search_interpolates_between_layers() {
|
||||
// A profile peaking between layers 2 and 3 (log-parabola), one flat profile.
|
||||
let mut t = PeakTracker::new(2);
|
||||
for m in 0..6 {
|
||||
let v = (-((m as f32 - 2.4).powi(2)) / 2.0).exp();
|
||||
t.push(&[v, 0.1]);
|
||||
}
|
||||
let (d, c) = t.finish(0.0);
|
||||
assert!((d[0] - 2.4).abs() < 0.05, "{d:?}");
|
||||
assert!(c[0] > 0.5 && c[1] < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoothing_fills_holes_and_respects_edges() {
|
||||
let (w, h) = (40, 20);
|
||||
// A guide with a hard edge at x = 20; data known only at two pixels, one per side.
|
||||
let guide: Vec<f32> = (0..w * h).map(|i| if i % w < 20 { 0.0 } else { 1.0 }).collect();
|
||||
let mut d = vec![0.0f32; w * h];
|
||||
let mut wd = vec![1e-4f32; w * h];
|
||||
(d[10 * w + 5], wd[10 * w + 5]) = (1.0, 1.0);
|
||||
(d[10 * w + 35], wd[10 * w + 35]) = (5.0, 1.0);
|
||||
let mut solver = WlsSolver::new(&guide, w, h, 3.0, 0.04);
|
||||
solver.set_weights(&wd);
|
||||
let (u, rel) = solver.solve(&d, CG_ITERS);
|
||||
assert!(rel < 1e-4, "residual {rel}");
|
||||
// Each side follows its own sample (the data floor on the unknown pixels pulls a little
|
||||
// toward zero), and the edge keeps them apart.
|
||||
assert!((u[3 * w + 15] - 1.0).abs() < 0.15, "left side follows its sample: {}", u[3 * w + 15]);
|
||||
assert!((u[15 * w + 25] - 5.0).abs() < 0.6, "right side follows its sample: {}", u[15 * w + 25]);
|
||||
assert!(u[10 * w + 19] < 1.5 && u[10 * w + 20] > 4.0, "the edge holds: {} | {}", u[10 * w + 19], u[10 * w + 20]);
|
||||
// The sweeps alone leave the holes near zero; the solve fills them.
|
||||
let guess = sweeps(&d, &wd, &solver.ex, &solver.ey, w, h, 3.0);
|
||||
assert!(guess[3 * w + 15] < 0.5);
|
||||
assert_eq!(median3(&[0.5; 9], 3, 3)[4], 0.5);
|
||||
}
|
||||
|
||||
/// Three layers: the left third sharp in layer 0, the middle in 1, the right in 2. The depth
|
||||
/// map says so, sub-layer in between, with high confidence on the texture.
|
||||
#[test]
|
||||
fn depth_from_focus_finds_the_sharp_layer() {
|
||||
let (w, h) = (96, 48);
|
||||
let texture = |x: usize, y: usize| if ((x / 2) + (y / 2)).is_multiple_of(2) { 0.2 } else { 0.8 };
|
||||
let flat = 0.5;
|
||||
let layers: Vec<Vec<f32>> = (0..3).map(|k| (0..w * h).map(|i| if (i % w) / 32 == k { texture(i % w, i / w) } else { flat }).collect()).collect();
|
||||
let guide: Vec<f32> = (0..w * h).map(|i| texture(i % w, i / w)).collect();
|
||||
let refs: Vec<&[f32]> = layers.iter().map(Vec::as_slice).collect();
|
||||
let dm = depth_from_focus(&refs, &guide, w, h, &DepthParams::default());
|
||||
assert_eq!((dm.w, dm.h, dm.depth.len()), (w, h, w * h));
|
||||
for (x, want) in [(8, 0.0), (48, 1.0), (88, 2.0)] {
|
||||
let d = dm.depth[24 * w + x];
|
||||
assert!((d - want).abs() < 0.35, "x {x}: depth {d}, want {want}");
|
||||
assert!(dm.conf[24 * w + x] > 0.3, "x {x}: conf {}", dm.conf[24 * w + x]);
|
||||
}
|
||||
assert!(dm.depth.iter().all(|v| (0.0..=2.0).contains(v)));
|
||||
// Degenerate inputs are fine.
|
||||
assert_eq!(depth_from_focus(&[], &guide, w, h, &DepthParams::default()).depth, vec![0.0; w * h]);
|
||||
// A guide or a plane of the wrong length is no input: the empty map, never a panic.
|
||||
let empty = DepthMap { depth: vec![0.0; w * h], conf: vec![0.0; w * h], w, h };
|
||||
assert_eq!(depth_from_focus(&refs, &guide[1..], w, h, &DepthParams::default()), empty);
|
||||
let short = &refs[0][..w * h - 1];
|
||||
assert_eq!(depth_from_focus(&[refs[0], short], &guide, w, h, &DepthParams::default()), empty);
|
||||
assert_eq!(depth_from_focus(&refs, &guide, w + 1, h, &DepthParams::default()).w, w + 1);
|
||||
let one = depth_from_focus(&refs[..1], &guide, w, h, &DepthParams::default());
|
||||
assert!(one.depth.iter().all(|v| *v == 0.0));
|
||||
}
|
||||
}
|
||||
+26
-54
@@ -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 {
|
||||
@@ -177,30 +160,19 @@ pub(crate) fn polar(src: &Image, out: Rect, ctx: &Ctx, mode: PolarMode) -> Vec<f
|
||||
let (w, h) = (b.width().max(1) as f32, b.height().max(1) as f32);
|
||||
let (cx, cy) = (b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0);
|
||||
let rmax = w.min(h) / 2.0;
|
||||
// Photoshop 27.11: the angle runs counter-clockwise from 12 o'clock, and points past the
|
||||
// source's edge repeat its edge pixels (the corners outside the circle are filled, not empty).
|
||||
// Photoshop evaluates the map at pixel corners and reads the source at the resulting index
|
||||
// (half a pixel from `remap`'s centres): within 2 levels on a whole test chart in both directions.
|
||||
remap(src, out, ctx, Edge::Repeat, |x, y| polar_map(x - 0.5, y - 0.5, mode, b, (w, h), (cx, cy), rmax))
|
||||
}
|
||||
|
||||
fn polar_map(x: f32, y: f32, mode: PolarMode, b: Rect, (w, h): (f32, f32), (cx, cy): (f32, f32), rmax: f32) -> (f32, f32) {
|
||||
let (sx, sy) = match mode {
|
||||
remap(src, out, ctx, Edge::Transparent, |x, y| match mode {
|
||||
PolarMode::RectangularToPolar => {
|
||||
// Output is polar: angle from 12 o'clock counter-clockwise → source x, radius → source y.
|
||||
// Output is polar: angle from 12 o'clock clockwise → source x,
|
||||
// radius → source y.
|
||||
let (dx, dy) = (x - cx, y - cy);
|
||||
// The 12 o'clock seam itself reads the source's right edge (360°, not 0°).
|
||||
let theta = (-dx).atan2(-dy);
|
||||
let theta = if theta <= 0.0 { theta + std::f32::consts::TAU } else { theta };
|
||||
let theta = dx.atan2(-dy).rem_euclid(std::f32::consts::TAU);
|
||||
let r = (dx * dx + dy * dy).sqrt();
|
||||
(b.x0 as f32 + theta / std::f32::consts::TAU * w, b.y0 as f32 + r / rmax * h)
|
||||
}
|
||||
PolarMode::PolarToRectangular => {
|
||||
// Photoshop's angle is one column ahead of its radius row (fitted exactly).
|
||||
let theta = (x + 1.0 - b.x0 as f32) / w * std::f32::consts::TAU;
|
||||
let theta = (x - b.x0 as f32) / w * std::f32::consts::TAU;
|
||||
let r = (y - b.y0 as f32) / h * rmax;
|
||||
(cx - r * theta.sin(), cy - r * theta.cos())
|
||||
(cx + r * theta.sin(), cy - r * theta.cos())
|
||||
}
|
||||
};
|
||||
(sx + 0.5, sy + 0.5)
|
||||
})
|
||||
}
|
||||
@@ -18,8 +18,7 @@ pub fn magic_erase(target: &mut Surface, region: &Region, amount: f32, selection
|
||||
let amount = if amount.is_finite() { amount.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let bbox = region.bbox;
|
||||
let bw = bbox.width() as usize;
|
||||
let Some(pixels) = bw.checked_mul(bbox.height() as usize) else { return Rect::EMPTY };
|
||||
if bbox.is_empty() || amount <= 0.0 || region.mask.len() != pixels {
|
||||
if bbox.is_empty() || amount <= 0.0 || region.mask.len() != bw * bbox.height() as usize {
|
||||
return Rect::EMPTY;
|
||||
}
|
||||
let fmt = target.format();
|
||||
@@ -148,16 +147,6 @@ mod tests {
|
||||
assert_eq!(l.rgba(5, 5), [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_region_mask_is_rejected_without_overflow() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let before = s.pixel(0, 0);
|
||||
let oversized = Region { bbox: Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX), mask: vec![255] };
|
||||
assert_eq!(magic_erase(&mut s, &oversized, 1.0, None, None), Rect::EMPTY);
|
||||
assert_eq!(s.pixel(0, 0), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_inputs_do_nothing() {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-135
@@ -16,15 +16,6 @@
|
||||
//! source (the usual case), the vote copies the source pixels exactly; only along the seams
|
||||
//! between copied pieces does it blend.
|
||||
//!
|
||||
//! A thin hole (a selected wire, string or hair: long, but only a few patches thick) is
|
||||
//! measured by its thickness, not its bounds (#1366): the pyramid stops where it is still about
|
||||
//! two patches thick. That level is big, so the onion peel starts it only where its exhaustive
|
||||
//! search stays affordable; otherwise a membrane across the hole does, which over so short a
|
||||
//! distance is the surroundings themselves. Shrunk to where its bounds are four patches across,
|
||||
//! a long thin hole swallows its surroundings, the matches found there copy whatever happens to
|
||||
//! look like the blur (a dark blotch, or a piece of another string), and the finer levels,
|
||||
//! which only refine those matches, keep them: the removed object's outline stays visible.
|
||||
//!
|
||||
//! This is what Photoshop's Content-Aware Fill does, measured as a black box on Photoshop 25.4
|
||||
//! (see `content_aware`): with colour adaptation off its fill is pieces of the image copied pixel
|
||||
//! for pixel, typically 15–40 px across, joined by seams a few pixels wide; with colour
|
||||
@@ -65,13 +56,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) {
|
||||
@@ -285,38 +278,6 @@ fn hole_bbox(w: usize, h: usize, hole: &[bool]) -> Option<(usize, usize, usize,
|
||||
b
|
||||
}
|
||||
|
||||
/// A hole at most `1 / THIN_RATIO` as thick as its bounds are long counts as thin (a wire, a
|
||||
/// string, a hair, a long brush stroke): see the module docs.
|
||||
const THIN_RATIO: usize = 4;
|
||||
|
||||
/// How thick the hole within bounds `hb` of a `w`-wide role grid is: twice the largest distance
|
||||
/// from a hole pixel to the nearest pixel that is not one (a long, thin hole is thin however long
|
||||
/// its bounds).
|
||||
fn hole_thickness(w: usize, hb: Bounds, px: &[Px]) -> usize {
|
||||
// The bounds and a ring of non-hole cells around them (also past the grid's edge).
|
||||
let (gw, gh) = (hb.width() + 2, hb.height() + 2);
|
||||
let outside: Vec<bool> = par::map(gw * gh, gw * gh >= PAR_MIN, |i| {
|
||||
let (gx, gy) = (i % gw, i / gw);
|
||||
if gx == 0 || gy == 0 || gx > hb.width() || gy > hb.height() {
|
||||
return true;
|
||||
}
|
||||
px.get((hb.y0 + gy - 1) * w + hb.x0 + gx - 1) != Some(&Px::Hole)
|
||||
});
|
||||
let deepest = crate::selection::edt(&outside, gw, gh).into_iter().fold(0.0f32, f32::max);
|
||||
(2.0 * deepest).ceil() as usize
|
||||
}
|
||||
|
||||
/// Most target pixels × sources the onion peel of a thin hole may compare (its search is
|
||||
/// exhaustive, though most comparisons stop early: about a second at this many).
|
||||
const PEEL_BUDGET: usize = 1 << 28;
|
||||
|
||||
/// Whether the onion peel of the hole cells within `hb` of a `w`-wide role grid from `sources`
|
||||
/// source centres stays within [`PEEL_BUDGET`].
|
||||
fn peel_affordable(w: usize, px: &[Px], hb: Bounds, sources: usize) -> bool {
|
||||
let holes: usize = (hb.y0..hb.y1).map(|y| px.get(y * w + hb.x0..y * w + hb.x1).map_or(0, |row| row.iter().filter(|k| **k == Px::Hole).count())).sum();
|
||||
holes.saturating_mul(sources) <= PEEL_BUDGET
|
||||
}
|
||||
|
||||
/// Bounds of the hole pixels of a role grid.
|
||||
fn hole_bounds(w: usize, h: usize, px: &[Px]) -> Option<Bounds> {
|
||||
// Per row the first and last hole column; the union of the rows' spans.
|
||||
@@ -547,9 +508,6 @@ struct Solver<'a> {
|
||||
ctl: photocraft_raster::Interrupt<'a>,
|
||||
}
|
||||
|
||||
/// A channel deviation under which a patch counts as flat for [`fit`] (about 5 levels of 255).
|
||||
const FLAT: f32 = 0.02;
|
||||
|
||||
/// Gain and bias of one channel for target moments `(Σt, Σt²)` and source moments `(Σs, Σs²)`
|
||||
/// over `n` samples, within the limits.
|
||||
#[inline]
|
||||
@@ -558,20 +516,7 @@ pub(crate) fn fit(a: Adapt, n: f32, st: f32, stt: f32, ss: f32, sss: f32) -> (f3
|
||||
let vt = (stt / n - mt * mt).max(0.0);
|
||||
let vs = (sss / n - ms * ms).max(0.0);
|
||||
let g = if vs > 1e-10 { (vt / vs).sqrt() } else { 1.0 };
|
||||
let (mut lo, mut hi) = a.gain;
|
||||
// Nearly flat patches of about the same colour (their means within the bias of each other):
|
||||
// the ratio of their deviations is noise, so match the mean first, keeping the gain where the
|
||||
// bias can still bring the source's mean to the target's. Gained about zero, a gain at its
|
||||
// limit moved bright means further than the bias could bring back, and each channel by a
|
||||
// different amount: a tinted blotch in a smooth gradient. Textured patches, and flat ones
|
||||
// whose colours differ more (Content-Aware Move's Color), are fitted as before.
|
||||
if vt.max(vs) < FLAT * FLAT && (mt - ms).abs() <= a.bias && ms > 1e-6 {
|
||||
let (glo, ghi) = ((mt - a.bias) / ms, (mt + a.bias) / ms);
|
||||
if glo.max(lo) <= ghi.min(hi) {
|
||||
(lo, hi) = (glo.max(lo), ghi.min(hi));
|
||||
}
|
||||
}
|
||||
let g = g.clamp(lo, hi);
|
||||
let g = g.clamp(a.gain.0, a.gain.1);
|
||||
let b = (mt - g * ms).clamp(-a.bias, a.bias);
|
||||
(g, b)
|
||||
}
|
||||
@@ -1614,20 +1559,14 @@ fn complete_cow<'a>(
|
||||
let r = p.patch_radius.max(1);
|
||||
let psz = 2 * r + 1;
|
||||
// Pyramid: shrink until the hole is about four patches across (a deeper pyramid decides the
|
||||
// structure where the texture has averaged out, and fills flat). A thin hole stops while it
|
||||
// is still about two patches thick: through it, not along it, is what counts (#1366).
|
||||
// structure where the texture has averaged out, and fills flat).
|
||||
let mut levels = vec![Level { w, h, img, px: Cow::Borrowed(px) }];
|
||||
let mut boxes = vec![hb0];
|
||||
let mut ext = hb0.width().max(hb0.height());
|
||||
let thickness = hole_thickness(w, hb0, px);
|
||||
let thin = thickness.saturating_mul(THIN_RATIO) <= ext;
|
||||
if thin {
|
||||
ext = thickness;
|
||||
}
|
||||
let mut exts = vec![ext];
|
||||
while let Some(l) = levels.last() {
|
||||
ctl.check()?;
|
||||
if ext <= if thin { 2 * psz } else { 4 * psz } || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
|
||||
if ext <= 4 * psz || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
|
||||
break;
|
||||
}
|
||||
let d = downsample(l, ch);
|
||||
@@ -1683,21 +1622,9 @@ fn complete_cow<'a>(
|
||||
}
|
||||
}
|
||||
}
|
||||
// A thin hole, when peeling it would take too long, takes the membrane below: a few
|
||||
// patches thick here, a membrane across it is its surroundings.
|
||||
None if sources.len() > 0 && (!thin || peel_affordable(lw, &lpx, hb, sources.len())) => {
|
||||
onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)?
|
||||
}
|
||||
None if sources.len() > 0 => onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)?,
|
||||
None => {
|
||||
let mut unknown: Vec<bool> = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect();
|
||||
// Across a thin hole the membrane starts a patch radius out, past the
|
||||
// anti-aliased fringe or halo that a selection drawn close to the object leaves.
|
||||
if thin {
|
||||
let hole: Vec<bool> = lpx.iter().map(|k| *k == Px::Hole).collect();
|
||||
for (u, d) in unknown.iter_mut().zip(crate::selection::edt(&hole, lw, lh)) {
|
||||
*u |= d <= r as f32;
|
||||
}
|
||||
}
|
||||
let unknown: Vec<bool> = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect();
|
||||
let m = membrane_fill(lw, lh, ch, &limg, &unknown);
|
||||
for (i, k) in lpx.iter().enumerate() {
|
||||
if *k == Px::Hole {
|
||||
@@ -2032,21 +1959,6 @@ fn min_cut(across: usize, along: usize, err: impl Fn(usize, usize) -> f32) -> Ve
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The mean is matched first: on a nearly flat bright patch with a noisy deviation ratio the
|
||||
/// gain stays where the bias can still reach the target's mean.
|
||||
#[test]
|
||||
fn fit_matches_the_mean_before_the_contrast() {
|
||||
let a = Adapt { gain: (0.9, 1.1), bias: 0.04 };
|
||||
// Target mean 0.70, source mean 0.70; deviations 0.01 vs 0.004 (ratio 2.5).
|
||||
let n = 25.0;
|
||||
let (mt, ms, sdt, sds) = (0.70f32, 0.70f32, 0.01f32, 0.004f32);
|
||||
let (g, b) = fit(a, n, mt * n, (sdt * sdt + mt * mt) * n, ms * n, (sds * sds + ms * ms) * n);
|
||||
assert!((g * ms + b - mt).abs() < 1e-5, "mean {} vs {mt} (g {g}, b {b})", g * ms + b);
|
||||
// Where the mean can't be reached anyway, the limits hold as before.
|
||||
let (g, b) = fit(a, n, 0.2 * n, 0.04 * n, 0.9 * n, 0.81 * n);
|
||||
assert!((a.gain.0..=a.gain.1).contains(&g) && b.abs() <= a.bias + 1e-6);
|
||||
}
|
||||
|
||||
/// Smooth gradient plus deterministic fine noise.
|
||||
fn texture(w: usize, h: usize) -> Vec<f32> {
|
||||
let mut rng = Rng::new(7);
|
||||
@@ -2171,43 +2083,6 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hole_is_as_thick_as_it_is_through_not_along() {
|
||||
let (w, h) = (240, 120);
|
||||
let all = Bounds { x0: 0, y0: 0, x1: w, y1: h };
|
||||
let thickness = |hole: &[bool]| {
|
||||
let px: Vec<Px> = hole.iter().map(|&o| if o { Px::Hole } else { Px::Source }).collect();
|
||||
hole_thickness(w, hole_bounds(w, h, &px).unwrap_or(all), &px)
|
||||
};
|
||||
// A band 200 px long and 9 px thick; a disc of radius 20; the band touching the edge
|
||||
// counts the outside of the image as not hole.
|
||||
let band: Vec<bool> = (0..w * h).map(|i| (20..220).contains(&(i % w)) && (50..59).contains(&(i / w))).collect();
|
||||
assert!((9..=11).contains(&thickness(&band)), "{}", thickness(&band));
|
||||
assert!((39..=42).contains(&thickness(&disc(w, h, 120.0, 60.0, 20.0))));
|
||||
let edge: Vec<bool> = (0..w * h).map(|i| i / w < 9).collect();
|
||||
assert!((9..=11).contains(&thickness(&edge)), "{}", thickness(&edge));
|
||||
}
|
||||
|
||||
/// A long thin hole goes the thin way (a shallow pyramid that starts from a membrane): it is
|
||||
/// filled, only it changes, and the result does not depend on the thread count either.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn thin_holes_are_filled_the_same_on_any_number_of_threads() {
|
||||
let (w, h) = (300, 200);
|
||||
let img = busy(w, h, 3, 5);
|
||||
// A wavy line 6 px thick across the image.
|
||||
let hole: Vec<bool> = (0..w * h).map(|i| (((i % w) as f32 * 0.05).sin() * 30.0 + 100.0 - (i / w) as f32).abs() < 3.0).collect();
|
||||
let px = roles(w, h, &hole);
|
||||
let run = |threads: usize| {
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap();
|
||||
pool.install(|| complete_masked(w, h, 3, &img, &px, &CompleteParams::default(), &photocraft_raster::Interrupt::NONE)).unwrap().unwrap()
|
||||
};
|
||||
let one = run(1);
|
||||
assert_eq!(bits(&one), bits(&run(7)));
|
||||
assert!((0..w * h).all(|i| hole[i] || (0..3).all(|c| one[i * 3 + c].to_bits() == img[i * 3 + c].to_bits())));
|
||||
assert!((0..w * h).filter(|&i| hole[i]).all(|i| (0..3).all(|c| one[i * 3 + c].is_finite())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn results_do_not_depend_on_the_thread_count() {
|
||||
|
||||
+23
-112
@@ -26,13 +26,11 @@ pub mod camera_raw;
|
||||
pub mod color_to_alpha;
|
||||
pub mod content_aware;
|
||||
mod denoise;
|
||||
pub mod dff;
|
||||
pub mod direct;
|
||||
mod distort;
|
||||
mod distort2;
|
||||
pub mod erase;
|
||||
pub mod exif;
|
||||
pub mod faces;
|
||||
pub mod features;
|
||||
mod fxutil;
|
||||
mod gallery;
|
||||
@@ -482,8 +480,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>>,
|
||||
},
|
||||
@@ -555,8 +552,7 @@ impl FilterParams {
|
||||
let g = |sigma: f32| Halo::Radius((sigma.max(0.0) * 3.0).ceil() as i32 + 1);
|
||||
match self {
|
||||
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius),
|
||||
FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)),
|
||||
FilterParams::SmartSharpen { radius, .. } => g(*radius),
|
||||
FilterParams::UnsharpMask { radius, .. } | FilterParams::SmartSharpen { radius, .. } => g(*radius),
|
||||
FilterParams::BoxBlur { radius }
|
||||
| FilterParams::SurfaceBlur { radius, .. }
|
||||
| FilterParams::Median { radius }
|
||||
@@ -602,13 +598,6 @@ impl FilterParams {
|
||||
self.halo() == Halo::Bounds
|
||||
}
|
||||
|
||||
/// Whether the filter renders new pixels without reading the layer's own (Fibers): it fills
|
||||
/// the reference bounds even where the layer is transparent, as Photoshop's does on an
|
||||
/// empty layer, while still reading no neighbourhood.
|
||||
pub fn renders_from_nothing(&self) -> bool {
|
||||
matches!(self, FilterParams::Fibers { .. })
|
||||
}
|
||||
|
||||
/// Human-readable name.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -745,13 +734,13 @@ 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),
|
||||
FilterParams::FindEdges => stylize::find_edges(src, out, ctx),
|
||||
FilterParams::Solarize => stylize::solarize(src, out, ctx),
|
||||
FilterParams::Solarize => stylize::per_pixel(src, out, ctx, |v| if v > 0.5 { 1.0 - v } else { v }),
|
||||
FilterParams::Invert => stylize::per_pixel(src, out, ctx, |v| 1.0 - v),
|
||||
FilterParams::Desaturate => stylize::desaturate(src, out, ctx),
|
||||
FilterParams::Twirl { angle } => distort::twirl(src, out, ctx, *angle),
|
||||
@@ -910,34 +899,16 @@ 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;
|
||||
|
||||
/// The area a filter writes for a layer whose pixels cover `content`:
|
||||
/// global filters and renders that ignore the layer's pixels (Fibers) write the reference
|
||||
/// bounds; others the content grown by the halo (blurs spread into transparent areas).
|
||||
/// Clipped to the selection's bounds when there is one.
|
||||
/// global filters write the reference bounds; others the content grown by
|
||||
/// the halo (blurs spread into transparent areas). Clipped to the
|
||||
/// selection's bounds when there is one.
|
||||
pub fn output_area(params: &FilterParams, content: Rect, bounds: Rect, selection_bounds: Option<Rect>) -> Rect {
|
||||
let mut area = match params.halo_for(bounds) {
|
||||
Halo::Bounds => bounds,
|
||||
Halo::Radius(_) if params.renders_from_nothing() => bounds,
|
||||
Halo::Radius(r) => {
|
||||
if content.is_empty() {
|
||||
Rect::EMPTY
|
||||
@@ -966,8 +937,7 @@ pub fn apply_in(surface: &Surface, params: &FilterParams, area: Rect, bounds: Re
|
||||
}
|
||||
|
||||
/// [`apply_in`] that can be cancelled (checked before each tile) and reports progress per tile
|
||||
/// group. `None` when cancelled, or when a checked filter rejects geometry/allocation;
|
||||
/// the input surface is never modified.
|
||||
/// group. `None` when `ctl` was cancelled; the input surface is never modified.
|
||||
pub fn apply_in_with(
|
||||
surface: &Surface,
|
||||
params: &FilterParams,
|
||||
@@ -990,7 +960,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,
|
||||
@@ -1000,13 +969,13 @@ pub fn apply_tiled(
|
||||
tile: i32,
|
||||
extent: Option<Rect>,
|
||||
) -> Surface {
|
||||
// Preserve the input if a checked filter allocation cannot be completed.
|
||||
// Preserve the input if a checked Shape Blur allocation cannot be completed.
|
||||
apply_tiled_with(surface, params, area, bounds, selection, tile, extent, &photocraft_raster::Interrupt::NONE).unwrap_or_else(|| surface.clone())
|
||||
}
|
||||
|
||||
/// [`apply_tiled`] with cancellation (checked before each tile, so a cancel takes effect within
|
||||
/// one tile's work) and progress (after each group of tiles). `None` when cancelled,
|
||||
/// or when a checked filter rejects geometry or cannot allocate a tile.
|
||||
/// or when Shape Blur cannot allocate a tile.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn apply_tiled_with(
|
||||
surface: &Surface,
|
||||
@@ -1031,29 +1000,15 @@ fn apply_tiled_with_impl(
|
||||
tile: i32,
|
||||
extent: Option<Rect>,
|
||||
ctl: &photocraft_raster::Interrupt,
|
||||
prepare_fast_paths: bool,
|
||||
prepare_radial: 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;
|
||||
}
|
||||
if let FilterParams::UnsharpMask { amount, radius, threshold } = *params
|
||||
&& (!sharpen::exact::valid_params(amount, radius, threshold)
|
||||
|| !sharpen::exact::valid_area(area)
|
||||
|| !sharpen::exact::valid_area(bounds)
|
||||
|| extent.is_some_and(|e| !sharpen::exact::valid_area(e)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut out = surface.clone();
|
||||
if area.is_empty() {
|
||||
return Some(out);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if prepare_fast_paths && let Some(result) = sharpen::fft::apply(surface, params, area, selection, extent, ctl) {
|
||||
return result;
|
||||
}
|
||||
if let Some(boxes) = blur::box_widths(params) {
|
||||
return apply_box_blur(out, surface, area, extent, selection, &boxes, ctl);
|
||||
}
|
||||
@@ -1061,15 +1016,15 @@ fn apply_tiled_with_impl(
|
||||
return result;
|
||||
}
|
||||
// The public tile override must not create a zero-step loop or an
|
||||
// unbounded scratch buffer for these checked filters. Automatic tiles are unchanged.
|
||||
let tile = if matches!(params, FilterParams::ShapeBlur { .. } | FilterParams::UnsharpMask { .. }) { tile.clamp(1, 2048) } else { tile };
|
||||
// unbounded Shape Blur scratch buffer. Standard automatic tiles are unchanged.
|
||||
let tile = if matches!(params, FilterParams::ShapeBlur { .. }) { tile.clamp(1, 2048) } else { tile };
|
||||
let fmt = surface.format();
|
||||
let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha };
|
||||
// Prepare the immutable sample transforms once for all output tiles. If the
|
||||
// optional table allocation fails, use the existing sampler; only a real
|
||||
// cancellation returns None. Source reads keep their upstream semantics.
|
||||
let radial = match params {
|
||||
FilterParams::RadialBlur { amount, method, quality, center_x, center_y } if prepare_fast_paths => {
|
||||
FilterParams::RadialBlur { amount, method, quality, center_x, center_y } if prepare_radial => {
|
||||
blur::radial::Plan::for_area(bounds, *amount, *method, *quality, (*center_x, *center_y), area, ctl)
|
||||
}
|
||||
_ => None,
|
||||
@@ -1078,17 +1033,6 @@ fn apply_tiled_with_impl(
|
||||
return None;
|
||||
}
|
||||
let halo = params.halo_for(bounds);
|
||||
if matches!(params, FilterParams::UnsharpMask { .. }) {
|
||||
let Halo::Radius(reach) = halo else { return None };
|
||||
let (w, h) = (area.width().min(tile as u32) as usize, area.height().min(tile as u32) as usize);
|
||||
let read_samples = w.checked_add(2 * reach as usize)?.checked_mul(h.checked_add(2 * reach as usize)?)?.checked_mul(fmt.channels())?;
|
||||
let tile_count = u64::from(area.width()).div_ceil(tile as u64).checked_mul(u64::from(area.height()).div_ceil(tile as u64))?;
|
||||
// Reject hostile source-window sizes before Image::read allocates them.
|
||||
// Ordinary filter parameters and automatic tiles fit these bounds.
|
||||
if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::<f32>())? > isize::MAX as usize || tile_count > 1_048_576 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let shared = (halo == Halo::Bounds).then(|| Image::read(surface, bounds.union(&area)));
|
||||
// Shape rasterization depends only on params, not on a tile's pixels.
|
||||
let shape = match params {
|
||||
@@ -1146,15 +1090,12 @@ fn apply_tiled_with_impl(
|
||||
None if ctl.cancelled() => None,
|
||||
None => Some(kernel(params, src, *t, &ctx)),
|
||||
},
|
||||
(None, None) => match *params {
|
||||
FilterParams::UnsharpMask { amount, radius, threshold } => sharpen::exact::run(src, *t, &ctx, amount, radius, threshold, ctl),
|
||||
_ => Some(kernel(params, src, *t, &ctx)),
|
||||
},
|
||||
(None, None) => Some(kernel(params, src, *t, &ctx)),
|
||||
}) else {
|
||||
return (*t, Vec::new());
|
||||
};
|
||||
if let Some(sel) = selection {
|
||||
mix_selection(&mut data, *t, sel, src, fmt.alpha);
|
||||
mix_selection(&mut data, *t, sel, src);
|
||||
}
|
||||
tick();
|
||||
(*t, data)
|
||||
@@ -1182,11 +1123,9 @@ fn apply_tiled_with_impl(
|
||||
if ctl.cancelled() {
|
||||
return None;
|
||||
}
|
||||
// Checked filters can reject malformed geometry or an allocation failure.
|
||||
// Shape Blur can reject malformed geometry or an allocation failure.
|
||||
// Do not hand an incomplete tile to Surface::write_region.
|
||||
if (shape.is_some() || matches!(params, FilterParams::UnsharpMask { .. }))
|
||||
&& results.iter().any(|(t, data)| data.len() != t.width() as usize * t.height() as usize * fmt.channels())
|
||||
{
|
||||
if shape.is_some() && results.iter().any(|(t, data)| data.len() != t.width() as usize * t.height() as usize * fmt.channels()) {
|
||||
return None;
|
||||
}
|
||||
for (t, data) in results {
|
||||
@@ -1240,7 +1179,7 @@ fn apply_box_blur(
|
||||
let write = |(s, v): &mut (Surface, Vec<blur::Strip>)| {
|
||||
for (t, data) in v.iter_mut() {
|
||||
if let Some(sel) = selection {
|
||||
mix_selection(data, *t, sel, &Image::read(surface, *t), fmt.alpha);
|
||||
mix_selection(data, *t, sel, &Image::read(surface, *t));
|
||||
}
|
||||
s.write_region(*t, data);
|
||||
}
|
||||
@@ -1263,40 +1202,15 @@ fn apply_box_blur(
|
||||
}
|
||||
|
||||
/// Mixes filtered `data` for `t` back toward the original pixels in `orig` by the selection's
|
||||
/// coverage (channel 0 of `sel`). Changing alpha requires premultiplied colour interpolation;
|
||||
/// otherwise hidden colours leak into feathered edges and alpha-removing filters darken them.
|
||||
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image, alpha: bool) {
|
||||
/// coverage (channel 0 of `sel`).
|
||||
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image) {
|
||||
let w = t.width() as usize;
|
||||
// The selection over `t` read once, not a lookup and an allocation per pixel (#4533).
|
||||
let mask = sel.read_region(t);
|
||||
let sc = sel.channels().max(1);
|
||||
for (i, px) in data.chunks_exact_mut(orig.ch.max(1)).enumerate() {
|
||||
let (x, y) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32);
|
||||
let k = mask.get(i * sc).copied().unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
let k = sel.pixel(x, y)[0].clamp(0.0, 1.0);
|
||||
if k >= 1.0 {
|
||||
continue;
|
||||
}
|
||||
if k <= 0.0 {
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
*v = orig.get(x, y, c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if alpha {
|
||||
let a = px.len() - 1;
|
||||
let original_alpha = orig.get(x, y, a);
|
||||
let filtered_alpha = px[a];
|
||||
if original_alpha != filtered_alpha {
|
||||
let mixed_alpha = original_alpha + (filtered_alpha - original_alpha) * k;
|
||||
let original_weight = original_alpha * (1.0 - k);
|
||||
let filtered_weight = filtered_alpha * k;
|
||||
for (c, v) in px[..a].iter_mut().enumerate() {
|
||||
*v = if mixed_alpha > 0.0 { (orig.get(x, y, c) * original_weight + *v * filtered_weight) / mixed_alpha } else { 0.0 };
|
||||
}
|
||||
px[a] = mixed_alpha;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (c, v) in px.iter_mut().enumerate() {
|
||||
let o = orig.get(x, y, c);
|
||||
*v = o + (*v - o) * k;
|
||||
@@ -1315,9 +1229,6 @@ mod tests_ext;
|
||||
#[cfg(test)]
|
||||
mod tests_mosaic;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_selection_mix;
|
||||
|
||||
/// The current upstream sampler through the identical source/selection/tile pipeline.
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
@@ -35,8 +35,7 @@ pub struct RefineParams {
|
||||
pub feather: f32,
|
||||
/// 0–100.
|
||||
pub contrast: f32,
|
||||
/// −100–100. 100% moves the edge by `max(radius, 4)` pixels. Values between whole
|
||||
/// pixels blend the two neighbouring shifts, so the slider does not jump.
|
||||
/// −100–100 (% of `max(radius, 4 px)`).
|
||||
pub shift_edge: f32,
|
||||
}
|
||||
|
||||
@@ -60,18 +59,8 @@ impl RefineParams {
|
||||
fn feather_sigma(&self) -> f32 {
|
||||
self.feather.max(0.0) / 2.0
|
||||
}
|
||||
/// Signed shift in pixels, fractional. 100% is `max(radius, 4)` px, clamped to ±64.
|
||||
fn shift_amount(&self) -> f32 {
|
||||
let px = self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0);
|
||||
if px.is_finite() { px.clamp(-64.0, 64.0) } else { 0.0 }
|
||||
}
|
||||
/// How many pixels the mask can move: the farther of the two integer radii a fractional
|
||||
/// shift blends. Whole-pixel amounts stay exact (`ceil(4) == 4`).
|
||||
fn shift_px(&self) -> i32 {
|
||||
let px = self.shift_amount();
|
||||
let reach = px.abs().ceil();
|
||||
let signed = if px.is_sign_negative() { -reach } else { reach };
|
||||
if signed.is_finite() { signed as i32 } else { 0 }
|
||||
(self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0)).round().clamp(-64.0, 64.0) as i32
|
||||
}
|
||||
fn uses_image(&self) -> bool {
|
||||
self.radius > 0.0
|
||||
@@ -381,38 +370,13 @@ where
|
||||
*v = ((*v - 0.5) * gain + 0.5).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
let amount = p.shift_amount();
|
||||
if amount.abs() >= 1.0e-4 {
|
||||
a = shift_mask(&a, w, h, amount, morphology);
|
||||
let d = p.shift_px();
|
||||
if d != 0 {
|
||||
a = morphology(&a, w, h, d.unsigned_abs() as usize, d > 0);
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// Grow or shrink `src` by a fractional pixel distance. Whole pixels use one morphology;
|
||||
/// the remainder blends that result with the next pixel, so 0.3 px is not the same step as 1 px.
|
||||
fn shift_mask<F>(src: &[f32], w: usize, h: usize, amount: f32, morphology: F) -> Vec<f32>
|
||||
where
|
||||
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy,
|
||||
{
|
||||
let mag = amount.abs();
|
||||
if !mag.is_finite() || mag < 1.0e-4 {
|
||||
return src.to_vec();
|
||||
}
|
||||
let grow = amount > 0.0;
|
||||
let whole = mag.floor() as usize;
|
||||
let frac = mag - whole as f32;
|
||||
if frac < 1.0e-4 {
|
||||
return morphology(src, w, h, whole, grow);
|
||||
}
|
||||
let lo = if whole == 0 { src.to_vec() } else { morphology(src, w, h, whole, grow) };
|
||||
let hi = morphology(src, w, h, whole + 1, grow);
|
||||
let n = w.saturating_mul(h);
|
||||
if lo.len() < n || hi.len() < n || lo.len() != hi.len() {
|
||||
return hi;
|
||||
}
|
||||
lo.into_iter().zip(hi).map(|(a, b)| a + (b - a) * frac).collect()
|
||||
}
|
||||
|
||||
/// Refines a selection mask. `mask(r)` returns coverage over `r` (row-major); `content` bounds
|
||||
/// its non-zero pixels; the result is clipped to `canvas`. `sampler` supplies the guide image
|
||||
/// (only read when `radius > 0`). Returns `None` when nothing remains selected.
|
||||
@@ -756,16 +720,6 @@ mod tests {
|
||||
let shrunk = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -50.0, ..Default::default() });
|
||||
assert_eq!(shrunk[5 * w + 27], 1.0);
|
||||
assert_eq!(shrunk[5 * w + 28], 0.0);
|
||||
// Radius 0 scales 100% to 4 px, so 12.5% is half a pixel and 25% is one pixel.
|
||||
// The pixel just outside a hard edge fades in instead of snapping on.
|
||||
let half = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 12.5, ..Default::default() });
|
||||
let one = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 25.0, ..Default::default() });
|
||||
assert_eq!(m[5 * w + 30], 0.0);
|
||||
assert!((half[5 * w + 30] - 0.5).abs() < 0.02, "half pixel {}", half[5 * w + 30]);
|
||||
assert_eq!(one[5 * w + 30], 1.0);
|
||||
assert_eq!(half[5 * w + 31], 0.0, "the blend must not jump a second pixel");
|
||||
let pulled = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -12.5, ..Default::default() });
|
||||
assert!((pulled[5 * w + 29] - 0.5).abs() < 0.02, "half-pixel contract {}", pulled[5 * w + 29]);
|
||||
let sm = refine_buffer(None, &m, w, h, &RefineParams { smooth: 50.0, ..Default::default() });
|
||||
assert!(sm[5 * w + 10] == 1.0 && sm[5 * w + 50] == 0.0);
|
||||
}
|
||||
|
||||
@@ -128,32 +128,12 @@ pub(crate) fn edge_of(u: UndefinedAreas) -> Edge {
|
||||
/// Shifts the bounds' content by whole pixels.
|
||||
pub(crate) fn offset(src: &Image, out: Rect, ctx: &Ctx, dx: i32, dy: i32, undefined: UndefinedAreas) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
// Subtraction can leave i32 even for valid command parameters. Apply the edge
|
||||
// policy before narrowing: saturating first would change the wrapped pixel.
|
||||
let coordinate = |position: i32, distance: i32, start: i32, end: i32| -> Option<i32> {
|
||||
if start >= end {
|
||||
return None;
|
||||
}
|
||||
let p = i64::from(position) - i64::from(distance);
|
||||
let (start, end) = (i64::from(start), i64::from(end));
|
||||
let mapped = match undefined {
|
||||
UndefinedAreas::Wrap => start + (p - start).rem_euclid(end - start),
|
||||
UndefinedAreas::Repeat => p.clamp(start, end - 1),
|
||||
UndefinedAreas::Transparent if p >= start && p < end => p,
|
||||
UndefinedAreas::Transparent => return None,
|
||||
};
|
||||
i32::try_from(mapped).ok()
|
||||
};
|
||||
let edge = edge_of(undefined);
|
||||
let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n);
|
||||
for y in out.y0..out.y1 {
|
||||
let sy = coordinate(y, dy, ctx.bounds.y0, ctx.bounds.y1);
|
||||
for x in out.x0..out.x1 {
|
||||
let sx = coordinate(x, dx, ctx.bounds.x0, ctx.bounds.x1);
|
||||
for c in 0..n {
|
||||
res.push(match (sx, sy) {
|
||||
(Some(sx), Some(sy)) => src.get(sx, sy, c),
|
||||
_ => 0.0,
|
||||
});
|
||||
res.push(src.get_edge(x - dx, y - dy, c, edge, ctx.bounds));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,61 +186,3 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod offset_overflow_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn offset_limits_match_equivalent_small_offsets_for_every_edge_policy() {
|
||||
for bounds in [Rect::new(0, 0, 3, 5), Rect::new(-7, 11, -4, 16)] {
|
||||
let mut src = Image::new(bounds, 4);
|
||||
// Include signed/HDR values and varying alpha; offset must only move samples.
|
||||
for (i, sample) in src.data.iter_mut().enumerate() {
|
||||
*sample = i as f32 * 0.125 - 2.0;
|
||||
}
|
||||
let ctx = Ctx { bounds, mode: photocraft_color::ColorMode::Rgb, alpha: true };
|
||||
for policy in [UndefinedAreas::Wrap, UndefinedAreas::Repeat, UndefinedAreas::Transparent] {
|
||||
for dx in [i32::MIN, i32::MAX, -8, 0, 7] {
|
||||
for dy in [i32::MIN, i32::MAX, -9, 0, 6] {
|
||||
let reduce = |d: i32, size: i32| match policy {
|
||||
UndefinedAreas::Wrap => d.rem_euclid(size),
|
||||
_ => d.clamp(-100, 100),
|
||||
};
|
||||
let (rx, ry) = (reduce(dx, 3), reduce(dy, 5));
|
||||
let mut expected = Vec::new();
|
||||
for y in bounds.y0..bounds.y1 {
|
||||
for x in bounds.x0..bounds.x1 {
|
||||
for c in 0..4 {
|
||||
expected.push(src.get_edge(x - rx, y - ry, c, edge_of(policy), bounds));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(offset(&src, bounds, &ctx, dx, dy, policy), expected, "{bounds:?} {policy:?} {dx} {dy}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_wrap_handles_bounds_at_signed_coordinate_limits() {
|
||||
for start in [i32::MIN, i32::MAX - 3] {
|
||||
let bounds = Rect::new(start, start, start + 3, start + 3);
|
||||
let mut src = Image::new(bounds, 1);
|
||||
src.data = (0..9).map(|i| i as f32).collect();
|
||||
let ctx = Ctx { bounds, mode: photocraft_color::ColorMode::Grayscale, alpha: false };
|
||||
assert_eq!(offset(&src, bounds, &ctx, i32::MIN, i32::MIN, UndefinedAreas::Wrap), vec![8.0, 6.0, 7.0, 2.0, 0.0, 1.0, 5.0, 3.0, 4.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_wrap_accepts_minimum_signed_distance() {
|
||||
let bounds = Rect::new(0, 0, 3, 1);
|
||||
let mut src = Image::new(bounds, 1);
|
||||
src.data = vec![0.1, 0.5, 0.9];
|
||||
let ctx = Ctx { bounds, mode: photocraft_color::ColorMode::Grayscale, alpha: false };
|
||||
let got = offset(&src, bounds, &ctx, i32::MIN, 0, UndefinedAreas::Wrap);
|
||||
assert_eq!(got, vec![0.9, 0.1, 0.5]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -63,7 +63,6 @@ fn composite_area(
|
||||
coverage: impl Fn(i32, i32) -> f32 + Sync,
|
||||
color: impl Fn(i32, i32) -> [f32; 4] + Sync,
|
||||
lock_transparency: bool,
|
||||
inks: Option<[f32; 4]>,
|
||||
) {
|
||||
// Composite tile by tile (in parallel on native); tiles with zero coverage are left untouched,
|
||||
// so their copy-on-write storage (and GPU residency) survives.
|
||||
@@ -94,14 +93,6 @@ fn composite_area(
|
||||
o[3] = d[3];
|
||||
}
|
||||
from_rgba_into(&fmt, o, &mut enc);
|
||||
// A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is.
|
||||
if let Some(i) = inks
|
||||
&& fmt.mode == photocraft_color::ColorMode::Cmyk
|
||||
&& blend_mode == BlendMode::Normal
|
||||
&& k >= 1.0
|
||||
{
|
||||
enc[..4].copy_from_slice(&i);
|
||||
}
|
||||
px.copy_from_slice(&enc[..n]);
|
||||
}
|
||||
}
|
||||
@@ -153,7 +144,6 @@ pub fn paint_gradient(
|
||||
c
|
||||
},
|
||||
lock_transparency,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,25 +179,6 @@ pub fn bucket_fill_src(
|
||||
selection: Option<&Surface>,
|
||||
lock_transparency: bool,
|
||||
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
|
||||
) -> bool {
|
||||
bucket_fill_inks(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, src, None)
|
||||
}
|
||||
|
||||
/// [`bucket_fill_src`] where `inks` (CMYK, 0..1) are what a fully covered pixel of a CMYK target
|
||||
/// gets instead of `src`'s sRGB stand-in (a colour that was picked as CMYK).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn bucket_fill_inks(
|
||||
s: &mut Surface,
|
||||
area: Rect,
|
||||
seed: (i32, i32),
|
||||
tolerance: f32,
|
||||
contiguous: bool,
|
||||
anti_alias: bool,
|
||||
opacity: f32,
|
||||
selection: Option<&Surface>,
|
||||
lock_transparency: bool,
|
||||
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
|
||||
inks: Option<[f32; 4]>,
|
||||
) -> bool {
|
||||
if !area.contains(seed.0, seed.1) {
|
||||
return false;
|
||||
@@ -215,7 +186,7 @@ pub fn bucket_fill_inks(
|
||||
let img = crate::selection::rgba8_image(s, area);
|
||||
let Some(region) = crate::selection::wand_region(&img, area, seed, tolerance, contiguous, anti_alias) else { return false };
|
||||
drop(img);
|
||||
bucket_fill_region_inks(s, ®ion, opacity, selection, lock_transparency, src, inks);
|
||||
bucket_fill_region_src(s, ®ion, opacity, selection, lock_transparency, src);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -228,31 +199,10 @@ pub fn bucket_fill_region_src(
|
||||
selection: Option<&Surface>,
|
||||
lock_transparency: bool,
|
||||
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
|
||||
) {
|
||||
bucket_fill_region_inks(s, region, opacity, selection, lock_transparency, src, None);
|
||||
}
|
||||
|
||||
/// [`bucket_fill_region_src`] with the CMYK `inks` of [`bucket_fill_inks`].
|
||||
pub fn bucket_fill_region_inks(
|
||||
s: &mut Surface,
|
||||
region: &crate::selection::Region,
|
||||
opacity: f32,
|
||||
selection: Option<&Surface>,
|
||||
lock_transparency: bool,
|
||||
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
|
||||
inks: Option<[f32; 4]>,
|
||||
) {
|
||||
// Composite only over the filled region's box.
|
||||
let b = region.bbox;
|
||||
composite_area(
|
||||
s,
|
||||
b,
|
||||
BlendMode::Normal,
|
||||
|x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)),
|
||||
src,
|
||||
lock_transparency,
|
||||
inks,
|
||||
);
|
||||
composite_area(s, b, BlendMode::Normal, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, lock_transparency);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+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.
|
||||
|
||||
Loaded 100 of 1015 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user