Merge origin/main into pr-6526-fixup

This commit is contained in:
David Mann
2026-10-27 02:23:26 +01:00
787 changed files with 74328 additions and 12415 deletions
+4
View File
@@ -1,3 +1,7 @@
# Refuse to run under a nextest older than the fixed release: GHSA-vc25-qg58-w57c is fixed in
# 0.9.150, and CI installs exactly that version (an older nextest exits 92 REQUIRED_VERSION_NOT_MET).
nextest-version = "0.9.150"
# 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).
+167 -185
View File
@@ -1,10 +1,14 @@
name: CI
# Quality gates for every push to main and every pull request. The Linux gate runs through the
# suite's shared gate workflow in craft-common-actions (`cargo xtask ci`); the Windows legs, the
# test variant and the corpus, web and fuzz jobs are PhotoCraft's own.
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
# `labeled` so that adding `full-ci` to an open PR starts a run with Windows in it.
types: [opened, synchronize, reopened, labeled]
workflow_dispatch:
inputs:
fuzz:
@@ -15,6 +19,10 @@ on:
description: "Seconds per fuzz target"
default: "600"
# The token is only used to check out the code: no job writes to the repository.
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
@@ -22,52 +30,72 @@ env:
# 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).
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
# that lands while main's run is in progress waits for it, so every main state that gets a run is
# checked to the end. (Cancelling here left main unverified whenever merges came close together.)
# A newer push to a PR branch cancels its older run. Every push to main gets a run of its own that
# is never cancelled, so each state of main is checked to the end. Adding any label but `full-ci`
# gets a group of its own, so it neither runs anything nor cancels a run.
concurrency:
group: ci-${{ github.ref }}
group: ci-${{ github.event_name == 'pull_request' && (github.event.action != 'labeled' || github.event.label.name == 'full-ci') && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get Linux and
# Windows. Other PRs get Linux, plus Windows when they touch code whose behaviour differs by
# platform: the app shell, UI, text and fonts, GPU, pen input, native saves, build scripts or
# the lockfile. Format changes must exercise the Windows-only publication barriers (#8034).
# macOS is tested only by release.yml.
# Whether the Windows legs and the web build run. main, manual runs and PRs labelled `full-ci`
# get both. Other PRs get Windows when they touch code whose behaviour differs by platform: the
# app shell, UI, text and fonts, GPU, pen input, native saves, build scripts or the lockfile.
# Format changes must exercise the Windows-only publication barriers (#8034). macOS is tested
# only by release.yml.
platforms:
if: github.event.action != 'labeled' || github.event.label.name == 'full-ci'
timeout-minutes: 10
name: select platforms
runs-on: ubuntu-latest
outputs:
legs: ${{ steps.pick.outputs.legs }}
windows: ${{ steps.pick.outputs.windows }}
web: ${{ steps.pick.outputs.web }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
- id: pick
env:
EVENT: ${{ github.event_name }}
BASE: ${{ github.event.pull_request.base.sha }}
FULL: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') }}
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)]"
if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then
echo "legs=$all" >> "$GITHUB_OUTPUT"
echo "windows=true" >> "$GITHUB_OUTPUT"
echo "web=true" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -80,66 +108,99 @@ jobs:
echo "web=false" >> "$GITHUB_OUTPUT"
fi
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif|format)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
echo "legs=$all" >> "$GITHUB_OUTPUT"
echo "windows=true" >> "$GITHUB_OUTPUT"
else
echo "legs=[$linux]" >> "$GITHUB_OUTPUT"
echo "windows=false" >> "$GITHUB_OUTPUT"
fi
test:
timeout-minutes: 100
name: test (${{ matrix.os }}${{ matrix.part != 'all' && format(', {0}', matrix.part) || '' }})
# The Linux gate. Its rust-cache entry is the one `test (without heif)` restores.
gate:
if: github.event.action != 'labeled' || github.event.label.name == 'full-ci'
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# egui/winit/rfd.
packages: >-
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
libgtk-3-dev
# The tests build with the optional craft-fonts input (crates/text/build.rs), so the
# Japanese font tests that skip without it run. Same commit as release.yml.
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
# Cargo.toml's dev profile already keeps line tables only.
dev-debug: ""
env: |
PHOTOCRAFT_NO_PRINT=1
LPDEST=photocraft-no-such-printer
timeout-minutes: 100
# fmt, clippy, layers, scorecard, tests, panic_hunt, doctests and the wasm32 check, with
# `--locked --features heif`, then the missing-translations report (xtask/src/main.rs).
command: cargo xtask ci
# 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. They stay outside the shared gate for sccache,
# which needs its own action step.
windows:
needs: platforms
runs-on: ${{ matrix.os }}
if: needs.platforms.outputs.windows == 'true'
timeout-minutes: 100
name: test (windows-latest, ${{ matrix.part }})
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.platforms.outputs.legs) }}
part: [unit, integration]
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' }}
CARGO_PROFILE_DEV_DEBUG: "0"
# The all-language UI tests shape real Japanese, Chinese, Korean and Devanagari labels
# with kittest's strict `MissingGlyphPolicy::Panic`. A hosted Windows image has no
# guaranteed Japanese or Simplified Chinese system font, and egui 0.37 panics on a
# missing glyph; the app's lazy system fallback runs after layout, too late for the
# first headless frame. Use the same pinned central fonts as Linux and release, so every
# language is drawn by a real embedded face rather than skipping. Same commit as the gate
# above and release.yml.
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Unused toolchains in the image (Android SDK, .NET, GHC, CodeQL) are deleted in the
# background: the space is free before the craft-fonts build needs it, and nothing waits.
- name: Install Linux system dependencies (egui/winit/rfd)
if: runner.os == 'Linux'
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
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: b076417b5880bd4154bd22f00695b1d28c6bea83
path: craft-fonts
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: clippy, rustfmt
components: clippy
# 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.
# Only main saves caches; PRs restore main's, so the repo's 10 GB cache budget keeps 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.
# Both 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@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }}
save-if: ${{ github.ref == 'refs/heads/main' && matrix.part == 'integration' }}
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'
- name: Install sccache
uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.18.0
- name: Use sccache (Windows)
if: runner.os == 'Windows'
- name: Use sccache
env:
MAIN: ${{ github.ref == 'refs/heads/main' }}
run: |
@@ -148,149 +209,80 @@ jobs:
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.
- name: Clippy
if: matrix.part == 'all' || matrix.part == 'integration'
if: matrix.part == 'integration'
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
- name: Dependency layering
if: matrix.part == 'all' || matrix.part == 'integration'
if: matrix.part == 'integration'
run: cargo xtask layers
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
# prefs audit). Static: it reads committed files only, no benchmarks run here.
- name: Scorecard up to date
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
- uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
with:
tool: cargo-nextest@0.9.146
tool: cargo-nextest@0.9.150
# `--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
# 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.
# dependency cache.
- name: Test
env:
PART: ${{ matrix.part }}
run: |
case "$PART" in
unit) targets=(--lib --bins) ;;
integration) targets=(--test '*') ;;
*) targets=(--lib --bins --tests) ;;
*) targets=(--test '*') ;;
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'
if: 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
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build" error
# for distributors who leave it out. Runs beside the gate and restores the gate's dependency
# cache without saving one: the dependencies are the same, and the 10 GB cache budget is tight
# (#2688).
test-without-heif:
if: github.event.action != 'labeled' || github.event.label.name == 'full-ci'
timeout-minutes: 60
name: test (without heif)
# The gate's runner: its cache entry is keyed on the OS.
runs-on: ubuntu-24.04
env:
# The gate sets these; rust-cache hashes CARGO_* and RUST* variables into its key.
CARGO_INCREMENTAL: 0
CARGO_NET_RETRY: 10
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 ]
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# 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
- name: Install Linux build dependencies
uses: storytold/craft-common-actions/actions/install-apt-packages@main
with:
packages: >-
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
libgtk-3-dev
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
# gate.yml keys its cache `<cache-version>-rust-<hash of Cargo.toml>-gate`.
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
shared-key: test
prefix-key: v1-rust
shared-key: ${{ hashFiles('Cargo.toml') }}-gate
save-if: false
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
- uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
with:
tool: cargo-nextest@0.9.146
tool: cargo-nextest@0.9.150
# 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:
if: github.event.action != 'labeled' || github.event.label.name == 'full-ci'
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
@@ -306,7 +298,9 @@ jobs:
CARGO_PROFILE_RELEASE_LTO: "false"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
@@ -314,7 +308,7 @@ jobs:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Restore test corpora
uses: actions/cache@v4
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: corpus/
key: corpus-${{ hashFiles('xtask/src/corpus_pins.rs', 'xtask/*.sha256') }}
@@ -323,23 +317,6 @@ jobs:
- name: Corpus tests
run: cargo xtask test-corpus
wasm:
timeout-minutes: 30
name: wasm32 check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: wasm32
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Check wasm-safe crates
run: cargo xtask wasm
web:
timeout-minutes: 60
name: web build and wasm size gate
@@ -351,7 +328,9 @@ jobs:
# Match release.yml so PR results reflect what would actually ship.
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
@@ -409,6 +388,7 @@ jobs:
- { crate: psd, name: metadata }
- { crate: psd, name: tagged }
- { crate: psd, name: resources }
- { crate: psd, name: asl }
- { crate: codecs, name: decode_any }
- { crate: codecs, name: decode_png }
- { crate: codecs, name: decode_jpeg }
@@ -419,8 +399,10 @@ jobs:
- { crate: affinity, name: import_preview, args: "--features io" }
- { crate: format, name: load }
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # nightly
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: crates/${{ matrix.target.crate }}/fuzz
@@ -433,7 +415,7 @@ jobs:
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time="$FUZZ_SECONDS"
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: fuzz-${{ matrix.target.crate }}-${{ matrix.target.name }}
path: crates/${{ matrix.target.crate }}/fuzz/artifacts
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
name: mdBook build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- name: Install mdBook
run: cargo install mdbook --version 0.4.52 --locked
+3 -1
View File
@@ -9,6 +9,8 @@ name: FreeBSD
# or dependency change: a drifted dependency still shows up within a day. PRs that change the
# FreeBSD packaging itself still run it.
on:
push:
branches: [release]
schedule:
- cron: "17 2 * * *"
pull_request:
@@ -30,7 +32,7 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
cargo-build-extra-args: "--features heif"
# The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a device at
# once, so photocraft-gpu's tests run one at a time, like ui-egui's integration tests.
+3 -3
View File
@@ -40,7 +40,7 @@ jobs:
runs-on: macos-15
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
persist-credentials: false
@@ -72,7 +72,7 @@ jobs:
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: perf-results
path: |
@@ -83,7 +83,7 @@ jobs:
- name: Upload baseline
if: always() && inputs.update_baseline
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: perf-baseline
path: perf/baseline.json
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
with:
app-name: photocraft
app-title: PhotoCraft
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+6 -2
View File
@@ -11,10 +11,14 @@ permissions:
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
# Track shared CI improvements on main.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with:
timeout-minutes: 120
fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
secrets: inherit
+2
View File
@@ -7,6 +7,8 @@ name: Windows ARM64
# Nightly on main (dependency and runner-image drift shows up within a day), and on PRs that
# change the Windows packaging itself.
on:
push:
branches: [release]
pull_request:
paths:
- .github/workflows/windows-arm64.yml
+4 -1
View File
@@ -26,6 +26,7 @@ next to them.
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <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`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <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) |
@@ -89,6 +90,8 @@ The other built-in ICC profiles and the generated LUT looks are produced by code
| `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below |
| `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 |
| `docs/images/gradient-overlay-method-*.png` | Gradient Overlay Method selector, before and after (synthetic gradient) | @oRE-o | Rendered offscreen with the `snapshot` example; no external artwork | MIT OR Apache-2.0 |
| `docs/images/cpu-pixel-alignment/*.png` | CPU canvas pixel alignment, before and after #8321 | PhotoCraft contributors | Original synthetic noise rendered with the offscreen `snapshot` example; see the adjacent README | MIT OR Apache-2.0, adjacent `LICENSE-MIT` and `LICENSE-APACHE` |
Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in
[`docs/images/SOURCES.md`](docs/images/SOURCES.md)):
@@ -114,7 +117,7 @@ next to the files:
| Path (fetched) | Title | Author | Source | License |
|---|---|---|---|---|
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
| `corpus/photoshop/` (258 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
| `corpus/gimp/` (28 files, 251 KB) | GIMP XCF oracles: 40×30 procedural layers written by GIMP 3.2.6 through the corpus generator `tools/gimp-xcf/xcf_fixtures.py`, each with GIMP's merged rendering as a PNG | PhotoCraft contributors (authored with GIMP 3.2.6 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`gimp/`, with its generator and README) | MIT OR Apache-2.0 |
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
Generated
+693 -339
View File
File diff suppressed because it is too large Load Diff
+38 -11
View File
@@ -4,13 +4,13 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.14.0"
version = "0.16.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["serde"] }
# internal
photocraft-geom = { path = "crates/geom" }
photocraft-color = { path = "crates/color" }
@@ -39,11 +39,16 @@ photocraft-tablet = { path = "crates/tablet" }
photocraft-ipp = { path = "crates/ipp" }
# external
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
clap = { version = "4", features = ["derive", "env"] }
clap_complete = "4.6.11"
clap_mangen = { version = "0.3.3", features = ["env"] }
arc-swap = "1"
bytemuck = { version = "1", features = ["derive"] }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
@@ -52,18 +57,20 @@ image = { version = "0.25", default-features = false }
zune-core = "0.5"
zune-jpeg = "0.5"
proptest = "1"
eframe = { version = "0.36", default-features = false }
eframe = { version = "0.37", default-features = false }
# Without `default_fonts`, which embeds egui's fonts uncompressed: ui-egui ships them deflated
# (egui_fonts.rs; the web build's size gate).
egui = { version = "0.36", default-features = false }
egui_kittest = "0.36"
egui = { version = "0.37", default-features = false }
egui_kittest = "0.37"
rfd = "0.17"
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
criterion = { version = "0.5", default-features = false }
criterion = { version = "0.8", default-features = false }
getrandom = "0.3"
# Monotonic clock on native and wasm (screenshot deadlines: a wall clock can jump).
web-time = "1"
[workspace.lints.rust]
unsafe_code = "forbid"
@@ -72,15 +79,18 @@ unsafe_code = "forbid"
dbg_macro = "warn"
todo = "warn"
# Dependencies carry no debug info: their frames in a backtrace lose file:line, and the target dir
# and link times shrink. The test profile inherits both settings.
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev]
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.
# time and PDB size for the test binaries (#2204). CI's Windows legs build with no debuginfo at all
# (CARGO_PROFILE_DEV_DEBUG=0). 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
@@ -169,3 +179,20 @@ opt-level = "z"
[profile.wasm-release.package.photocraft-paint]
opt-level = 3
# Keep the complete egui family and native event bridge on reviewed fork commits.
[patch.crates-io]
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+17
View File
@@ -22,3 +22,20 @@ Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and
BIZ UDMincho; not the web build), under the SIL Open Font License 1.1. Their licence texts
ship with the package as OFL-<family>.txt.
The egui-icons font subset is embedded from epaint_default_fonts by crates/ui-egui/build.rs
and registered in the native and web fallback stacks by crates/ui-egui/src/egui_fonts.rs.
It is a subset of emoji-icon-font by John Slegers; the same upstream MIT license applies.
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
Original: https://github.com/jslegers/emoji-icon-font
The following notice is reproduced verbatim from emoji-icon-font-mit-license.txt:
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+2 -2
View File
@@ -185,8 +185,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
<table>
<tr>
<td width="33%" valign="top">
<h4>🧰 67 tools</h4>
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
<h4>🧰 68 tools</h4>
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Art History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
</td>
<td width="33%" valign="top">
<h4>🖌️ A real brush engine</h4>
+6
View File
@@ -28,12 +28,18 @@ photocraft-engine = { workspace = true }
photocraft-io = { workspace = true }
photocraft-format = { path = "../../crates/format" }
photocraft-codecs = { workspace = true }
clap = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
[dev-dependencies]
# Synthetic camera raws (#7789).
photocraft-raw = { workspace = true, features = ["testgen"] }
# Shell completions and man pages (examples/man-and-completions.rs, for the packages); not in
# the binary.
clap_complete = { workspace = true }
clap_mangen = { workspace = true }
[build-dependencies]
# Icon + VERSIONINFO for photocraft-cli.exe; build.rs only uses it when targeting Windows.
@@ -0,0 +1,41 @@
//! Writes photocraft-cli's shell completions ([`clap_complete`]: bash, zsh, fish, elvish,
//! PowerShell) and man pages ([`clap_mangen`]: `photocraft-cli.1` and one per subcommand, such as
//! `photocraft-cli-convert.1`) into a directory. The Linux and FreeBSD packages ship them
//! (`packaging/linux/package.sh`, `packaging/freebsd/package.sh`):
//!
//! ```sh
//! cargo run -p photocraft-cli --example man-and-completions -- <out-dir>
//! ```
use std::path::Path;
use std::process::ExitCode;
use clap::ValueEnum;
use clap_complete::Shell;
fn main() -> ExitCode {
let mut args = std::env::args_os().skip(1);
let (Some(dir), None) = (args.next(), args.next()) else {
eprintln!("usage: man-and-completions <out-dir>");
return ExitCode::from(2);
};
let dir = Path::new(&dir);
match generate(dir) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {}: {e}", dir.display());
ExitCode::FAILURE
}
}
}
fn generate(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let mut cmd = photocraft_cli::command();
for &shell in Shell::value_variants() {
clap_complete::generate_to(shell, &mut cmd, "photocraft-cli", dir)?;
}
// A fresh command: `clap_complete::generate_to` built `cmd`, adding the `help` subcommand,
// which `clap_mangen` would otherwise give pages of its own (`photocraft-cli-help-convert.1`).
clap_mangen::generate_to(photocraft_cli::command(), dir)
}
+116
View File
@@ -0,0 +1,116 @@
use std::path::{Path, PathBuf};
use photocraft_engine::actions_cmds::Action;
use serde_json::{Value, json};
/// Why an actions file can't be played.
#[derive(Debug, thiserror::Error)]
pub enum ActionsError {
#[error("actions JSON: {0}")]
Json(#[source] serde_json::Error),
/// The steps don't parse ([`photocraft_engine::automate_cmds::parse_action`]).
#[error(transparent)]
Steps(#[from] photocraft_engine::EngineError),
#[error("--action picks an action from an Actions set ({{\"actions\": [{{\"name\", \"steps\"}}…]}}), but this file holds one action's steps")]
NotASet,
#[error("Actions set: {0}")]
Set(#[source] serde_json::Error),
#[error("no action named `{name}` in the set (it holds {})", quoted(names))]
NoSuchAction { name: String, names: Vec<String> },
#[error("the Actions set holds {} actions: pick one with --action <name> ({})", names.len(), quoted(names))]
PickOne { names: Vec<String> },
#[error("{}: {source}", path.display())]
Read { path: PathBuf, source: std::io::Error },
#[error("{}: {source}", path.display())]
Utf8 { path: PathBuf, source: std::string::FromUtf8Error },
/// A Photoshop Actions file that doesn't import ([`photocraft_engine::atn_cmds::import`]).
#[error("{}: {message}", path.display())]
Atn { path: PathBuf, message: String },
#[error("no action named `{name}` in the set `{set}` (it holds {})", quoted(names))]
NoSuchAtnAction { name: String, set: String, names: Vec<String> },
#[error("{}: the set holds no actions", path.display())]
EmptyAtn { path: PathBuf },
#[error("the set `{set}` holds {} actions: pick one with --action <name> ({})", names.len(), quoted(names))]
PickOneAtn { set: String, names: Vec<String> },
}
/// `` `a`, `b` ``: the action names an error lists.
fn quoted(names: &[String]) -> String {
names.iter().map(|n| format!("`{n}`")).collect::<Vec<_>>().join(", ")
}
fn names(actions: &[Action]) -> Vec<String> {
actions.iter().map(|a| a.name.clone()).collect()
}
/// Parse an actions file: the steps of a recorded action or a droplet (`[id, params]` pairs,
/// `{"command": id, "params": {…}}` objects or bare ids; `"id"` is accepted for `"command"`), as a
/// list or wrapped in `{"actions": […]}`, `{"steps": […]}` or a droplet (#489).
pub fn parse_actions(text: &str) -> Result<Vec<(String, Value)>, ActionsError> {
let v: Value = serde_json::from_str(text).map_err(ActionsError::Json)?;
steps_of(&v)
}
fn steps_of(v: &Value) -> Result<Steps, ActionsError> {
Ok(photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(v), "batch --actions")?)
}
/// Engine commands to run in order: `(id, params)`.
pub type Steps = Vec<(String, Value)>;
/// The steps `batch` plays on each file, and the actions they can call. An Actions set (the
/// Actions panel's `actions.json`: `{"actions": [{"name", "steps"}…]}`, or the bare list) plays
/// the action `name` picks (optional when the set holds one) with the whole set loaded, as
/// Photoshop's Batch does; anything else is one action's steps ([`parse_actions`]) (#2786).
pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec<Action>), ActionsError> {
let v: Value = serde_json::from_str(text).map_err(ActionsError::Json)?;
let list = v.get("actions").unwrap_or(&v);
let is_set = list.as_array().is_some_and(|a| !a.is_empty() && a.iter().all(|x| x.get("name").is_some_and(Value::is_string)));
if !is_set {
if name.is_some() {
return Err(ActionsError::NotASet);
}
return Ok((steps_of(&v)?, Vec::new()));
}
let set: Vec<Action> = serde_json::from_value(list.clone()).map_err(ActionsError::Set)?;
let action = match (name, set.as_slice()) {
(Some(n), _) => set.iter().find(|a| a.name == n).ok_or_else(|| ActionsError::NoSuchAction { name: n.to_owned(), names: names(&set) })?,
(None, [only]) => only,
(None, _) => return Err(ActionsError::PickOne { names: names(&set) }),
};
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
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: &Path, name: Option<&str>) -> Result<(Steps, Vec<Action>, Vec<String>), ActionsError> {
let bytes = std::fs::read(path).map_err(|source| ActionsError::Read { path: path.to_path_buf(), source })?;
let is_atn = 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(|source| ActionsError::Utf8 { path: path.to_path_buf(), source })?;
let (steps, set) = load_actions(&text, name)?;
return Ok((steps, set, Vec::new()));
}
let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|message| ActionsError::Atn { path: path.to_path_buf(), message })?;
let action = match (name, imp.actions.as_slice()) {
(Some(n), _) => imp.actions.iter().find(|a| a.name == n).ok_or_else(|| ActionsError::NoSuchAtnAction {
name: n.to_owned(),
set: imp.set.clone(),
names: names(&imp.actions),
})?,
(None, [only]) => only,
(None, []) => return Err(ActionsError::EmptyAtn { path: path.to_path_buf() }),
(None, _) => return Err(ActionsError::PickOneAtn { set: imp.set.clone(), names: names(&imp.actions) }),
};
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))
}
+428
View File
@@ -0,0 +1,428 @@
//! The command line, as clap types. Values are parsed into their types here, so a bad one is a
//! usage error naming its flag (exit 2) and the handlers never see it (#7071). A flag a
//! subcommand doesn't take is a usage error too, so a typo such as `--fromat` can't be silently
//! ignored (#423), and so is a repeated one: `--quality 80 --quality 60` is more likely a mistake
//! than a choice. A value starting with `-` is written `--out=-image.png` (or `./-image.png`), so a
//! value flag never takes the next flag as its value; negative numbers are kept where they mean
//! something.
use std::ffi::OsString;
use std::fmt;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use clap::error::ErrorKind;
use clap::parser::ValueSource;
use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand};
use photocraft_automation::args::{AutomationArgs, ControlArgs, blank_env_is_unset, conflict, control_port, name_env_vars};
use photocraft_codecs::{JpegFormat, TiffCompression};
use photocraft_io::{DdsCompression, DpxBits, TgaBits};
use serde_json::Value;
/// photocraft-cli: headless PhotoCraft
#[derive(Parser)]
#[command(name = "photocraft-cli", version = photocraft_engine::build_info::long_version_static(), arg_required_else_help = true)]
pub(crate) struct Cli {
#[command(subcommand)]
pub(crate) command: Command,
}
#[derive(Subcommand)]
pub(crate) enum Command {
Convert(ConvertArgs),
Info(InfoArgs),
Run(RunArgs),
Batch(BatchArgs),
Droplet(DropletArgs),
Commands(CommandsArgs),
Mcp(McpArgs),
Serve(ServeArgs),
}
// How an image is written: shared by `convert`, `run` and `batch`.
#[derive(Args)]
pub(crate) struct ExportArgs {
/// Output format, overriding the output's extension (png, jpg, tif, webp, …)
#[arg(long, value_name = "EXT")]
pub(crate) format: Option<String>,
/// JPEG or WebP quality, 1-100. A WebP written with a quality is lossy, without one lossless
// allow_negative_numbers makes `--quality -1` an out-of-range value rather than an unexpected argument.
#[arg(long, value_name = "1-100", value_parser = clap::value_parser!(u8).range(1..=100), allow_negative_numbers = true)]
pub(crate) quality: Option<u8>,
/// Keep the layers in TIFF output (Photoshop layer data); TIFF output is flat without it
#[arg(long)]
pub(crate) tiff_layers: bool,
/// Compress TIFF output: none (the default), lzw or zip
#[arg(long, value_name = "none|lzw|zip", value_parser = tiff_compression)]
pub(crate) tiff_compression: Option<TiffCompression>,
/// 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
#[arg(long, value_name = "24|32", value_parser = tga_bits)]
pub(crate) tga_bits: Option<TgaBits>,
/// A DPX's bits per sample. Without it: 8 for an 8-bit document, else 16
#[arg(long, value_name = "8|10|12|16", value_parser = dpx_bits)]
pub(crate) dpx_bits: Option<DpxBits>,
/// A DDS texture's compression: none (uncompressed, the default), bc1 (DXT1: alpha on or
/// off), bc2 (DXT3) or bc3 (DXT5)
#[arg(long, value_name = "none|bc1|bc2|bc3", value_parser = dds_compression)]
pub(crate) dds_compression: Option<DdsCompression>,
/// Also write a DDS texture's full mip chain
#[arg(long)]
pub(crate) dds_mipmaps: bool,
/// A JPEG's scans, as JPEG Options picks them: baseline (the default), optimized (Huffman
/// tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so only Gray
/// and CMYK shrink) or progressive
#[arg(long, value_name = "baseline|optimized|progressive", value_parser = jpeg_format)]
pub(crate) jpeg_format: Option<JpegFormat>,
}
/// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …)
///
/// An <OUT> that is the <IN> file is refused, as the result would replace the original;
/// --in-place allows it.
#[derive(Args)]
pub(crate) struct ConvertArgs {
/// The file to read
#[arg(value_name = "IN")]
pub(crate) input: PathBuf,
/// The file to write; its extension picks the format unless --format is given
#[arg(value_name = "OUT")]
pub(crate) output: PathBuf,
/// Allow an <OUT> that is the <IN> file, replacing the original
#[arg(long)]
pub(crate) in_place: bool,
#[command(flatten)]
pub(crate) export: ExportArgs,
}
/// Print the document as JSON (size, mode, depth, layer tree)
#[derive(Args)]
pub(crate) struct InfoArgs {
pub(crate) file: PathBuf,
/// One line of JSON instead of pretty-printed
#[arg(long)]
pub(crate) compact: bool,
}
/// Open a file (or start a new document), run engine commands in order, save the result
///
/// Prints each command's JSON result. Give exactly one of <FILE> or --new, and at least one --cmd
/// or --out. An --out that is the <FILE> is refused, as the result would replace the original;
/// --in-place allows it.
#[derive(Args)]
#[command(group(ArgGroup::new("source").args(["file", "new"]).required(true)))]
#[command(group(ArgGroup::new("work").args(["cmd", "out"]).required(true).multiple(true)))]
pub(crate) struct RunArgs {
/// The document to open
pub(crate) file: Option<PathBuf>,
/// Start from a new document with these JSON parameters instead of opening a file
#[arg(long, value_name = "JSON", value_parser = json)]
pub(crate) new: Option<Value>,
/// An engine command to run; repeat for several, run in order
#[arg(long, value_name = "ID", action = ArgAction::Append)]
pub(crate) cmd: Vec<String>,
/// JSON parameters for the --cmd before it (default {})
#[arg(long, value_name = "JSON", action = ArgAction::Append, value_parser = json)]
pub(crate) params: Vec<Value>,
/// Save the result to this file
#[arg(long, value_name = "FILE")]
pub(crate) out: Option<PathBuf>,
/// Allow an --out that is the <FILE>, replacing the original
#[arg(long)]
pub(crate) in_place: bool,
#[command(flatten)]
pub(crate) export: ExportArgs,
/// `--cmd` and `--params` values in command-line order, set by [`parse`].
#[arg(skip)]
pub(crate) steps: Vec<Step>,
}
/// One `--cmd` or `--params` of `run`, in command-line order.
pub(crate) enum Step {
Cmd(String),
Params(Value),
}
/// 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, {"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 when the set holds more than one) with the whole set
/// loaded, so its steps can play other actions of the set.
#[derive(Args)]
pub(crate) struct BatchArgs {
/// The action list: actions.json, a droplet, or a Photoshop Actions file (.atn)
#[arg(long, value_name = "FILE")]
pub(crate) actions: PathBuf,
/// Which action of an Actions set to play
#[arg(long, value_name = "NAME")]
pub(crate) action: Option<String>,
/// The folder of images to process
#[arg(long = "in", value_name = "DIR")]
pub(crate) in_dir: PathBuf,
/// The folder to write the results to, as <stem>.<ext>
#[arg(long, value_name = "DIR")]
pub(crate) out: PathBuf,
/// An --out folder that is the --in folder is refused, as the results would replace the
/// originals; --in-place allows it
#[arg(long)]
pub(crate) in_place: bool,
#[command(flatten)]
pub(crate) export: ExportArgs,
}
/// Run a droplet (File › Automate › Create Droplet) on images and folders
#[derive(Args)]
pub(crate) struct DropletArgs {
/// The droplet to run
#[arg(value_name = "FILE.pcdroplet")]
pub(crate) file: String,
/// Images and folders to run it on
#[arg(value_name = "FILE-OR-DIR", required = true)]
pub(crate) inputs: Vec<String>,
/// The folder to write the results to
#[arg(long, value_name = "DIR")]
pub(crate) out: Option<String>,
}
/// List the engine command registry
#[derive(Args)]
pub(crate) struct CommandsArgs {
/// Print the entries as JSON
#[arg(long)]
pub(crate) json: bool,
/// Only the commands matching this text
#[arg(long, value_name = "TEXT", allow_negative_numbers = true)]
pub(crate) filter: Option<String>,
}
/// Run the MCP server on stdio (headless engine, or bridge to a running `photocraft --control <port>`)
///
/// Headless: each automation root grants that direction of file access; an omitted root denies it.
/// Prefer absolute root paths in MCP client configurations. Bridge: set the roots on the desktop
/// app; root flags are refused. The token flags require --bridge (or PHOTOCRAFT_CONTROL_PORT).
#[derive(Args)]
pub(crate) struct McpArgs {
/// Bridge to the PhotoCraft app listening here instead of a headless engine; a bare port means
/// 127.0.0.1:<PORT>
#[arg(long, value_name = "127.0.0.1:PORT", env = "PHOTOCRAFT_CONTROL_PORT", value_parser = bridge)]
pub(crate) bridge: Option<Bridge>,
/// `--bridge` came from PHOTOCRAFT_CONTROL_PORT, set by [`parse`].
#[arg(skip)]
pub(crate) bridge_from_env: bool,
#[command(flatten)]
pub(crate) control: ControlArgs,
#[command(flatten)]
pub(crate) automation: AutomationArgs,
}
/// Keep one headless session open and answer JSON lines on stdio, or on 127.0.0.1:<PORT>
///
/// Each request is a JSON line {"id", "method", "params"}. Methods: engine.execute,
/// jobs.list/cancel, engine.commands,
/// doc.open/new/place/save/exportLayers/exportDataSets/importDataSets/inspect/render/select/close,
/// session.list, batch, methods (docs/control-protocol.md#headless-server). The token flags require
/// --port (or PHOTOCRAFT_CONTROL_PORT); stdio needs no control token.
#[derive(Args)]
pub(crate) struct ServeArgs {
/// Listen on 127.0.0.1:<PORT> instead of stdio
#[arg(long, value_name = "PORT", env = "PHOTOCRAFT_CONTROL_PORT", value_parser = control_port)]
pub(crate) port: Option<u16>,
/// `--port` came from PHOTOCRAFT_CONTROL_PORT, set by [`parse`].
#[arg(skip)]
pub(crate) port_from_env: bool,
#[command(flatten)]
pub(crate) control: ControlArgs,
#[command(flatten)]
pub(crate) automation: AutomationArgs,
}
/// Why a value was refused: clap prints it after the flag and its value (exit 2).
#[derive(Debug, thiserror::Error)]
enum ValueError {
#[error("expected 24 or 32")]
TgaBits,
#[error("expected 8, 10, 12 or 16")]
DpxBits,
#[error("expected none, bc1, bc2 or bc3")]
DdsCompression,
#[error("expected none, lzw or zip")]
TiffCompression,
#[error("expected baseline, optimized or progressive")]
JpegFormat,
/// Why a `--bridge` value, or a PHOTOCRAFT_CONTROL_PORT value for `mcp`, is refused.
#[error("expected a port, 0-65535 (--bridge also takes 127.0.0.1:<PORT>)")]
Bridge,
}
/// Why `parse` refuses `mcp` or `serve` options clap accepted: clap prints it as a usage error
/// (exit 2).
#[derive(Debug, thiserror::Error)]
enum ServerOptionError {
/// PHOTOCRAFT_CONTROL_PORT holds a port, as for the desktop app's --control: only the flag
/// takes host:port.
#[error("invalid value '{0}' for 'PHOTOCRAFT_CONTROL_PORT': {bridge}", bridge = ValueError::Bridge)]
BridgeVar(String),
/// With --bridge the roots are the desktop process's to set: accepting them here promises
/// authority the bridge can't give (#8035).
#[error("configure automation roots on the desktop app; --automation-read-root and --automation-write-root require headless MCP mode")]
BridgeRoots,
/// A token flag without the bridge or port that reads it would be ignored (#8035).
#[error("{flag} requires {transport}")]
NeedsTransport { flag: &'static str, transport: &'static str },
}
impl ServerOptionError {
fn into_clap(self, cmd: &mut clap::Command) -> clap::Error {
let kind = match self {
Self::BridgeVar(_) => ErrorKind::ValueValidation,
Self::BridgeRoots => ErrorKind::ArgumentConflict,
Self::NeedsTransport { .. } => ErrorKind::MissingRequiredArgument,
};
cmd.error(kind, self)
}
}
fn tga_bits(s: &str) -> Result<TgaBits, ValueError> {
s.parse().ok().and_then(TgaBits::from_bits).ok_or(ValueError::TgaBits)
}
fn dpx_bits(s: &str) -> Result<DpxBits, ValueError> {
s.parse().ok().and_then(DpxBits::from_bits).ok_or(ValueError::DpxBits)
}
fn dds_compression(s: &str) -> Result<DdsCompression, ValueError> {
DdsCompression::from_name(s).ok_or(ValueError::DdsCompression)
}
fn tiff_compression(s: &str) -> Result<TiffCompression, ValueError> {
match s {
"none" => Ok(TiffCompression::None),
"lzw" => Ok(TiffCompression::Lzw),
"zip" => Ok(TiffCompression::Deflate),
_ => Err(ValueError::TiffCompression),
}
}
fn jpeg_format(s: &str) -> Result<JpegFormat, ValueError> {
JpegFormat::parse(s).ok_or(ValueError::JpegFormat)
}
/// Where `mcp --bridge` connects. Shown as the address: `127.0.0.1:<PORT>` for a port.
#[derive(Clone)]
pub(crate) enum Bridge {
/// A bare port: the app on 127.0.0.1.
Port(u16),
/// `host:port`, as given. Whether the host is loopback is the bridge's to say.
Addr(String),
}
impl fmt::Display for Bridge {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Bridge::Port(port) => SocketAddr::from((Ipv4Addr::LOCALHOST, *port)).fmt(f),
Bridge::Addr(addr) => f.write_str(addr),
}
}
}
/// `mcp --bridge`, or PHOTOCRAFT_CONTROL_PORT, which holds a port.
fn bridge(s: &str) -> Result<Bridge, ValueError> {
match control_port(s) {
Ok(port) => Ok(Bridge::Port(port)),
Err(_) if s.contains(':') => Ok(Bridge::Addr(s.to_owned())),
Err(_) => Err(ValueError::Bridge),
}
}
/// `--new` and `--params`: bad JSON is a usage error, before any work. Whether the value is an
/// object is the command's to say.
fn json(s: &str) -> Result<Value, serde_json::Error> {
serde_json::from_str(s)
}
/// `--cmd` and `--params` values back in command-line order: clap keeps each flag's values apart,
/// their indices say how they interleave.
fn steps(m: &ArgMatches) -> Vec<Step> {
let mut steps: Vec<(usize, Step)> = Vec::new();
if let (Some(at), Ok(Some(ids))) = (m.indices_of("cmd"), m.try_get_many::<String>("cmd")) {
steps.extend(at.zip(ids).map(|(i, id)| (i, Step::Cmd(id.clone()))));
}
if let (Some(at), Ok(Some(params))) = (m.indices_of("params"), m.try_get_many::<Value>("params")) {
steps.extend(at.zip(params).map(|(i, p)| (i, Step::Params(p.clone()))));
}
steps.sort_by_key(|s| s.0);
steps.into_iter().map(|(_, s)| s).collect()
}
/// Parse the arguments (without the binary name). clap reads them as `OsString`s: a path need not
/// be valid Unicode (#1108), text must. A flag left out falls back to its environment variable,
/// unless that is empty, and a bad value from one is a usage error naming the variable (#2491). A
/// bare `--help` or `-h` is help even after a mistake on the same line (`--out --help`, `mcp
/// stray.psd --help`): clap reports the mistake first.
pub(crate) fn parse(args: &[OsString]) -> Result<Cli, clap::Error> {
let mut cmd = Cli::command().mut_subcommands(|sub| sub.mut_args(blank_env_is_unset));
let argv = std::iter::once(OsString::from("photocraft-cli")).chain(args.iter().cloned());
let matches = match cmd.try_get_matches_from_mut(argv) {
Err(e) if args.iter().take_while(|a| *a != "--").any(|a| *a == "--help" || *a == "-h") => {
let help = [OsString::from("photocraft-cli"), args.first().cloned().unwrap_or_default(), OsString::from("--help")];
return Err(cmd.try_get_matches_from_mut(help).err().unwrap_or(e));
}
matches => matches.map_err(|e| name_env_vars(e, &cmd, args))?,
};
let mut cli = Cli::from_arg_matches(&matches)?;
let from_env = |m: &ArgMatches, id: &str| m.value_source(id) == Some(ValueSource::EnvVariable);
match (&mut cli.command, matches.subcommand()) {
(Command::Run(r), Some((_, m))) => r.steps = steps(m),
(Command::Mcp(a), Some((name, m))) => {
a.bridge_from_env = from_env(m, "bridge");
if a.bridge_from_env
&& let Some(Bridge::Addr(v)) = &a.bridge
{
return Err(ServerOptionError::BridgeVar(v.clone()).into_clap(&mut cmd));
}
if let Some(sub) = cmd.find_subcommand_mut(name) {
// Only the bridge reads a token, so a stale exported pair doesn't stop a headless
// run.
if a.bridge.is_some() {
conflict(sub, m, "control_token", "control_token_file")?;
}
// The roots are the headless engine's: the variable switching to the bridge would
// drop them, and the error names it.
if a.bridge_from_env {
conflict(sub, m, "automation_read_root", "bridge")?;
conflict(sub, m, "automation_write_root", "bridge")?;
}
if a.bridge.is_some() && (a.automation.automation_read_root.is_some() || a.automation.automation_write_root.is_some()) {
return Err(ServerOptionError::BridgeRoots.into_clap(sub));
}
check_server(sub, m, a.bridge.is_some(), "--bridge")?;
}
}
(Command::Serve(a), Some((name, m))) => {
a.port_from_env = from_env(m, "port");
if let Some(sub) = cmd.find_subcommand_mut(name) {
// Only the TCP server reads a token, so a stale exported pair doesn't stop stdio.
if a.port.is_some() {
conflict(sub, m, "control_token", "control_token_file")?;
}
check_server(sub, m, a.port.is_some(), "--port")?;
}
}
_ => {}
}
Ok(cli)
}
/// Refuse a token flag without the bridge or port that reads it (as a flag or its variable). A
/// token variable alone may be a stale export, so it isn't refused.
fn check_server(sub: &mut clap::Command, m: &ArgMatches, has_transport: bool, transport: &'static str) -> Result<(), clap::Error> {
for (id, flag) in [("control_token", "--control-token"), ("control_token_file", "--control-token-file")] {
if m.value_source(id) == Some(ValueSource::CommandLine) && !has_transport {
return Err(ServerOptionError::NeedsTransport { flag, transport }.into_clap(sub));
}
}
Ok(())
}
+82
View File
@@ -0,0 +1,82 @@
use std::io::Write;
use std::net::{Ipv4Addr, SocketAddr};
use serde_json::Value;
use photocraft_automation::{Headless, PhotocraftMcp, security};
use crate::{
Result,
args::{CommandsArgs, McpArgs, ServeArgs},
output::{JsonStyle, print_json},
};
pub(crate) fn commands(a: &CommandsArgs, out: &mut dyn Write) -> Result {
let h = Headless::new();
let list = h.command_list();
let needle = a.filter.as_deref().map(str::to_lowercase);
let items: Vec<&Value> = list
.as_array()
.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)))
.collect();
if a.json {
return print_json(out, &Value::Array(items.into_iter().cloned().collect()), JsonStyle::Pretty);
}
for c in items {
let menu = c["menu"].as_array().map(|m| m.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" > ")).unwrap_or_default();
writeln!(out, "{:<44} {:<32} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or(""), menu)?;
}
Ok(())
}
pub(crate) fn serve(a: &ServeArgs, err: &mut dyn Write) -> Result {
use std::sync::{Arc, Mutex};
let h = Arc::new(Mutex::new(Headless::with_workspace(a.automation.workspace()?)));
match a.port {
Some(port) => {
if a.port_from_env {
// Name the mode switch: an exported variable changes what this command does, and
// unsetting it (or setting it empty) is the way back to stdio.
let _ = writeln!(err, "photocraft-cli: PHOTOCRAFT_CONTROL_PORT={port} is set; serving on TCP instead of stdio (unset it to serve stdio)");
}
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port)).to_string();
let (supplied, token_file) = (a.control.control_token.as_deref(), a.control.control_token_file.as_deref());
let token = security::server_token(supplied, token_file)?;
if let Some(path) = token_file {
let _ = writeln!(err, "photocraft-cli control token file: {}", path.display());
} else if supplied.is_none() {
let _ = writeln!(err, "photocraft-cli control token: {token}");
}
photocraft_automation::rpc::serve_tcp(&addr, h, token, |local| {
let _ = writeln!(err, "photocraft-cli serving on {local}");
})?;
Ok(())
}
None => {
let stdin = std::io::stdin();
Ok(photocraft_automation::rpc::serve_lines(&h, stdin.lock(), std::io::stdout())?)
}
}
}
pub(crate) fn mcp(a: &McpArgs, err: &mut dyn Write) -> Result {
let server = match &a.bridge {
Some(addr) => {
if a.bridge_from_env {
// Name the mode switch, as `serve` does: the way back to headless is to unset it.
let _ = writeln!(
err,
"photocraft-cli: PHOTOCRAFT_CONTROL_PORT is set; bridging to the app on {addr} instead of a headless engine (unset it to run headless)"
);
}
let token = security::client_token(a.control.control_token.as_deref(), a.control.control_token_file.as_deref())?;
PhotocraftMcp::bridge(&addr.to_string(), &token)?
}
None => PhotocraftMcp::headless_with_workspace(a.automation.workspace()?),
};
let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?;
Ok(rt.block_on(server.serve_stdio())?)
}
+210
View File
@@ -0,0 +1,210 @@
use std::io::Write;
use std::path::{Path, PathBuf};
use photocraft_automation::Headless;
use photocraft_engine::actions_cmds::Action;
use photocraft_io::ExportOptions;
use serde_json::json;
use crate::{
CliError, Result,
actions::load_actions_file,
args::{BatchArgs, DropletArgs},
output::warn_all,
};
/// 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 {
matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(a), Ok(b)) if a == b)
}
pub(crate) fn is_input(p: &Path) -> bool {
let known = |e: &str| {
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "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()))
}
/// Drops the Save and view steps the desktop records from `action` (Batch saves each result
/// itself, #3143), keeping its unchecked and dialog flags on the steps that remain (#6860).
fn strip_shell_steps(action: &mut Action) {
use photocraft_engine::actions_cmds::{shell_save_command, shell_view_command};
let (mut steps, mut disabled, mut dialogs) = (Vec::new(), std::collections::BTreeSet::new(), std::collections::BTreeSet::new());
for (i, (id, p)) in std::mem::take(&mut action.steps).into_iter().enumerate() {
if shell_save_command(&id) || shell_view_command(&id) {
continue;
}
if action.disabled.contains(&i) {
disabled.insert(steps.len());
}
if action.dialogs.contains(&i) {
dialogs.insert(steps.len());
}
steps.push((id, p));
}
(action.steps, action.disabled, action.dialogs) = (steps, disabled, dialogs);
}
pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result {
let (in_dir, out_dir) = (a.in_dir.as_path(), a.out.as_path());
let (actions, mut set, warnings) = load_actions_file(&a.actions, a.action.as_deref())?;
set.iter_mut().for_each(strip_shell_steps);
warn_all(err, &warnings);
let opts = ExportOptions::from(&a.export);
// Results are saved as `<out>/<stem>.<ext>`, so an `--out` that is the `--in` folder would
// replace the originals (#492).
if !a.in_place && same_dir(in_dir, out_dir) {
return Err(CliError::OutIsIn(out_dir.to_path_buf()));
}
std::fs::create_dir_all(out_dir)?;
let mut inputs: Vec<PathBuf> = std::fs::read_dir(in_dir)
.map_err(|source| CliError::ReadDir { path: in_dir.to_path_buf(), source })?
.flatten()
.map(|e| e.path())
.filter(|p| is_input(p))
.collect();
inputs.sort();
let (mut ok, mut failed) = (0, 0);
let mut written = photocraft_engine::file_cmds::OutputClaims::default();
// `--format .jpg` names outputs `<stem>.jpg`, as `--format jpg` does (#490).
let format = a.export.format.as_deref().map(|f| f.strip_prefix('.').unwrap_or(f));
for input in &inputs {
let ext = format.map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let target = out_dir.join(format!("{stem}.{ext}"));
let target_text = target.to_string_lossy();
let r = (|| -> Result<Vec<String>> {
written.check(&target_text).map_err(CliError::OutputClaimed)?;
let mut h = Headless::trusted_local();
// Steps that play another action find it in the set; the session's playback stack
// stops an action that calls itself.
h.session.actions.list = set.clone();
h.open(input)?;
for (id, p) in &actions {
if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) {
continue;
}
let r = h.command_run(id, p.clone()).map_err(|source| CliError::Command { id: id.clone(), source })?;
// A failed step inside a called action makes this file an error, not a saved result.
if let Some(message) = photocraft_engine::actions_cmds::nested_failure(id, &r) {
return Err(CliError::NestedFailure { id: id.clone(), message });
}
}
let r = h.save(None, Some(&target), Some(&ext), &opts)?;
Ok(serde_json::from_value(r["warnings"].clone()).unwrap_or_default())
})();
match r {
Ok(ws) => {
ok += 1;
written.record(&target_text, &input.to_string_lossy());
let _ = writeln!(out, "ok {} -> {}", input.display(), target.display());
warn_all(err, &ws);
}
Err(e) => {
failed += 1;
let _ = writeln!(err, "FAIL {}: {e}", input.display());
}
}
}
let _ = writeln!(out, "{ok} succeeded, {failed} failed");
if failed > 0 { Err(CliError::FilesFailed(failed)) } else { Ok(()) }
}
pub(crate) fn droplet(a: &DropletArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result {
let (file, inputs) = (&a.file, &a.inputs);
let mut p = json!({"droplet": file, "input": inputs});
if let Some(o) = &a.out {
p["output"] = json!(o);
}
let mut s = photocraft_engine::Session::new();
let r = s.execute("file.automate.runDroplet", p)?;
for f in r["files"].as_array().into_iter().flatten() {
let _ = writeln!(out, "ok {}", f.as_str().unwrap_or_default());
}
let errors = r["errors"].as_array().cloned().unwrap_or_default();
for e in &errors {
let _ = writeln!(err, "FAIL {}: {}", e["file"].as_str().unwrap_or_default(), e["error"].as_str().unwrap_or_default());
}
if errors.is_empty() { Ok(()) } else { Err(CliError::FilesFailed(errors.len())) }
}
#[cfg(test)]
mod batch_action_set_tests {
use super::*;
use crate::test_support::{Invoke, invoke};
use serde_json::Value;
/// A batched action that plays another action of its set finds it in every file's session; a
/// failure inside the called action fails the file, and a self-call stops instead of looping.
#[test]
fn batch_plays_an_action_that_calls_another_action_of_its_set() {
let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
std::fs::create_dir_all(dir.join("in")).unwrap();
for name in ["a", "b"] {
let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path(&format!("in/{name}.png"))]);
assert_eq!(code, 0, "{stderr}");
}
let set = json!({"version": 1, "actions": [
{"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}]]},
{"name": "Rotate", "steps": [["image.imageRotation.90cw", {}]]},
{"name": "Calls bad", "steps": [["actions.play", {"action": "Bad"}]]},
{"name": "Bad", "steps": [["layer.delete", {"layer": 999_999}]]},
{"name": "Loop", "steps": [["actions.play", {"action": "Loop"}]]},
]});
std::fs::write(dir.join("actions.json"), set.to_string()).unwrap();
let batch = |action: &str| invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]);
let Invoke { code, stdout, stderr } = batch("Outer");
assert_eq!(code, 0, "{stdout}{stderr}");
for name in ["a", "b"] {
let Invoke { code, stdout: info, stderr } = invoke(&["info", &path(&format!("Outer/{name}.png")), "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "rotated by the called action");
}
for action in ["Calls bad", "Loop"] {
let Invoke { code, stdout, stderr } = batch(action);
assert_ne!(code, 0, "{stdout}");
assert_eq!(stderr.matches("FAIL").count(), 2, "{stderr}");
assert!(!dir.join(action).join("a.png").exists(), "a file whose called action failed is not saved");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// Save and view steps recorded inside a batched action set are skipped, also in a called
/// action, and unchecked steps stay unchecked once they are gone (#6860).
#[test]
fn batch_skips_save_and_view_steps_inside_an_action_set() {
let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-save-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
std::fs::create_dir_all(dir.join("in")).unwrap();
let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]);
assert_eq!(code, 0, "{stderr}");
let set = json!({"version": 1, "actions": [
{"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}], ["file.saveAs", {}]]},
{"name": "Rotate", "steps": [
["file.save", {}],
["image.imageRotation.90cw", {}],
["view.fitOnScreen", {}],
["image.imageRotation.90cw", {}],
["file.saveAs", {}],
], "disabled": [3], "dialogs": [1]},
]});
std::fs::write(dir.join("actions.json"), set.to_string()).unwrap();
for action in ["Outer", "Rotate"] {
let Invoke { code, stdout, stderr } =
invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]);
assert_eq!(code, 0, "{stdout}{stderr}");
let Invoke { code, stdout: info, stderr } = invoke(&["info", &path(&format!("{action}/a.png")), "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "{action}: rotated once, the unchecked step skipped");
}
let _ = std::fs::remove_dir_all(&dir);
}
}
+226
View File
@@ -0,0 +1,226 @@
use std::io::Write;
use std::path::Path;
use photocraft_automation::{Headless, files};
use serde_json::{Value, json};
use photocraft_engine::type_extra_cmds::missing_font_warnings;
use photocraft_io::ExportOptions;
use crate::{
CliError, Result,
args::{ConvertArgs, InfoArgs, RunArgs, Step},
output::{JsonStyle, print_json, warn_all},
};
/// An output that is the input file itself, however it is spelt (relative, with `./`, through a
/// hard or symbolic link), would replace the original, and a lossy re-encode can't be undone: it
/// is refused unless `--in-place` asks for it, as `batch` refuses an `--out` that is its `--in`
/// folder (#7437). The paths are compared when the command starts and the save opens `output`
/// again later, so another process swapping in a link meanwhile goes unnoticed: this guards
/// against mistakes, it is not a security boundary.
fn refuse_overwriting_input(in_place: bool, input: &Path, output: &Path) -> Result {
if !in_place && photocraft_engine::file_cmds::same_file(input, output) {
return Err(CliError::OutIsInput(output.to_path_buf()));
}
Ok(())
}
pub(crate) fn convert(a: &ConvertArgs, _out: &mut dyn Write, err: &mut dyn Write) -> Result {
let (input, output) = (&a.input, &a.output);
let opts = ExportOptions::from(&a.export);
refuse_overwriting_input(a.in_place, input, output)?;
let mut o = files::open(input)?;
// Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw.
photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document);
warn_all(err, &o.warnings);
warn_all(err, &missing_font_warnings(&o.document));
let ws = files::save(&o.document, output, a.export.format.as_deref(), &opts, None)?;
warn_all(err, &ws);
Ok(())
}
pub(crate) fn info(a: &InfoArgs, out: &mut dyn Write) -> Result {
let file = &a.file;
let mut h = Headless::trusted_local();
let opened = h.open(file)?;
let mut doc = h.inspect(None)?;
let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default();
if let Some(st) = h.session.active() {
warnings.extend(missing_font_warnings(&st.doc).into_iter().map(Value::String));
}
doc["warnings"] = Value::Array(warnings);
doc["file"] = json!(file.to_string_lossy());
// Drop per-session noise.
if let Value::Object(m) = &mut doc {
for k in ["history", "canUndo", "canRedo", "revision"] {
m.remove(k);
}
}
print_json(out, &doc, if a.compact { JsonStyle::Compact } else { JsonStyle::Pretty })
}
/// `(id, params)` pairs from repeated `--cmd`/`--params` flags.
fn command_list(a: &RunArgs) -> Result<Vec<(String, Value)>> {
let mut cmds: Vec<(String, Value)> = Vec::new();
for step in &a.steps {
match step {
Step::Cmd(id) => cmds.push((id.clone(), json!({}))),
Step::Params(p) => cmds.last_mut().ok_or(CliError::ParamsBeforeCmd)?.1 = p.clone(),
}
}
Ok(cmds)
}
pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) -> Result {
let opts = ExportOptions::from(&a.export);
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();
// clap's `source` group gives exactly one of the two, and its `work` group a --cmd or --out.
if let Some(new) = &a.new {
h.command_run("file.new", new.clone()).map_err(CliError::New)?;
} else if let Some(file) = &a.file {
// Refused before the file is read or any command runs.
if let Some(out) = &a.out {
refuse_overwriting_input(a.in_place, file, out)?;
}
let o = h.open(file)?;
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
warn_all(err, &ws);
reported_fonts.extend(ws);
}
let cmds = command_list(a)?;
for (id, params) in cmds {
let r = h.command_run(&id, params).map_err(|source| CliError::Command { id: id.clone(), source })?;
if let Some(message) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
return Err(CliError::NestedFailure { id, message });
}
let new_warnings: Vec<String> =
h.session.active().map(|st| missing_font_warnings(&st.doc)).unwrap_or_default().into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &new_warnings);
let mut result = json!({"command": id, "result": r});
if !new_warnings.is_empty() {
result["warnings"] = json!(new_warnings);
}
print_json(out, &result, JsonStyle::Compact)?;
}
// A `run` with only `--out` still reports missing fonts before exporting.
let remaining: Vec<String> =
h.session.active().map(|st| missing_font_warnings(&st.doc)).unwrap_or_default().into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &remaining);
if let Some(o) = a.out.as_deref() {
let r = h.save(None, Some(o), a.export.format.as_deref(), &opts)?;
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(())
}
#[cfg(test)]
mod missing_font_warning_tests {
use super::*;
use crate::{
batch::is_input,
test_support::{Invoke, invoke},
};
#[test]
fn missing_typeface_is_reported_by_run_info_and_convert() {
let family = "DefinitelyMissingPhotoCraftTypeface1251";
let folder = std::env::temp_dir().join(format!("photocraft-cli-font-warnings-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let native = folder.join("missing.pcraft");
let png = folder.join("missing.png");
let native_str = native.to_string_lossy().to_string();
let png_str = png.to_string_lossy().to_string();
let params = json!({"x": 6, "y": 38, "text": "Missing font warning", "font": family, "size": 20}).to_string();
let Invoke { code, stdout, stderr } = invoke(&[
"run",
"--new",
r#"{"width":256,"height":96,"background":"white"}"#,
"--cmd",
"type.create",
"--params",
&params,
"--cmd",
"type.resolveMissingFonts",
"--out",
&native_str,
]);
assert_eq!(code, 0, "{stderr}");
assert_eq!(stderr.matches(family).count(), 1, "multi-command runs should not repeat the same missing font warning");
let commands: Vec<Value> = stdout.lines().map(|s| serde_json::from_str(s).unwrap()).collect();
assert_eq!(commands.len(), 2, "{stdout}");
assert!(commands[0]["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family))));
assert!(commands[1].get("warnings").is_none(), "only newly detected missing fonts are reported");
let Invoke { code, stdout, stderr } = invoke(&["info", &native_str, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert!(info["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family))));
assert!(stderr.is_empty(), "info puts warnings in JSON, not stderr");
let Invoke { code, stderr, .. } = invoke(&["convert", &native_str, &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(stderr.contains(&format!("warning: font '{family}'")), "{stderr}");
assert!(png.is_file());
// Even with no commands, run --out checks the opened document before export.
let Invoke { code, stdout, stderr } = invoke(&["run", &native_str, "--out", &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(stdout.is_empty());
assert_eq!(stderr.matches(family).count(), 1);
let _ = std::fs::remove_dir_all(&folder);
}
#[test]
fn convert_reads_and_writes_openraster() {
let folder = std::env::temp_dir().join(format!("photocraft-cli-ora-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let path = |name: &str| folder.join(name).to_string_lossy().to_string();
let (native, ora, png) = (path("layers.pcraft"), path("layers.ora"), path("back.png"));
let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":16,"height":12}"#, "--cmd", "layer.new.layer", "--out", &native]);
assert_eq!(code, 0, "{stderr}");
let Invoke { code, stderr, .. } = invoke(&["convert", &native, &ora]);
assert_eq!(code, 0, "{stderr}");
let Invoke { code, stdout, stderr } = invoke(&["info", &ora, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(info["layers"].as_array().map(Vec::len), Some(2), "{info}");
let Invoke { 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);
}
#[test]
fn no_type_layers_do_not_produce_missing_font_warnings() {
let folder = std::env::temp_dir().join(format!("photocraft-cli-no-font-warning-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let native = folder.join("blank.pcraft");
let png = folder.join("blank.png");
let native_str = native.to_string_lossy().to_string();
let png_str = png.to_string_lossy().to_string();
let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":16,"height":16}"#, "--out", &native_str]);
assert_eq!(code, 0, "{stderr}");
assert!(!stderr.contains("font '"), "{stderr}");
let Invoke { code, stdout, stderr } = invoke(&["info", &native_str, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert!(info["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap_or_default().contains("font '")));
let Invoke { code, stderr, .. } = invoke(&["convert", &native_str, &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(!stderr.contains("font '"), "{stderr}");
let _ = std::fs::remove_dir_all(&folder);
}
}
+42
View File
@@ -0,0 +1,42 @@
use std::path::PathBuf;
use photocraft_automation::AutomationError;
use crate::actions::ActionsError;
/// Why a subcommand failed: printed as `error: {e}`, exit 1.
#[derive(Debug, thiserror::Error)]
pub(crate) enum CliError {
#[error(transparent)]
Automation(#[from] AutomationError),
#[error(transparent)]
Engine(#[from] photocraft_engine::EngineError),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Actions(#[from] ActionsError),
#[error("{}: {source}", path.display())]
ReadDir { path: PathBuf, source: std::io::Error },
#[error("--params must follow a --cmd")]
ParamsBeforeCmd,
#[error("--new: {0}")]
New(#[source] AutomationError),
#[error("`{id}`: {source}")]
Command { id: String, source: AutomationError },
/// A step inside an action the command played failed ([`photocraft_engine::actions_cmds::nested_failure`]).
#[error("`{id}`: {message}")]
NestedFailure { id: String, message: String },
#[error("{} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it", .0.display())]
OutIsInput(PathBuf),
#[error("--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them", .0.display())]
OutIsIn(PathBuf),
/// Another input already wrote this output ([`photocraft_engine::file_cmds::OutputClaims::check`]).
#[error("{0}")]
OutputClaimed(String),
#[error("{0} file(s) failed")]
FilesFailed(usize),
}
pub(crate) type Result<T = (), E = CliError> = std::result::Result<T, E>;
+57 -850
View File
@@ -3,234 +3,63 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::ffi::OsString;
use std::io::Write;
use std::path::{Path, PathBuf};
use photocraft_automation::{AuthorizedWorkspace, Headless, PhotocraftMcp, files, security};
use photocraft_engine::actions_cmds::Action;
use photocraft_io::ExportOptions;
use serde_json::{Value, json};
use clap::CommandFactory;
pub const USAGE: &str = "\
photocraft-cli: headless PhotoCraft
mod actions;
mod args;
mod automation;
mod batch;
mod document;
mod error;
mod output;
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
An <out> that is the <in> file is refused, as the result would replace the original;
--in-place allows it.
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data);
--tiff-compression compresses it (none, the default; lzw; zip).
--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.
--jpeg-format picks a JPEG's scans, as JPEG Options does: baseline (the default), optimized
(Huffman tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so
only Gray and CMYK shrink) or progressive.
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] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
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] [--tiff-compression <none|lzw|zip>] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps] [--jpeg-format <baseline|optimized|progressive>]
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,
{\"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
when the set holds more than one) with the whole set loaded, so its steps can play other actions
of the set. An --out folder that is the --in folder is refused, as the results would replace
the originals; --in-place allows it.
photocraft-cli droplet <file.pcdroplet> <file-or-dir>… [--out <dir>]
Run a droplet (File › Automate › Create Droplet) on images and folders.
photocraft-cli commands [--json] [--filter <text>]
List the engine command registry.
photocraft-cli mcp [--bridge <127.0.0.1:port>] [--control-token <64-hex> | --control-token-file <path>]
[--automation-read-root <dir>] [--automation-write-root <dir>]
Run the MCP server on stdio (headless engine, or bridge to a running `photocraft --control <port>`).
photocraft-cli serve [--port <port>] [--control-token <64-hex> | --control-token-file <path>]
[--automation-read-root <dir>] [--automation-write-root <dir>]
Keep one headless session open and answer JSON lines ({\"id\",\"method\",\"params\"}) on stdio,
or on 127.0.0.1:<port>. Methods: engine.execute, jobs.list/cancel, engine.commands,
doc.open/new/place/save/exportLayers/exportDataSets/importDataSets/inspect/render/select/close, session.list, batch, methods
(docs/control-protocol.md#headless-server).
pub use actions::{ActionsError, Steps, load_actions, load_actions_file, parse_actions};
use args::{Command, parse};
use automation::{commands, mcp, serve};
use batch::{batch, droplet};
use document::{convert, info, run_cmds};
pub(crate) use error::{CliError, Result};
photocraft-cli <subcommand> --help (or -h) prints this text. A flag the subcommand doesn't take is
an error.
";
struct Args {
positional: Vec<String>,
flags: Vec<(String, Option<String>)>,
/// The command line's clap definition, for generating shell completions and man pages
/// (`examples/man-and-completions.rs`).
pub fn command() -> clap::Command {
args::Cli::command()
}
/// A subcommand: the flags it reads (taking a value, or given bare) and what it runs. A flag
/// outside these is a usage error, so a typo such as `--fromat` can't be silently ignored (#423).
struct Subcommand {
name: &'static str,
values: &'static [&'static str],
bare: &'static [&'static str],
run: fn(&Args, &mut dyn Write, &mut dyn Write) -> R,
}
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand {
name: "convert",
values: &["--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression", "--tiff-compression", "--jpeg-format"],
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
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",
"--tiff-compression",
"--jpeg-format",
],
bare: &["--tiff-layers", "--dds-mipmaps"],
run: run_cmds,
},
Subcommand {
name: "batch",
values: &[
"--actions",
"--action",
"--in",
"--out",
"--format",
"--quality",
"--tga-bits",
"--dpx-bits",
"--dds-compression",
"--tiff-compression",
"--jpeg-format",
],
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
run: batch,
},
Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet },
Subcommand { name: "commands", values: &["--filter"], bare: &["--json"], run: |a, out, _| commands(a, out) },
Subcommand {
name: "mcp",
values: &["--bridge", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"],
bare: &[],
run: |a, _, _| mcp(a),
},
Subcommand {
name: "serve",
values: &["--port", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"],
bare: &[],
run: |a, _, err| serve(a, err),
},
];
/// The subcommand's arguments, or `None` when they ask for help (`--help` or `-h`).
fn parse(sub: &Subcommand, args: &[String]) -> Result<Option<Args>, String> {
let mut a = Args { positional: Vec::new(), flags: Vec::new() };
let unknown = |flag: &str| format!("unknown flag {flag} for {}", sub.name);
let mut i = 0;
while let Some(s) = args.get(i) {
if s == "--help" || s == "-h" {
return Ok(None);
}
if let Some((k, v)) = s.split_once('=').filter(|(k, _)| k.starts_with("--")) {
if sub.bare.contains(&k) {
return Err(format!("{k} takes no value"));
}
if !sub.values.contains(&k) {
return Err(unknown(k));
}
a.flags.push((k.to_owned(), Some(v.to_owned())));
} else if sub.values.contains(&s.as_str()) {
let v = args.get(i + 1).ok_or_else(|| format!("{s} needs a value"))?;
a.flags.push((s.clone(), Some(v.clone())));
i += 1;
} else if sub.bare.contains(&s.as_str()) {
a.flags.push((s.clone(), None));
} else if s.starts_with("--") {
return Err(unknown(s));
} else {
a.positional.push(s.clone());
}
i += 1;
}
Ok(Some(a))
}
impl Args {
fn get(&self, k: &str) -> Option<&str> {
self.flags.iter().rev().find(|(f, _)| f == k).and_then(|(_, v)| v.as_deref())
}
fn has(&self, k: &str) -> bool {
self.flags.iter().any(|(f, _)| f == k)
}
}
type R = Result<(), String>;
/// [`run`] on the raw process arguments. An argument that is not valid Unicode is a usage error
/// naming its position and a lossy rendering (exit 2), never a panic: `std::env::args()` aborts
/// the process on one (issue #1108).
pub fn run_os(args: &[std::ffi::OsString], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
let mut strings = Vec::with_capacity(args.len());
for (i, a) in args.iter().enumerate() {
match a.clone().into_string() {
Ok(s) => strings.push(s),
Err(raw) => {
let _ = writeln!(err, "error: argument {} is not valid Unicode: `{}`\n\n{USAGE}", i + 1, raw.to_string_lossy());
return 2;
}
}
}
run(&strings, out, err)
}
/// Run the CLI. Returns the process exit code (0 ok, 1 failure, 2 usage).
pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
let Some(cmd) = args.first() else {
let _ = write!(err, "{USAGE}");
return 2;
};
match cmd.as_str() {
"-h" | "--help" | "help" => {
let _ = write!(out, "{USAGE}");
return 0;
}
"--version" | "version" => {
let _ = writeln!(out, "photocraft-cli {}", photocraft_engine::build_info::long_version());
return 0;
}
_ => {}
}
let Some(sub) = SUBCOMMANDS.iter().find(|s| s.name == cmd) else {
let _ = writeln!(err, "error: unknown command `{cmd}`\n\n{USAGE}");
return 2;
};
let parsed = match parse(sub, &args[1..]) {
Ok(Some(p)) => p,
// `<subcommand> --help`: usage, without reading inputs or starting a server.
Ok(None) => {
let _ = write!(out, "{USAGE}");
return 0;
}
/// Run the CLI on its arguments (without the program name). Returns the process exit code (0 ok,
/// 1 failure, 2 usage). clap reads the arguments as they are: a path that is not valid Unicode (a
/// Latin-1 file name on Linux) is taken, and one where text is expected is a usage error (exit 2).
/// Never a panic, as `std::env::args()` aborts on one (issue #1108).
pub fn run<I, T>(args: I, out: &mut dyn Write, err: &mut dyn Write) -> i32
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
// Help and `--version` (`help`, `-h`, `<subcommand> --help`, `-V`) come back as errors too,
// for `out`, exit 0: the subcommand never runs.
let cli = match parse(&args) {
Ok(cli) => cli,
Err(e) => {
let _ = writeln!(err, "error: {e}\n\n{USAGE}");
return 2;
let to: &mut dyn Write = if e.use_stderr() { err } else { out };
let _ = write!(to, "{}", e.render());
return e.exit_code();
}
};
match (sub.run)(&parsed, out, err) {
let result = match &cli.command {
Command::Convert(a) => convert(a, out, err),
Command::Info(a) => info(a, out),
Command::Run(a) => run_cmds(a, out, err),
Command::Batch(a) => batch(a, out, err),
Command::Droplet(a) => droplet(a, out, err),
Command::Commands(a) => commands(a, out),
Command::Mcp(a) => mcp(a, err),
Command::Serve(a) => serve(a, err),
};
match result {
Ok(()) => 0,
Err(e) => {
let _ = writeln!(err, "error: {e}");
@@ -239,640 +68,18 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
}
}
fn export_opts(a: &Args) -> Result<ExportOptions, String> {
// TIFF output is flat unless asked: scripted conversions keep predictable sizes.
let mut o = ExportOptions { tiff_layers: a.has("--tiff-layers"), ..Default::default() };
if let Some(q) = a.get("--quality") {
let q = q.parse().ok().filter(|q| (1..=100).contains(q)).ok_or_else(|| format!("bad --quality `{q}`: expected a whole number from 1 to 100"))?;
o.encode.jpeg_quality = q;
// Asking for a quality asks for a lossy WebP; the default WebP stays lossless.
o.encode.webp_quality = q;
o.encode.webp_lossless = false;
}
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("--tiff-compression") {
o.encode.tiff_compression = match c {
"none" => photocraft_codecs::TiffCompression::None,
"lzw" => photocraft_codecs::TiffCompression::Lzw,
"zip" => photocraft_codecs::TiffCompression::Deflate,
_ => return Err(format!("bad --tiff-compression `{c}`: expected none, lzw or zip")),
};
}
if let Some(f) = a.get("--jpeg-format") {
o.encode.jpeg_format =
photocraft_codecs::JpegFormat::parse(f).ok_or_else(|| format!("bad --jpeg-format `{f}`: expected baseline, optimized or progressive"))?;
}
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)
}
fn warn_all(err: &mut dyn Write, ws: &[String]) {
for w in ws {
let _ = writeln!(err, "warning: {w}");
}
}
/// The engine knows which type families aren't installed. Warn before an export
/// uses a fallback face rather than silently changing the appearance of the text.
fn missing_font_warnings(fonts: Vec<String>) -> Vec<String> {
fonts.into_iter().map(|font| format!("font '{font}' is not installed; text may render using a fallback face")).collect()
}
fn automation_workspace(args: &Args) -> Result<AuthorizedWorkspace, String> {
AuthorizedWorkspace::new(args.get("--automation-read-root").map(Path::new), args.get("--automation-write-root").map(Path::new))
.map_err(|error| error.to_string())
}
/// An output that is the input file itself, however it is spelt (relative, with `./`, through a
/// hard or symbolic link), would replace the original, and a lossy re-encode can't be undone: it
/// is refused unless `--in-place` asks for it, as `batch` refuses an `--out` that is its `--in`
/// folder (#7437). The paths are compared when the command starts and the save opens `output`
/// again later, so another process swapping in a link meanwhile goes unnoticed: this guards
/// against mistakes, it is not a security boundary.
fn refuse_overwriting_input(a: &Args, input: &str, output: &str) -> R {
if !a.has("--in-place") && photocraft_engine::file_cmds::same_file(input, output) {
return Err(format!("{output} is the input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it"));
}
Ok(())
}
fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
let [input, output] = a.positional.as_slice() else {
return Err("convert needs <in> <out>".into());
};
let opts = export_opts(a)?;
refuse_overwriting_input(a, input, output)?;
let mut o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
// Preferences › Raw Defaults › Resolution at its default, as `run` and `info` open a raw.
photocraft_engine::file_cmds::raw_defaults(&photocraft_engine::Session::new(), &o.warnings, &mut o.document);
warn_all(err, &o.warnings);
warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&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(())
}
fn print_json(out: &mut dyn Write, v: &Value, compact: bool) -> R {
let s = if compact { serde_json::to_string(v) } else { serde_json::to_string_pretty(v) }.map_err(|e| e.to_string())?;
writeln!(out, "{s}").map_err(|e| e.to_string())
}
fn info(a: &Args, out: &mut dyn Write) -> R {
let [file] = a.positional.as_slice() else {
return Err("info needs <file>".into());
};
let mut h = Headless::trusted_local();
let opened = h.open(Path::new(file)).map_err(|e| e.to_string())?;
let mut doc = h.inspect(None).map_err(|e| e.to_string())?;
let mut warnings = opened["warnings"].as_array().cloned().unwrap_or_default();
if let Some(st) = h.session.active() {
warnings.extend(missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)).into_iter().map(Value::String));
}
doc["warnings"] = Value::Array(warnings);
doc["file"] = json!(file);
// Drop per-session noise.
if let Value::Object(m) = &mut doc {
for k in ["history", "canUndo", "canRedo", "revision"] {
m.remove(k);
}
}
print_json(out, &doc, a.has("--compact"))
}
/// `(id, params)` pairs from repeated `--cmd`/`--params` flags.
fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
let mut cmds: Vec<(String, Value)> = Vec::new();
for (k, v) in &a.flags {
match (k.as_str(), v) {
("--cmd", Some(id)) => cmds.push((id.clone(), json!({}))),
("--params", Some(p)) => {
let last = cmds.last_mut().ok_or("--params must follow a --cmd")?;
last.1 = serde_json::from_str(p).map_err(|e| format!("bad --params JSON for `{}`: {e}", last.0))?;
}
_ => {}
}
}
Ok(cmds)
}
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}"))?;
h.command_run("file.new", p).map_err(|e| format!("--new: {e}"))?;
}
_ => return Err("run needs exactly one of <file> or --new <json>".into()),
}
let cmds = command_list(a)?;
if cmds.is_empty() && a.get("--out").is_none() {
return Err("run needs at least one --cmd (or --out)".into());
}
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) {
return Err(format!("`{id}`: {e}"));
}
let new_warnings: Vec<String> = h
.session
.active()
.map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)))
.unwrap_or_default()
.into_iter()
.filter(|w| reported_fonts.insert(w.clone()))
.collect();
warn_all(err, &new_warnings);
let mut result = json!({"command": id, "result": r});
if !new_warnings.is_empty() {
result["warnings"] = json!(new_warnings);
}
print_json(out, &result, true)?;
}
// A `run` with only `--out` still reports missing fonts before exporting.
let remaining: Vec<String> = h
.session
.active()
.map(|st| missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&st.doc)))
.unwrap_or_default()
.into_iter()
.filter(|w| reported_fonts.insert(w.clone()))
.collect();
warn_all(err, &remaining);
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(())
}
/// Parse an actions file: the steps of a recorded action or a droplet (`[id, params]` pairs,
/// `{"command": id, "params": {…}}` objects or bare ids; `"id"` is accepted for `"command"`), as a
/// list or wrapped in `{"actions": […]}`, `{"steps": […]}` or a droplet (#489).
pub fn parse_actions(text: &str) -> Result<Vec<(String, Value)>, String> {
let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?;
steps_of(&v)
}
fn steps_of(v: &Value) -> Result<Steps, String> {
photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(v), "batch --actions").map_err(|e| e.to_string())
}
/// Engine commands to run in order: `(id, params)`.
pub type Steps = Vec<(String, Value)>;
/// The steps `batch` plays on each file, and the actions they can call. An Actions set (the
/// Actions panel's `actions.json`: `{"actions": [{"name", "steps"}…]}`, or the bare list) plays
/// the action `name` picks (optional when the set holds one) with the whole set loaded, as
/// Photoshop's Batch does; anything else is one action's steps ([`parse_actions`]) (#2786).
pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec<Action>), String> {
let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?;
let list = v.get("actions").unwrap_or(&v);
let is_set = list.as_array().is_some_and(|a| !a.is_empty() && a.iter().all(|x| x.get("name").is_some_and(Value::is_string)));
if !is_set {
if name.is_some() {
return Err("--action picks an action from an Actions set ({\"actions\": [{\"name\", \"steps\"}…]}), but this file holds one action's steps".into());
}
return Ok((steps_of(&v)?, Vec::new()));
}
let set: Vec<Action> = serde_json::from_value(list.clone()).map_err(|e| format!("Actions set: {e}"))?;
let names = || set.iter().map(|a| format!("`{}`", a.name)).collect::<Vec<_>>().join(", ");
let action = match (name, set.as_slice()) {
(Some(n), _) => set.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set (it holds {})", names()))?,
(None, [only]) => only,
(None, _) => return Err(format!("the Actions set holds {} actions: pick one with --action <name> ({})", set.len(), names())),
};
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
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 {
matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(a), Ok(b)) if a == b)
}
fn is_input(p: &Path) -> bool {
let known = |e: &str| {
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "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()))
}
/// Drops the Save and view steps the desktop records from `action` (Batch saves each result
/// itself, #3143), keeping its unchecked and dialog flags on the steps that remain (#6860).
fn strip_shell_steps(action: &mut Action) {
use photocraft_engine::actions_cmds::{shell_save_command, shell_view_command};
let (mut steps, mut disabled, mut dialogs) = (Vec::new(), std::collections::BTreeSet::new(), std::collections::BTreeSet::new());
for (i, (id, p)) in std::mem::take(&mut action.steps).into_iter().enumerate() {
if shell_save_command(&id) || shell_view_command(&id) {
continue;
}
if action.disabled.contains(&i) {
disabled.insert(steps.len());
}
if action.dialogs.contains(&i) {
dialogs.insert(steps.len());
}
steps.push((id, p));
}
(action.steps, action.disabled, action.dialogs) = (steps, disabled, dialogs);
}
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, mut set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
set.iter_mut().for_each(strip_shell_steps);
for w in &warnings {
let _ = writeln!(err, "warning: {w}");
}
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).
if !a.has("--in-place") && same_dir(&in_dir, &out_dir) {
return Err(format!(
"--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them",
out_dir.display()
));
}
std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?;
let mut inputs: Vec<PathBuf> =
std::fs::read_dir(&in_dir).map_err(|e| format!("{}: {e}", in_dir.display()))?.flatten().map(|e| e.path()).filter(|p| is_input(p)).collect();
inputs.sort();
let (mut ok, mut failed) = (0, 0);
let mut written = photocraft_engine::file_cmds::OutputClaims::default();
// `--format .jpg` names outputs `<stem>.jpg`, as `--format jpg` does (#490).
let format = a.get("--format").map(|f| f.strip_prefix('.').unwrap_or(f));
for input in &inputs {
let ext = format.map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let target = out_dir.join(format!("{stem}.{ext}"));
let target_text = target.to_string_lossy();
let r = (|| -> Result<Vec<String>, String> {
written.check(&target_text)?;
let mut h = Headless::trusted_local();
// Steps that play another action find it in the set; the session's playback stack
// stops an action that calls itself.
h.session.actions.list = set.clone();
h.open(input).map_err(|e| e.to_string())?;
for (id, p) in &actions {
if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) {
continue;
}
let r = h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?;
// A failed step inside a called action makes this file an error, not a saved result.
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(id, &r) {
return Err(format!("`{id}`: {e}"));
}
}
let r = h.save(None, Some(&target), Some(&ext), &opts).map_err(|e| e.to_string())?;
Ok(serde_json::from_value(r["warnings"].clone()).unwrap_or_default())
})();
match r {
Ok(ws) => {
ok += 1;
written.record(&target_text, &input.to_string_lossy());
let _ = writeln!(out, "ok {} -> {}", input.display(), target.display());
warn_all(err, &ws);
}
Err(e) => {
failed += 1;
let _ = writeln!(err, "FAIL {}: {e}", input.display());
}
}
}
let _ = writeln!(out, "{ok} succeeded, {failed} failed");
if failed > 0 { Err(format!("{failed} file(s) failed")) } else { Ok(()) }
}
fn droplet(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let (file, inputs) = a.positional.split_first().ok_or("droplet needs <file.pcdroplet> and inputs")?;
if inputs.is_empty() {
return Err("droplet needs at least one input file or folder".into());
}
let mut p = json!({"droplet": file, "input": inputs});
if let Some(o) = a.get("--out") {
p["output"] = json!(o);
}
let mut s = photocraft_engine::Session::new();
let r = s.execute("file.automate.runDroplet", p).map_err(|e| e.to_string())?;
for f in r["files"].as_array().into_iter().flatten() {
let _ = writeln!(out, "ok {}", f.as_str().unwrap_or_default());
}
let errors = r["errors"].as_array().cloned().unwrap_or_default();
for e in &errors {
let _ = writeln!(err, "FAIL {}: {}", e["file"].as_str().unwrap_or_default(), e["error"].as_str().unwrap_or_default());
}
if errors.is_empty() { Ok(()) } else { Err(format!("{} file(s) failed", errors.len())) }
}
fn commands(a: &Args, out: &mut dyn Write) -> R {
let h = Headless::new();
let list = h.command_list();
let needle = a.get("--filter").map(str::to_lowercase);
let items: Vec<&Value> = list
.as_array()
.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)))
.collect();
if a.has("--json") {
return print_json(out, &Value::Array(items.into_iter().cloned().collect()), false);
}
for c in items {
let menu = c["menu"].as_array().map(|m| m.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" > ")).unwrap_or_default();
writeln!(out, "{:<44} {:<32} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or(""), menu).map_err(|e| e.to_string())?;
}
Ok(())
}
fn serve(a: &Args, err: &mut dyn Write) -> R {
use std::sync::{Arc, Mutex};
let h = Arc::new(Mutex::new(Headless::with_workspace(automation_workspace(a)?)));
match a.get("--port") {
Some(port) => {
let port: u16 = port.parse().map_err(|_| format!("bad --port `{port}`"))?;
let addr = format!("127.0.0.1:{port}");
let (supplied, token_file) = security::token_inputs(a.get("--control-token").map(str::to_owned), a.get("--control-token-file").map(PathBuf::from));
let token = security::server_token(supplied.as_deref(), token_file.as_deref()).map_err(|e| e.to_string())?;
if let Some(path) = &token_file {
let _ = writeln!(err, "photocraft-cli control token file: {}", path.display());
} else if supplied.is_none() {
let _ = writeln!(err, "photocraft-cli control token: {token}");
}
photocraft_automation::rpc::serve_tcp(&addr, h, token, |local| {
let _ = writeln!(err, "photocraft-cli serving on {local}");
})
.map_err(|e| e.to_string())
}
None => {
let stdin = std::io::stdin();
photocraft_automation::rpc::serve_lines(&h, stdin.lock(), std::io::stdout()).map_err(|e| e.to_string())
}
}
}
fn mcp(a: &Args) -> R {
let server = match a.get("--bridge") {
Some(addr) => {
let (supplied, token_file) = security::token_inputs(a.get("--control-token").map(str::to_owned), a.get("--control-token-file").map(PathBuf::from));
let token = security::client_token(supplied.as_deref(), token_file.as_deref()).map_err(|e| e.to_string())?;
PhotocraftMcp::bridge(addr, &token).map_err(|e| e.to_string())?
}
None => PhotocraftMcp::headless_with_workspace(automation_workspace(a)?),
};
let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().map_err(|e| e.to_string())?;
rt.block_on(server.serve_stdio()).map_err(|e| e.to_string())
}
#[cfg(test)]
mod missing_font_warning_tests {
use super::*;
fn invoke(args: &[&str]) -> (i32, String, String) {
let mut output = Vec::new();
let mut errors = Vec::new();
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let code = run(&args, &mut output, &mut errors);
(code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap())
pub(crate) mod test_support {
/// What [`crate::run`] returned and wrote.
pub(crate) struct Invoke {
pub(crate) code: i32,
pub(crate) stdout: String,
pub(crate) stderr: String,
}
#[test]
fn missing_typeface_is_reported_by_run_info_and_convert() {
let family = "DefinitelyMissingPhotoCraftTypeface1251";
let folder = std::env::temp_dir().join(format!("photocraft-cli-font-warnings-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let native = folder.join("missing.pcraft");
let png = folder.join("missing.png");
let native_str = native.to_string_lossy().to_string();
let png_str = png.to_string_lossy().to_string();
let params = json!({"x": 6, "y": 38, "text": "Missing font warning", "font": family, "size": 20}).to_string();
let (code, stdout, stderr) = invoke(&[
"run",
"--new",
r#"{"width":256,"height":96,"background":"white"}"#,
"--cmd",
"type.create",
"--params",
&params,
"--cmd",
"type.resolveMissingFonts",
"--out",
&native_str,
]);
assert_eq!(code, 0, "{stderr}");
assert_eq!(stderr.matches(family).count(), 1, "multi-command runs should not repeat the same missing font warning");
let commands: Vec<Value> = stdout.lines().map(|s| serde_json::from_str(s).unwrap()).collect();
assert_eq!(commands.len(), 2, "{stdout}");
assert!(commands[0]["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family))));
assert!(commands[1].get("warnings").is_none(), "only newly detected missing fonts are reported");
let (code, stdout, stderr) = invoke(&["info", &native_str, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert!(info["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|s| s.contains(family))));
assert!(stderr.is_empty(), "info puts warnings in JSON, not stderr");
let (code, _, stderr) = invoke(&["convert", &native_str, &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(stderr.contains(&format!("warning: font '{family}'")), "{stderr}");
assert!(png.is_file());
// Even with no commands, run --out checks the opened document before export.
let (code, stdout, stderr) = invoke(&["run", &native_str, "--out", &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(stdout.is_empty());
assert_eq!(stderr.matches(family).count(), 1);
let _ = std::fs::remove_dir_all(&folder);
}
#[test]
fn convert_reads_and_writes_openraster() {
let folder = std::env::temp_dir().join(format!("photocraft-cli-ora-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let path = |name: &str| folder.join(name).to_string_lossy().to_string();
let (native, ora, png) = (path("layers.pcraft"), path("layers.ora"), path("back.png"));
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":16,"height":12}"#, "--cmd", "layer.new.layer", "--out", &native]);
assert_eq!(code, 0, "{stderr}");
let (code, _, stderr) = invoke(&["convert", &native, &ora]);
assert_eq!(code, 0, "{stderr}");
let (code, stdout, stderr) = invoke(&["info", &ora, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(info["layers"].as_array().map(Vec::len), Some(2), "{info}");
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);
}
#[test]
fn no_type_layers_do_not_produce_missing_font_warnings() {
let folder = std::env::temp_dir().join(format!("photocraft-cli-no-font-warning-{}", std::process::id()));
std::fs::create_dir_all(&folder).unwrap();
let native = folder.join("blank.pcraft");
let png = folder.join("blank.png");
let native_str = native.to_string_lossy().to_string();
let png_str = png.to_string_lossy().to_string();
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":16,"height":16}"#, "--out", &native_str]);
assert_eq!(code, 0, "{stderr}");
assert!(!stderr.contains("font '"), "{stderr}");
let (code, stdout, stderr) = invoke(&["info", &native_str, "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&stdout).unwrap();
assert!(info["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap_or_default().contains("font '")));
let (code, _, stderr) = invoke(&["convert", &native_str, &png_str]);
assert_eq!(code, 0, "{stderr}");
assert!(!stderr.contains("font '"), "{stderr}");
let _ = std::fs::remove_dir_all(&folder);
}
}
#[cfg(test)]
mod batch_action_set_tests {
use super::*;
fn invoke(args: &[&str]) -> (i32, String, String) {
let (mut output, mut errors) = (Vec::new(), Vec::new());
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let code = run(&args, &mut output, &mut errors);
(code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap())
}
/// A batched action that plays another action of its set finds it in every file's session; a
/// failure inside the called action fails the file, and a self-call stops instead of looping.
#[test]
fn batch_plays_an_action_that_calls_another_action_of_its_set() {
let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
std::fs::create_dir_all(dir.join("in")).unwrap();
for name in ["a", "b"] {
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path(&format!("in/{name}.png"))]);
assert_eq!(code, 0, "{stderr}");
}
let set = json!({"version": 1, "actions": [
{"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}]]},
{"name": "Rotate", "steps": [["image.imageRotation.90cw", {}]]},
{"name": "Calls bad", "steps": [["actions.play", {"action": "Bad"}]]},
{"name": "Bad", "steps": [["layer.delete", {"layer": 999_999}]]},
{"name": "Loop", "steps": [["actions.play", {"action": "Loop"}]]},
]});
std::fs::write(dir.join("actions.json"), set.to_string()).unwrap();
let batch = |action: &str| invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]);
let (code, stdout, stderr) = batch("Outer");
assert_eq!(code, 0, "{stdout}{stderr}");
for name in ["a", "b"] {
let (code, info, stderr) = invoke(&["info", &path(&format!("Outer/{name}.png")), "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "rotated by the called action");
}
for action in ["Calls bad", "Loop"] {
let (code, stdout, stderr) = batch(action);
assert_ne!(code, 0, "{stdout}");
assert_eq!(stderr.matches("FAIL").count(), 2, "{stderr}");
assert!(!dir.join(action).join("a.png").exists(), "a file whose called action failed is not saved");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// Save and view steps recorded inside a batched action set are skipped, also in a called
/// action, and unchecked steps stay unchecked once they are gone (#6860).
#[test]
fn batch_skips_save_and_view_steps_inside_an_action_set() {
let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-save-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
std::fs::create_dir_all(dir.join("in")).unwrap();
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]);
assert_eq!(code, 0, "{stderr}");
let set = json!({"version": 1, "actions": [
{"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}], ["file.saveAs", {}]]},
{"name": "Rotate", "steps": [
["file.save", {}],
["image.imageRotation.90cw", {}],
["view.fitOnScreen", {}],
["image.imageRotation.90cw", {}],
["file.saveAs", {}],
], "disabled": [3], "dialogs": [1]},
]});
std::fs::write(dir.join("actions.json"), set.to_string()).unwrap();
for action in ["Outer", "Rotate"] {
let (code, stdout, stderr) =
invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]);
assert_eq!(code, 0, "{stdout}{stderr}");
let (code, info, stderr) = invoke(&["info", &path(&format!("{action}/a.png")), "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "{action}: rotated once, the unchecked step skipped");
}
let _ = std::fs::remove_dir_all(&dir);
pub(crate) fn invoke(args: &[&str]) -> Invoke {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
let code = crate::run(args, &mut stdout, &mut stderr);
Invoke { code, stdout: String::from_utf8(stdout).unwrap(), stderr: String::from_utf8(stderr).unwrap() }
}
}
+3 -4
View File
@@ -1,9 +1,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
fn main() {
// `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must be a
// usage error, not a panic before anything is printed (issue #1108).
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
let code = photocraft_cli::run_os(&args, &mut std::io::stdout(), &mut std::io::stderr());
// `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must reach clap,
// not panic before anything is printed (issue #1108).
let code = photocraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
std::process::exit(code);
}
+51
View File
@@ -0,0 +1,51 @@
use photocraft_io::ExportOptions;
use serde_json::Value;
use std::io::Write;
use crate::{Result, args::ExportArgs};
/// The `--format` family's options. A bad value never gets here: clap refused it.
impl From<&ExportArgs> for ExportOptions {
fn from(a: &ExportArgs) -> Self {
// TIFF output is flat unless asked: scripted conversions keep predictable sizes.
let mut o = ExportOptions { tiff_layers: a.tiff_layers, tga_bits: a.tga_bits, ..Default::default() };
if let Some(q) = a.quality {
o.encode.jpeg_quality = q;
// Asking for a quality asks for a lossy WebP; the default WebP stays lossless.
o.encode.webp_quality = q;
o.encode.webp_lossless = false;
}
o.encode.dpx_bits = a.dpx_bits;
if let Some(c) = a.tiff_compression {
o.encode.tiff_compression = c;
}
if let Some(f) = a.jpeg_format {
o.encode.jpeg_format = f;
}
if let Some(c) = a.dds_compression {
o.encode.dds.compression = c;
}
o.encode.dds.mipmaps = a.dds_mipmaps;
o
}
}
pub(crate) fn warn_all(err: &mut dyn Write, ws: &[String]) {
for w in ws {
let _ = writeln!(err, "warning: {w}");
}
}
/// How [`print_json`] lays the JSON out.
pub(crate) enum JsonStyle {
Pretty,
Compact,
}
pub(crate) fn print_json(out: &mut dyn Write, v: &Value, style: JsonStyle) -> Result {
let s = match style {
JsonStyle::Pretty => serde_json::to_string_pretty(v),
JsonStyle::Compact => serde_json::to_string(v),
}?;
Ok(writeln!(out, "{s}")?)
}
+535 -106
View File
@@ -6,10 +6,17 @@ use std::process::{Command, Stdio};
use serde_json::{Value, json};
/// The binary, without the variables `mcp` and `serve` fall back to: a test that wants one sets it.
fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_photocraft-cli"))
let mut cmd = Command::new(crate::binary());
for var in ENV_VARS {
cmd.env_remove(var);
}
cmd
}
const ENV_VARS: [&str; 3] = ["PHOTOCRAFT_CONTROL_PORT", "PHOTOCRAFT_CONTROL_TOKEN", "PHOTOCRAFT_CONTROL_TOKEN_FILE"];
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("pc-cli-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
@@ -23,23 +30,33 @@ fn write_png(path: &Path, w: u32, h: u32, seed: u8) {
std::fs::write(path, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
}
fn ok(cmd: &mut Command) -> (String, String) {
/// What a successful run printed.
struct Printed {
stdout: String,
stderr: String,
}
fn ok(cmd: &mut Command) -> Printed {
let o = cmd.output().unwrap();
let (out, err) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned());
assert!(o.status.success(), "exit {:?}\nstdout: {out}\nstderr: {err}", o.status);
(out, err)
let (stdout, stderr) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned());
assert!(o.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", o.status);
Printed { stdout, stderr }
}
#[test]
fn usage_and_unknown_command() {
let o = bin().output().unwrap();
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("USAGE"));
assert!(String::from_utf8_lossy(&o.stderr).contains("convert"));
let o = bin().arg("frobnicate").output().unwrap();
assert_eq!(o.status.code(), Some(2));
let (out, _) = ok(bin().arg("--help"));
// `help` names a subcommand or nothing: anything else is a usage error too.
let o = bin().args(["help", "frobnicate"]).output().unwrap();
assert_eq!(o.status.code(), Some(2));
assert!(!o.stderr.is_empty());
let out = ok(bin().arg("--help")).stdout;
assert!(out.contains("convert"));
let (out, _) = ok(bin().arg("--version"));
let out = ok(bin().arg("--version")).stdout;
assert!(out.starts_with("photocraft-cli "));
}
@@ -53,9 +70,23 @@ fn every_subcommand_answers_help() {
let o = bin().current_dir(&d).arg(sub).arg(help).arg("--out").arg("x.png").stdin(Stdio::null()).output().unwrap();
let out = String::from_utf8_lossy(&o.stdout);
assert_eq!(o.status.code(), Some(0), "{sub} {help}: {}", String::from_utf8_lossy(&o.stderr));
assert!(out.contains("USAGE") && out.contains("photocraft-cli batch"), "{sub} {help}: {out}");
assert!(out.contains(&format!("photocraft-cli {sub}")), "{sub} {help}: {out}");
}
}
// Help where a flag's value should be is still help, not a missing value or a file named `-h`.
// So is help after another mistake.
for args in [
&["run", "x.png", "--out", "--help"][..],
&["commands", "--filter", "-h"],
&["mcp", "--bridge", "-h"],
&["serve", "--port", "--help"],
&["mcp", "stray.psd", "--help"],
&["serve", "--port", "abc", "--port", "1", "-h"],
] {
let o = bin().current_dir(&d).args(args).stdin(Stdio::null()).output().unwrap();
assert_eq!(o.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&o.stderr));
assert!(String::from_utf8_lossy(&o.stdout).contains(&format!("photocraft-cli {}", args[0])), "{args:?}");
}
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0, "help wrote nothing");
}
@@ -73,7 +104,7 @@ fn unknown_flags_are_usage_errors() {
let o = cmd.output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{err}");
assert!(err.contains(&format!("unknown flag {flag}")), "{err}");
assert!(err.contains(flag), "{err}");
};
for typo in [&["--fromat", "jpg"][..], &["--fromat=jpg"][..]] {
fails(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(typo), "--fromat");
@@ -86,13 +117,29 @@ fn unknown_flags_are_usage_errors() {
fails(bin().args(["info", "--json"]).arg(input.join("a.png")), "--json");
let o = bin().args(["commands", "--json=yes"]).output().unwrap();
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("--json takes no value"));
assert!(String::from_utf8_lossy(&o.stderr).contains("--json"));
// A flag can't be a value: a missing value is reported, not filled with the next flag.
let o = bin()
.args(["batch", "--actions"])
.arg(&actions)
.arg("--in")
.arg(&input)
.arg("--out")
.arg(&out_dir)
.args(["--format", "--quality", "80"])
.output()
.unwrap();
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("a value is required for '--format"));
assert!(!out_dir.exists(), "nothing written");
// `--flag=value` still passes a value that starts with `--`.
ok(bin().args(["commands", "--filter=--json"]));
// Every flag still works in both forms.
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
let out = ok(bin().args(["commands", "--json", "--filter=gaussian"])).stdout;
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]));
let out = ok(bin().args(["commands", "--filter", needle])).stdout;
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([
@@ -101,10 +148,83 @@ fn unknown_flags_are_usage_errors() {
"80",
]));
assert!(out_dir.join("a.jpg").exists());
let (out, _) = ok(bin().arg("info").arg(input.join("a.png")).arg("--compact"));
let out = ok(bin().arg("info").arg(input.join("a.png")).arg("--compact")).stdout;
assert!(!out.trim().contains('\n'), "compact JSON is one line");
}
fn server_usage_error(args: &[&str], expected: &str) {
let o = bin().args(args).env_remove("PHOTOCRAFT_CONTROL_TOKEN").env_remove("PHOTOCRAFT_CONTROL_TOKEN_FILE").stdin(Stdio::null()).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{args:?}: {err}");
assert!(o.stdout.is_empty(), "usage errors must leave protocol stdout empty: {args:?}");
assert!(err.contains(expected), "{args:?}: expected {expected:?} in {err}");
}
#[test]
fn server_modes_reject_ignored_or_conflicting_options() {
for sub in ["mcp", "serve"] {
server_usage_error(&[sub, "stray.psd"], "unexpected argument 'stray.psd'");
for flag in ["--control-token", "--control-token-file"] {
server_usage_error(&[sub, flag, "unused"], if sub == "mcp" { "requires --bridge" } else { "requires --port" });
}
for flag in ["--automation-read-root", "--automation-write-root"] {
server_usage_error(&[sub, flag, ".", flag, "."], "cannot be used multiple times");
server_usage_error(&[sub, &format!("{flag}=")], &format!("a value is required for '{flag} "));
server_usage_error(&[sub, flag, "--automation-write-root", "."], &format!("a value is required for '{flag} "));
}
let transport = if sub == "mcp" { "--bridge" } else { "--port" };
for flag in [transport, "--control-token", "--control-token-file"] {
// Valid values, so the repeat is what is refused (a transport value is parsed first).
server_usage_error(&[sub, flag, "7878", &format!("{flag}=7879")], "cannot be used multiple times");
server_usage_error(&[sub, flag, ""], &format!("for '{flag} "));
}
server_usage_error(&[sub, transport, "7878", "--control-token", "unused", "--control-token-file", "unused"], "cannot be used with");
}
for flag in ["--automation-read-root", "--automation-write-root"] {
server_usage_error(&["mcp", "--bridge", "127.0.0.1:7878", flag, "."], "configure automation roots on the desktop app");
}
}
/// Each launch line would, before validation, have started a TCP server and written the token file.
#[test]
fn server_validation_does_not_consume_stdio_or_create_files() {
let d = tmp("server-validation");
let token = d.join("must-not-be-created.token");
let other = d.join("other.token");
let token_flag = format!("--control-token-file={}", token.display());
let other_flag = format!("--control-token-file={}", other.display());
for tail in [&["stray"][..], &["--automation-read-root", ".", "--automation-read-root", "."], &["--port", "0"], &[&other_flag]] {
let mut child = bin()
.current_dir(&d)
.args(["serve", "--port", "0", &token_flag])
.args(tail)
.env_remove("PHOTOCRAFT_CONTROL_TOKEN")
.env_remove("PHOTOCRAFT_CONTROL_TOKEN_FILE")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
// Keep stdin open: invalid launch arguments must fail before starting a server.
let input = child.stdin.take().unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while child.try_wait().unwrap().is_none() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(10));
}
if child.try_wait().unwrap().is_none() {
child.kill().unwrap();
child.wait().unwrap();
panic!("{tail:?}: invalid configuration started a server");
}
drop(input);
let o = child.wait_with_output().unwrap();
assert_eq!(o.status.code(), Some(2), "{tail:?}: {}", String::from_utf8_lossy(&o.stderr));
assert!(o.stdout.is_empty(), "{tail:?}");
assert!(!token.exists() && !other.exists(), "{tail:?}: validation must happen before creating credentials");
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn convert_png_pcraft_png_is_lossless() {
let d = tmp("convert");
@@ -128,7 +248,7 @@ fn convert_errors() {
let o = bin().args(["convert", "/missing/in.png"]).arg(d.join("x.png")).output().unwrap();
assert_eq!(o.status.code(), Some(1));
let o = bin().args(["convert", "only-one"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
assert_eq!(o.status.code(), Some(2));
std::fs::remove_dir_all(d).unwrap();
}
@@ -137,14 +257,14 @@ fn info_prints_layer_tree_json() {
let d = tmp("info");
let a = d.join("a.png");
write_png(&a, 10, 6, 3);
let (out, _) = ok(bin().arg("info").arg(&a));
let out = ok(bin().arg("info").arg(&a)).stdout;
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["width"], 10);
assert_eq!(v["height"], 6);
// An RGBA PNG opens as a normal "Layer 0", not the locked Background (as in Photoshop).
assert_eq!(v["layers"][0]["name"], "Layer 0");
assert!(v.get("history").is_none());
let (out, _) = ok(bin().arg("info").arg(&a).arg("--compact"));
let out = ok(bin().arg("info").arg(&a).arg("--compact")).stdout;
assert_eq!(out.trim().lines().count(), 1);
std::fs::remove_dir_all(d).unwrap();
}
@@ -173,10 +293,10 @@ fn info_and_convert_report_decode_warnings() {
for (name, bytes, warning, export) in cases {
let file = d.join(name);
std::fs::write(&file, bytes).unwrap();
let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact"));
let out = ok(bin().arg("info").arg(&file).arg("--compact")).stdout;
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 err = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png")))).stderr;
let expected: Vec<String> = warning.into_iter().chain(export).map(|w| format!("warning: {w}")).collect();
assert_eq!(
err.trim(),
@@ -194,18 +314,19 @@ fn info_and_convert_report_decode_warnings() {
fn run_commands_and_save() {
let d = tmp("run");
let out_pc = d.join("r.pcraft");
let (out, _) = ok(bin()
let out = ok(bin()
.args(["run", "--new", r#"{"width":40,"height":30,"name":"Run"}"#])
.args(["--cmd", "layer.new.layer", "--params", r#"{"name":"Ink"}"#])
.args(["--cmd", "paint.stroke", "--params", r##"{"points":[[2,2,1],[30,20,1]],"size":5,"color":"#00ff00"}"##])
.args(["--cmd", "document.inspect"])
.arg("--out")
.arg(&out_pc));
.arg(&out_pc))
.stdout;
let lines: Vec<Value> = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[2]["command"], "document.inspect");
assert_eq!(lines[2]["result"]["name"], "Run");
let (info, _) = ok(bin().arg("info").arg(&out_pc));
let info = ok(bin().arg("info").arg(&out_pc)).stdout;
let v: Value = serde_json::from_str(&info).unwrap();
// Saving a native document gives the reopened bundle its output filename.
assert_eq!(v["name"], "r.pcraft");
@@ -246,13 +367,14 @@ fn run_stops_after_failed_action_without_exporting() {
fn run_continues_after_successful_action_and_exports() {
let d = tmp("run-successful-action");
let out = d.join("result.png");
let (stdout, _) = ok(bin()
let stdout = ok(bin()
.args(["run", "--new", r#"{"width":8,"height":8}"#])
.args(["--cmd", "actions.record", "--params", r#"{"name":"Add"}"#])
.args(["--cmd", "layer.new.layer", "--cmd", "actions.stop"])
.args(["--cmd", "actions.play", "--params", r#"{"action":"Add"}"#])
.args(["--cmd", "document.inspect", "--out"])
.arg(&out));
.arg(&out))
.stdout;
let rows: Vec<Value> = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect();
let playback = rows.iter().find(|row| row["command"] == "actions.play").unwrap();
assert_eq!(playback["result"]["ran"], 1);
@@ -267,10 +389,13 @@ fn run_errors() {
let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown command"));
// Neither a --cmd nor --out, and JSON that doesn't parse, are usage errors naming the flag.
let o = bin().args(["run", "--new", "{}", "--params", "{}"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("--cmd"));
let o = bin().args(["run", "--new", "{", "--cmd", "x"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("--new"));
// Params must be a JSON object, for `--new` and for each `--params`.
let o = bin().args(["run", "--new", "[3]", "--cmd", "layer.new.layer"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
@@ -310,10 +435,10 @@ fn batch_applies_actions_to_directory() {
std::fs::write(inp.join("notes.txt"), "ignored").unwrap();
let actions = d.join("actions.json");
std::fs::write(&actions, json!([{"command": "layer.new.layer", "params": {"name": "Batch"}}]).to_string()).unwrap();
let (out, _) = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).args(["--format", "pcraft"]));
let out = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).args(["--format", "pcraft"])).stdout;
assert!(out.contains("2 succeeded, 0 failed"), "{out}");
for n in ["one", "two"] {
let (info, _) = ok(bin().arg("info").arg(outp.join(format!("{n}.pcraft"))));
let info = ok(bin().arg("info").arg(outp.join(format!("{n}.pcraft")))).stdout;
let v: Value = serde_json::from_str(&info).unwrap();
assert_eq!(v["layers"][0]["name"], "Batch");
}
@@ -347,7 +472,7 @@ fn batch_skips_recorded_view_and_save_steps() {
assert!(o.status.success(), "{name}: {stdout}\n{stderr}");
assert!(stdout.contains("1 succeeded, 0 failed"), "{name}: {stdout}");
let output = d.join(name).join("a.png");
let (info, _) = ok(bin().arg("info").arg(&output));
let info = ok(bin().arg("info").arg(&output)).stdout;
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(12)), "{name}: {info}");
assert_eq!(std::fs::read(&source).unwrap(), original, "{name} changed its input");
@@ -433,8 +558,8 @@ fn batch_format_with_leading_dot() {
}
}
/// #491: `--quality` outside 1-100 is an error for every subcommand, whatever the number's size,
/// and nothing is written.
/// #491: `--quality` outside 1-100 is a usage error for every subcommand, whatever the number's
/// size, and nothing is written.
#[test]
fn quality_outside_1_to_100_is_refused() {
let d = tmp("quality");
@@ -450,12 +575,12 @@ fn quality_outside_1_to_100_is_refused() {
run.arg("run").arg(&a).args(["--cmd", "image.adjustments.invert", "--out"]).arg(&out).args(["--quality", q]);
for cmd in [&mut convert, &mut run] {
let o = cmd.output().unwrap();
assert_eq!(o.status.code(), Some(1), "--quality {q}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --quality `{q}`: expected a whole number from 1 to 100")), "--quality {q}");
assert_eq!(o.status.code(), Some(2), "--quality {q}");
assert!(String::from_utf8_lossy(&o.stderr).contains("--quality"), "--quality {q}");
assert!(!out.exists(), "--quality {q} wrote a file");
}
let o = batch_with(&d, &input, "qb", invert, &["--format", "jpg", "--quality", q]);
assert_eq!(o.status.code(), Some(1), "batch --quality {q}");
assert_eq!(o.status.code(), Some(2), "batch --quality {q}");
assert!(!d.join("qb").exists(), "batch --quality {q} wrote a folder");
}
// The documented range still encodes, lower quality making a smaller file.
@@ -471,7 +596,7 @@ fn quality_outside_1_to_100_is_refused() {
}
/// 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).
/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is a usage error (#1049).
#[test]
fn dpx_converts_both_ways_with_the_bits_asked_for() {
let d = tmp("dpx");
@@ -488,7 +613,7 @@ fn dpx_converts_both_ways_with_the_bits_asked_for() {
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 info = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact")).stdout;
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}");
@@ -498,15 +623,16 @@ fn dpx_converts_both_ways_with_the_bits_asked_for() {
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}");
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "--dpx-bits {bad}");
assert!(err.contains("--dpx-bits") && err.contains("expected 8, 10, 12 or 16"), "--dpx-bits {bad}: {err}");
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).
/// isn't none, bc1, bc2 or bc3 is a usage error and nothing is written (#1327).
#[test]
fn dds_converts_both_ways_with_the_compression_asked_for() {
let d = tmp("dds");
@@ -531,14 +657,14 @@ fn dds_converts_both_ways_with_the_compression_asked_for() {
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_eq!(o.status.code(), Some(2), "--dds-compression {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains("--dds-compression"), "--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.
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is a
/// usage error and nothing is written.
#[test]
fn tga_bits_choose_the_targa_depth() {
let d = tmp("tga-bits");
@@ -557,14 +683,55 @@ fn tga_bits_choose_the_targa_depth() {
for bad in ["16", "0", "abc", ""] {
let out = d.join("bad.tga");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--tga-bits", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--tga-bits {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --tga-bits `{bad}`: expected 24 or 32")), "--tga-bits {bad}");
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "--tga-bits {bad}");
assert!(err.contains("--tga-bits") && err.contains("expected 24 or 32"), "--tga-bits {bad}: {err}");
assert!(!out.exists(), "--tga-bits {bad} wrote a file");
}
}
/// #7437: `run <file> --out` to that same file, however `--out` is spelt, is refused before the
/// file is read or any command runs, unless `--in-place` asks for it (as `batch` does, #492).
#[test]
fn run_refuses_to_write_over_its_input() {
let d = tmp("run-in-place");
write_png(&d.join("in.png"), 8, 4, 7);
let before = std::fs::read(d.join("in.png")).unwrap();
let run = |out: &Path, extra: &[&str]| {
bin().current_dir(&d).args(["run", "in.png", "--cmd", "image.adjustments.invert", "--out"]).arg(out).args(extra).output().unwrap()
};
let mut outs = vec![PathBuf::from("in.png"), PathBuf::from("./in.png"), d.join("in.png"), d.join(".").join("in.png")];
std::fs::hard_link(d.join("in.png"), d.join("hard.png")).unwrap();
outs.push(PathBuf::from("hard.png"));
#[cfg(unix)]
{
std::os::unix::fs::symlink(d.join("in.png"), d.join("soft.png")).unwrap();
outs.push(PathBuf::from("soft.png"));
}
for out in &outs {
for extra in [&[][..], &["--format", "png"][..]] {
let o = run(out, extra);
let (stdout, err) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
assert_eq!(o.status.code(), Some(1), "--out {} {extra:?}: {err}", out.display());
assert!(err.contains("is the input file") && err.contains("--in-place"), "{err}");
assert!(stdout.is_empty(), "--out {} {extra:?} ran commands: {stdout}", out.display());
assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before, "--out {} {extra:?} changed the original", out.display());
}
}
// Another file is written as before, leaving the original alone.
ok(bin().current_dir(&d).args(["run", "in.png", "--cmd", "image.adjustments.invert", "--out", "out.png"]));
assert!(d.join("out.png").is_file());
assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before);
// `--in-place` opts in: the inverted result replaces the original, which still opens.
ok(bin().current_dir(&d).args(["run", "in.png", "--cmd", "image.adjustments.invert", "--out", "in.png", "--in-place"]));
assert_ne!(std::fs::read(d.join("in.png")).unwrap(), before, "--in-place left the original unchanged");
let out = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"])).stdout;
let info: Value = serde_json::from_str(&out).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}");
}
/// `--jpeg-format` picks the JPEG's scans: progressive writes a progressive frame (SOF2). One it
/// doesn't name is refused and nothing is written.
/// doesn't name is a usage error and nothing is written.
#[test]
fn jpeg_format_chooses_the_scans() {
let d = tmp("jpeg-format");
@@ -584,8 +751,9 @@ fn jpeg_format_chooses_the_scans() {
for bad in ["Progressive", "1", ""] {
let out = d.join("bad.jpg");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--jpeg-format", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--jpeg-format {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --jpeg-format `{bad}`")), "--jpeg-format {bad}");
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "--jpeg-format {bad}");
assert!(err.contains("--jpeg-format") && err.contains("expected baseline, optimized or progressive"), "--jpeg-format {bad}: {err}");
assert!(!out.exists(), "--jpeg-format {bad} wrote a file");
}
}
@@ -625,7 +793,7 @@ fn batch_refuses_to_write_over_its_inputs() {
}
}
// A separate folder runs as before, leaving the originals alone.
let (out, _) = ok(bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out", "out"]));
let out = ok(bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out", "out"])).stdout;
assert!(out.contains("2 succeeded, 0 failed"), "{out}");
assert_eq!(snapshot(), before);
// `--in-place` opts in: each result replaces its original.
@@ -667,16 +835,16 @@ fn convert_refuses_to_write_over_its_input() {
assert_eq!(std::fs::read(d.join("in.png")).unwrap(), before);
// `--in-place` opts in: the result replaces the original, which still opens.
ok(bin().current_dir(&d).args(["convert", "in.png", "in.png", "--in-place"]));
let (out, _) = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"]));
let Printed { stdout: out, .. } = ok(bin().current_dir(&d).args(["info", "in.png", "--compact"]));
let info: Value = serde_json::from_str(&out).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(4)), "{info}");
}
#[test]
fn commands_listing() {
let (out, _) = ok(bin().arg("commands"));
let out = ok(bin().arg("commands")).stdout;
assert!(out.contains("file.new"));
let (out, _) = ok(bin().args(["commands", "--json", "--filter", "layer.new"]));
let out = ok(bin().args(["commands", "--json", "--filter", "layer.new"])).stdout;
let v: Value = serde_json::from_str(&out).unwrap();
assert!(
v.as_array()
@@ -690,7 +858,7 @@ fn commands_listing() {
fn in_process_run_matches_binary() {
let mut out = Vec::new();
let mut err = Vec::new();
let code = photocraft_cli::run(&["commands".into(), "--filter".into(), "file.new".into()], &mut out, &mut err);
let code = photocraft_cli::run(["commands", "--filter", "file.new"], &mut out, &mut err);
assert_eq!(code, 0);
assert!(String::from_utf8(out).unwrap().contains("file.new"));
}
@@ -700,20 +868,8 @@ fn mcp_stdio_handshake_and_tool_call() {
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
let mut stdin = child.stdin.take().unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
let mut send = |v: Value| {
writeln!(stdin, "{v}").unwrap();
stdin.flush().unwrap();
};
let mut recv = |id: u64| -> Value {
loop {
let mut line = String::new();
assert!(stdout.read_line(&mut line).unwrap() > 0, "server closed");
let v: Value = serde_json::from_str(&line).unwrap();
if v["id"] == id {
return v;
}
}
};
let mut send = |v: Value| mcp_send(&mut stdin, v);
let mut recv = |id: u64| mcp_recv(&mut stdout, id);
send(json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}));
let init = recv(1);
@@ -733,6 +889,61 @@ fn mcp_stdio_handshake_and_tool_call() {
let _ = child.wait();
}
/// Send one JSON-RPC line to an `mcp` child.
fn mcp_send(stdin: &mut impl Write, v: Value) {
writeln!(stdin, "{v}").unwrap();
stdin.flush().unwrap();
}
/// Read an `mcp` child's lines up to the reply to `id`.
fn mcp_recv(stdout: &mut impl BufRead, id: u64) -> Value {
loop {
let mut line = String::new();
assert!(stdout.read_line(&mut line).unwrap() > 0, "server closed");
let v: Value = serde_json::from_str(&line).unwrap();
if v["id"] == id {
return v;
}
}
}
/// `mcp --bridge` takes a port or anything with a `:`; anything else is a usage error. A bare port
/// means 127.0.0.1, and the bridge connects at the first call, so with no app there the call fails
/// naming that address.
#[test]
fn mcp_bridge_takes_a_port_or_an_address() {
let o = bin().args(["mcp", "--bridge", "nope"]).stdin(Stdio::null()).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{err}");
assert!(err.contains("invalid value 'nope' for '--bridge"), "{err}");
assert!(err.contains("expected a port, 0-65535 (--bridge also takes 127.0.0.1:<PORT>)"), "{err}");
// Nothing listens on port 1 in a test run. The token only has to be well-formed.
let token = "a".repeat(64);
let mut child = bin()
.args(["mcp", "--bridge", "1", "--control-token", token.as_str()])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
mcp_send(
&mut stdin,
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}),
);
mcp_recv(&mut stdout, 1);
mcp_send(&mut stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
mcp_send(&mut stdin, json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"session_list","arguments":{}}}));
let r = mcp_recv(&mut stdout, 2);
assert_eq!(r["result"]["isError"], true, "{r}");
assert!(r["result"]["content"][0]["text"].as_str().unwrap_or_default().contains("127.0.0.1:1"), "{r}");
let _ = child.kill();
let _ = child.wait();
}
/// `--format` mapping two inputs to one name fails the second instead of overwriting the first (#420).
#[test]
fn batch_fails_inputs_that_share_an_output_name() {
@@ -761,7 +972,7 @@ fn batch_fails_inputs_that_share_an_output_name() {
assert_eq!(std::fs::read_dir(&out_dir).unwrap().count(), 1);
// Keeping each file's format, every input gets its own output.
let out_dir = d.join("same");
let (out, _) = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir));
let out = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir)).stdout;
assert!(out.contains("3 succeeded, 0 failed"), "{out}");
}
@@ -777,7 +988,7 @@ fn droplet_runs_an_action_on_files() {
)
.unwrap();
let out_dir = d.join("out");
let (out, _) = ok(bin().arg("droplet").arg(&droplet).arg(d.join("a.png")).arg(d.join("b.png")).arg("--out").arg(&out_dir));
let out = ok(bin().arg("droplet").arg(&droplet).arg(d.join("a.png")).arg(d.join("b.png")).arg("--out").arg(&out_dir)).stdout;
assert_eq!(out.lines().filter(|l| l.starts_with("ok")).count(), 2, "{out}");
let img = photocraft_codecs::decode(&std::fs::read(out_dir.join("a.png")).unwrap()).unwrap();
assert_eq!(img.dimensions(), (4, 8));
@@ -789,7 +1000,7 @@ fn droplet_runs_an_action_on_files() {
fn tiff_output_is_flat_unless_tiff_layers_is_given() {
let d = tmp("tiff-layers");
let layer_count = |path: &std::path::Path| -> usize {
let (info, _) = ok(bin().arg("info").arg(path));
let info = ok(bin().arg("info").arg(path)).stdout;
let v: Value = serde_json::from_str(&info).unwrap();
v["layers"].as_array().unwrap().len()
};
@@ -815,19 +1026,38 @@ fn tiff_output_is_flat_unless_tiff_layers_is_given() {
}
/// Issue #1108: a non-Unicode argument (a Latin-1 file name on Linux) used to panic in
/// `std::env::args()` before anything was printed. It is a usage error naming the argument.
/// `std::env::args()` before anything was printed. clap reads the arguments as they are: a path
/// takes one, and text (`--filter`) refuses it with a usage error.
#[cfg(unix)]
#[test]
fn a_non_unicode_argument_is_a_usage_error_not_a_panic() {
fn a_non_unicode_path_is_taken_and_non_unicode_text_is_a_usage_error() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let name = std::ffi::OsStr::from_bytes(b"caf\xe9.png");
for args in [vec![std::ffi::OsStr::new("info"), name], vec![std::ffi::OsStr::new("convert"), name, std::ffi::OsStr::new("out.png")]] {
let o = bin().args(&args).output().unwrap();
let d = tmp("non-unicode");
let name = OsStr::from_bytes(b"caf\xe9.png");
// No such file yet: the handler runs and fails on it (exit 1), no usage error.
for args in [vec![OsStr::new("info"), name], vec![OsStr::new("convert"), name, OsStr::new("out.png")]] {
let o = bin().current_dir(&d).args(&args).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{err}");
assert!(err.contains("argument 2 is not valid Unicode") && err.contains("caf\u{fffd}.png"), "{err}");
assert_eq!(o.status.code(), Some(1), "{args:?}: {err}");
assert!(!err.contains("panicked"), "{err}");
}
// Linux file systems take the name (macOS ones refuse it). `convert` reads it; `info` opens
// through the automation layer, which still needs UTF-8 paths, and says so (exit 1).
if cfg!(target_os = "linux") {
write_png(&d.join(name), 8, 4, 3);
ok(bin().current_dir(&d).arg("convert").arg(name).arg("out.png"));
assert!(d.join("out.png").is_file());
let o = bin().current_dir(&d).arg("info").arg(name).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(1), "{err}");
assert!(err.contains("UTF-8"), "{err}");
}
let o = bin().args([OsStr::new("commands"), OsStr::new("--filter"), name]).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!((o.status.code(), o.stdout.is_empty()), (Some(2), true), "{err}");
assert!(err.contains("UTF-8") && !err.contains("panicked"), "{err}");
std::fs::remove_dir_all(d).unwrap();
}
/// `batch --actions set.atn`: a Photoshop Actions file plays like an Actions set, and its steps
@@ -850,7 +1080,7 @@ fn batch_plays_a_photoshop_actions_file() {
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));
let err = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir)).stderr;
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.
@@ -860,31 +1090,40 @@ fn batch_plays_a_photoshop_actions_file() {
assert!(!o.status.success());
}
/// `photocraft_cli::run` in-process: `(exit code, stdout, stderr)`.
fn invoke(args: &[&str]) -> (i32, String, String) {
let (mut out, mut err) = (Vec::new(), Vec::new());
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let code = photocraft_cli::run(&args, &mut out, &mut err);
(code, String::from_utf8(out).unwrap(), String::from_utf8(err).unwrap())
/// What `photocraft_cli::run` returned and wrote, in-process.
struct Invoke {
code: i32,
stdout: String,
stderr: String,
}
fn invoke(args: &[&str]) -> Invoke {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
let code = photocraft_cli::run(args, &mut stdout, &mut stderr);
Invoke { code, stdout: String::from_utf8(stdout).unwrap(), stderr: String::from_utf8(stderr).unwrap() }
}
const SUBCOMMANDS: [&str; 8] = ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"];
/// Pins top-level dispatch: help goes to stdout (exit 0), no arguments is a usage error on stderr.
/// Pins top-level dispatch: help and the version go to stdout (exit 0), no arguments is a usage
/// error on stderr. There is no `version` subcommand.
#[test]
fn top_level_help_version_and_no_arguments() {
let (code, out, err) = invoke(&[]);
let Invoke { code, stdout: out, stderr: err } = invoke(&[]);
assert_eq!((code, out.is_empty(), err.is_empty()), (2, true, false));
for help in ["--help", "-h", "help"] {
let (code, out, err) = invoke(&[help]);
let Invoke { code, stdout: out, stderr: err } = invoke(&[help]);
assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{help}");
}
for version in ["--version", "version"] {
let (code, out, err) = invoke(&[version]);
for version in ["--version", "-V"] {
let Invoke { code, stdout: out, stderr: err } = invoke(&[version]);
assert_eq!((code, err.as_str()), (0, ""), "{version}");
assert!(out.starts_with("photocraft-cli "), "{version}: {out}");
assert!(out.starts_with("photocraft-cli ") && out.ends_with('\n') && out.lines().count() == 1, "{version}: {out}");
}
let (code, out, err) = invoke(&["frobnicate"]);
let Invoke { code, stdout: out, stderr: err } = invoke(&["version"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("version"), "{err}");
let Invoke { code, stdout: out, stderr: err } = invoke(&["frobnicate"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("frobnicate"), "{err}");
}
@@ -895,7 +1134,7 @@ fn top_level_help_version_and_no_arguments() {
fn every_subcommand_help_returns_on_stdout() {
for sub in SUBCOMMANDS {
for help in ["--help", "-h"] {
let (code, out, err) = invoke(&[sub, help]);
let Invoke { code, stdout: out, stderr: err } = invoke(&[sub, help]);
assert_eq!((code, out.is_empty(), err.is_empty()), (0, false, true), "{sub} {help}: {err}");
// `commands` would also exit 0 with output if it ran; its list names `file.new`, help doesn't.
assert!(!out.contains("file.new"), "{sub} {help} ran instead of printing help: {out}");
@@ -909,34 +1148,47 @@ fn every_subcommand_rejects_an_unknown_flag() {
for sub in SUBCOMMANDS {
for flag in [&["--fromat"][..], &["--fromat=jpg"][..], &["--fromat", "jpg"][..]] {
let args: Vec<&str> = [sub].into_iter().chain(flag.iter().copied()).collect();
let (code, out, err) = invoke(&args);
let Invoke { code, stdout: out, stderr: err } = invoke(&args);
assert_eq!((code, out.is_empty()), (2, true), "{args:?}: {err}");
assert!(err.contains("--fromat"), "{args:?}: {err}");
}
}
}
/// `--flag=value`, a bare flag given a value, a value flag without one, and repeated flags.
/// `--flag=value`, a bare flag given a value, a value flag without one, repeated flags, and values
/// that start with `-`.
#[test]
fn flag_value_forms_and_repeats() {
let ids = |out: &str| out.lines().filter_map(|l| l.split_whitespace().next()).map(str::to_owned).collect::<Vec<_>>();
let (code, eq, err) = invoke(&["commands", "--filter=file.new"]);
let Invoke { code, stdout: eq, stderr: err } = invoke(&["commands", "--filter=file.new"]);
assert_eq!(code, 0, "{err}");
let (_, spaced, _) = invoke(&["commands", "--filter", "file.new"]);
let spaced = invoke(&["commands", "--filter", "file.new"]).stdout;
assert_eq!(eq, spaced);
assert!(ids(&eq).contains(&"file.new".to_string()), "{eq}");
// The last of a repeated value flag wins, whichever order they come in.
let (code, out, err) = invoke(&["commands", "--filter", "zzz-no-match", "--filter", "file.new"]);
// A repeated flag is a usage error naming it, more likely a mistake than a choice.
for repeat in [&["commands", "--filter", "zzz-no-match", "--filter", "file.new"][..], &["commands", "--json", "--json"][..]] {
let Invoke { code, stdout: out, stderr: err } = invoke(repeat);
assert_eq!((code, out.is_empty()), (2, true), "{repeat:?}");
assert!(err.contains(repeat[1]) && err.contains("cannot be used multiple times"), "{repeat:?}: {err}");
}
let Invoke { code, stderr: err, .. } = invoke(&["convert", "a.png", "b.jpg", "--quality", "80", "--quality", "60"]);
assert_eq!(code, 2);
assert!(err.contains("--quality"), "{err}");
// A value starting with `-` needs `=`; without it the token is a flag, never the value. A
// negative number is still a value.
let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--filter=-zzz-no-match"]);
assert_eq!((code, out.as_str()), (0, ""), "{err}");
for value in ["-zzz-no-match", "--json"] {
let Invoke { code, stdout: out, .. } = invoke(&["commands", "--filter", value]);
assert_eq!((code, out.is_empty()), (2, true), "--filter {value}");
}
let Invoke { code, stderr: err, .. } = invoke(&["commands", "--filter", "-1"]);
assert_eq!(code, 0, "{err}");
assert_eq!(out, eq);
let (code, out, err) = invoke(&["commands", "--filter", "file.new", "--filter", "zzz-no-match"]);
assert_eq!(code, 0, "{err}");
assert!(out.is_empty(), "{out}");
// A bare flag takes no value; a value flag needs one.
let (code, out, err) = invoke(&["commands", "--json=1"]);
let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--json=1"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("--json"), "{err}");
let (code, out, err) = invoke(&["commands", "--filter"]);
let Invoke { code, stdout: out, stderr: err } = invoke(&["commands", "--filter"]);
assert_eq!((code, out.is_empty()), (2, true));
assert!(err.contains("--filter"), "{err}");
}
@@ -945,7 +1197,7 @@ fn flag_value_forms_and_repeats() {
/// `--params` ahead of every `--cmd` fails naming `--params`.
#[test]
fn run_pairs_params_with_the_preceding_cmd() {
let (code, out, err) = invoke(&[
let Invoke { code, stdout: out, stderr: err } = invoke(&[
"run",
"--new",
r#"{"width":8,"height":8}"#,
@@ -973,7 +1225,7 @@ fn run_pairs_params_with_the_preceding_cmd() {
// Layers list top first: Third, the unnamed middle one, First, Background. Pairing --params
// with --cmd by position (params[i] -> cmd[i]) would name the middle one Third instead.
assert_eq!((names[0], names[2]), ("Third", "First"), "{names:?}");
let (code, _, err) = invoke(&["run", "--new", "{}", "--params", "{}", "--cmd", "layer.new.layer"]);
let Invoke { code, stderr: err, .. } = invoke(&["run", "--new", "{}", "--params", "{}", "--cmd", "layer.new.layer"]);
assert_ne!(code, 0);
assert!(err.contains("--params"), "{err}");
}
@@ -992,3 +1244,180 @@ fn convert_opens_a_raw_at_the_raw_defaults_resolution() {
assert_eq!(doc.resolution_dpi, 300.0);
std::fs::remove_dir_all(d).unwrap();
}
/// `--out=-image.png` writes `-image.png`; `--out -image.png` is a usage error and writes nothing.
#[test]
fn a_value_starting_with_a_dash_is_written_with_equals() {
let d = tmp("dash-value");
let o = bin().current_dir(&d).args(["run", "--new", r#"{"width":4,"height":4}"#, "--out", "-image.png"]).output().unwrap();
assert_eq!(o.status.code(), Some(2), "{}", String::from_utf8_lossy(&o.stderr));
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0);
ok(bin().current_dir(&d).args(["run", "--new", r#"{"width":4,"height":4}"#, "--out=-image.png"]));
assert!(d.join("-image.png").is_file());
std::fs::remove_dir_all(d).unwrap();
}
/// `run` takes exactly one of `<FILE>` or `--new`, and `mcp`/`serve` one of `--control-token` or
/// `--control-token-file`: anything else is a usage error naming the flags, before any work.
#[test]
fn either_or_flags_are_usage_errors() {
let Invoke { code, stderr: err, .. } = invoke(&["run", "a.png", "--new", "{}", "--cmd", "layer.new.layer"]);
assert_eq!(code, 2);
assert!(err.contains("--new") && err.contains("cannot be used with"), "{err}");
let Invoke { code, stderr: err, .. } = invoke(&["run", "--cmd", "layer.new.layer"]);
assert_eq!(code, 2);
assert!(err.contains("--new"), "{err}");
let Invoke { code, stderr: err, .. } = invoke(&["run", "--new", "{}", "--cmd", "layer.new.layer", "--params", "{"]);
assert_eq!(code, 2);
assert!(err.contains("--params"), "{err}");
let token = "a".repeat(64);
for mode in [["mcp", "--bridge", "1"], ["serve", "--port", "0"]] {
let o = bin().args(mode).args(["--control-token", token.as_str(), "--control-token-file", "token"]).stdin(Stdio::null()).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{mode:?}: {err}");
assert!(err.contains("--control-token-file") && err.contains("cannot be used with"), "{mode:?}: {err}");
}
for port in ["70000", "x"] {
let o = bin().args(["serve", "--port", port]).stdin(Stdio::null()).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "--port {port}: {err}");
assert!(err.contains("--port"), "--port {port}: {err}");
}
}
/// How a run of the binary ended.
struct Exited {
code: Option<i32>,
stderr: String,
}
/// The binary run with `args` and one variable set, stdin at its end.
fn with_var(args: &[&str], var: &str, value: &str) -> Exited {
let o = bin().args(args).env(var, value).stdin(Stdio::null()).output().unwrap();
Exited { code: o.status.code(), stderr: String::from_utf8_lossy(&o.stderr).into_owned() }
}
/// A port nothing listens on, for a `serve` to take.
fn free_port() -> u16 {
std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port()
}
/// `serve` on TCP never exits: read its stderr until it says it is serving (or its stderr closes),
/// then kill it.
fn serve_tcp_stderr(cmd: &mut Command) -> String {
use std::io::Read as _;
let mut child = cmd.stdin(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap();
// Read on a thread, so a `serve` that stays silent with stderr open fails the test after the
// deadline instead of hanging it.
let mut stderr = child.stderr.take().unwrap();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
while let Ok(n) = stderr.read(&mut buf) {
if n == 0 || tx.send(buf[..n].to_vec()).is_err() {
break;
}
}
});
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let mut err = String::new();
while !err.contains("photocraft-cli serving on") {
match rx.recv_timeout(deadline.saturating_duration_since(std::time::Instant::now())) {
Ok(chunk) => err.push_str(&String::from_utf8_lossy(&chunk)),
Err(_) => break,
}
}
let _ = child.kill();
let _ = child.wait();
err
}
/// #2491: `PHOTOCRAFT_CONTROL_PORT` turns `serve` from stdio into a TCP listener and `mcp` from a
/// headless engine into a bridge, and both say so on stderr. A flag wins, an empty value counts as
/// unset, and a bad value is a usage error naming the variable.
#[test]
fn control_port_env_switches_modes_with_a_notice_and_flags_win() {
let port = free_port();
let err = serve_tcp_stderr(bin().arg("serve").env("PHOTOCRAFT_CONTROL_PORT", port.to_string()));
assert!(err.contains(&format!("PHOTOCRAFT_CONTROL_PORT={port} is set; serving on TCP instead of stdio")), "{err}");
assert!(err.contains("unset it to serve stdio") && err.contains("photocraft-cli serving on"), "{err}");
// No token is given, so the bridge fails after the notice.
let err = with_var(&["mcp"], "PHOTOCRAFT_CONTROL_PORT", &port.to_string()).stderr;
assert!(err.contains(&format!("PHOTOCRAFT_CONTROL_PORT is set; bridging to the app on 127.0.0.1:{port}")), "{err}");
assert!(err.contains("unset it to run headless"), "{err}");
// The flag wins, so the variable's bad value is never parsed.
let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(port.to_string()).env("PHOTOCRAFT_CONTROL_PORT", "not-a-port"));
assert!(err.contains("photocraft-cli serving on") && !err.contains("PHOTOCRAFT_CONTROL_PORT"), "{err}");
// Empty or blank counts as unset: `serve` reads stdio (at its end, so it exits at once) and
// `mcp` stays headless.
// A root on the command line is the headless engine's: the variable can't switch to a bridge
// that would drop it.
for root in ["--automation-read-root", "--automation-write-root"] {
let Exited { code, stderr: err } = with_var(&["mcp", root, "."], "PHOTOCRAFT_CONTROL_PORT", &port.to_string());
assert_eq!(code, Some(2), "{root}: {err}");
assert!(err.contains(root) && err.contains("cannot be used with 'PHOTOCRAFT_CONTROL_PORT'") && !err.contains("bridging"), "{root}: {err}");
}
for empty in ["", " "] {
let Exited { code, stderr: err } = with_var(&["serve"], "PHOTOCRAFT_CONTROL_PORT", empty);
assert!(code == Some(0) && !err.contains("PHOTOCRAFT_CONTROL_PORT"), "{empty:?}: {err}");
let err = with_var(&["mcp"], "PHOTOCRAFT_CONTROL_PORT", empty).stderr;
assert!(!err.contains("bridging"), "{empty:?}: {err}");
}
for (sub, bad) in [
("serve", "not-a-port"),
("serve", "70000"),
("mcp", "nope"),
("mcp", "70000"),
// `--bridge` takes host:port, the variable only a port.
("mcp", "127.0.0.1:7878"),
("mcp", ":7878"),
("mcp", "bad:port"),
] {
let Exited { code, stderr: err } = with_var(&[sub], "PHOTOCRAFT_CONTROL_PORT", bad);
assert_eq!(code, Some(2), "{sub} {bad}: {err}");
assert!(err.contains(&format!("invalid value '{bad}' for 'PHOTOCRAFT_CONTROL_PORT'")), "{sub} {bad}: {err}");
}
}
/// `--control-token` and `--control-token-file` fall back to `PHOTOCRAFT_CONTROL_TOKEN` and
/// `PHOTOCRAFT_CONTROL_TOKEN_FILE`. One from the environment and the other as a flag is the
/// either/or usage error, naming the variable, when a token is used: a stale exported pair doesn't
/// stop headless `mcp` or stdio `serve`. Help names the token's variable, never its value.
#[test]
fn control_token_env_vars_are_the_flags_fallbacks() {
let token = "a".repeat(64);
for mode in [["mcp", "--bridge", "1"], ["serve", "--port", "0"]] {
let Exited { code, stderr: err } = with_var(&[&mode[..], &["--control-token", token.as_str()][..]].concat(), "PHOTOCRAFT_CONTROL_TOKEN_FILE", "token");
assert_eq!(code, Some(2), "{mode:?}: {err}");
assert!(err.contains("PHOTOCRAFT_CONTROL_TOKEN_FILE") && err.contains("cannot be used with"), "{mode:?}: {err}");
let Exited { code, stderr: err } = with_var(&[&mode[..], &["--control-token-file", "token"][..]].concat(), "PHOTOCRAFT_CONTROL_TOKEN", &token);
assert_eq!(code, Some(2), "{mode:?}: {err}");
assert!(err.contains("PHOTOCRAFT_CONTROL_TOKEN") && err.contains("cannot be used with") && !err.contains(&token), "{mode:?}: {err}");
}
for sub in ["mcp", "serve"] {
let o = bin().arg(sub).env("PHOTOCRAFT_CONTROL_TOKEN", &token).env("PHOTOCRAFT_CONTROL_TOKEN_FILE", "token").stdin(Stdio::null()).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert!(!err.contains("cannot be used with"), "{sub}: {err}");
// stdio `serve` exits 0 at the end of its input; how headless `mcp` ends there isn't
// pinned, so it only must not be the usage error.
if sub == "serve" {
assert_eq!(o.status.code(), Some(0), "{sub}: {err}");
} else {
assert_ne!(o.status.code(), Some(2), "{sub}: {err}");
}
let o = bin().args([sub, "--help"]).env("PHOTOCRAFT_CONTROL_TOKEN", &token).output().unwrap();
let out = String::from_utf8_lossy(&o.stdout);
assert!(out.contains("[env: PHOTOCRAFT_CONTROL_TOKEN]") && !out.contains(&token), "{sub}: {out}");
}
// The server takes the variable's token, so it neither generates nor prints one; an empty
// variable counts as unset.
let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(free_port().to_string()).env("PHOTOCRAFT_CONTROL_TOKEN", &token));
assert!(err.contains("photocraft-cli serving on") && !err.contains("control token"), "{err}");
let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(free_port().to_string()).env("PHOTOCRAFT_CONTROL_TOKEN", ""));
assert!(err.contains("photocraft-cli serving on") && err.contains("photocraft-cli control token: "), "{err}");
}
+6
View File
@@ -1,4 +1,10 @@
//! The crate's integration tests, linked into one test binary.
mod cli;
mod man_and_completions;
mod orientation;
/// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid.
fn binary() -> std::ffi::OsString {
std::env::var_os("NEXTEST_BIN_EXE_photocraft_cli").unwrap_or_else(|| env!("CARGO_BIN_EXE_photocraft-cli").into())
}
@@ -0,0 +1,55 @@
//! The clap definition is consistent, and every shell's completions and every man page
//! (`examples/man-and-completions.rs`) can be generated from it.
use clap::ValueEnum;
use clap_complete::Shell;
#[test]
fn command_is_consistent() {
photocraft_cli::command().debug_assert();
}
#[test]
fn completions_generate_for_every_shell() {
for &shell in Shell::value_variants() {
let mut out = Vec::new();
clap_complete::generate(shell, &mut photocraft_cli::command(), "photocraft-cli", &mut out);
assert!(!out.is_empty(), "{shell}");
}
}
#[test]
fn man_pages_render_for_the_command_and_every_subcommand() {
let cmd = photocraft_cli::command();
for page in std::iter::once(&cmd).chain(cmd.get_subcommands()) {
let mut out = Vec::new();
clap_mangen::Man::new(page.clone()).render(&mut out).unwrap();
assert!(!out.is_empty(), "{}", page.get_name());
}
}
/// The example's two `generate_to` calls, repeated here, write the five completion files under the
/// names `packaging/linux/package.sh` and `packaging/freebsd/package.sh` install, `photocraft-cli.1`
/// and a page per subcommand, and no pages for the `help` subcommand.
#[test]
fn the_example_writes_the_files_the_packages_install() {
let dir = std::env::temp_dir().join(format!("pc-cli-{}-man-and-completions", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut cmd = photocraft_cli::command();
for &shell in Shell::value_variants() {
clap_complete::generate_to(shell, &mut cmd, "photocraft-cli", &dir).unwrap();
}
clap_mangen::generate_to(photocraft_cli::command(), &dir).unwrap();
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
written.sort();
let completions = ["photocraft-cli.bash", "_photocraft-cli", "photocraft-cli.fish", "photocraft-cli.elv", "_photocraft-cli.ps1"];
let subcommands = ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"];
let mut expected: Vec<String> = completions.map(String::from).into();
expected.push("photocraft-cli.1".into());
expected.extend(subcommands.iter().map(|s| format!("photocraft-cli-{s}.1")));
expected.sort();
assert_eq!(written, expected);
std::fs::remove_dir_all(&dir).unwrap();
}
+9 -4
View File
@@ -10,7 +10,7 @@ use photocraft_codecs::{ChannelLayout, DecodeOptions, EncodeOptions, Format, Ima
use serde_json::Value;
fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_photocraft-cli"))
Command::new(crate::binary())
}
fn tmp(name: &str) -> PathBuf {
@@ -125,7 +125,8 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() {
let (a, b) = (d.join("a.pcraft"), d.join("b.pcraft"));
ok(bin().arg("convert").arg(&jpg).arg(&a));
ok(bin().arg("convert").arg(&a).arg(&b));
// Everything but the file's own path and saved document name must match.
// Everything but the file's own path and saved document name (which the first snapshot is
// named after) must match.
let info = |p: &Path| {
let mut v = serde_json::from_str::<Value>(&ok(bin().arg("info").arg(p))).unwrap();
v.as_object_mut().unwrap().remove("file");
@@ -136,8 +137,12 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() {
assert_eq!((ia["width"].as_u64(), ia["height"].as_u64()), (Some(16), Some(32)));
assert_eq!(ia["name"], "a.pcraft");
assert_eq!(ib["name"], "b.pcraft");
ia.as_object_mut().unwrap().remove("name");
ib.as_object_mut().unwrap().remove("name");
assert_eq!(ia["snapshots"][0]["name"], "a.pcraft");
assert_eq!(ib["snapshots"][0]["name"], "b.pcraft");
for v in [&mut ia, &mut ib] {
v.as_object_mut().unwrap().remove("name");
v["snapshots"][0].as_object_mut().unwrap().remove("name");
}
assert_eq!(ia, ib);
let (pa, pb) = (d.join("a.png"), d.join("b.png"));
ok(bin().arg("convert").arg(&a).arg(&pa));
+2 -2
View File
@@ -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 = ["wgpu", "accesskit", "clipboard"] }
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", "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", "RequestMode", "Navigator"] }
[dev-dependencies]
serde_json = { workspace = true }
+3 -2
View File
@@ -9,8 +9,8 @@ use photocraft_engine::{
};
pub type Batch = BTreeMap<String, Option<Arc<[u8]>>>;
/// Three sampled images per preset (tip, dual tip, texture), plus group/index/action records.
pub const MAX_RECORDS: usize = MAX_GROUPS + MAX_PRESETS_PER_GROUP * 3 + 2;
/// Three sampled images per preset (tip, dual tip, texture), plus group/index/action/pattern records.
pub const MAX_RECORDS: usize = MAX_GROUPS + MAX_PRESETS_PER_GROUP * 3 + 3;
#[derive(Default)]
struct State {
@@ -45,6 +45,7 @@ pub fn record_limit(name: &str) -> Result<u64, String> {
match name {
INDEX_FILE => Ok(MAX_INDEX_BYTES),
ACTIONS_FILE => Ok(MAX_ACTIONS_BYTES),
PATTERNS_FILE => Ok(MAX_PATTERNS_BYTES),
_ if name.starts_with("tips/") && leaf.ends_with(".pctip") => Ok(MAX_TIP_BYTES),
_ if !name.contains('/') && name.ends_with(".pcbrushes") => Ok(MAX_GROUP_BYTES),
_ => Err("unknown preset record type".into()),
+28 -20
View File
@@ -4,7 +4,7 @@ use std::cell::Cell;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, PoisonError};
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image};
use photocraft_doc::Document;
@@ -17,12 +17,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
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .act, .ase), which go to the preset libraries.
/// Photoshop brushes (.abr), gradients (.grd), layer styles (.asl) and swatches (.aco, .act, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "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", "dcm", "dicom", "dc3", "iff", "lbm", "ilbm", "heic",
"heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "af", "afdesign",
"afphoto", "afpub",
"heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "asl", "svg", "svgz", "aco", "act", "ase", "af",
"afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
@@ -80,6 +80,7 @@ pub fn start() {
canvas,
options,
Box::new(move |cc| {
PhotocraftApp::install_type_reset_hooks(&cc.egui_ctx);
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
let inbox: Inbox = Arc::default();
let place_inbox: Inbox = Arc::default();
@@ -218,7 +219,7 @@ fn load_font_manifest(served: Served, ctx: egui::Context) {
files.entry(f.family.clone()).or_default().push((f.file.clone(), f.integrity.clone()));
}
log::info!("photocraft-web: {} served font families", files.len());
served.lock().unwrap_or_else(|e| e.into_inner()).files = files;
served.lock().unwrap_or_else(PoisonError::into_inner).files = files;
// The families for the font menus, and the script fallbacks (the manifest's `scripts`).
served_fonts::add_fonts(&fonts);
// Fetch now the fallbacks of the scripts the browser's languages use (an Arabic reader gets
@@ -233,20 +234,21 @@ fn browser_languages() -> Vec<String> {
web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|l| l.as_string()).collect()).unwrap_or_default()
}
/// GET `url` (relative to the page) and read the whole body. With `integrity` (a Subresource
/// Integrity value), the browser rejects a body whose hash differs.
/// GET `url` (a relative path on the same origin) and read the whole body. With `integrity` (a
/// Subresource Integrity value), the browser rejects a body whose hash differs.
async fn fetch_bytes(url: &str, integrity: Option<&str>) -> Result<Vec<u8>, String> {
use wasm_bindgen_futures::JsFuture;
if !served_fonts::is_relative_path(url) {
return Err("url must be a relative path".into());
}
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let window = web_sys::window().ok_or("no window")?;
let request = match integrity {
Some(sri) => {
let init = web_sys::RequestInit::new();
init.set_integrity(sri);
window.fetch_with_str_and_init(url, &init)
}
None => window.fetch_with_str(url),
};
let init = web_sys::RequestInit::new();
init.set_mode(web_sys::RequestMode::SameOrigin);
if let Some(sri) = integrity {
init.set_integrity(sri);
}
let request = window.fetch_with_str_and_init(url, &init);
let resp: web_sys::Response = JsFuture::from(request).await.map_err(js)?.dyn_into().map_err(|_| "not a Response")?;
if !resp.ok() {
return Err(format!("HTTP {}", resp.status()));
@@ -294,7 +296,7 @@ impl WebShell {
fn serve_fonts(&mut self, ctx: &egui::Context) {
let requested = served_fonts::take_requests();
let (fetch, arrived) = {
let mut s = self.served.lock().unwrap_or_else(|e| e.into_inner());
let mut s = self.served.lock().unwrap_or_else(PoisonError::into_inner);
let mut fetch = Vec::new();
for family in requested {
if let Some(files) = s.files.get(&family).cloned()
@@ -311,7 +313,7 @@ impl WebShell {
wasm_bindgen_futures::spawn_local(async move {
match fetch_bytes(&file, integrity.as_deref()).await {
Ok(bytes) => {
served.lock().unwrap_or_else(|e| e.into_inner()).arrived.push((family, bytes));
served.lock().unwrap_or_else(PoisonError::into_inner).arrived.push((family, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't fetch served font {file}: {e}"),
@@ -327,6 +329,10 @@ impl eframe::App for WebShell {
// A tap reaches eframe as Pointer Events and Touch Events: keep one press per tap, or the
// twin press closes the menu the first one opened (#2095).
photocraft_ui_egui::touch_twins::drop_twins(ctx, raw_input);
// The browser build has no TCP control server, so `inject_synthetic` finds an empty
// queue, but the clipboard key mapping (`Event::PasteImage`), legacy pinch, pen-hold and
// titlebar normalization live in the app hook and must run here too.
eframe::App::raw_input_hook(&mut self.app, ctx, raw_input);
}
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
@@ -346,7 +352,7 @@ impl eframe::App for WebShell {
match f.bytes_async().await {
Ok(bytes) => {
let queue = if place { &place_inbox } else { &inbox };
queue.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
queue.lock().unwrap_or_else(PoisonError::into_inner).push((name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
@@ -355,8 +361,9 @@ impl eframe::App for WebShell {
});
}
self.serve_fonts(ctx);
self.app.logic(ctx, frame);
self.app.logic_frame(ctx, frame);
self.save_presets(ctx);
self.app.finish_type_reset_logic(ctx);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
@@ -383,8 +390,9 @@ impl eframe::App for WebShell {
});
});
}
self.app.ui(ui, frame);
self.app.ui_frame(ui, frame);
self.save_presets(ui.ctx());
self.app.finish_type_reset_pass(ui.ctx());
}
}
+9 -4
View File
@@ -29,18 +29,23 @@ photocraft-automation = { workspace = true }
photocraft-ui-egui = { workspace = true }
# Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate).
photocraft-tablet = { workspace = true }
eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence"] }
eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence", "clipboard"] }
egui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true }
thiserror = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-update = { workspace = true }
rfd = { workspace = true }
arboard = { version = "3.6", features = ["wayland-data-control"] }
arboard = { version = "3.7", features = ["wayland-data-control"] }
open = "5"
# Reads the saved window layout (eframe persistence) to drop values that would abort startup.
ron = "0.12"
# Background release checks; HTTPS with Rust TLS, no async runtime.
[target.'cfg(target_os = "macos")'.dependencies]
# Safe CoreGraphics bindings, compatible with the existing macOS 11 deployment target.
@@ -54,12 +59,12 @@ fmv-macos-events = "=0.1.0"
# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position), and play the
# system alert sound for Beep When Done (services.rs, NSBeep). Already in
# Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions.
objc2 = "=0.6.4"
objc2 = "=0.6.5"
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] }
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSGraphics", "NSResponder", "NSView", "NSWindow"] }
# The macOS menu bar (mac_menu.rs): a standalone native menu crate (from the tauri-apps org, but
# not Tauri: no webview, no JS), AppKit menus behind a safe API (no GTK with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.1", default-features = false }
muda = { version = "=0.21.2", default-features = false }
# Quit with `terminate:` from the main queue, outside winit's event handler (mac_window.rs).
# Already in Cargo.lock (winit, objc2-app-kit); pinned exactly to the same version.
dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
+340
View File
@@ -0,0 +1,340 @@
//! The desktop app's command line, as clap types (#7071): `photocraft --help` comes from these doc
//! comments, so the help and the parser can't disagree. Anything they don't declare is a usage
//! error naming it (exit status 2), so a typo such as `--safegpu` doesn't open the window and then
//! fail to open the flag as a file (#6431); so is a flag without its value, rather than a silent
//! fall back to a generated token or to no root (#6429), a repeated flag, and a bad
//! `--present-mode` or `--frame-latency`. A value never starts with `-` (write `--opt=-value`),
//! except for negative numbers, and a file whose name starts with `-` goes after `--`.
//!
//! The control and automation flags are `photocraft-automation`'s, shared with `photocraft-cli`.
//! Only the desktop app reads the automation roots from `PHOTOCRAFT_AUTOMATION_*_ROOT` (#1551).
use std::ffi::OsString;
use std::fmt;
use std::path::PathBuf;
use clap::builder::FalseyValueParser;
use clap::parser::ValueSource;
use clap::{Command, CommandFactory, FromArgMatches, Parser};
use eframe::wgpu::PresentMode;
use photocraft_automation::args::{AutomationArgs, ControlArgs, blank_env_is_unset, conflict, control_port, name_env_vars};
use crate::presentation;
/// Opens PhotoCraft with the given files
#[derive(Parser)]
#[command(name = "photocraft", version = photocraft_engine::build_info::long_version_static(), after_help = ENVIRONMENT)]
pub(crate) struct LaunchArgs {
/// Files to open. On Windows and Linux they open as new tabs in the PhotoCraft already running.
/// Put `--` before a name that starts with `-`
pub(crate) files: Vec<PathBuf>,
/// Start with the CPU renderer for this launch (after a graphics driver crash)
#[arg(long)]
pub(crate) safe_gpu: bool,
/// macOS: keep the menus inside the window instead of the menu bar (the variable: 0, false, no
/// or off is off; empty is unset)
#[arg(long, env = "PHOTOCRAFT_IN_WINDOW_MENUS", value_parser = FalseyValueParser::new())]
pub(crate) in_window_menus: bool,
/// Windows and Linux: start a separate PhotoCraft instead of opening the files in the one
/// already running
#[arg(long)]
pub(crate) new_instance: bool,
/// How frames reach the display: fifo, autovsync or novsync (also immediate or mailbox).
/// Without it, PHOTOCRAFT_PRESENT_MODE
#[arg(long, value_name = "MODE", value_parser = present_mode)]
pub(crate) present_mode: Option<PresentMode>,
/// Frames queued for the display, 1 to 3. Without it, PHOTOCRAFT_FRAME_LATENCY
// allow_negative_numbers makes `--frame-latency -1` a bad value rather than an unexpected argument.
#[arg(long, value_name = "1-3", value_parser = frame_latency, allow_negative_numbers = true)]
pub(crate) frame_latency: Option<u32>,
/// Time each brush stroke's frames: stderr prints a summary per stroke, a CSV file path also
/// records every frame. Without it, PHOTOCRAFT_STROKE_TRACE
#[arg(long, value_name = "stderr|FILE.csv")]
pub(crate) stroke_trace: Option<String>,
/// Start the localhost JSON-lines control server on this port
#[arg(long, value_name = "PORT", env = "PHOTOCRAFT_CONTROL_PORT", value_parser = control_port, allow_negative_numbers = true)]
pub(crate) control: Option<u16>,
#[command(flatten)]
pub(crate) auth: ControlArgs,
#[command(flatten)]
pub(crate) automation: AutomationArgs,
/// The per-process options given on the command line, set by [`parse`].
#[arg(skip)]
pub(crate) launch_options: Vec<PerProcess>,
}
/// The variables that aren't a flag's fallback, for the end of `--help`.
const ENVIRONMENT: &str = "\
Environment: PHOTOCRAFT_NEW_INSTANCE (anything but 0 or empty: as --new-instance), PHOTOCRAFT_CONFIG_DIR (the settings folder)";
/// An option that applies only to the process it's given to: a launch with any of them keeps its
/// own process instead of handing its files to the running one (`single_instance`). Shown as the
/// flag, or the variable.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PerProcess {
AutomationReadRoot,
AutomationWriteRoot,
SafeGpu,
InWindowMenus,
PresentMode,
FrameLatency,
StrokeTrace,
/// PHOTOCRAFT_GPU_ADAPTER, when startup will act on it (`gpu_startup`). Not a flag.
GpuAdapterEnv,
}
impl PerProcess {
/// The flags, in the order a log line lists them. A flag's variable asks for the same thing, so
/// it counts too (#7242); the variables that aren't a flag's fallback are counted by
/// `single_instance::env_launch_options`.
pub(crate) const FLAGS: [PerProcess; 7] =
[Self::AutomationReadRoot, Self::AutomationWriteRoot, Self::SafeGpu, Self::InWindowMenus, Self::PresentMode, Self::FrameLatency, Self::StrokeTrace];
/// The flag's clap argument id.
fn id(self) -> Option<&'static str> {
Some(match self {
Self::AutomationReadRoot => "automation_read_root",
Self::AutomationWriteRoot => "automation_write_root",
Self::SafeGpu => "safe_gpu",
Self::InWindowMenus => "in_window_menus",
Self::PresentMode => "present_mode",
Self::FrameLatency => "frame_latency",
Self::StrokeTrace => "stroke_trace",
Self::GpuAdapterEnv => return None,
})
}
}
impl fmt::Display for PerProcess {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::AutomationReadRoot => "--automation-read-root",
Self::AutomationWriteRoot => "--automation-write-root",
Self::SafeGpu => "--safe-gpu",
Self::InWindowMenus => "--in-window-menus",
Self::PresentMode => "--present-mode",
Self::FrameLatency => "--frame-latency",
Self::StrokeTrace => "--stroke-trace",
Self::GpuAdapterEnv => "PHOTOCRAFT_GPU_ADAPTER",
})
}
}
/// The files to open, as [`LaunchArgs::file_names`] sorts them.
#[derive(Debug, PartialEq)]
pub(crate) struct FileNames {
pub(crate) openable: Vec<String>,
/// Names that are not valid Unicode, shown lossily: reported, not opened.
pub(crate) unreadable: Vec<String>,
}
impl LaunchArgs {
/// The files to open, and those whose names are not valid Unicode (#1108): a file manager can
/// hand over a Latin-1 name on Linux. Those are reported in a notice once the app is up.
pub(crate) fn file_names(&self) -> FileNames {
let mut names = FileNames { openable: Vec::new(), unreadable: Vec::new() };
for f in &self.files {
match f.to_str() {
Some(s) => names.openable.push(s.to_owned()),
None => names.unreadable.push(f.to_string_lossy().into_owned()),
}
}
names
}
}
/// The command line, with the desktop app's variables for the automation roots. An empty or blank
/// variable counts as unset.
pub(crate) fn command() -> Command {
LaunchArgs::command()
.mut_arg("automation_read_root", |a| a.env("PHOTOCRAFT_AUTOMATION_READ_ROOT"))
.mut_arg("automation_write_root", |a| a.env("PHOTOCRAFT_AUTOMATION_WRITE_ROOT"))
.mut_args(blank_env_is_unset)
}
/// Parse the launch arguments (without the binary name) with `cmd`, from [`command`]. Old macOS
/// passes a process serial number (`-psn_…`) when launched from Finder: it is dropped first, unless
/// it comes after `--`. A bad value from a variable is a usage error naming the variable.
pub(crate) fn parse(mut cmd: Command, args: impl IntoIterator<Item = OsString>) -> Result<LaunchArgs, clap::Error> {
let mut options = true;
let args: Vec<OsString> = args
.into_iter()
.filter(|a| {
options &= a != "--";
!(options && a.to_str().is_some_and(|s| s.starts_with("-psn_")))
})
.collect();
let argv = std::iter::once(OsString::from("photocraft")).chain(args.iter().cloned());
let matches = cmd.try_get_matches_from_mut(argv).map_err(|e| name_env_vars(e, &cmd, &args))?;
let mut launch = LaunchArgs::from_arg_matches(&matches)?;
// Only the control server reads a token, so a stale exported pair doesn't stop a launch.
if launch.control.is_some() {
conflict(&mut cmd, &matches, "control_token", "control_token_file")?;
}
// `PHOTOCRAFT_IN_WINDOW_MENUS=0` (or off, false, no) is a variable that turns the menus off.
let menus = launch.in_window_menus;
let given = |p: &PerProcess| match p.id().and_then(|id| matches.value_source(id)) {
Some(ValueSource::CommandLine) => true,
Some(ValueSource::EnvVariable) => *p != PerProcess::InWindowMenus || menus,
_ => false,
};
launch.launch_options = PerProcess::FLAGS.into_iter().filter(given).collect();
Ok(launch)
}
/// Why a value was refused: clap prints it after the flag and its value (exit 2).
#[derive(Debug, thiserror::Error)]
enum ValueError {
#[error("expected fifo, autovsync or novsync (or immediate, mailbox)")]
PresentMode,
#[error("expected 1, 2 or 3")]
FrameLatency,
}
fn present_mode(s: &str) -> Result<PresentMode, ValueError> {
presentation::present_mode(s).ok_or(ValueError::PresentMode)
}
fn frame_latency(s: &str) -> Result<u32, ValueError> {
presentation::frame_latency(s).ok_or(ValueError::FrameLatency)
}
#[cfg(test)]
mod tests {
use super::*;
use clap::error::ErrorKind;
/// Parse without the variables, so one exported where the tests run can't change them (the
/// process tests in `tests/command_line_usage.rs` cover the variables).
fn parse_args(args: &[&str]) -> Result<LaunchArgs, clap::Error> {
parse(command().mut_args(|a| a.env(None::<&str>)), args.iter().map(OsString::from))
}
fn usage_error(args: &[&str]) -> String {
let Err(e) = parse_args(args) else { panic!("{args:?} parsed") };
assert_eq!(e.exit_code(), 2, "{args:?}: {e}");
e.to_string()
}
#[test]
fn control_port_rejects_unparseable_values_naming_them() {
// Out of range, negative and empty values fail, naming the value (#701); a following
// flag is never taken as the value.
assert!(usage_error(&["--control", "78787"]).contains("invalid value '78787' for '--control <PORT>'"));
assert!(usage_error(&["--control", "-1"]).contains("invalid value '-1'"));
assert!(usage_error(&["--control="]).contains("invalid value '' for '--control <PORT>'"));
assert!(usage_error(&["--control", "--safe-gpu"]).contains("a value is required for '--control <PORT>'"));
}
#[test]
fn flags_without_a_value_are_usage_errors() {
// #6429: a missing value fails the launch, rather than falling back to a generated token
// or to no root. The tuning flags too, now (#7071).
for flag in [
"--control",
"--control-token",
"--control-token-file",
"--automation-read-root",
"--automation-write-root",
"--present-mode",
"--frame-latency",
"--stroke-trace",
] {
let err = usage_error(&[flag]);
assert!(err.contains(&format!("a value is required for '{flag} <")), "{flag}: {err}");
}
}
#[test]
fn bad_tuning_values_and_repeats_are_usage_errors() {
assert!(usage_error(&["--present-mode", "fast"]).contains("expected fifo, autovsync or novsync"));
for latency in ["0", "4", "-1", "one"] {
let err = usage_error(&["--frame-latency", latency]);
assert!(err.contains(&format!("invalid value '{latency}' for '--frame-latency <1-3>'")), "{err}");
}
assert!(usage_error(&["--safe-gpu", "--safe-gpu"]).contains("cannot be used multiple times"));
assert!(usage_error(&["--control", "1", "--control", "2"]).contains("cannot be used multiple times"));
// A token and a token file are either/or for the control server, and ignored without it.
let pair = ["--control-token", "abcd", "--control-token-file", "/tmp/token"];
assert!(usage_error(&[&["--control", "1"][..], &pair[..]].concat()).contains("cannot be used with"));
assert!(parse_args(&pair).is_ok());
}
#[test]
fn values_are_parsed_and_per_process_options_recorded() {
let parsed = parse_args(&[
"--stroke-trace",
"stderr",
"--control",
"50494",
"--control-token",
"abcd",
"--automation-read-root",
"/r",
"--automation-write-root",
"/w",
"--safe-gpu",
"--in-window-menus",
"--new-instance",
"--present-mode",
"Mailbox",
"--frame-latency",
"1",
"photo.psd",
])
.unwrap();
assert_eq!(parsed.control, Some(50494));
assert_eq!(parsed.auth.control_token.as_deref(), Some("abcd"));
assert_eq!(parsed.auth.control_token_file, None);
assert_eq!(parsed.automation.automation_read_root.as_deref(), Some(std::path::Path::new("/r")));
assert_eq!(parsed.automation.automation_write_root.as_deref(), Some(std::path::Path::new("/w")));
assert!(parsed.safe_gpu && parsed.in_window_menus && parsed.new_instance);
assert_eq!(parsed.present_mode, Some(PresentMode::AutoNoVsync));
assert_eq!(parsed.frame_latency, Some(1));
assert_eq!(parsed.stroke_trace.as_deref(), Some("stderr"));
// In a fixed order: `single_instance` only lists them in a log line.
assert_eq!(parsed.launch_options, PerProcess::FLAGS);
assert_eq!(parsed.file_names(), FileNames { openable: vec!["photo.psd".to_string()], unreadable: vec![] });
let parsed = parse_args(&["--control-token-file", "/tmp/token", "--new-instance"]).unwrap();
assert_eq!(parsed.auth.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token")));
assert!(parsed.launch_options.is_empty() && !parsed.in_window_menus, "{:?}", parsed.launch_options);
}
#[test]
fn version_stops_parsing() {
let e = parse_args(&["--version", "--control"]).err().unwrap();
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayVersion, 0));
assert_eq!(e.to_string(), format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
}
#[test]
fn help_stops_parsing_and_double_dash_ends_options() {
let e = parse_args(&["-h", "--safegpu", "--control"]).err().unwrap();
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayHelp, 0));
assert!(usage_error(&["--safegpu", "--", "--help"]).contains("unexpected argument '--safegpu'"));
let parsed = parse_args(&["a.psd", "--", "--help", "-x"]).unwrap();
assert_eq!(parsed.file_names().openable, ["a.psd", "--help", "-x"]);
}
#[test]
fn a_finder_process_serial_number_is_dropped() {
let parsed = parse_args(&["-psn_0_12345", "a.psd"]).unwrap();
assert_eq!(parsed.file_names().openable, ["a.psd"]);
let parsed = parse_args(&["--", "-psn_x.psd"]).unwrap();
assert_eq!(parsed.file_names().openable, ["-psn_x.psd"]);
}
/// #1108: a name that isn't valid Unicode is reported, not opened; a token must be text.
#[cfg(unix)]
#[test]
fn non_unicode_file_names_are_reported_and_tokens_rejected() {
use std::os::unix::ffi::OsStringExt as _;
let latin1 = OsString::from_vec(b"caf\xe9.psd".to_vec());
let parsed = parse(command().mut_args(|a| a.env(None::<&str>)), [latin1.clone()]).unwrap();
assert_eq!(parsed.file_names(), FileNames { openable: vec![], unreadable: vec!["caf\u{fffd}.psd".to_string()] });
let e = parse(command().mut_args(|a| a.env(None::<&str>)), [OsString::from("--control-token"), latin1]).err().unwrap();
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::InvalidUtf8, 2));
}
}
+268 -361
View File
@@ -1,14 +1,7 @@
//! 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 `--`.
//! Usage: `photocraft [options] [--] [files…]`; `photocraft --help` lists the options, which are
//! declared, with what makes a usage error (exit status 2), in `launch_args`.
//!
//! 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
@@ -31,7 +24,8 @@
//! `{"id":1,"ok":true,"result":…}`. See `photocraft_ui_egui::control` for the methods.
// Release builds on Windows are GUI-subsystem apps, so launching from the Start Menu or Explorer
// doesn't open a console window. (`--version` output then only shows when redirected.)
// doesn't open a console window. (`--help`, `--version` and usage errors then only show when
// redirected.)
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -49,6 +43,7 @@ mod file_dialog_probe;
mod gpu_startup;
#[cfg(target_os = "linux")]
mod icon_theme;
mod launch_args;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
@@ -74,6 +69,7 @@ mod single_instance;
// Tablet support (macOS AppKit monitor, Linux X11/Wayland, Windows barrel button).
mod tablet;
mod ui_state;
mod updates;
mod window_frame;
use photocraft_engine::Session;
@@ -96,6 +92,53 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
CUSTOM_TITLEBAR && !prefs.interface.system_title_bar
}
/// Preferences › Integrations, read before the window opens the way [`custom_titlebar`] is:
/// whether agents may drive this launch at all, and the port to listen on when no `--control`
/// was given (0 = only with `--control`). A missing or unreadable file keeps the defaults.
///
/// The section is read on its own, and when it doesn't read as a whole each value on its own,
/// as the app's lenient load reads them (#6611): a wrong-typed value elsewhere in the file, or
/// in the other field, must not turn Allow Agent Control back on while the dialog shows it off.
fn integrations(prefs_file: Option<&std::path::Path>) -> photocraft_engine::prefs::Integrations {
use serde_json::Value;
let mut out = photocraft_engine::prefs::Preferences::default().integrations;
let file: Option<Value> = prefs_file.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok());
let Some(section) = file.as_ref().and_then(|v| v.get("integrations")) else {
return out;
};
if let Ok(whole) = serde_json::from_value(section.clone()) {
return whole;
}
if let Some(allow) = section.get("allowAgentControl").and_then(Value::as_bool) {
out.allow_agent_control = allow;
}
if let Some(port) = section.get("controlPort").and_then(Value::as_u64).and_then(|p| u32::try_from(p).ok()) {
out.control_port = port;
}
out
}
/// The control port this launch listens on, and the warning to print when a request was refused.
/// `--control` (or `PHOTOCRAFT_CONTROL_PORT`) wins over the preference; Allow Agent Control off
/// refuses both, and says so rather than ignoring the flag silently. A preferred port outside
/// 1..=65535 is refused the same way.
fn control_port_for(cli: Option<u16>, prefs: &photocraft_engine::prefs::Integrations) -> (Option<u16>, Option<String>) {
if !prefs.allow_agent_control {
let warning = cli.map(|p| format!("photocraft: Preferences › Integrations › Allow Agent Control is off: not listening on port {p}\n"));
return (None, warning);
}
if let Some(port) = cli {
return (Some(port), None);
}
match prefs.control_port {
0 => (None, None),
p => match u16::try_from(p).ok().filter(|p| *p != 0) {
Some(port) => (Some(port), None),
None => (None, Some(format!("photocraft: Preferences › Integrations › Control Port {p} is not a usable port: not listening\n"))),
},
}
}
/// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame
/// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without
/// `centered`, Windows cascades each new window from the top-left corner, so it opened at a
@@ -115,6 +158,9 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
.with_titlebar_shown(false)
.with_title_shown(false),
centered: true,
// PhotoCraft owns its fallback fonts (`cjk_fonts`, craft-fonts): eframe's system-font
// provider would compete with it and make glyph choice machine-dependent (#8512).
system_font_fallback: false,
// eframe saves native window geometry and egui panel/window sizes on exit.
// Keep that state beside preferences, including config overrides and portable mode.
persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")),
@@ -147,360 +193,39 @@ fn at_least_min_size(mut builder: egui::ViewportBuilder) -> egui::ViewportBuilde
builder
}
/// Parse a `--control` / `PHOTOCRAFT_CONTROL_PORT` value. An unparseable port is an error that
/// names the value (issue #701): silently running with no control server leaves a launcher or
/// agent unable to tell a typo from a successful grant.
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> {
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};
#[test]
fn control_port_errors_exit_non_zero() {
assert_eq!(usage_exit_code(&[]), None);
let errors = vec![parse_control_port("nope", "--control").unwrap_err()];
assert_eq!(usage_exit_code(&errors), Some(2));
}
#[test]
fn control_port_accepts_valid_numbers() {
assert_eq!(parse_control_port("50494", "--control"), Ok(50494));
assert_eq!(parse_control_port(" 8080 ", "--control"), Ok(8080));
assert_eq!(parse_control_port("0", "PHOTOCRAFT_CONTROL_PORT"), Ok(0));
}
#[test]
fn control_port_rejects_unparseable_values_naming_them() {
// Out of range, a following flag eaten as the value, and an empty value all fail,
// naming the offending value in the message.
let err = parse_control_port("78787", "--control").unwrap_err();
assert!(err.contains("78787"), "{err}");
assert!(parse_control_port("--safe-gpu", "--control").is_err());
assert!(parse_control_port("", "--control").is_err());
}
}
#[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()
};
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),
#[cfg(not(target_arch = "wasm32"))]
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::PHOTOCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Err(e) = done {
eprintln!("PhotoCraft update helper failed: {e}");
std::process::exit(1);
}
}
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(());
}
// 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 {
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,
// The system font scan can take seconds (minutes on some Macs): run it off the UI thread.
photocraft_ui_egui::type_tool::warm_fonts();
// `--help` and `--version` print to standard output and exit 0. A usage error (an unknown
// option, a missing or bad value, a bad PHOTOCRAFT_CONTROL_PORT: issues #701, #6429, #6431)
// names the argument on standard error and exits 2, so a launcher sees the failure. Neither
// panics when the stream is closed or a pipe (`photocraft --help | head -1`).
let launch = launch_args::parse(launch_args::command(), std::env::args_os().skip(1)).unwrap_or_else(|e| e.exit());
let launch_args::FileNames { openable: files, unreadable: unreadable_paths } = launch.file_names();
let launch_args::LaunchArgs {
files: _,
safe_gpu,
new_instance,
mut launch_options,
present_mode_arg,
frame_latency_arg,
stroke_trace_arg,
in_window_menus,
help: _,
version: _,
errors: _,
new_instance,
present_mode,
frame_latency,
stroke_trace,
control: control_port,
auth,
automation,
mut launch_options,
} = launch;
// The log file lives under the settings directory; opened after the arguments, so `--version`
@@ -516,7 +241,7 @@ 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");
launch_options.push(launch_args::PerProcess::GpuAdapterEnv);
}
// 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
@@ -596,8 +321,12 @@ fn main() -> eframe::Result {
std::process::exit(1);
}
let (control_port, control_pref_warning) = control_port_for(control_port, &integrations(services::prefs_file().as_deref()));
if let Some(warning) = &control_pref_warning {
eprint!("{warning}");
}
let control = if let Some(port) = control_port {
let (supplied, token_file) = photocraft_automation::security::token_inputs(control_token, control_token_file);
let (supplied, token_file) = (auth.control_token, auth.control_token_file);
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
Ok(token) => token,
Err(e) => {
@@ -612,7 +341,7 @@ fn main() -> eframe::Result {
} else {
eprintln!("photocraft: using supplied control token");
}
let workspace = match photocraft_automation::AuthorizedWorkspace::new(automation_read_root.as_deref(), automation_write_root.as_deref()) {
let workspace = match automation.workspace() {
Ok(workspace) => workspace,
Err(error) => {
eprintln!("photocraft: cannot configure automation workspace: {error}");
@@ -701,7 +430,7 @@ fn main() -> eframe::Result {
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());
let present = presentation::Overrides::from_args_or_env(present_mode, frame_latency);
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.
@@ -745,6 +474,7 @@ fn main() -> eframe::Result {
};
#[cfg(not(target_os = "linux"))]
let session = Session::new();
PhotocraftApp::install_type_reset_hooks(&cc.egui_ctx);
let mut app = PhotocraftApp::new(session, services);
app.min_frame_ms = uncapped.then_some(1000.0 / 240.0);
app.integrated_titlebar = cfg!(target_os = "macos");
@@ -779,7 +509,7 @@ fn main() -> eframe::Result {
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 {
if let Some(v) = &stroke_trace {
app.stroke_trace.set(v);
}
info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() };
@@ -937,9 +667,10 @@ fn main() -> eframe::Result {
s.finish();
}
if let Ok(exe) = std::env::current_exe() {
// `--safe-gpu` first: after a `--`, it would be a file name.
let launched = std::process::Command::new(exe)
.args(std::env::args_os().skip(1))
.arg("--safe-gpu")
.args(std::env::args_os().skip(1))
.env_remove("WGPU_BACKEND")
.env("PHOTOCRAFT_GPU_STARTUP_FAILURE", &reason)
.spawn();
@@ -1001,10 +732,101 @@ mod tests {
egui::Panel::right("dock").default_size(290.0).show(ui, |ui| ui.set_min_width(ui.available_width()));
});
output.textures_delta.clear();
let panel = egui::containers::panel::PanelState::load(&restored, egui::Id::new("dock")).unwrap();
let panel = egui::containers::panel::PanelState::load(&restored, photocraft_ui_egui::panels::panel_state_id(&restored, "dock")).unwrap();
assert_eq!(panel.size().x, 410.0);
}
/// A 0.36 `ui.ron` saved panel widths under `Id::new("dock")`; the first 0.37 launch copies
/// them to the parent-scoped key, leaves the old one for a rollback, and a later resize
/// survives two more restarts.
#[test]
fn saved_036_panel_widths_migrate_and_survive_two_restarts() {
#[derive(Default)]
struct Storage(std::collections::BTreeMap<String, String>);
impl eframe::Storage for Storage {
fn get_string(&self, key: &str) -> Option<String> {
self.0.get(key).cloned()
}
fn set_string(&mut self, key: &str, value: String) {
self.0.insert(key.into(), value);
}
fn remove_string(&mut self, key: &str) {
self.0.remove(key);
}
fn flush(&mut self) {}
}
let state = |width: f32| egui::containers::panel::PanelState { outer_rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 600.0)) };
let input = || egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))), ..Default::default() };
let draw = |ctx: &egui::Context| {
let mut output = ctx.run_ui(input(), |ui| {
egui::Panel::right("dock").default_size(290.0).show(ui, |ui| ui.set_min_width(ui.available_width()));
});
output.textures_delta.clear();
};
let save = |ctx: &egui::Context| {
let mut storage = Storage::default();
ctx.memory(|memory| eframe::set_value(&mut storage, "egui", memory));
storage
};
let restore = |storage: &Storage| {
let ctx = egui::Context::default();
ctx.memory_mut(|memory| *memory = eframe::get_value(storage, "egui").expect("the saved memory"));
ctx
};
// The real 0.14/0.15 shape: the panel state under the old key inside the egui memory.
let ctx = egui::Context::default();
ctx.data_mut(|d| d.insert_persisted(egui::Id::unique("dock"), state(410.0)));
let storage = save(&ctx);
let first = restore(&storage);
photocraft_ui_egui::panels::migrate_panel_state(&first);
draw(&first);
let key = photocraft_ui_egui::panels::panel_state_id(&first, "dock");
assert_eq!(egui::containers::panel::PanelState::load(&first, key).map(|p| p.size().x), Some(410.0));
assert!(egui::containers::panel::PanelState::load(&first, egui::Id::unique("dock")).is_some(), "the 0.36 key is kept");
// Resize in 0.37: the new key wins from now on.
first.data_mut(|d| d.insert_persisted(key, state(500.0)));
let storage = save(&first);
for restart in 0..2 {
let next = restore(&storage);
photocraft_ui_egui::panels::migrate_panel_state(&next);
draw(&next);
let key = photocraft_ui_egui::panels::panel_state_id(&next, "dock");
let width = egui::containers::panel::PanelState::load(&next, key).map(|p| p.size().x).unwrap_or_default();
assert!((width - 500.0).abs() < 0.5, "restart {restart}: the old 410 key must not win (got {width})");
}
}
/// Schema-probe `ui.ron` shapes *reconstructed* from the documented 0.14/0.15/0.37 layouts,
/// not recorded machine saves: 0.14 and 0.15 both store the flat window entry, which the
/// first 0.37 launch rewrites; a 0.37 file is left alone. Keeping all three probes makes a
/// future change to any migration rule fail here first. They do not prove eframe's real
/// `Memory`/`PanelState` deserialization.
#[test]
fn the_ui_ron_schema_probe_fixtures_migrate() {
for (name, fixture) in [("0.14", include_str!("../tests/fixtures/ui-0.14.ron")), ("0.15", include_str!("../tests/fixtures/ui-0.15.ron"))] {
let text = super::ui_state::migrate_for_test(fixture).unwrap_or_else(|e| panic!("{name}: {e}")).unwrap_or_else(|| panic!("{name}: not rewritten"));
assert!(text.contains("normal_geometry"), "{name}: the flat window entry is gone: {text}");
assert!(!super::ui_state::saved_position_for_test(fixture), "{name}: a flat entry is not what 0.37 restores");
}
let old = include_str!("../tests/fixtures/ui-0.14.ron");
assert!(super::ui_state::migrate_for_test(old).expect("migratable").expect("rewritten").contains("x:1100.0"), "the 0.14 size is preserved");
let current = include_str!("../tests/fixtures/ui-0.15.ron");
assert!(
super::ui_state::migrate_for_test(current).expect("migratable").expect("rewritten").contains("maximized:true"),
"0.15 maximized state survives"
);
// Already 0.37-shaped: no rewrite, and (with no saved position) the window centres.
let current = include_str!("../tests/fixtures/ui-0.37.ron");
assert_eq!(super::ui_state::migrate_for_test(current), Ok(None));
assert!(!super::ui_state::saved_position_for_test(current));
// And a 0.37 file with a restorable outer position is a saved position.
let with_position = current.replace("outer_position_pixels:None", "outer_position_pixels:Some((x:2048.0,y:1228.0))");
assert!(super::ui_state::saved_position_for_test(&with_position));
}
#[test]
fn the_window_opens_centred_at_its_default_size() {
let o = super::native_options(super::CUSTOM_TITLEBAR);
@@ -1073,6 +895,91 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn integrations_preferences_decide_the_control_port() {
use photocraft_engine::prefs::Integrations;
let off = Integrations { allow_agent_control: false, control_port: 0 };
let on = Integrations { allow_agent_control: true, control_port: 0 };
let preferred = Integrations { allow_agent_control: true, control_port: 7878 };
// Nothing asked for, nothing listening.
assert_eq!(super::control_port_for(None, &on), (None, None));
// --control wins over the preference.
assert_eq!(super::control_port_for(Some(9000), &preferred), (Some(9000), None));
// The preference opens the port when --control is absent.
assert_eq!(super::control_port_for(None, &preferred), (Some(7878), None));
// Allow Agent Control off refuses both, and says so when --control asked for one
// rather than ignoring the flag.
assert_eq!(super::control_port_for(None, &off), (None, None));
let (port, warning) = super::control_port_for(Some(9000), &off);
assert_eq!(port, None);
assert!(warning.is_some_and(|w| w.contains("Allow Agent Control") && w.contains("9000")), "the refusal names the port");
let off_with_port = Integrations { allow_agent_control: false, control_port: 7878 };
assert_eq!(super::control_port_for(None, &off_with_port), (None, None), "off beats a preferred port");
// A port a hand-edited file could hold, outside 1..=65535, is refused and named.
let huge = Integrations { allow_agent_control: true, control_port: 70_000 };
let (port, warning) = super::control_port_for(None, &huge);
assert_eq!(port, None);
assert!(warning.is_some_and(|w| w.contains("70000")), "the refusal names the value");
}
#[test]
fn integrations_fall_back_to_the_defaults_without_a_readable_file() {
let dir = std::env::temp_dir().join(format!("pc-integrations-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("prefs.json");
let default = photocraft_engine::prefs::Integrations::default();
assert_eq!(super::integrations(None), default, "no preferences file");
std::fs::write(&file, "not json").unwrap();
assert_eq!(super::integrations(Some(&file)), default, "an unreadable file");
std::fs::write(&file, r#"{"integrations":{"allowAgentControl":false,"controlPort":7878}}"#).unwrap();
let read = super::integrations(Some(&file));
assert!(!read.allow_agent_control);
assert_eq!(read.control_port, 7878);
let _ = std::fs::remove_dir_all(&dir);
}
/// The app loads preferences leniently (#6611): one wrong-typed value resets only itself. So
/// a bad value elsewhere, or in the port, must not turn Allow Agent Control back on.
#[test]
fn a_bad_value_elsewhere_keeps_agent_control_off() {
let dir = std::env::temp_dir().join(format!("pc-integrations-lenient-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("prefs.json");
// What the Preferences dialog shows for the same file.
let shown = |text: &str| {
let mut session = photocraft_engine::Session::new();
session.load_prefs_json(text).unwrap();
session.prefs().integrations.clone()
};
let read = |text: &str| {
std::fs::write(&file, text).unwrap();
let read = super::integrations(Some(&file));
assert_eq!(read, shown(text), "{text}");
read
};
let r = read(r#"{"interface":{"enableAnimations":"nope"},"integrations":{"allowAgentControl":false,"controlPort":7878}}"#);
assert!(!r.allow_agent_control, "a bad value in another section");
assert_eq!(r.control_port, 7878);
let r = read(r#"{"integrations":{"allowAgentControl":false,"controlPort":"x"}}"#);
assert!(!r.allow_agent_control, "a bad port");
assert_eq!(r.control_port, 0, "the bad port alone takes its default");
// A bad Allow Agent Control alone takes its default.
let r = read(r#"{"integrations":{"allowAgentControl":"no","controlPort":7878}}"#);
assert_eq!(r, photocraft_engine::prefs::Integrations { allow_agent_control: true, control_port: 7878 });
read(r#"{"integrations":{"allowAgentControl":false,"controlPort":-1}}"#);
read(r#"{"integrations":{"allowAgentControl":false,"controlPort":4294967296}}"#);
read(r#"{"integrations":"off"}"#);
// serde reads a struct from a list too; the app does.
assert!(!read(r#"{"integrations":[false]}"#).allow_agent_control, "a list");
assert_eq!(read(r#"{"integrations":[false,7878]}"#).control_port, 7878);
let _ = std::fs::remove_dir_all(&dir);
}
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
#[test]
+39 -28
View File
@@ -2,8 +2,10 @@
//! 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.
//! queue). Unset, the platform default applies. A variable's value that isn't one of these is ignored
//! with a warning in the log, never a failed start; the `--present-mode` and `--frame-latency` flags
//! take the same values, and a bad one is a usage error (#7071). 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;
@@ -16,45 +18,54 @@ pub struct Overrides {
pub latency: Option<u32>,
}
/// A `--present-mode` / `PHOTOCRAFT_PRESENT_MODE` value, or `None` when it isn't one.
pub fn present_mode(v: &str) -> Option<PresentMode> {
match v.trim().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" => Some(PresentMode::Fifo),
"autovsync" => Some(PresentMode::AutoVsync),
"novsync" | "immediate" | "mailbox" | "autonovsync" => Some(PresentMode::AutoNoVsync),
_ => None,
}
}
/// A `--frame-latency` / `PHOTOCRAFT_FRAME_LATENCY` value (1 to 3), or `None` when it isn't one.
pub fn frame_latency(v: &str) -> Option<u32> {
v.trim().parse().ok().filter(|n| (1..=3).contains(n))
}
/// 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
let m = present_mode(v);
if m.is_none() {
warnings.push(format!("PHOTOCRAFT_PRESENT_MODE={v:?} isn't fifo, autovsync or novsync (immediate, mailbox); ignored"));
}
m
});
let latency = latency.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| {
let n = frame_latency(v);
if n.is_none() {
warnings.push(format!("PHOTOCRAFT_FRAME_LATENCY={v:?} isn't 1, 2 or 3; ignored"));
}
n
});
(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()));
/// `--present-mode` / `--frame-latency` when given (checked by the command line), else
/// `PHOTOCRAFT_PRESENT_MODE` / `PHOTOCRAFT_FRAME_LATENCY`, whose rejected values are logged.
pub fn from_args_or_env(mode: Option<PresentMode>, latency: Option<u32>) -> Self {
let env = |given: bool, name: &str| if given { None } else { std::env::var(name).ok() };
let (o, warnings) = parse(env(mode.is_some(), "PHOTOCRAFT_PRESENT_MODE").as_deref(), env(latency.is_some(), "PHOTOCRAFT_FRAME_LATENCY").as_deref());
for w in warnings {
log::warn!("{w}");
}
o
Self { mode: mode.or(o.mode), latency: latency.or(o.latency) }
}
/// `surface` with the overrides applied.
+9 -1
View File
@@ -1,6 +1,11 @@
//! Desktop colour capture starts only on an explicit UI request. No snapshots are written to
//! disk, transmitted or retained after a pick. Wayland delegates to its permission portal.
use photocraft_ui_egui::screen_picker::{Capture, MAX_PIXELS, Pending, ScreenImage, Service};
use photocraft_ui_egui::screen_picker::{Capture, Pending, Service};
// Only the platform capture backends (and the tests) build images and check sizes.
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
use photocraft_ui_egui::screen_picker::MAX_PIXELS;
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
use photocraft_ui_egui::screen_picker::ScreenImage;
pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option<Service> {
if !cfg!(any(target_os = "linux", target_os = "macos", target_os = "windows")) {
@@ -34,6 +39,7 @@ pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option<Servic
}))
}
#[derive(Clone, Copy)]
#[cfg_attr(not(any(target_os = "linux", target_os = "macos", target_os = "windows", test)), allow(dead_code))]
struct Placement {
index: usize,
x: i32,
@@ -41,9 +47,11 @@ struct Placement {
width: u32,
height: u32,
}
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
fn find_monitor(placements: &[Placement], x: i32, y: i32, w: u32, h: u32) -> Option<usize> {
placements.iter().find(|p| p.x == x && p.y == y && p.width == w && p.height == h).map(|p| p.index)
}
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", test))]
fn count(width: usize, height: usize) -> Result<usize, String> {
width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_PIXELS).ok_or_else(|| "Screen capture exceeds the 64 MP memory budget".into())
}
+18 -3
View File
@@ -15,13 +15,13 @@ use std::sync::Arc;
mod recovery_tests;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .act, .ase) and keyboard shortcut sets
/// Photoshop brushes (.abr), gradients (.grd), layer styles (.asl), swatches (.aco, .act, .ase) and keyboard shortcut sets
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "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", "dcm", "dicom", "dc3", "iff", "lbm",
"ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase",
"kys", "af", "afdesign", "afphoto", "afpub",
"ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "asl", "svg", "svgz", "aco", "act",
"ase", "kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -71,6 +71,7 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[
("Camera Raw", &["dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf"]),
("Brushes", &["abr"]),
("Gradients", &["grd"]),
("Styles", &["asl"]),
("Swatches", &["aco", "act", "ase"]),
("Keyboard Shortcuts", &["kys"]),
];
@@ -104,6 +105,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
// Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327).
("DDS", &["dds"]),
("Cineon", &["cin"]),
("Amiga IFF", &["iff"]),
];
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
@@ -560,6 +562,12 @@ fn shell_word(s: &str) -> String {
if s.bytes().all(|b| b.is_ascii_alphanumeric() || b"/._-+:,@".contains(&b)) { s.to_string() } else { format!("'{}'", s.replace('\'', r"'\''")) }
}
/// The release-check transport, or `None` inside Flatpak: the sandbox has no network (see the
/// manifest's finish-args) and `flatpak update` owns updates there.
fn update_transport(flatpak_id: Option<&std::ffi::OsStr>) -> Option<photocraft_engine::updates_cmds::CheckFn> {
flatpak_id.is_none().then(crate::updates::service)
}
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();
@@ -624,6 +632,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
inbox: None,
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
beep: Some(platform_beep),
check_updates: update_transport(std::env::var_os("FLATPAK_ID").as_deref()),
clipboard_set_image: Some({
let clip = clip.clone();
guarded_clipboard_set(move |w: u32, h: u32, px: &[u8]| {
@@ -903,6 +912,12 @@ mod tests {
);
}
#[test]
fn flatpak_builds_have_no_update_transport() {
assert!(update_transport(Some(std::ffi::OsStr::new("ai.storyteller.photocraft"))).is_none());
assert!(update_transport(None).is_some());
}
#[test]
fn xwayland_command_matches_how_photocraft_was_installed() {
// Flatpak: WAYLAND_DISPLAY doesn't reach the sandbox's socket choice; flatpak's flags do.
+30 -47
View File
@@ -41,6 +41,8 @@ use photocraft_automation::security::{LineRead, generate_token, read_bounded_lin
use photocraft_ui_egui::{OsEvent, OsEventsFn};
use serde_json::{Value, json};
use crate::launch_args::PerProcess;
const LOCK_FILE: &str = "instance.lock";
const INFO_FILE: &str = "instance.json";
/// The Unix socket a sandboxed primary listens on, in the app's runtime directory.
@@ -131,20 +133,19 @@ pub struct LaunchContext<'a> {
pub unreadable_paths: bool,
/// Options that only apply to the process they're given to (`--safe-gpu`, …), including the
/// ones this launch's environment sets (see [`env_launch_options`]).
pub launch_options: &'a [&'static str],
pub launch_options: &'a [PerProcess],
}
/// Variables documented as the equivalent of a per-launch flag, each with the flag it stands for
/// Variables documented as the equivalent of a per-launch flag, each with the option it stands for
/// (`PHOTOCRAFT_PRESENT_MODE` is `--present-mode`) and the rule that says whether its value asks for
/// that option at all. Setting one asks for the same thing as giving the flag, so it belongs to this
/// process alone.
const ENV_LAUNCH_OPTIONS: [(&str, &str, ValueRule); 6] = [
("PHOTOCRAFT_PRESENT_MODE", "--present-mode", present_mode_is_on),
("PHOTOCRAFT_FRAME_LATENCY", "--frame-latency", frame_latency_is_on),
("PHOTOCRAFT_STROKE_TRACE", "--stroke-trace", stroke_trace_is_on),
("PHOTOCRAFT_IN_WINDOW_MENUS", "--in-window-menus", flag_is_on),
("PHOTOCRAFT_AUTOMATION_READ_ROOT", "--automation-read-root", path_is_set),
("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "--automation-write-root", path_is_set),
/// process alone. These are read where they take effect, not by the command line; the variables
/// that are a flag's fallback (`PHOTOCRAFT_IN_WINDOW_MENUS`, the automation roots) are counted by
/// [`crate::launch_args::parse`], as clap reads them.
const ENV_LAUNCH_OPTIONS: [(&str, PerProcess, ValueRule); 3] = [
("PHOTOCRAFT_PRESENT_MODE", PerProcess::PresentMode, present_mode_is_on),
("PHOTOCRAFT_FRAME_LATENCY", PerProcess::FrameLatency, frame_latency_is_on),
("PHOTOCRAFT_STROKE_TRACE", PerProcess::StrokeTrace, stroke_trace_is_on),
];
/// Whether a variable's value asks this launch for its own option. Each rule matches how that
@@ -169,29 +170,15 @@ fn stroke_trace_is_on(value: &str) -> bool {
!matches!(value.trim(), "" | "0" | "off" | "false")
}
/// Any value except empty and exactly `0`, as `main.rs` reads `PHOTOCRAFT_IN_WINDOW_MENUS` - which is
/// why `off` means on there, why this rule is not shared with the stroke trace above, and why it does
/// not trim: a padded `" 0 "` enables the menus there, so it keeps a process here too.
fn flag_is_on(value: &str) -> bool {
!value.is_empty() && value != "0"
}
/// A path is an option; an empty value is no path. Not trimmed: a path may end in a space. An empty
/// root is read as no root unless `--control` is given, and that flag keeps its own process already.
fn path_is_set(value: &str) -> bool {
!value.is_empty()
}
/// The per-process options this launch's environment sets, named as the flags they stand for. A
/// launch that sets only variables would otherwise hand its files to the running instance, which
/// never sees the override, and exit.
pub fn env_launch_options() -> Vec<&'static str> {
/// The per-process options this launch's environment sets. A launch that sets only variables would
/// otherwise hand its files to the running instance, which never sees the override, and exit.
pub fn env_launch_options() -> Vec<PerProcess> {
env_launch_options_from(|name| std::env::var_os(name))
}
/// [`env_launch_options`] with the reader given, so the rule is testable without the environment.
fn env_launch_options_from(get: impl Fn(&str) -> Option<std::ffi::OsString>) -> Vec<&'static str> {
ENV_LAUNCH_OPTIONS.iter().filter(|(name, _, is_on)| get(name).is_some_and(|v| is_on(&v.to_string_lossy()))).map(|(_, flag, _)| *flag).collect()
fn env_launch_options_from(get: impl Fn(&str) -> Option<std::ffi::OsString>) -> Vec<PerProcess> {
ENV_LAUNCH_OPTIONS.iter().filter(|(name, _, is_on)| get(name).is_some_and(|v| is_on(&v.to_string_lossy()))).map(|(_, option, _)| *option).collect()
}
/// Why this launch runs as its own instance, or `None` to take part in single-instance.
@@ -209,7 +196,8 @@ pub fn skip_reason(c: &LaunchContext) -> Option<String> {
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(", ")));
let options: Vec<String> = c.launch_options.iter().map(ToString::to_string).collect();
return Some(format!("{} only applies to a new process", options.join(", ")));
}
None
}
@@ -898,8 +886,8 @@ mod tests {
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")));
let reason = skip_reason(&LaunchContext { launch_options: &[PerProcess::SafeGpu, PerProcess::GpuAdapterEnv], ..base });
assert_eq!(reason.as_deref(), Some("--safe-gpu, PHOTOCRAFT_GPU_ADAPTER only applies to a new process"));
}
/// #1540: inside a Flatpak sandbox the hand-off runs over a Unix socket in the app's runtime
@@ -983,30 +971,25 @@ mod tests {
#[test]
fn an_environment_override_counts_as_a_process_option() {
let with = |values: &[(&str, &str)]| env_launch_options_from(|n| values.iter().find(|(k, _)| *k == n).map(|(_, v)| (*v).into()));
assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), vec!["--present-mode"]);
assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), vec![PerProcess::PresentMode]);
assert_eq!(
with(&[("PHOTOCRAFT_STROKE_TRACE", "strokes.csv"), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "/tmp/out")]),
vec!["--stroke-trace", "--automation-write-root"]
with(&[("PHOTOCRAFT_STROKE_TRACE", "strokes.csv"), ("PHOTOCRAFT_FRAME_LATENCY", "2")]),
vec![PerProcess::FrameLatency, PerProcess::StrokeTrace]
);
// Nothing set, or a value that means off, is an ordinary launch. Each variable is off the way
// it is read where it takes effect, not by one rule shared across all six.
// it is read where it takes effect, not by one rule shared across all three.
assert!(with(&[]).is_empty());
assert!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "0"), ("PHOTOCRAFT_FRAME_LATENCY", "")]).is_empty());
assert!(with(&[("PHOTOCRAFT_FRAME_LATENCY", "")]).is_empty());
// The trace is off at `off` and `false` as well, and a value the presentation parser rejects
// is ignored there, so neither keeps a process of its own here.
assert!(with(&[("PHOTOCRAFT_STROKE_TRACE", "off"), ("PHOTOCRAFT_PRESENT_MODE", "maybe"), ("PHOTOCRAFT_FRAME_LATENCY", "4")]).is_empty());
assert!(with(&[("PHOTOCRAFT_AUTOMATION_READ_ROOT", "")]).is_empty());
// Spaces are part of the value only where the reader keeps them: the trace and the two
// presentation values are trimmed where they take effect, the menu flag and a path are not.
assert_eq!(with(&[("PHOTOCRAFT_PRESENT_MODE", " novsync "), ("PHOTOCRAFT_STROKE_TRACE", " 1 ")]), vec!["--present-mode", "--stroke-trace"]);
// The trace and the two presentation values are trimmed where they take effect.
assert_eq!(
with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " 0 "), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", " /tmp/out ")]),
vec!["--in-window-menus", "--automation-write-root"]
with(&[("PHOTOCRAFT_PRESENT_MODE", " novsync "), ("PHOTOCRAFT_STROKE_TRACE", " 1 ")]),
vec![PerProcess::PresentMode, PerProcess::StrokeTrace]
);
// A whitespace-only menu value is not empty, so it enables the menus there and here too.
assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", " ")]), vec!["--in-window-menus"]);
// `PHOTOCRAFT_IN_WINDOW_MENUS=off` means menus in the window there, so it means it here too.
assert_eq!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "off")]), vec!["--in-window-menus"]);
// The menus and the automation roots are flags' fallbacks: `launch_args::parse` counts them.
assert!(with(&[("PHOTOCRAFT_IN_WINDOW_MENUS", "1"), ("PHOTOCRAFT_AUTOMATION_WRITE_ROOT", "/tmp/out")]).is_empty());
let base = LaunchContext {
per_user_settings: true,
new_instance_flag: false,
+317 -17
View File
@@ -6,16 +6,32 @@
//! hand-edited file would then crash every launch. [`sanitize`] runs first and removes a file
//! holding such values (the window and panels open at their defaults; nothing else is lost).
//! A file that isn't RON at all is left alone: eframe already ignores it.
//!
//! egui 0.37 also replaced the flat `WindowSettings` (`inner_position_pixels`,
//! `inner_size_points: Option<Vec2>`, …) with `normal_geometry: { outer_position_pixels,
//! scale_factor, inner_size_points }`. eframe reads the file before any application code, so an
//! old value cannot be interpreted lazily: [`sanitize`] rewrites it before eframe loads it and
//! keeps a one-time `ui.ron.pre-0.37` backup. The old panel keys in the egui memory part are
//! untouched (`panels::panel_state_id` migrates them additively), but this window rewrite is
//! one-way: after it, a 0.36 build ignores the window entry and opens at its default geometry.
//! Restoring the backup is the documented rollback.
use std::path::Path;
use std::{collections::HashMap, path::Path};
/// Window positions, sizes and panel rects beyond this many points or pixels are corrupt.
const MAX_COORD: f64 = 1.0e6;
/// egui's zoom factor is the UI scale over the display scale (75% on a 2× display is 0.375,
/// 300% on a 1× display is 3).
const ZOOM: std::ops::RangeInclusive<f64> = 0.1..=10.0;
/// The size [`crate::native_options`] opens a window with when an old save has no usable size.
const DEFAULT_SIZE: f32 = 1440.0;
const DEFAULT_HEIGHT: f32 = 900.0;
/// Kept beside `ui.ron` before the one-way 0.36 → 0.37 window rewrite.
const BACKUP_SUFFIX: &str = "pre-0.37";
/// Remove `path` if it holds state that would crash startup. Never fails: errors are logged.
/// Remove `path` if it holds state that would crash startup, and migrate an old window entry to
/// the 0.37 schema before eframe reads it. Never fails: errors are logged and leave the file
/// alone (eframe then ignores what it cannot read).
pub fn sanitize(path: Option<&Path>) {
let Some(path) = path else { return };
let Ok(text) = std::fs::read_to_string(path) else { return };
@@ -24,30 +40,72 @@ pub fn sanitize(path: Option<&Path>) {
if let Err(e) = std::fs::remove_file(path) {
log::warn!("could not remove {}: {e}", path.display());
}
return;
}
match migrate_window(text) {
Ok(Some(text)) => write_migrated(path, &text),
Ok(None) => {}
Err(why) => log::warn!("not rewriting the saved window geometry in {}: {why}", path.display()),
}
}
/// Whether `path` holds a window position from an earlier session, which eframe restores. Then
/// `NativeOptions::centered` must be off: eframe applies it after restoring the saved geometry,
/// Write the migrated file, keeping a one-time backup. A failed backup aborts the rewrite: the
/// pre-0.37 window geometry is the documented rollback, so never destroy the only copy.
fn write_migrated(path: &Path, text: &str) {
let backup = backup_path(path);
if !backup.exists()
&& let Err(e) = std::fs::copy(path, &backup)
{
log::warn!("could not back up {} to {}: {e}; leaving it unchanged", path.display(), backup.display());
return;
}
if let Err(e) = std::fs::write(path, text) {
log::warn!("could not write the migrated window layout to {}: {e}", path.display());
}
}
fn backup_path(path: &Path) -> std::path::PathBuf {
let mut name = path.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_else(|| "ui.ron".into());
name.push(format!(".{BACKUP_SUFFIX}"));
path.with_file_name(name)
}
/// Whether `path` holds a window position that eframe restores, which means
/// `NativeOptions::centered` must be off: eframe applies `centered` after the restored geometry,
/// so it moved the window back to the centre of the main monitor on every launch (#4826).
///
/// Only the 0.37 `normal_geometry.outer_position_pixels` counts: `egui_winit::WindowGeometry`
/// sets it and `initialize_viewport_builder` applies it. A 0.36 flat entry is not restored by
/// 0.37 (`sanitize` rewrites it first), and its macOS `inner_position_pixels` was never an outer
/// position.
pub fn has_saved_position(path: Option<&Path>) -> bool {
let Some(text) = path.and_then(|p| std::fs::read_to_string(p).ok()) else { return false };
saved_position(&text)
}
fn saved_position(text: &str) -> bool {
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return false };
let Ok(kv) = ron::from_str::<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(_))))
let Some(geometry) = field(&window, "normal_geometry") else { return false };
matches!(field(geometry, "outer_position_pixels"), Some(ron::Value::Option(Some(_))))
}
/// The 0.36 → 0.37 rewrite of one `ui.ron` text, for the recorded 0.14/0.15 fixtures.
#[cfg(test)]
pub(crate) fn migrate_for_test(text: &str) -> Result<Option<String>, String> {
migrate_window(text.to_owned())
}
/// [`saved_position`] for the recorded fixtures.
#[cfg(test)]
pub(crate) fn saved_position_for_test(text: &str) -> bool {
saved_position(text)
}
/// `Err` names the first value that would crash startup.
fn check(text: &str) -> Result<(), String> {
// eframe stores a map of key → RON string; a file that doesn't parse is ignored by eframe.
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return Ok(()) };
let Ok(kv) = ron::from_str::<HashMap<String, String>>(text) else { return Ok(()) };
for (key, value) in &kv {
// eframe also skips an entry that doesn't parse.
let Ok(v) = ron::from_str::<ron::Value>(value) else { continue };
@@ -60,6 +118,108 @@ fn check(text: &str) -> Result<(), String> {
Ok(())
}
/// `Some(new_text)` when `text` is an old-schema file that should be rewritten; `None` when it
/// is already 0.37-shaped or holds no window entry; `Err` when the old window entry is unusable
/// and must be left untouched.
fn migrate_window(text: String) -> Result<Option<String>, String> {
let Ok(mut kv) = ron::from_str::<HashMap<String, String>>(&text) else { return Ok(None) };
let Some(value) = kv.get("window").cloned() else { return Ok(None) };
let Ok(window) = ron::from_str::<ron::Value>(&value) else { return Ok(None) };
if field(&window, "normal_geometry").is_some() {
return Ok(None);
}
if field(&window, "inner_size_points").is_none() && field(&window, "inner_position_pixels").is_none() && field(&window, "outer_position_pixels").is_none() {
return Ok(None);
}
let migrated = migrate_window_value(&window)?;
kv.insert("window".into(), migrated);
ron::to_string(&kv).map(Some).map_err(|e| format!("cannot write the new window entry: {e}"))
}
/// The RON payload eframe 0.37 deserializes as `egui_winit::WindowSettings`.
///
/// The field order and names mirror `WindowGeometry`; the types are local because
/// `egui_winit::WindowGeometry` keeps its fields private.
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq)]
struct PointV37 {
x: f32,
y: f32,
}
impl PointV37 {
fn sane(self) -> Option<Self> {
let ok = |v: f32| v.is_finite() && (v.abs() as f64) <= MAX_COORD;
(ok(self.x) && ok(self.y)).then_some(self)
}
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq)]
struct GeometryV37 {
outer_position_pixels: Option<PointV37>,
scale_factor: f32,
inner_size_points: PointV37,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq)]
struct WindowV37 {
normal_geometry: GeometryV37,
fullscreen: bool,
maximized: bool,
}
fn migrate_window_value(window: &ron::Value) -> Result<String, String> {
let size = old_size(window)?;
// The old macOS entry stored an inner (not outer) position in logical points and Linux saved
// no scale factor, so a faithful restore is only possible for the Windows physical outer
// position. Everywhere else the window opens centred with its saved size.
let position = if cfg!(target_os = "windows") { old_position(window, "outer_position_pixels") } else { None };
let window = WindowV37 {
normal_geometry: GeometryV37 {
outer_position_pixels: position,
// Physical pixels of the old Windows position: 0.37 divides by zoom × scale.
scale_factor: 1.0,
inner_size_points: size,
},
fullscreen: old_bool(window, "fullscreen"),
maximized: old_bool(window, "maximized"),
};
ron::to_string(&window).map_err(|e| format!("cannot serialize the new window entry: {e}"))
}
fn old_size(window: &ron::Value) -> Result<PointV37, String> {
let value = field(window, "inner_size_points");
let Some(ron::Value::Option(Some(size))) = value else {
// A missing size is the app's own opening size, not corrupt data.
return Ok(PointV37 { x: DEFAULT_SIZE, y: DEFAULT_HEIGHT });
};
let point = point_value(size).ok_or_else(|| "inner_size_points is not a finite (x, y) point".to_string())?;
let point = point.sane().ok_or_else(|| "inner_size_points is not finite or is absurdly large".to_string())?;
if point.x < 1.0 || point.y < 1.0 {
return Err("inner_size_points has a zero or negative side".into());
}
Ok(point)
}
/// One dimension of an old `Option<Pos2>` field, rejecting corrupt values rather than guessing.
fn old_position(window: &ron::Value, key: &str) -> Option<PointV37> {
match field(window, key) {
Some(ron::Value::Option(Some(p))) => point_value(p).and_then(PointV37::sane),
// `None`, missing, and malformed all mean "no position"; corrupt positions elsewhere are
// caught by `check`, which removes a file with non-finite values before this runs.
_ => None,
}
}
fn old_bool(window: &ron::Value, key: &str) -> bool {
matches!(field(window, key), Some(ron::Value::Bool(true)))
}
fn point_value(v: &ron::Value) -> Option<PointV37> {
let x = field(v, "x").and_then(as_f32)?;
let y = field(v, "y").and_then(as_f32)?;
Some(PointV37 { x, y })
}
/// Every float finite and within [`MAX_COORD`]; depth-limited (the file is untrusted).
fn floats_sane(v: &ron::Value, depth: usize) -> Result<(), String> {
if depth > 64 {
@@ -86,6 +246,13 @@ fn as_f64(v: &ron::Value) -> Option<f64> {
}
}
fn as_f32(v: &ron::Value) -> Option<f32> {
as_f64(v).and_then(|f| {
let f = f as f32;
f.is_finite().then_some(f)
})
}
fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> {
match v {
ron::Value::Map(m) => m.get(&ron::Value::String(name.into())),
@@ -95,7 +262,8 @@ fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> {
#[cfg(test)]
mod tests {
use super::{check, saved_position};
use super::{DEFAULT_HEIGHT, DEFAULT_SIZE, WindowV37, backup_path, check, migrate_window, saved_position};
use std::{collections::HashMap, path::PathBuf};
fn file(window: &str, zoom: &str) -> String {
let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))");
@@ -103,6 +271,17 @@ mod tests {
}
const WINDOW: &str = "(inner_position_pixels:Some((x:616.0,y:430.0)),fullscreen:false,inner_size_points:Some((x:1100.0,y:760.0)))";
const WINDOW_WITH_OUTER: &str = "(inner_position_pixels:Some((x:2048.0,y:1265.0)),outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
/// Read the window payload back as eframe 0.37 would.
fn window_of(text: &str) -> WindowV37 {
let kv: HashMap<String, String> = ron::from_str(text).expect("the migrated file is a RON map");
ron::from_str(kv.get("window").expect("a window entry")).expect("0.37 WindowSettings shape")
}
fn migrated(window: &str) -> String {
migrate_window(file(window, "1.0")).expect("migratable").expect("rewritten")
}
#[test]
fn a_saved_layout_is_kept() {
@@ -122,13 +301,83 @@ mod tests {
}
}
#[test]
fn an_old_size_and_maximized_state_survive() {
let text = migrated(WINDOW_WITH_OUTER);
let window = window_of(&text);
assert_eq!(window.normal_geometry.inner_size_points.x, 880.0);
assert_eq!(window.normal_geometry.inner_size_points.y, 480.0);
assert!(!window.fullscreen);
assert!(!window.maximized);
let maximized = migrated(&WINDOW.replace("fullscreen:false", "fullscreen:true,maximized:true"));
assert!(window_of(&maximized).fullscreen && window_of(&maximized).maximized);
}
#[test]
fn a_missing_size_uses_the_default_opening_size() {
// A window entry with a position but no size migrates and keeps the app's opening size.
let window = window_of(&migrated("(inner_position_pixels:Some((x:616.0,y:430.0)),fullscreen:false,maximized:false)"));
assert_eq!(window.normal_geometry.inner_size_points.x, DEFAULT_SIZE);
assert_eq!(window.normal_geometry.inner_size_points.y, DEFAULT_HEIGHT);
// A window entry with no geometry fields at all is left alone: it is not a 0.36 window.
assert_eq!(migrate_window(file("(fullscreen:false,maximized:false)", "1.0")), Ok(None));
// So is a file with no window-like fields at all.
assert_eq!(migrate_window(file("(something:1)", "1.0")), Ok(None));
}
#[test]
fn the_old_position_is_kept_only_on_windows() {
let text = migrated(WINDOW_WITH_OUTER);
let pos = window_of(&text).normal_geometry.outer_position_pixels;
if cfg!(target_os = "windows") {
let pos = pos.expect("the Windows physical outer position survives");
assert_eq!((pos.x, pos.y), (2048.0, 1228.0));
assert_eq!(window_of(&text).normal_geometry.scale_factor, 1.0);
} else {
assert!(pos.is_none(), "an old macOS/Linux position is not a restorable outer position");
}
}
#[test]
fn a_corrupt_old_size_is_not_rewritten() {
let bad = WINDOW.replace("(x:1100.0,y:760.0)", "(x:1100.0,y:0.0)");
assert!(migrate_window(file(&bad, "1.0")).is_err());
let huge = WINDOW.replace("(x:1100.0,y:760.0)", "(x:1100.0,y:9000000.0)");
assert!(migrate_window(file(&huge, "1.0")).is_err());
}
#[test]
fn an_already_migrated_file_is_left_alone() {
let text = migrated(WINDOW);
assert_eq!(migrate_window(text), Ok(None));
// A 0.36 build accepts the entry, but its missing flat geometry defaults to None.
// It cannot recover position or size from the nested 0.37 geometry.
let kv: HashMap<String, String> = ron::from_str(&migrated(WINDOW)).unwrap();
let old: OldWindow = ron::from_str(kv.get("window").unwrap()).unwrap();
assert!(old.inner_position_pixels.is_none() && old.outer_position_pixels.is_none());
assert!(old.inner_size_points.is_none(), "documented rollback loss of saved geometry");
// Restoring the original backup restores both the old position and size.
let restored: OldWindow = ron::from_str(WINDOW).unwrap();
let position = restored.inner_position_pixels.unwrap();
let size = restored.inner_size_points.unwrap();
assert_eq!((position.x, position.y), (616.0, 430.0));
assert_eq!((size.x, size.y), (1100.0, 760.0));
}
#[test]
fn a_saved_window_position_is_detected() {
// #4826: the position from the report (Linux Mint, window moved to the third monitor).
let window = "(inner_position_pixels:Some((x:2048.0,y:1265.0)),outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
assert!(saved_position(&file(window, "1.0")));
let no_position = "(inner_position_pixels:None,outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
// A 0.36 flat entry is not what eframe 0.37 restores (it would open at the OS default);
// `sanitize` rewrites it before eframe runs, and the migrated nested value is detected.
assert!(!saved_position(&file(WINDOW_WITH_OUTER, "1.0")), "an unmigrated flat entry is not a restorable position");
// An old macOS inner position is not an outer position either.
assert!(!saved_position(&file(WINDOW, "1.0")));
let no_position =
"(normal_geometry:(outer_position_pixels:None,scale_factor:1.0,inner_size_points:(x:880.0,y:480.0)),fullscreen:false,maximized:false)";
assert!(!saved_position(&file(no_position, "1.0")));
// The migrated nested geometry is what a 0.37 save holds (on Windows only, from the
// migration; a later 0.37 save on macOS/Linux has one too and eframe restores it).
let text = migrated(WINDOW_WITH_OUTER);
assert_eq!(saved_position(&text), cfg!(target_os = "windows"));
assert!(!saved_position(&format!("{{\"egui\":{:?}}}", "()")), "no window entry");
for text in ["", "not ron {{{", "{\"window\":\"(((\"}"] {
assert!(!saved_position(text), "{text:?}");
@@ -148,7 +397,7 @@ mod tests {
}
#[test]
fn sanitize_removes_only_a_bad_file() {
fn sanitize_removes_only_a_bad_file_and_migrates_a_good_one() {
let dir = std::env::temp_dir().join(format!("photocraft-ui-state-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let good = dir.join("good.ron");
@@ -159,7 +408,58 @@ mod tests {
super::sanitize(Some(&bad));
super::sanitize(Some(&dir.join("missing.ron")));
super::sanitize(None);
assert!(good.exists() && !bad.exists());
assert!(!bad.exists());
assert!(good.exists());
// Migrated in place, with the one-time pre-0.37 backup beside it.
let text = std::fs::read_to_string(&good).unwrap();
assert!(window_of(&text).normal_geometry.inner_size_points.x == 1100.0);
let backup = backup_path(&good);
assert!(backup.exists(), "the pre-0.37 layout is kept for rollback");
assert_eq!(std::fs::read_to_string(&backup).unwrap(), file(WINDOW, "1.0"));
// A second run leaves both untouched.
super::sanitize(Some(&good));
assert_eq!(std::fs::read_to_string(&good).unwrap(), text);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_backup_is_not_overwritten_by_a_later_migration() {
let dir = std::env::temp_dir().join(format!("photocraft-ui-state-backup-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("ui.ron");
std::fs::write(&path, file(WINDOW, "1.0")).unwrap();
super::sanitize(Some(&path));
let backup = backup_path(&path);
let original = std::fs::read_to_string(&backup).unwrap();
// Pretend a 0.36 run wrote its old schema again; the backup must stay the first one.
std::fs::write(&path, file(&WINDOW.replace("1100.0", "1111.0"), "1.0")).unwrap();
super::sanitize(Some(&path));
assert_eq!(std::fs::read_to_string(&backup).unwrap(), original);
assert!(std::fs::read_to_string(&path).unwrap().contains("1111"));
let _ = std::fs::remove_dir_all(&dir);
}
/// The 0.36 flat schema, kept to prove the new nested value is a one-way migration.
#[derive(Default, serde::Deserialize)]
#[serde(default)]
#[allow(dead_code)]
struct OldWindow {
inner_position_pixels: Option<Point>,
outer_position_pixels: Option<Point>,
fullscreen: bool,
maximized: bool,
inner_size_points: Option<Point>,
}
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct Point {
x: f32,
y: f32,
}
#[test]
fn the_backup_path_sits_beside_ui_ron() {
assert_eq!(backup_path(&PathBuf::from("/tmp/x/ui.ron")), PathBuf::from("/tmp/x/ui.ron.pre-0.37"));
}
}
+27
View File
@@ -0,0 +1,27 @@
//! Release checks through `craft-update`, the library every Craft App shares. Only public release
//! metadata is requested.
use photocraft_engine::updates_cmds::{CheckFn, CheckResult, Release};
pub fn service() -> CheckFn {
Box::new(|| {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("update check".into())
.spawn(move || {
let result = check();
if let Err(e) = &result {
log::warn!("Update check: {e}");
}
let _ = tx.send(result);
})
.map_err(|e| format!("Could not start update check: {e}"))?;
Ok(rx)
})
}
fn check() -> CheckResult {
let http = craft_update::Http::new("PhotoCraft/Update")?;
let found = craft_update::check_for_update(&craft_update::suite::PHOTOCRAFT, &http, photocraft_engine::build_info::VERSION)?;
Ok(found.map(|release| Release { version: release.tag.trim_start_matches(['v', 'V']).to_string(), url: release.html_url }))
}
+7
View File
@@ -0,0 +1,7 @@
These files are **schema probes**, not recorded machine saves.
`ui-0.14.ron`, `ui-0.15.ron` and `ui-0.37.ron` are minimal payloads reconstructed from the
documented eframe/egui layouts. They exercise `apps/photocraft/src/ui_state.rs` (the flat
window entry versus the nested `normal_geometry` entry) only. They do not prove that eframe
and egui deserialize a real `Memory` or `PanelState`; a captured 0.14/0.15/0.37 `ui.ron`
would be needed for that, with the machine-specific data reviewed first.
+1
View File
@@ -0,0 +1 @@
{"window":"(inner_position_pixels:Some((x:616.0,y:430.0)),outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:1100.0,y:760.0)))","egui":"()"}
+1
View File
@@ -0,0 +1 @@
{"window":"(inner_position_pixels:None,outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:true,inner_size_points:Some((x:1440.0,y:900.0)))","egui":"()"}
+1
View File
@@ -0,0 +1 @@
{"window":"(normal_geometry:(outer_position_pixels:None,scale_factor:2.0,inner_size_points:(x:1440.0,y:900.0)),fullscreen:false,maximized:false)","egui":"()"}
+102 -40
View File
@@ -4,36 +4,73 @@
//!
//! 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.
//! Cases whose arguments are valid add a usage error before them: `--control-token` with
//! `--control-token-file` is only checked once every argument is read (and only with `--control`),
//! so stderr shows how the other arguments were taken.
/// 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) {
/// The variables the command line reads: a test that wants one sets it.
const VARS: [&str; 6] = [
"PHOTOCRAFT_CONTROL_PORT",
"PHOTOCRAFT_CONTROL_TOKEN",
"PHOTOCRAFT_CONTROL_TOKEN_FILE",
"PHOTOCRAFT_AUTOMATION_READ_ROOT",
"PHOTOCRAFT_AUTOMATION_WRITE_ROOT",
"PHOTOCRAFT_IN_WINDOW_MENUS",
];
/// A usage error found only after every argument is read.
const LATE_ERROR: [&str; 6] = ["--control", "0", "--control-token", "abcd", "--control-token-file", "token"];
/// How a run of `photocraft` ended.
struct Run {
status: std::process::ExitStatus,
stdout: String,
stderr: String,
}
/// Run `photocraft` with `args` and `vars` in an isolated config dir.
fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run {
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"))
let mut cmd = std::process::Command::new(crate::binary());
for var in VARS {
cmd.env_remove(var);
}
let output = cmd
.args(args)
.envs(vars.iter().copied())
.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())
Run { status: output.status, stdout: String::from_utf8_lossy(&output.stdout).into_owned(), stderr: String::from_utf8_lossy(&output.stderr).into_owned() }
}
fn run_photocraft(args: &[&str]) -> Run {
run_with(args, &[])
}
/// [`LATE_ERROR`] then `args`: exit 2, and the stderr.
fn late_error(args: &[&str], vars: &[(&str, &str)]) -> String {
let Run { status, stdout, stderr } = run_with(&[&LATE_ERROR[..], args].concat(), vars);
assert_eq!(status.code(), Some(2), "{args:?}: {stderr}");
assert!(stderr.contains("cannot be used with"), "{args:?}: {stderr}");
assert!(stdout.is_empty(), "{args:?}: {stdout}");
stderr
}
#[test]
fn help_prints_usage_and_exits_0() {
for flag in ["--help", "-h"] {
let (status, stdout, stderr) = run_photocraft(&[flag]);
let Run { 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("Usage: photocraft"), "{flag} stdout: {stdout}");
for option in ["--safe-gpu", "--new-instance", "--control <PORT>", "--version", "`--`", "PHOTOCRAFT_AUTOMATION_READ_ROOT", "PHOTOCRAFT_NEW_INSTANCE"] {
assert!(stdout.contains(option), "{flag} usage must list {option}: {stdout}");
}
assert!(!stderr.contains("Couldn't open"), "{flag} was taken as a file: {stderr}");
@@ -42,54 +79,79 @@ fn help_prints_usage_and_exits_0() {
#[test]
fn help_wins_over_later_arguments() {
let (status, stdout, _) = run_photocraft(&["--help", "picture.psd", "--safegpu"]);
let Run { status, stdout, .. } = run_photocraft(&["--help", "picture.psd", "--safegpu"]);
assert_eq!(status.code(), Some(0));
assert!(stdout.starts_with("Usage: photocraft"), "stdout: {stdout}");
assert!(stdout.contains("Usage: photocraft"), "stdout: {stdout}");
}
#[test]
fn version_prints_the_long_version() {
let Run { status, stdout, .. } = run_photocraft(&["--version", "--safegpu"]);
assert_eq!(status.code(), Some(0));
assert_eq!(stdout, format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
}
#[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}");
for (args, named) in [
(&["--safegpu"][..], "unexpected argument '--safegpu'"),
(&["-x"][..], "unexpected argument '-x'"),
(&["--help=yes"][..], "unexpected value 'yes' for '--help'"),
// An unknown flag after a bad value is reported first: clap drops the pending value's error.
(&["--control", "nope", "--safegpu"][..], "unexpected argument '--safegpu'"),
(&["--control", "nope"][..], "invalid value 'nope' for '--control <PORT>'"),
] {
let Run { status, stdout, stderr } = run_photocraft(args);
assert_eq!(status.code(), Some(2), "{args:?} must be a usage error, stderr: {stderr}");
assert!(stderr.contains(named), "stderr: {stderr}");
assert!(stderr.contains("--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}");
// nor is an unknown option; only the late error is reported.
let stderr = late_error(&["--", "--help", "-x"], &[]);
assert!(!stderr.contains("unexpected"), "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}");
let stderr = late_error(&["-psn_0_12345"], &[]);
assert!(!stderr.contains("unexpected"), "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}");
let stderr = late_error(&["--new-instance"], &[]);
assert!(!stderr.contains("unexpected"), "stderr: {stderr}");
}
/// The variables stand in for their flags: a flag wins, an empty variable counts as unset, a bad
/// value is a usage error naming the variable, and a flag without its value is still an error
/// when its variable is set (#6429).
#[test]
fn variables_are_the_flags_fallbacks() {
let Run { status, stderr, .. } = run_with(&[], &[("PHOTOCRAFT_CONTROL_PORT", "nope")]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("invalid value 'nope' for 'PHOTOCRAFT_CONTROL_PORT'"), "stderr: {stderr}");
// With the flag (in LATE_ERROR), the variable is never read.
let stderr = late_error(&[], &[("PHOTOCRAFT_CONTROL_PORT", "nope")]);
assert!(!stderr.contains("PHOTOCRAFT_CONTROL_PORT"), "stderr: {stderr}");
// The variable turns the control server on as the flag does, so the token pair is checked.
let Run { status, stderr, .. } = run_with(&LATE_ERROR[2..], &[("PHOTOCRAFT_CONTROL_PORT", "0")]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("cannot be used with"), "stderr: {stderr}");
let Run { status, stderr, .. } = run_with(&["--automation-read-root"], &[("PHOTOCRAFT_AUTOMATION_READ_ROOT", "/env/read")]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("a value is required for '--automation-read-root <DIR>'"), "stderr: {stderr}");
// The other half of the token pair from a variable is the same either/or error, naming it
// but never showing the token.
let token = "a".repeat(64);
let Run { status, stderr, .. } = run_with(&["--control", "0", "--control-token-file", "token"], &[("PHOTOCRAFT_CONTROL_TOKEN", token.as_str())]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("PHOTOCRAFT_CONTROL_TOKEN") && stderr.contains("cannot be used with") && !stderr.contains(&token), "stderr: {stderr}");
}
+34 -10
View File
@@ -9,15 +9,26 @@
/// workspace check instead of failing on token validation first.
const VALID_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
/// How a run of `photocraft` ended.
struct Run {
status: std::process::ExitStatus,
stderr: String,
}
/// Run `photocraft` with the given arguments in an isolated config dir and
/// return the exit status and stderr. Each call gets its own config dir (tests run in
/// return how it ended. Each call gets its own config dir (tests run in
/// parallel), and no display variables, so a regression fails fast instead of opening a window.
fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String) {
fn run_photocraft(args: &[&str]) -> Run {
run_with(args, &[])
}
/// [`run_photocraft`], with `vars` set.
fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run {
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-control-exit-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"))
let output = std::process::Command::new(crate::binary())
.args(args)
.env("PHOTOCRAFT_CONFIG_DIR", &dir)
// Isolate from the host environment so the test is reproducible.
@@ -29,37 +40,40 @@ fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String) {
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.envs(vars.iter().copied())
.output()
.expect("failed to run photocraft binary");
std::fs::remove_dir_all(&dir).ok();
(output.status, String::from_utf8_lossy(&output.stderr).into_owned())
Run { status: output.status, stderr: String::from_utf8_lossy(&output.stderr).into_owned() }
}
#[test]
fn invalid_control_token_exits_with_status_2() {
// `validate_token` rejects a token that is not exactly 64 hex characters.
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", "invalid"]);
let Run { status, stderr } = run_photocraft(&["--control", "0", "--control-token", "invalid"]);
assert_eq!(status.code(), Some(2), "invalid control token must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure control authentication"), "stderr: {stderr}");
}
#[test]
fn both_token_and_token_file_exits_with_status_2() {
// `server_token` rejects the combination of a supplied token and a token file.
// The command line rejects a supplied token together with a token file (#7071), before
// `server_token` would.
let dir = std::env::temp_dir().join(format!("photocraft-token-file-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let token_file = dir.join("token.txt");
std::fs::write(&token_file, VALID_TOKEN).unwrap();
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--control-token-file", token_file.to_str().unwrap()]);
let Run { status, stderr } = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--control-token-file", token_file.to_str().unwrap()]);
std::fs::remove_dir_all(&dir).ok();
assert_eq!(status.code(), Some(2), "token + token file must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure control authentication"), "stderr: {stderr}");
assert!(stderr.contains("cannot be used with"), "stderr: {stderr}");
}
#[test]
fn missing_automation_read_root_exits_with_status_2() {
// `AuthorizedWorkspace::new` fails when the read root does not exist.
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-read-root", "/nonexistent/photocraft-test-path"]);
let Run { status, stderr } =
run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-read-root", "/nonexistent/photocraft-test-path"]);
assert_eq!(status.code(), Some(2), "missing read root must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure automation workspace"), "stderr: {stderr}");
}
@@ -67,7 +81,17 @@ fn missing_automation_read_root_exits_with_status_2() {
#[test]
fn missing_automation_write_root_exits_with_status_2() {
// `AuthorizedWorkspace::new` fails when the write root does not exist.
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-write-root", "/nonexistent/photocraft-test-path"]);
let Run { status, stderr } =
run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-write-root", "/nonexistent/photocraft-test-path"]);
assert_eq!(status.code(), Some(2), "missing write root must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure automation workspace"), "stderr: {stderr}");
}
#[test]
fn automation_roots_come_from_the_environment_too() {
for var in ["PHOTOCRAFT_AUTOMATION_READ_ROOT", "PHOTOCRAFT_AUTOMATION_WRITE_ROOT"] {
let Run { status, stderr } = run_with(&["--control", "0", "--control-token", VALID_TOKEN], &[(var, "/nonexistent/photocraft-test-path")]);
assert_eq!(status.code(), Some(2), "{var}: missing root must exit 2, stderr: {stderr}");
assert!(stderr.contains("cannot configure automation workspace"), "{var}: stderr: {stderr}");
}
}
+5
View File
@@ -3,3 +3,8 @@
mod command_line_usage;
mod control_exit_code;
mod startup_without_display;
/// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid.
fn binary() -> std::ffi::OsString {
std::env::var_os("NEXTEST_BIN_EXE_photocraft").unwrap_or_else(|| env!("CARGO_BIN_EXE_photocraft").into())
}
@@ -30,7 +30,7 @@ fn missing_display_preserves_existing_gpu_crash_evidence() {
}
fn launch_without_display(dir: &std::path::Path) -> String {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"))
let output = std::process::Command::new(crate::binary())
.env("PHOTOCRAFT_CONFIG_DIR", dir)
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
+20 -1
View File
@@ -63,4 +63,23 @@ layers because that would lose their group's blending semantics.
Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. `batch` refuses an output directory that is its input directory (the results would replace the originals) unless `--in-place` is given. Run untrusted action files only in an appropriately isolated account or environment.
CLI parsing reports malformed flags and JSON as errors, and a flag the subcommand doesn't take (a typo such as `--fromat`) is a usage error (exit status 2) rather than being ignored. It is not a security policy layer. Engine commands remain responsible for validating their own parameters.
CLI parsing checks the flags and their values before any work starts. A flag the subcommand doesn't take (a typo such as `--fromat`), a flag given twice (only `--cmd` and `--params` may repeat), a value out of range (`--quality 0`), malformed `--new` or `--params` JSON, `<FILE>` together with `--new`, and `--control-token` together with `--control-token-file` are all usage errors (exit status 2), rather than being ignored or failing later. A value flag never takes the next flag as its value, so a value starting with `-` is written with `=` (`--out=-image.png`) or as `./-image.png`; negative numbers such as `--filter -1` still work. A path that is not valid Unicode reaches the command instead of being a usage error: `convert` reads and writes it, while `info`, `run` and `batch` open files through the automation layer, which still needs UTF-8 paths and fails with exit status 1 (`droplet` takes its arguments as text, so it refuses such a path as a usage error). `photocraft-cli --version` (or `-V`) prints the version; there is no `version` subcommand.
`mcp` and `serve` reject positional arguments, empty values and options for the wrong mode before
starting a server. Explicit control-token flags require `mcp --bridge` or `serve --port` (or
`PHOTOCRAFT_CONTROL_PORT`, below). Configure automation roots on the desktop process when using
bridge mode; the CLI rejects root flags there. See the [MCP launch guide](mcp.md) for examples.
`mcp` and `serve` fall back to environment variables: `PHOTOCRAFT_CONTROL_TOKEN` and `PHOTOCRAFT_CONTROL_TOKEN_FILE` for the token flags, and `PHOTOCRAFT_CONTROL_PORT` for `serve --port` and `mcp --bridge` (which then bridges to `127.0.0.1:<port>`). The automation roots are flags only: the desktop app's `PHOTOCRAFT_AUTOMATION_READ_ROOT` and `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` grant the CLI nothing. A flag wins over its variable, an empty variable counts as unset, and a bad value is a usage error naming the variable. When `PHOTOCRAFT_CONTROL_PORT` switches `serve` to TCP or `mcp` to bridging, the command says so on standard error. A token together with a token file is a usage error only where a token is read (`serve` on TCP, `mcp` bridging), so a stale exported pair doesn't stop stdio `serve` or headless `mcp`. An automation root given to `mcp` while the variable would switch it to bridging is a usage error too: the roots are the headless engine's, and the bridge would drop them. `<subcommand> --help` lists each variable (unless it is set but empty), never with the token's value. A variable holds a port, so `PHOTOCRAFT_CONTROL_PORT=127.0.0.1:7878` is refused; only `--bridge` takes `host:port`.
This checking is not a security policy layer. Engine commands remain responsible for validating their own parameters.
### Shell completions and man pages
The Linux `.deb` and `.rpm` packages install `man photocraft-cli` (plus a page per subcommand, such as `man photocraft-cli-convert`) and completions for bash, zsh and fish (zsh's in `/usr/share/zsh/site-functions`, which the `.deb` also links from Debian's `/usr/share/zsh/vendor-completions`); the Linux tarball and the FreeBSD tarball carry the same files, the FreeBSD one under `man/man1` and `share/`. The pages list each flag's environment variable, as `--help` does. Without a package, generate them from a source checkout:
```sh
cargo run -p photocraft-cli --example man-and-completions -- out/
```
`out/` then holds `photocraft-cli.bash`, `_photocraft-cli` (zsh), `photocraft-cli.fish`, `photocraft-cli.elv` (elvish), `_photocraft-cli.ps1` (PowerShell) and the `.1` man pages. For example, copy `photocraft-cli.bash` to `~/.local/share/bash-completion/completions/photocraft-cli`, `photocraft-cli.fish` into `~/.config/fish/completions/`, `_photocraft-cli` into a directory on zsh's `$fpath`, and read a page with `man -l out/photocraft-cli.1`. The desktop app `photocraft` has neither.
+10
View File
@@ -20,6 +20,16 @@ Bridge to a running desktop application with the same private token file used by
photocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file /private/path/control.token
```
Use absolute root paths in MCP client configurations, since a relative root is resolved from the
client's launch directory. In bridge mode the roots are configured on the desktop app, and root
flags on `photocraft-cli mcp --bridge` are rejected. Explicit `--control-token` and
`--control-token-file` flags require `--bridge` (or `PHOTOCRAFT_CONTROL_PORT`) and are mutually
exclusive; bridge credentials can also come from `PHOTOCRAFT_CONTROL_TOKEN` or
`PHOTOCRAFT_CONTROL_TOKEN_FILE`. Each launch option
except `--help`/`-h` takes one non-empty value and cannot be repeated. Stray positional arguments,
missing values and options for the wrong mode are usage errors (exit 2) reported on stderr before
the server starts.
Tools cover session/document operations, command discovery and execution, batching, and—when bridged—UI inspection and control. Tool schemas improve correctness but are not authorization boundaries.
## From an installed release
+1
View File
@@ -6,6 +6,7 @@ PhotoCraft's release workflow builds platform packages and a draft GitHub Releas
- platform-specific signing/notarization steps exist for macOS and Windows;
- signing secrets are scoped through the GitHub `release` environment;
- the Azure Trusted Signing client that Windows signing loads is pinned to a version and SHA-256, and refused on a mismatch;
- release artifacts include `SHA256SUMS.txt`;
- the final release job receives `contents: write`, while build jobs use narrower access;
- artifacts remain in a draft release until a maintainer publishes them.
+1 -1
View File
@@ -4,7 +4,7 @@
## Layered TIFF
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: a TIFF holds one transparency sample, so extra alpha channels are not written; Lab documents are saved flat. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`.
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: up to sixteen extra samples (spot colours and alpha channels, plus a Quick Mask) are written in the IFD and round-trip, with Photoshop image resources for the channel name, ink colour and solidity; Lab documents are saved flat, with their extra samples. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`.
## WebP
+31
View File
@@ -79,6 +79,9 @@ sync_github_name = true
[people.maxm11]
sync_github_name = true
[people.mweinbach]
sync_github_name = true
[people.skraus2]
sync_github_name = true
@@ -251,3 +254,31 @@ sync_github_name = true
[people.benandez]
real_name = "Martin Ball"
[people.ronibalthazar]
sync_github_name = true
[people.batuhangozegu]
real_name = "Batuhan Gözegü"
sync_github_name = false
[people.drunkeninja42]
real_name = "Kushagra Sharma"
[people.WebWork-rrs]
display_name = "Ratul Paul"
[people.softwaregames1]
sync_github_name = true
[people.VoiceLessQ]
display_name = "VoiceLessQ"
sync_github_name = false
[people.erenova]
display_name = "Erenova"
sync_github_name = false
[people.jasallen]
real_name = "Jason DeBoever"
sync_github_name = true
+26 -6
View File
@@ -15,6 +15,11 @@ const DOCUMENT_KIND: &[u8; 4] = b"nsrP";
/// A save appends one table; real files have one to a few hundred.
const MAX_TABLES: usize = 4096;
/// Distinct ids the allocation tables may name; each costs a map slot even when only deleted.
const MAX_ENTRIES: usize = 1 << 22;
/// Bytes of entry names the allocation tables may name. A name is retained twice (in the entry
/// and as its map key), and tables can point many names at the same bytes.
const MAX_NAME_BYTES: usize = 32 << 20;
/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes.
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
@@ -164,8 +169,9 @@ impl<'a> Archive<'a> {
pub fn open(bytes: &'a [u8], limits: Limits) -> Result<Self, Error> {
let header = header(bytes)?;
// Newest record per id: (table date, table index, record).
let mut latest: HashMap<u32, (u64, usize, Option<Entry>)> = HashMap::new();
let mut latest: HashMap<u32, (u64, usize, Option<Box<Entry>>)> = HashMap::new();
let mut names: HashMap<u32, String> = HashMap::new();
let mut name_bytes = 0usize;
let mut seen = HashSet::new();
let mut next = header.fat_offset;
let mut tables = 0usize;
@@ -222,19 +228,31 @@ impl<'a> Archive<'a> {
} else {
stored
};
record = Some(Entry { id, name: String::new(), offset, size, compressed, crc, compression });
record = Some(Box::new(Entry { id, name: String::new(), offset, size, compressed, crc, compression }));
}
2 => {}
_ => return Err(Error::Malformed("unknown allocation record")),
}
if flag == 0 {
let len = usize::from(c.u16()?);
let name = String::from_utf8_lossy(c.take(len)?).into_owned();
names.entry(id).or_insert(name);
let raw = c.take(len)?;
// Only the first name per id is kept: charge and copy just that one.
if let std::collections::hash_map::Entry::Vacant(slot) = names.entry(id) {
let name = String::from_utf8_lossy(raw).into_owned();
name_bytes = name_bytes.saturating_add(name.len());
if name_bytes > MAX_NAME_BYTES {
return Err(Error::Limit("archive entry names too large"));
}
slot.insert(name);
}
}
// Equal dates: the first table in the chain (the newest) wins, as in afread.
let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d);
if replace {
// Deletion records are five bytes each: cap what distinct ids may retain.
if latest.len() >= MAX_ENTRIES && !latest.contains_key(&id) {
return Err(Error::Limit("too many archive entries"));
}
latest.insert(id, (date, tables, record));
}
}
@@ -248,7 +266,7 @@ impl<'a> Archive<'a> {
for (id, (_, _, record)) in latest {
if let (Some(mut e), Some(name)) = (record, names.get(&id)) {
e.name = name.clone();
entries.insert(e.name.clone(), e);
entries.insert(e.name.clone(), *e);
}
}
Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits })
@@ -278,6 +296,9 @@ impl<'a> Archive<'a> {
if total > self.limits.max_total {
return Err(Error::Limit("document too large"));
}
// Charge before decoding: a corrupt entry that fails its checksum has already cost the
// decompression, and an importer that recovers must not get it again for free.
self.extracted = total;
let mut c = Cursor::new(self.bytes, offset(e.offset)?);
if c.take(4)? != b"#Fil" {
return Err(Error::Malformed("archive entry has no data block"));
@@ -297,7 +318,6 @@ impl<'a> Archive<'a> {
if crc32fast::hash(&out) != e.crc {
return Err(Error::Malformed("archive entry checksum mismatch"));
}
self.extracted = total;
Ok(out)
}
}
+42 -2
View File
@@ -9,17 +9,21 @@ 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;
/// Curve records decoded across the document. A shared curve is decoded again for each
/// occurrence, and records that only move handles add no points.
const MAX_OUTLINE_RECORDS: usize = 8_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,
records_left: usize,
}
impl Default for Budget {
fn default() -> Self {
Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS }
Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS, records_left: MAX_OUTLINE_RECORDS }
}
}
@@ -30,6 +34,12 @@ impl Budget {
Ok(())
}
/// Charge curve records before they are decoded.
fn records(&mut self, n: usize) -> Result<(), &'static str> {
self.records_left = self.records_left.checked_sub(n).ok_or("curve 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)));
@@ -138,7 +148,21 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
Value::UInt(n) => usize::try_from(*n).ok()?,
_ => return None,
};
let corners = s.obj(crvs, b"CnrD").and_then(|c| corner_radii(s.positional(c).collect()));
let corners = match s.obj(crvs, b"CnrD") {
Some(c) => {
let fields: Vec<&Value> = s.positional(c).collect();
// The corner arrays are scanned for every occurrence of a shared curve, so charge
// them like records before they are walked.
if let Some(Value::Array(idx)) = fields.get(1)
&& let Err(reason) = r.geometry_budget.records(idx.len())
{
r.warn(reason);
return None;
}
corner_radii(fields)
}
None => None,
};
let mut path = Path::default();
let mut seen = 0usize;
for index in 0..count {
@@ -152,6 +176,10 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
r.warn("curves with more than four million nodes");
return None;
}
if let Err(reason) = r.geometry_budget.records(records.len()) {
r.warn(reason);
return None;
}
let anchors = anchors(records)?;
let radii = if index == 0 { corners.as_ref() } else { None };
if let Some(sp) = subpath(&anchors, closed, radii) {
@@ -258,3 +286,15 @@ fn corner_radii(fields: Vec<&Value>) -> Option<Vec<(usize, f64)>> {
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn curve_records_are_charged_across_the_document() {
let mut b = Budget::default();
assert!(b.records(MAX_OUTLINE_RECORDS).is_ok());
assert_eq!(b.records(1), Err("curve geometry budget exhausted"));
}
}
+75 -18
View File
@@ -288,6 +288,12 @@ const MAX_NODES: usize = 500_000;
/// nodes, so a shared group holding a large null-filled child array could still be rescanned
/// many times; this bounds that work. Generous relative to MAX_NODES for real documents.
const MAX_CHILD_SLOTS: usize = 8_000_000;
/// Bytes of layer names the model may retain across all occurrences.
const MAX_NAME_BYTES: usize = 64 << 20;
/// Page rectangles the model may retain across all spreads (real books have a few hundred).
const MAX_PAGES: usize = 1 << 18;
/// Bytes of font names the text runs may retain across the document.
const MAX_FONT_NAME_BYTES: usize = 16 << 20;
/// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
@@ -304,6 +310,9 @@ pub(crate) struct Reader<'s, 'a, 'b> {
warnings: BTreeMap<String, usize>,
nodes: usize,
child_slots: usize,
name_bytes: usize,
pages: usize,
font_name_bytes: 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
@@ -320,6 +329,9 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
warnings: BTreeMap::new(),
nodes: 0,
child_slots: 0,
name_bytes: 0,
pages: 0,
font_name_bytes: 0,
active: HashSet::new(),
geometry_budget: crate::geometry::Budget::default(),
pixels_left: limits.max_pixel_bytes,
@@ -361,7 +373,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
self.warn("an unknown kind of page");
continue;
}
let pages = pages(s, spread);
let pages = self.pages(spread)?;
let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
x0: 0.0,
@@ -394,10 +406,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
// Charge every raw child slot (null slots included) against a document-wide budget
// before filtering. A shared group with a large null-filled Chld array could otherwise
// be rescanned through each reference without the node limit noticing.
self.child_slots = self.child_slots.saturating_add(s.array_len(parent, b"Chld").unwrap_or(0));
if self.child_slots > MAX_CHILD_SLOTS {
return Err(Error::Limit("too many child slots in the layer tree"));
}
self.charge_slots(s.array_len(parent, b"Chld").unwrap_or(0))?;
for c in s.objs(parent, b"Chld") {
if s.is(c, b"MRst") {
// A mask layer inside a group or layer masks that container.
@@ -419,6 +428,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
Ok((out, mask))
}
/// Counts raw array slots (null ones included) against [`MAX_CHILD_SLOTS`].
pub(crate) fn charge_slots(&mut self, n: usize) -> Result<(), Error> {
self.child_slots = self.child_slots.saturating_add(n);
if self.child_slots > MAX_CHILD_SLOTS {
return Err(Error::Limit("too many child slots in the layer tree"));
}
Ok(())
}
fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result<Option<Node>, Error> {
let s = self.s;
if depth > MAX_TREE_DEPTH {
@@ -437,7 +455,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let world = local.then(parent);
let class = s.class(id).unwrap_or(Tag(0));
let name = s.str(id, b"Desc").unwrap_or_default().to_string();
// A shared layer's name is copied for every occurrence, so charge the bytes first.
let name = s.str(id, b"Desc").unwrap_or_default();
self.name_bytes = self.name_bytes.saturating_add(name.len());
if self.name_bytes > MAX_NAME_BYTES {
return Err(Error::Limit("layer names too large"));
}
let name = name.to_string();
let visible = s.bool(id, b"Visi").unwrap_or(true);
let locked = s.bool(id, b"Edtb").is_some_and(|e| !e);
let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0));
@@ -460,6 +484,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
let (mask, attached_mask) = self.attached_masks(id, world)?;
let pixel_mask = attached_mask.or(child_mask);
self.active.remove(&id);
self.charge_slots(s.array_len(id, b"FiEf").unwrap_or(0))?;
if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) {
self.warn("layer effects (shadows, glows, outlines, bevels…)");
}
@@ -536,6 +561,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
/// an attached mask layer is a pixel mask. Adjustments attached the same way are reported.
fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option<Path>, Option<Image>), Error> {
let s = self.s;
// Every occurrence of a shared layer scans its whole attachment array, null slots included.
self.charge_slots(s.array_len(id, b"AdCh").unwrap_or(0))?;
let attached = s.objs(id, b"AdCh");
let mut mask: Option<Path> = None;
let mut pixels: Option<Image> = None;
@@ -584,21 +611,51 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
}
}
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
/// equal pages (Publisher 1 facing spreads).
fn pages(s: &Stream, spread: ObjId) -> Vec<Rect> {
if let Some(md) = s.obj(spread, b"SpMd") {
let v: Vec<Rect> = s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect();
if !v.is_empty() {
return v;
impl Reader<'_, '_, '_> {
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
/// equal pages (Publisher 1 facing spreads). A spread listed several times, or sharing one
/// large page array, is expanded into a new vector each time, so the slots scanned and the
/// rectangles retained are charged across the document.
fn pages(&mut self, spread: ObjId) -> Result<Vec<Rect>, Error> {
let s = self.s;
if let Some(md) = s.obj(spread, b"SpMd") {
self.charge_slots(s.array_len(md, b"PagR").unwrap_or(0))?;
let remaining = MAX_PAGES.saturating_sub(self.pages);
let v: Vec<Rect> = s
.objs(md, b"PagR")
.into_iter()
.filter_map(|p| s.floats::<4>(p, b"rctp").map(rect))
.filter(|r| r.x1 > r.x0 && r.y1 > r.y0)
.take(remaining.saturating_add(1))
.collect();
if !v.is_empty() {
self.charge_pages(v.len())?;
return Ok(v);
}
}
Ok(match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) {
(Some(b), Some(n @ 2..=16)) => {
self.charge_pages(n as usize)?;
let w = (b.x1 - b.x0) / n as f64;
(0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect()
}
_ => Vec::new(),
})
}
match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) {
(Some(b), Some(n @ 2..=16)) => {
let w = (b.x1 - b.x0) / n as f64;
(0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect()
/// Counts retained font-name bytes against [`MAX_FONT_NAME_BYTES`]; false once it is spent.
pub(crate) fn charge_font_name(&mut self, n: usize) -> bool {
self.font_name_bytes = self.font_name_bytes.saturating_add(n);
self.font_name_bytes <= MAX_FONT_NAME_BYTES
}
/// Counts retained page rectangles against [`MAX_PAGES`].
fn charge_pages(&mut self, n: usize) -> Result<(), Error> {
self.pages = self.pages.saturating_add(n);
if self.pages > MAX_PAGES {
return Err(Error::Limit("too many pages"));
}
_ => Vec::new(),
Ok(())
}
}
+7 -1
View File
@@ -232,9 +232,15 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Pai
}
};
let grad = s.obj(fill, b"Grad")?;
// A shared gradient is decoded again for every occurrence: charge the arrays scanned
// and copy only as many positions as there are stops to keep.
if r.charge_slots(s.array_len(grad, b"Cols").unwrap_or(0).saturating_add(s.array_len(grad, b"Posn").unwrap_or(0))).is_err() {
r.warn("gradients beyond the document work budget (left empty)");
return Some(Paint::None);
}
let cols = s.objs(grad, b"Cols");
let posn: Vec<Vec<f64>> = match s.field(grad, b"Posn") {
Some(Value::Array(a)) => a.iter().filter_map(floats).collect(),
Some(Value::Array(a)) => a.iter().filter_map(floats).take(MAX_STOPS).collect(),
_ => Vec::new(),
};
let mut stops = Vec::new();
+15 -7
View File
@@ -181,24 +181,32 @@ fn plane(
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE));
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
_ => Vec::new(),
// Borrow the file-controlled arrays: collecting the whole state array first costs its full
// length on every occurrence of a shared layer, however small the declared grid is.
let states: &[Value] = match s.field(bitmap, &tag(b"Sta", c)) {
Some(Value::Array(a)) => a,
_ => &[],
};
let tiles = s.objs(bitmap, &tag(b"Idx", c));
let mut tiles = match s.field(bitmap, &tag(b"Idx", c)) {
Some(Value::Array(a)) => a.as_slice(),
_ => &[],
}
.iter()
.filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None });
// The scan below runs for every occurrence of a shared bitmap, whatever it produces.
r.charge_slots(states.len().min(tw.saturating_mul(th)))?;
let row = (w as usize) * bps;
let mut out = vec![0u8; row * h as usize];
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
let mut next = 0usize;
for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
let state = &if let Value::UInt(u) = state { u8::try_from(*u).unwrap_or(255) } else { 255 };
let (ty, tx) = (t / tw.max(1), t % tw.max(1));
let fill = match state {
0 | 1 => None,
2 => Some(0xFF),
4 => {
let Some(blck) = tiles.get(next).copied() else { return Err(Error::Malformed("missing pixel tile")) };
next += 1;
let Some(blck) = tiles.next() else { return Err(Error::Malformed("missing pixel tile")) };
let (ox, oy) = (tx * TILE, ty * TILE);
if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 {
continue;
+2 -1
View File
@@ -332,7 +332,8 @@ impl Parser<'_> {
0x2c => {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
let size = usize::from(self.c.u16()?);
if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
// A zero width passes the length check for any count, then costs a Value per record.
if size == 0 || n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
return Err(Error::Malformed("array longer than its data"));
}
let mut v = self.vec(n)?;
+48
View File
@@ -258,6 +258,20 @@ mod archive {
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
}
#[test]
fn a_failed_read_still_spends_the_extraction_allowance() {
let d = doc();
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
let mut broken = file.clone();
broken[90] ^= 0x55;
// Room for exactly one read: the failed attempt must use it up, so a caller that
// recovers from the checksum error cannot make the same entry decode again.
let mut a = Archive::open(&broken, Limits { max_entry: usize::MAX, max_total: d.len(), ..Limits::default() }).unwrap();
assert!(a.read("doc.dat").is_err());
assert_eq!(a.extracted(), d.len());
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
}
#[test]
fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() {
let big = vec![7u8; 1 << 20];
@@ -302,6 +316,17 @@ mod archive {
}
}
#[test]
fn fixed_records_of_zero_width_are_rejected_before_any_value_is_made() {
// Four million records of no bytes each pass a length check that multiplies by the width.
let mut f = vec![0x80 | 0x2c];
f.extend(tag(b"Big_").0.to_le_bytes());
f.extend(4_000_000u32.to_le_bytes());
f.extend(0u16.to_le_bytes());
let d = synth::stream(&[(tag(b"Big_"), F::Raw(f))]);
assert_eq!(stream::parse(&d).unwrap_err(), Error::Malformed("array longer than its data"));
}
#[test]
fn a_small_compressed_entry_cannot_make_the_stream_allocate_gigabytes() {
// Each decoded value takes about 40 bytes, and a bool array packs eight in a byte: 16 MiB
@@ -398,6 +423,29 @@ mod shapes {
}
}
#[test]
fn artistic_text_reads_its_characters_runs_and_anchor() {
let glyphs = F::Obj(tag(b"Glyp"), vec![(tag(b"Utf8"), F::Str("Hi".into()))]);
let run = F::Obj(tag(b"Item"), vec![]);
let attributes = F::Obj(tag(b"GAtt"), vec![(tag(b"Runs"), F::Objs(tag(b"Runs"), vec![vec![(tag(b"Indx"), F::I32(2)), (tag(b"Item"), run)]]))]);
let block = vec![(tag(b"Glyp"), glyphs), (tag(b"GAtt"), attributes)];
let text = F::Def(
100,
vec![tag(b"TxtA")],
vec![
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 100.0, 50.0]))])),
(tag(b"StSt"), F::Obj(tag(b"StSt"), vec![(tag(b"Blok"), F::Objs(tag(b"Blok"), vec![block]))])),
],
);
let document = read(vec![text], 100);
let Kind::Text(t) = &document.spreads[0].nodes[0].kind else { panic!("expected text") };
assert_eq!(t.runs.len(), 1);
assert_eq!(t.runs[0].text, "Hi");
assert_eq!(t.align, crate::model::Align::Left);
assert_eq!(t.anchor, Point { x: 0.0, y: 0.0 });
assert!(t.frame.is_none());
}
#[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);
+50 -18
View File
@@ -4,7 +4,7 @@
use crate::model::{Affine, Align, Point, Reader, Text, TextRun, rect};
use crate::paint::{self, Paint};
use crate::stream::{ObjId, Value};
use crate::stream::{ObjId, Stream, Value};
/// Characters read per text node (stories are split into blocks; this caps hostile ones).
const MAX_CHARS: usize = 1 << 20;
@@ -21,20 +21,7 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
let mut placeholders = false;
for block in s.objs(story, b"Blok") {
let glyphs = s.obj(block, b"Glyp")?;
let chars: Vec<char> = match s.str(glyphs, b"Utf8") {
Some(t) => t.chars().collect(),
None => {
// Inline objects (page numbers, anchors) each take one index before the segment text.
let mut v = Vec::new();
for seg in s.objs(glyphs, b"Mixd") {
let inline = s.objs(seg, b"Glys").len();
v.extend(std::iter::repeat_n('\u{FFFC}', inline));
placeholders |= inline > 0;
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars());
}
v
}
};
let chars = block_chars(s, glyphs, total, &mut placeholders);
total = total.checked_add(chars.len())?;
if total > MAX_CHARS {
r.warn("text longer than a million characters (truncated)");
@@ -78,6 +65,32 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world })
}
/// The characters of one text block. A block without a UTF-8 string is a list of segments: each
/// inline object (page number, anchor) takes one `U+FFFC` before its segment's text. Stops once
/// `total` plus these characters would pass the cap.
fn block_chars(s: &Stream, glyphs: ObjId, total: usize, placeholders: &mut bool) -> Vec<char> {
// One past the allowance is enough to trip the check in `read`; both arms stop there.
let room = MAX_CHARS.saturating_sub(total).saturating_add(1);
match s.str(glyphs, b"Utf8") {
Some(t) => t.chars().take(room).collect(),
None => {
// Inline objects (page numbers, anchors) each take one index before the segment text.
let mut v = Vec::new();
// Shared segments repeated by reference must not expand beyond the allowance first.
for seg in s.objs(glyphs, b"Mixd") {
if v.len() >= room {
break;
}
let inline = s.objs(seg, b"Glys").len();
v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(room - v.len())));
*placeholders |= inline > 0;
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(room.saturating_sub(v.len())));
}
v
}
}
}
fn slot<T: Copy>(v: Option<&Value>, i: usize, f: impl Fn(&Value) -> Option<T>) -> Option<T> {
match v? {
Value::Array(a) => a.get(i).and_then(f),
@@ -100,6 +113,24 @@ fn int(v: &Value) -> Option<i64> {
}
}
/// Longest font name kept; real PostScript and family names are far shorter.
const MAX_FONT_NAME: usize = 256;
/// A font name for one run. Runs repeat the same shared string, so its copies are charged
/// against a document-wide byte budget; an overlong name or an exhausted budget leaves it empty.
fn font_name(r: &mut Reader, font: Option<ObjId>, tag: &[u8; 4]) -> String {
let name = font.and_then(|f| r.s.str(f, tag)).unwrap_or_default();
if name.len() > MAX_FONT_NAME {
r.warn("font names longer than 256 bytes (left empty)");
return String::new();
}
if !r.charge_font_name(name.len()) {
r.warn("font names beyond the document metadata budget (left empty)");
return String::new();
}
name.to_string()
}
fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextRun {
let s = r.s;
let doubles = s.field(attrs, b"Doub");
@@ -119,11 +150,12 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextR
r.warn("horizontally scaled text");
}
let font = s.obj(attrs, b"DFnt");
let get = |tag: &[u8; 4]| font.and_then(|f| s.str(f, tag)).unwrap_or_default().to_string();
let postscript = font_name(r, font, b"Post");
let family = font_name(r, font, b"Famy");
TextRun {
text,
postscript: get(b"Post"),
family: get(b"Famy"),
postscript,
family,
weight: font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400),
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
size,
+1
View File
@@ -21,4 +21,5 @@ rustfft = "6.4.1"
rayon = { workspace = true }
[dev-dependencies]
photocraft-testkit = { workspace = true }
serde_json = { workspace = true }
+57 -7
View File
@@ -1,13 +1,33 @@
//! Timing for the heavier second-batch filters on a ~24 MP (6000×4000) RGBA8 layer.
//! `cargo run --release -p photocraft-algo --example bench_filters2 [filter-name-substring]`
//!
//! `cargo run --release -p photocraft-algo --example bench_filters2 [name-substring] [--reps N] [--quick] [--json <file>]`
//!
//! `--json` writes the report `cargo xtask perf` reads: `{"rows": [{"name", "samples_ms"}]}`.
//! `--quick` runs the list on a 1500×1000 layer instead, for `cargo xtask perf --quick`.
use photocraft_algo::*;
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
fn main() {
let only = std::env::args().nth(1);
let (w, h) = (6000, 4000);
let a: Vec<String> = std::env::args().skip(1).collect();
let flag = |k: &str| a.iter().position(|v| v == k);
let value = |k: &str| flag(k).and_then(|i| a.get(i + 1)).cloned();
let mut only: Option<String> = None;
let mut i = 0;
while i < a.len() {
match a[i].as_str() {
"--json" | "--reps" => i += 2,
"--quick" => i += 1,
_ => {
only.get_or_insert_with(|| a[i].clone());
i += 1;
}
}
}
let reps: usize = value("--reps").and_then(|v| v.parse().ok()).unwrap_or(1).max(1);
let quick = flag("--quick").is_some();
let (w, h) = if quick { (1500, 1000) } else { (6000, 4000) };
let r = Rect::new(0, 0, w, h);
let mut s = Surface::new(PixelFormat::RGBA8);
// Busy deterministic content (gradients plus texture) so edge-aware filters do real work.
@@ -55,19 +75,49 @@ fn main() {
},
FilterParams::Relight { angle: 45.0, elevation: 40.0, intensity: 40.0, ambient: 55.0, warmth: 0.0, softness: 25.0 },
];
let mut rows: Vec<(String, Vec<f64>)> = Vec::new();
for p in cases {
if only.as_deref().is_some_and(|o| !p.label().to_lowercase().contains(&o.to_lowercase())) {
continue;
}
let area = output_area(&p, s.content_bounds(), r, None).intersect(&r);
let t = std::time::Instant::now();
let out = apply(&s, &p, area, r, None);
// Smart Blur's cost peaks near radius 12, where High quality visits every pixel in the circle.
let name = match p {
FilterParams::SmartBlur { radius, .. } => format!("{} r{radius}", p.label()),
_ => p.label().to_string(),
};
println!("{name:<18} {w}x{h} RGBA8: {:.2?}", t.elapsed());
drop(out);
let mut samples = Vec::with_capacity(reps);
for _ in 0..reps {
let t = std::time::Instant::now();
let out = apply(&s, &p, area, r, None);
let ms = t.elapsed().as_secs_f64() * 1e3;
samples.push(ms);
drop(out);
}
let mut sorted = samples.clone();
sorted.sort_by(f64::total_cmp);
let median = match sorted.len() % 2 {
1 => sorted[sorted.len() / 2],
_ => (sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2.0,
};
println!("{name:<18} {w}x{h} RGBA8: {median:.1} ms{reps_note}", reps_note = if reps > 1 { format!(" (median of {reps})") } else { String::new() });
rows.push((name, samples));
}
if let Some(path) = value("--json") {
// Two fields per row and ASCII filter labels: hand-written rather than a serde dependency
// this crate does not otherwise need.
let body: Vec<String> = rows
.iter()
.map(|(name, samples)| {
let s: Vec<String> = samples.iter().map(|v| format!("{v:.4}")).collect();
format!("{{\"name\":\"{name}\",\"samples_ms\":[{}]}}", s.join(","))
})
.collect();
let text = format!("{{\"rows\":[{}]}}", body.join(","));
if let Err(e) = std::fs::write(&path, text) {
eprintln!("write {path}: {e}");
std::process::exit(1);
}
println!("report: {path}");
}
}
+145 -26
View File
@@ -1,42 +1,161 @@
//! Time and peak memory of the Photomerge multi-band blend: `n` RGB sources of `mp` megapixels
//! (3:2), each overlapping the next by half its width.
//! `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]` (default 4 × 24 MP).
//! Peak memory is the growth of the process's high-water mark during the blend (Linux only).
//! Time and peak memory of Photomerge's multi-band blend on overlapping RGB sources.
//! Backwards compatible: `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]`.
//! `cargo xtask perf --bench bench_multiband --quick` also collects repeatable JSON timings.
//! The peak is the entire process's resident-set high-water mark, not an estimate of allocations
//! by the blend alone (`photocraft_testkit::perf`: the kernel's mark on Linux, sampled elsewhere).
use std::path::PathBuf;
use std::time::Instant;
use photocraft_algo::panorama::{self, RoiImage};
use photocraft_testkit::perf::{self, RssSampler, Summary};
use serde_json::json;
/// The process's peak resident set in MiB (`VmHWM`), where `/proc` has it.
fn peak_mib() -> Option<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)
const ROW: &str = "Photomerge multiband blend";
#[derive(Debug, PartialEq)]
struct Options {
n: usize,
mp: f64,
reps: usize,
json: Option<PathBuf>,
}
fn main() {
let mut args = std::env::args().skip(1);
let n: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(4).max(1);
let mp: f64 = args.next().and_then(|a| a.parse().ok()).unwrap_or(24.0);
let sh = ((mp * 1e6 / 1.5).sqrt().round() as usize).max(2);
/// Parses legacy `[n] [mp]` arguments and the perf runner's `--reps`/`--json` flags.
/// Rejects missing, out-of-range, or non-finite values before allocating images.
fn options(args: impl IntoIterator<Item = String>) -> Result<Options, String> {
let mut out = Options { n: 4, mp: 24.0, reps: 1, json: None };
let mut positional = Vec::new();
let mut it = args.into_iter();
while let Some(arg) = it.next() {
match arg.as_str() {
"--json" => {
let path = it.next().filter(|p| !p.starts_with('-')).ok_or("--json needs a file path")?;
out.json = Some(PathBuf::from(path));
}
"--reps" => {
out.reps = it.next().ok_or("--reps needs an integer")?.parse().map_err(|_| "--reps needs an integer")?;
}
_ if !arg.starts_with('-') => positional.push(arg),
_ => return Err(format!("unknown option: {arg}")),
}
}
if positional.len() > 2 {
return Err("expected at most [n] [mp]".into());
}
if let Some(n) = positional.first() {
out.n = n.parse().map_err(|_| "n must be an integer")?;
}
if let Some(mp) = positional.get(1) {
out.mp = mp.parse().map_err(|_| "mp must be a number")?;
}
if !(1..=16).contains(&out.n) || !(1..=25).contains(&out.reps) || !out.mp.is_finite() || !(0.01..=36.0).contains(&out.mp) {
return Err("expected 1..=16 images, 0.01..=36 MP per image and 1..=25 repetitions".into());
}
Ok(out)
}
/// Builds deterministic overlapping RGB inputs and times each multi-band blend.
/// Writes individual samples and whole-process peak RSS to JSON when requested.
fn run() -> Result<(), String> {
let o = options(std::env::args().skip(1))?;
let sh = ((o.mp * 1e6 / 1.5).sqrt().round() as usize).max(2);
let sw = sh * 3 / 2;
let step = sw / 2;
let (cw, ch) = (step * (n - 1) + sw, sh);
let rois: Vec<RoiImage> = (0..n)
let (cw, ch) = (step * (o.n - 1) + sw, sh);
let rois: Vec<RoiImage> = (0..o.n)
.map(|k| {
let px = (0..sw * sh * 3).map(|i| ((i / 3 % sw * 7 + i / 3 / sw * 3 + k * 31 + i % 3 * 50) % 251) as f32 / 251.0).collect();
RoiImage { x0: (step * k) as i32, y0: 0, w: sw, h: sh, ch: 3, px, alpha: vec![1.0; sw * sh] }
})
.collect();
// Each canvas column belongs to the image whose centre is nearest, as a seam would split it.
let wts: Vec<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())
let wts: Vec<Vec<f32>> = (0..o.n)
.map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(o.n - 1) == k { 1.0 } else { 0.0 }).collect())
.collect();
let levels = panorama::blend_levels(cw, ch).min(7);
let before = peak_mib();
let t0 = Instant::now();
let (px, cov) = panorama::multiband(cw, ch, &rois, &wts, levels);
let ms = t0.elapsed().as_secs_f64() * 1000.0;
let peak = peak_mib().zip(before).map_or("n/a".to_string(), |(a, b)| format!("{:.0} MiB", a - b));
println!("{n} × {sw}×{sh} ({mp} MP) RGB, canvas {cw}×{ch}, {levels} levels: {ms:.0} ms, peak +{peak} during the blend");
std::hint::black_box((px, cov));
let before = perf::process_peak_rss_bytes().or_else(perf::current_rss_bytes);
let sampler = RssSampler::start(std::time::Duration::from_millis(2));
let mut samples_ms = Vec::with_capacity(o.reps);
for _ in 0..o.reps {
let t0 = Instant::now();
let output = panorama::multiband(cw, ch, &rois, &wts, levels);
samples_ms.push(t0.elapsed().as_secs_f64() * 1000.0);
std::hint::black_box(&output);
}
let peak = perf::process_peak_rss_bytes().or(sampler.peak());
let delta = before.zip(peak).map(|(a, b)| b.saturating_sub(a) as f64 / (1024.0 * 1024.0));
// The same nearest-rank p50 that `cargo xtask perf` reports for these samples.
let p50 = Summary::of(&samples_ms).map_or(f64::NAN, |s| s.p50);
println!(
"{} × {}×{} ({:.2} MP) RGB, canvas {}×{}, {} levels: {:.1} ms p50 ({} runs), peak +{}",
o.n,
sw,
sh,
o.mp,
cw,
ch,
levels,
p50,
o.reps,
delta.map_or("n/a".to_string(), |m| format!("{m:.0} MiB"))
);
if let Some(path) = o.json {
let context = json!({"sources": o.n, "mp_per_source": o.mp, "canvas_width": cw, "canvas_height": ch, "levels": levels});
let report = perf::report("bench_multiband", context, vec![perf::row(ROW, &samples_ms, peak, None)], Some(&sampler));
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
}
std::fs::write(&path, serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?).map_err(|e| format!("write {}: {e}", path.display()))?;
}
Ok(())
}
/// Runs the benchmark, reporting argument or output errors with a failing exit status.
fn main() {
if let Err(e) = run() {
eprintln!("bench_multiband: {e}");
std::process::exit(2);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Converts borrowed argument slices for the owned-string command-line parser.
fn parse(args: &[&str]) -> Result<Options, String> {
options(args.iter().map(|s| s.to_string()))
}
/// Confirms backwards compatibility with the positional and perf-runner forms.
#[test]
fn parses_old_positional_args_and_perf_runner_args() {
assert_eq!(parse(&[]).unwrap().n, 4);
assert_eq!(
parse(&["2", "0.25", "--reps", "3", "--json", "out.json"]).unwrap(),
Options { n: 2, mp: 0.25, reps: 3, json: Some(PathBuf::from("out.json")) }
);
// The 24-36 MP range AGENTS.md benchmarks at, as before the flags were added.
assert_eq!(parse(&["4", "36"]).unwrap().mp, 36.0);
}
/// Checks invalid counts, non-finite sizes, repetitions, and missing flag values.
#[test]
fn rejects_bad_benchmark_args() {
for args in [
vec!["0"],
vec!["17"],
vec!["2", "0"],
vec!["2", "NaN"],
vec!["2", "inf"],
vec!["2", "37"],
vec!["--reps", "0"],
vec!["--reps", "26"],
vec!["--json"],
vec!["--json", "--reps", "5"],
vec!["--reps"],
vec!["--unknown"],
] {
assert!(parse(&args).is_err(), "{args:?}");
}
}
}
+6 -1
View File
@@ -1,5 +1,6 @@
//! Full filter timing, including tile reads, filtering, writeback and pruning.
//! RAYON_NUM_THREADS=16 cargo run --release -p photocraft-algo --example bench_pixelate -- facet 6000 4000 u8 3 output.bin
//! Also: crystallize, crystallize-large, pointillize, pointillize-large.
//! Optional final argument after output.bin: rgb (default), gray, cmyk or lab.
//! Run the same example at the base commit and the PR commit, then compare output.bin with cmp.
use photocraft_algo::{FilterParams, apply_in};
@@ -29,11 +30,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
_ => return Err("depth must be u8, u16 or f32".into()),
};
let params = match case {
"crystallize" => FilterParams::Crystallize { cell_size: 10.0, seed: 37 },
"crystallize-large" => FilterParams::Crystallize { cell_size: 100.0, seed: 37 },
"pointillize" => FilterParams::Pointillize { cell_size: 5.0, seed: 37, background: [1.0; 4] },
"pointillize-large" => FilterParams::Pointillize { cell_size: 100.0, seed: 37, background: [1.0; 4] },
"facet" => FilterParams::Facet,
"halftone" => FilterParams::ColorHalftone { max_radius: 8.0, angles: [108.0, 162.0, 90.0, 45.0] },
"halftone-small" => FilterParams::ColorHalftone { max_radius: 2.0, angles: [108.0, 162.0, 90.0, 45.0] },
"halftone-large" => FilterParams::ColorHalftone { max_radius: 64.0, angles: [108.0, 162.0, 90.0, 45.0] },
_ => return Err("case must be facet, halftone, halftone-small or halftone-large".into()),
_ => return Err("unknown pixelate benchmark case".into()),
};
let mode = match args.get(6).map(String::as_str).unwrap_or("rgb") {
"rgb" => ColorMode::Rgb,
+32
View File
@@ -0,0 +1,32 @@
//! Timing and output hash for Content-Aware Scale's seam carving core (P28).
//!
//! `cargo run --release -p photocraft-algo --example bench_seam [width height new_width channels]`
//!
//! Prints the carve time and an FNV-1a hash of the output's raw f32 bits, so two builds can be
//! compared with `cmp`-level strictness without writing tens of megabytes of pixels to disk.
use std::time::Instant;
fn main() {
let a: Vec<String> = std::env::args().skip(1).collect();
let num = |i: usize, d: usize| a.get(i).and_then(|s| s.parse().ok()).unwrap_or(d);
let (w, h, new_w, ch) = (num(0, 6000), num(1, 4000), num(2, 5400), num(3, 4));
if !(1..=4).contains(&ch) {
eprintln!("channels must be 1..=4");
std::process::exit(2);
}
// Deterministic photo-ish content: gradients with fine detail, so the energy map carries
// structure instead of one flat value per row.
let img: Vec<f32> = (0..w * h)
.flat_map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let d = ((x * 0.13).sin() * (y * 0.07).cos()).abs() * 0.3;
[(x / w as f32) * 0.6 + d, (y / h as f32) * 0.9 - d, d, 1.0].into_iter().take(ch)
})
.collect();
let t = Instant::now();
let out = photocraft_algo::seam::carve_width(w, h, ch, &img, None, new_w);
let ms = t.elapsed().as_secs_f64() * 1e3;
let hash =
out.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, v| v.to_bits().to_le_bytes().iter().fold(h, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x100_0000_01b3)));
println!("{w}x{h}x{ch} -> {new_w}: {ms:.1} ms, {} samples, fnv {hash:016x}", out.len());
}
+78
View File
@@ -0,0 +1,78 @@
//! Timing of Content-Aware Scale's seam carving alone (no document plumbing), on a synthetic
//! photo-like RGBA image: perf scenario P28's 24 MP width change (#211), the same image's height
//! (the transposed pass) and two images short enough for the exact full-resolution seam search.
//! Prints an FNV-style hash of each output's bits (over 32-bit words), so a speed-up can be checked
//! to choose exactly the same seams.
//! `cargo run --release -p photocraft-algo --example bench_seam_cas [reps] [case filter]`
use std::time::Instant;
use photocraft_algo::seam::carve;
/// Smooth gradients, a few hard-edged shapes and fine noise: enough structure that seams are
/// steered by content, as in a photo.
fn photo(w: usize, h: usize) -> Vec<f32> {
let mut seed = 0x9e37_79b9u32;
let mut noise = move || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
(seed % 1000) as f32 / 1000.0 - 0.5
};
let mut img = Vec::with_capacity(w * h * 4);
for y in 0..h {
for x in 0..w {
let (u, v) = (x as f32 / w as f32, y as f32 / h as f32);
let sky = 0.35 + 0.4 * v;
let disc = ((u - 0.3).powi(2) + (v - 0.55).powi(2)) < 0.02;
let bar = (0.62..0.66).contains(&u) && v > 0.3;
let hill = v > 0.7 + 0.08 * (u * 11.0).sin();
let (r, g, b) = if disc {
(0.85, 0.3, 0.2)
} else if bar {
(0.1, 0.1, 0.12)
} else if hill {
(0.2, 0.45 + 0.1 * (u * 40.0).sin(), 0.15)
} else {
(sky * 0.6, sky * 0.8, sky)
};
let n = noise() * 0.02;
img.extend_from_slice(&[r + n, g + n, b + n, 1.0]);
}
}
img
}
fn fnv(v: &[f32]) -> u64 {
v.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, f| (h ^ u64::from(f.to_bits())).wrapping_mul(0x0100_0000_01b3))
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let reps = args.get(1).and_then(|s| s.parse::<usize>().ok()).unwrap_or(1).clamp(1, 20);
let only = args.get(2).cloned();
// (name, w, h, new w, new h)
let cases = [
("24 MP to 90 % width (P28, proxy rows)", 6000, 4000, 5400, 4000),
("24 MP to 90 % height (transposed, proxy rows)", 6000, 4000, 6000, 3600),
("5.4 MP to 90 % width (exact)", 3000, 1800, 2700, 1800),
("1.5 MP to 70 % width (exact)", 1500, 1000, 1050, 1000),
];
for (name, w, h, nw, nh) in cases {
if only.as_ref().is_some_and(|o| !name.contains(o.as_str())) {
continue;
}
let img = photo(w, h);
let mut times = Vec::with_capacity(reps);
let mut hash = 0;
for _ in 0..reps {
let t = Instant::now();
let out = carve(w, h, 4, &img, None, nw, nh);
times.push(t.elapsed().as_secs_f64() * 1000.0);
hash = fnv(&out);
}
times.sort_by(f64::total_cmp);
let p50 = times[times.len() / 2];
let max = times[times.len() - 1];
println!("{name:<48} p50 {p50:>9.1} ms max {max:>9.1} ms output {hash:016x}");
}
}
File diff suppressed because it is too large Load Diff
+320 -67
View File
@@ -56,8 +56,11 @@ pub fn validate_curve(points: &[[f32; 2]]) -> Result<(), String> {
use crate::photo_util::{hash01, linear_to_srgb, par_rows, srgb_to_linear};
mod local_tone;
mod per_pixel;
mod whites_blacks;
pub use per_pixel::{CURVE_LUT, Grade, Grain, PerPixel, Vignette, VignetteStyle, WB_STEPS, WhitesBlacks, per_pixel, white_black_points};
/// How the pixels encode light: the sRGB curve (gamma 0) or a pure power law (ProPhoto's 1.8,
/// Adobe RGB's 2.2). The tone controls work on light, so the document's own curve decodes it.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -166,6 +169,13 @@ pub struct CameraRaw {
/// The document's transfer curve: 0 for sRGB's, else the gamma of a power-law space (1.8
/// for ProPhoto). Set from the document's profile; Whites and Blacks act on light.
pub encoding_gamma: f32,
// Calibration (Camera Raw's Calibration panel)
/// Shadows › Tint: −100 (green) … 100 (magenta), strongest in the darkest tones.
pub shadow_tint: f32,
/// Red, Green and Blue Primary › Hue: −100 … 100, the primary turned towards its neighbours.
pub calibration_hue: [f32; 3],
/// Red, Green and Blue Primary › Saturation: −100 (grey) … 100 (purer).
pub calibration_sat: [f32; 3],
}
impl Default for CameraRaw {
@@ -222,6 +232,9 @@ impl Default for CameraRaw {
seed: 0,
pixel_scale: 1.0,
encoding_gamma: 0.0,
shadow_tint: 0.0,
calibration_hue: [0.0; 3],
calibration_sat: [0.0; 3],
}
}
}
@@ -229,6 +242,90 @@ impl Default for CameraRaw {
/// Hue band centres of the Color Mixer (degrees).
pub const HSL_BANDS: [f32; 8] = [0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 270.0, 300.0];
/// How far a primary turns at Hue ±100 (degrees), as the Color Mixer's hue sliders do.
const CALIBRATION_TURN: f32 = 30.0;
/// How much of each primary (red, green, blue) a hue is made of: triangular weights around
/// 0°, 120° and 240° that sum to one, so a colour between two primaries follows both.
pub fn calibration_weights(hue: f32) -> [f32; 3] {
let h = hue.rem_euclid(360.0);
let mut w = [0.0f32; 3];
for (i, centre) in [0.0f32, 120.0, 240.0].iter().enumerate() {
let d = (h - centre).abs().min(360.0 - (h - centre).abs());
w[i] = (1.0 - d / 120.0).max(0.0);
}
let sum: f32 = w.iter().sum();
if sum > 1e-6 { w.map(|v| v / sum) } else { [1.0, 0.0, 0.0] }
}
/// The Calibration panel's primaries on one linear-light pixel: its hue turns by the weighted
/// Hue of the primaries it is made of (±100 is ±30°, positive towards the next primary) and its
/// saturation scales by their weighted Saturation (−100 greys it); the luminance is kept, so
/// neutrals are untouched.
fn calibrate(c: [f32; 3], hue: [f32; 3], sat: [f32; 3]) -> [f32; 3] {
let [h, s, v] = rgb_to_hsv(c.map(|x| x.max(0.0)));
if s <= 1e-5 || v <= 1e-6 {
return c;
}
let w = calibration_weights(h);
let (mut dh, mut ds) = (0.0f32, 0.0f32);
for i in 0..3 {
dh += w[i] * hue[i].clamp(-100.0, 100.0);
ds += w[i] * sat[i].clamp(-100.0, 100.0);
}
let nh = h + dh / 100.0 * CALIBRATION_TURN;
let ns = (s * (1.0 + ds / 100.0)).clamp(0.0, 1.0);
let out = hsv_to_rgb(nh, ns, v);
let (l0, l1) = (luma(c), luma(out));
if l1 > 1e-6 { out.map(|x| x * l0 / l1) } else { out }
}
/// Shadows › Tint on one linear-light pixel: `tint` −1 (green) … 1 (magenta), weighted by how
/// dark the pixel is; the luminance is kept.
fn shadow_tint(c: [f32; 3], tint: f32) -> [f32; 3] {
let l = luma(c).max(0.0);
// Linear 0.18 is middle grey: full strength below it, fading out towards white.
let weight = (1.0 - (l / 0.18).sqrt().min(1.0)).powi(2) * 0.5 + (1.0 - l.min(1.0)).powi(4) * 0.5;
let k = tint * 0.12 * weight * (l + 0.02);
let o = [c[0] + k, c[1] - k, c[2] + k].map(|v| v.max(0.0));
let l2 = luma(o);
if l2 > 1e-6 { o.map(|v| v * l / l2) } else { o }
}
/// Camera Raw's Auto for the Light panel: Exposure, Contrast, Highlights, Shadows, Whites and
/// Blacks from the image's luminance histogram (`px` in the document's encoding). A well
/// exposed image gets near-zero settings; a dark one more exposure and lifted shadows; one
/// with a crushed or blown end gets Blacks or Whites back from the edge. The other panels are
/// left alone, as Camera Raw's Auto leaves them.
pub fn auto_tone(px: &[[f32; 4]], p: &mut CameraRaw) {
if px.is_empty() {
return;
}
let mut lum: Vec<f32> = px.iter().map(|q| luma([q[0], q[1], q[2]]).clamp(0.0, 1.0)).collect();
lum.sort_by(|a, b| a.total_cmp(b));
let at = |f: f32| lum[((lum.len() - 1) as f32 * f).round() as usize];
let (black, shadow, median, light, white) = (at(0.001), at(0.1), at(0.5), at(0.9), at(0.999));
// Exposure: the median towards middle grey (sRGB 0.46), damped so flat scenes aren't
// forced to it.
let median_lin = srgb_to_linear(median).max(1e-4);
p.exposure = (0.6 * (0.18f32 / median_lin).log2()).clamp(-2.0, 2.0);
// Blacks and Whites bring the ends to the edges without clipping more than Camera Raw does.
p.blacks = ((0.04 - black) * 400.0).clamp(-40.0, 40.0);
p.whites = ((0.96 - white) * 300.0).clamp(-40.0, 40.0);
// Shadows up when the darker tenth is crushed, highlights down when the brighter tenth is
// near white.
p.shadows = ((0.25 - shadow) * 200.0).clamp(0.0, 50.0);
p.highlights = ((light - 0.85) * -300.0).clamp(-50.0, 0.0);
// A little contrast for a flat histogram.
let spread = (light - shadow).max(0.0);
p.contrast = ((0.6 - spread) * 60.0).clamp(0.0, 25.0);
// Shown as Camera Raw shows them: Exposure to a hundredth of a stop, the others whole.
p.exposure = (p.exposure * 100.0).round() / 100.0;
for v in [&mut p.blacks, &mut p.whites, &mut p.shadows, &mut p.highlights, &mut p.contrast] {
*v = v.round();
}
}
fn luma(c: [f32; 3]) -> f32 {
0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
}
@@ -534,6 +631,10 @@ impl CameraRaw {
fn grade_neutral(&self) -> bool {
self.grade_shadows.neutral() && self.grade_midtones.neutral() && self.grade_highlights.neutral() && self.grade_global.neutral()
}
/// The Calibration panel at rest.
fn calibration_neutral(&self) -> bool {
self.shadow_tint == 0.0 && self.calibration_hue.iter().chain(&self.calibration_sat).all(|v| *v == 0.0)
}
fn curves_neutral(&self) -> bool {
[self.curve_highlights, self.curve_lights, self.curve_darks, self.curve_shadows].iter().all(|v| *v == 0.0)
&& [&self.point_curve, &self.point_curve_red, &self.point_curve_green, &self.point_curve_blue].iter().all(|c| is_linear(c))
@@ -541,6 +642,7 @@ impl CameraRaw {
/// True when every control is neutral (the filter is an identity).
pub fn is_identity(&self) -> bool {
self.wb_neutral()
&& self.calibration_neutral()
&& self.tone_neutral()
&& self.dehaze == 0.0
&& self.vibrance == 0.0
@@ -580,14 +682,27 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
let ps = p.pixel_scale.clamp(0.01, 1.0);
let long = w.max(h) as f32;
// 1. White balance + exposure in linear light.
if !p.wb_neutral() {
let mut g = white_balance_gains(p.temperature, p.tint);
let norm = luma(g);
g = g.map(|v| v / norm * 2f32.powf(p.exposure));
if let Some(g) = per_pixel::gains(p) {
par_rows(px, w, 1, |_, row| {
for q in row.iter_mut() {
for c in 0..3 {
q[c] = linear_to_srgb((srgb_to_linear(q[c]) * g[c]).max(0.0));
*q = balance(*q, g);
}
});
}
// 1b. Calibration: the primaries turned and purer or greyer, and the shadows tinted, in
// linear light (the camera's primaries are what Camera Raw's panel calibrates).
if !p.calibration_neutral() {
let primaries = p.calibration_hue.iter().chain(&p.calibration_sat).any(|v| *v != 0.0);
let tint = p.shadow_tint.clamp(-100.0, 100.0) / 100.0;
par_rows(px, w, 1, |_, row| {
for q in row.iter_mut() {
let c = [srgb_to_linear(q[0]), srgb_to_linear(q[1]), srgb_to_linear(q[2])];
let mut o = if primaries { calibrate(c, p.calibration_hue, p.calibration_sat) } else { c };
if tint != 0.0 {
o = shadow_tint(o, tint);
}
for k in 0..3 {
q[k] = linear_to_srgb(o[k].max(0.0));
}
}
});
@@ -606,7 +721,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
let clar = (p.clarity != 0.0).then(|| guided_fast(&lc, &lc, w, h, ((long * 0.02) as usize).max(3), 0.005));
let tex = (p.texture != 0.0).then(|| gauss(&lc, w, h, (long * 0.002).max(1.5 * ps)));
let k_con = p.contrast / 100.0 * 0.6;
let tau = std::f32::consts::TAU;
par_rows(px, w, 1, |y, row| {
for (x, q) in row.iter_mut().enumerate() {
let i = y * w + x;
@@ -619,22 +733,14 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
if let Some(t) = &tex {
lv += p.texture / 100.0 * 0.9 * (lc[i] - t[i]).clamp(-0.1, 0.1);
}
if k_con != 0.0 {
let xx = lv.clamp(0.0, 1.0);
lv += -k_con * (tau * xx).sin() / tau;
}
let lv = lv.max(0.0) + (l0 - lc[i]);
let ratio = if l0 > 1e-5 { lv / l0 } else { 1.0 };
for c in 0..3 {
q[c] = if l0 > 1e-5 { q[c] * ratio } else { q[c] + (lv - l0) };
}
*q = tone(*q, l0, lc[i], lv, k_con);
}
});
}
// 3b. Whites and Blacks: global curves adapted to the image's black and white points.
if p.whites != 0.0 || p.blacks != 0.0 {
let enc = Encoding::of(p.encoding_gamma);
let (bp, wp) = luminance_points(px, w, h, enc);
let (bp, wp) = luminance_points(px, w, h, enc, |q| q);
let (xw, xb) = whites_blacks::amounts(p.whites.clamp(-100.0, 100.0), p.blacks.clamp(-100.0, 100.0), bp, wp);
whites_blacks::apply(px, w, xw, xb, enc);
}
@@ -646,14 +752,7 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
let (master, cr, cg, cb) = (pts(&p.point_curve), pts(&p.point_curve_red), pts(&p.point_curve_green), pts(&p.point_curve_blue));
let hsl = !p.hsl_neutral();
let grade = !p.grade_neutral();
let wheel_tint = |wh: &Wheel| -> [f32; 3] {
let c = hsv_to_rgb(wh.hue, 1.0, 1.0);
let l = luma(c);
[c[0] - l, c[1] - l, c[2] - l].map(|v| v * wh.sat / 100.0 * 0.3)
};
let tints = [wheel_tint(&p.grade_shadows), wheel_tint(&p.grade_midtones), wheel_tint(&p.grade_highlights), wheel_tint(&p.grade_global)];
let blend = 0.15 + p.grade_blending.clamp(0.0, 100.0) / 100.0 * 0.35;
let bal = p.grade_balance.clamp(-100.0, 100.0) / 100.0 * 0.25;
let Grade { tints, lums, blend, balance: bal } = Grade::new(p);
par_rows(px, w, 1, |_, row| {
for q in row.iter_mut() {
let mut c = [q[0], q[1], q[2]];
@@ -706,7 +805,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
let hi = smoothstep(0.5 + bal - blend, 1.0, l);
let mid = (1.0 - sh - hi).max(0.0);
let masks = [sh, mid, hi, 1.0];
let lums = [p.grade_shadows.lum, p.grade_midtones.lum, p.grade_highlights.lum, p.grade_global.lum];
for k in 0..4 {
for ch in 0..3 {
c[ch] += masks[k] * tints[k][ch];
@@ -732,11 +830,7 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
vignette(px, w, h, p);
}
if p.grain_amount > 0.0 {
let cell = 1.0 + p.grain_size / 25.0;
let rough = p.grain_roughness / 100.0;
let amt = p.grain_amount / 100.0 * 0.12;
let seed = p.seed as u64;
let pscale = 1.0 / ps;
let Grain { cell, roughness: rough, amount: amt, seed, scale: pscale } = Grain::new(p);
par_rows(px, w, 1, |y, row| {
for (x, q) in row.iter_mut().enumerate() {
// Sample in full-resolution coordinates so previews match the result.
@@ -759,24 +853,60 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
}
}
/// The end of stage 3 on one pixel: Contrast (strength `k_con`) on the luminance `lv` that
/// Clarity and Texture left, then the pixel rescaled to it. `l0` is its luminance, `lc` that
/// clipped to 1.
fn tone(mut q: [f32; 4], l0: f32, lc: f32, mut lv: f32, k_con: f32) -> [f32; 4] {
if k_con != 0.0 {
let tau = std::f32::consts::TAU;
let xx = lv.clamp(0.0, 1.0);
lv += -k_con * (tau * xx).sin() / tau;
}
let lv = lv.max(0.0) + (l0 - lc);
let ratio = if l0 > 1e-5 { lv / l0 } else { 1.0 };
for c in 0..3 {
q[c] = if l0 > 1e-5 { q[c] * ratio } else { q[c] + (lv - l0) };
}
q
}
/// Stage 3 on one pixel when Contrast is its only control.
fn contrast(q: [f32; 4], k_con: f32) -> [f32; 4] {
let l0 = luma([q[0], q[1], q[2]]).max(0.0);
let lc = l0.min(1.0);
tone(q, l0, lc, lc, k_con)
}
/// Stage 1 on one pixel: white balance and exposure `gains` in linear light.
fn balance(mut q: [f32; 4], g: [f32; 3]) -> [f32; 4] {
for c in 0..3 {
q[c] = linear_to_srgb((srgb_to_linear(q[c]) * g[c]).max(0.0));
}
q
}
/// The image's black and white points as sRGB-encoded grey levels: the 3rd percentile of the
/// luminance and the 99.5th of the brightest channel, on a copy at most 256 pixels across (so a
/// preview proxy and the full image agree).
fn luminance_points(px: &[[f32; 4]], w: usize, h: usize, enc: Encoding) -> (f32, f32) {
/// preview proxy and the full image agree). `map` is applied to each pixel first.
fn luminance_points(px: &[[f32; 4]], w: usize, h: usize, enc: Encoding, map: impl Fn([f32; 4]) -> [f32; 4] + Sync) -> (f32, f32) {
let f = w.max(h).div_ceil(256).max(1);
let (sw, sh) = (w.div_ceil(f), h.div_ceil(f));
let mut sum = vec![[0.0f64; 4]; sw * sh];
for y in 0..h {
for x in 0..w {
let q = px[y * w + x];
let s = &mut sum[(y / f) * sw + x / f];
for c in 0..3 {
s[c] += f64::from(enc.decode(q[c]).clamp(0.0, 1.0));
// Each band of `f` rows sums into its own row of cells, in the same order as one pass would.
let bands = crate::photo_util::par_map(sh, |sy| {
let mut sum = vec![[0.0f64; 4]; sw];
for y in sy * f..((sy + 1) * f).min(h) {
for x in 0..w {
let q = map(px[y * w + x]);
let s = &mut sum[x / f];
for c in 0..3 {
s[c] += f64::from(enc.decode(q[c]).clamp(0.0, 1.0));
}
s[3] += 1.0;
}
s[3] += 1.0;
}
}
let cells: Vec<[f32; 3]> = sum.iter().filter(|s| s[3] > 0.0).map(|s| [0, 1, 2].map(|c| (s[c] / s[3]) as f32)).collect();
sum
});
let cells: Vec<[f32; 3]> = bands.iter().flatten().filter(|s| s[3] > 0.0).map(|s| [0, 1, 2].map(|c| (s[c] / s[3]) as f32)).collect();
if cells.is_empty() {
return (0.0, 1.0);
}
@@ -818,23 +948,25 @@ fn dehaze(px: &mut [[f32; 4]], w: usize, h: usize, amount: f32) {
*s = s.map(|v| v / c.max(1.0));
}
let r = (sw.max(sh) / 60).max(2);
let min_filter = |v: &[f32]| -> Vec<f32> {
// The minimum (or, with `f32::max`, maximum) over each (2r + 1)² window.
let rank_filter = |v: &[f32], start: f32, pick: fn(f32, f32) -> f32| -> Vec<f32> {
let mut a = vec![0.0f32; sw * sh];
for y in 0..sh {
for x in 0..sw {
let (x0, x1) = (x.saturating_sub(r), (x + r).min(sw - 1));
a[y * sw + x] = (x0..=x1).map(|xx| v[y * sw + xx]).fold(f32::MAX, f32::min);
a[y * sw + x] = (x0..=x1).map(|xx| v[y * sw + xx]).fold(start, pick);
}
}
let mut b = vec![0.0f32; sw * sh];
for y in 0..sh {
for x in 0..sw {
let (y0, y1) = (y.saturating_sub(r), (y + r).min(sh - 1));
b[y * sw + x] = (y0..=y1).map(|yy| a[yy * sw + x]).fold(f32::MAX, f32::min);
b[y * sw + x] = (y0..=y1).map(|yy| a[yy * sw + x]).fold(start, pick);
}
}
b
};
let min_filter = |v: &[f32]| rank_filter(v, f32::MAX, f32::min);
let dark = min_filter(&small.iter().map(|c| c[0].min(c[1]).min(c[2])).collect::<Vec<_>>());
// Atmospheric light: mean colour of the brightest 0.1 % of the dark channel.
let mut idx: Vec<usize> = (0..sw * sh).collect();
@@ -848,7 +980,15 @@ fn dehaze(px: &mut [[f32; 4]], w: usize, h: usize, amount: f32) {
}
let air = air.map(|v| v.max(0.2));
let norm_dark = min_filter(&small.iter().map(|c| (c[0] / air[0]).min(c[1] / air[1]).min(c[2] / air[2])).collect::<Vec<_>>());
let t_raw: Vec<f32> = norm_dark.iter().map(|d| 1.0 - 0.95 * d).collect();
// Opened (a maximum filter of the same radius after the minimum): the minimum grows every
// dark object by `r`, so sky beside a dark building took its dark channel, counted as
// haze-free and kept a bright halo (#9273). Opening shrinks dark regions back to their
// outline and is still a lower bound of the dark channel over the window. It raises the dark
// channel on average, so the 0.95 weight is scaled to keep the slider's overall strength.
let opened = rank_filter(&norm_dark, f32::MIN, f32::max);
let mean = |v: &[f32]| v.iter().sum::<f32>() / v.len().max(1) as f32;
let weight = 0.95 * mean(&norm_dark) / mean(&opened).max(1e-6);
let t_raw: Vec<f32> = opened.iter().map(|d| 1.0 - weight * d).collect();
let guide: Vec<f32> = small.iter().map(|c| luma(*c)).collect();
let t = guided(&guide, &t_raw, sw, sh, r * 2, 1e-3);
let ks = k as f32;
@@ -923,24 +1063,7 @@ fn detail(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, ps: f32) {
}
fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) {
let amt = p.vignette_amount / 100.0;
let mid = p.vignette_midpoint.clamp(0.0, 100.0) / 100.0;
let feather = p.vignette_feather.clamp(0.0, 100.0) / 100.0;
let round = p.vignette_roundness.clamp(-100.0, 100.0) / 100.0;
let hl = p.vignette_highlights.clamp(0.0, 100.0) / 100.0;
let style = p.vignette_style.as_str();
let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0);
// Roundness: 0 follows the frame's aspect, +1 a circle, −1 a rounded rectangle.
let (ax, ay) = if round >= 0.0 {
let r = cx.min(cy) + (cx.max(cy) - cx.min(cy)) * (1.0 - round);
(if cx >= cy { r } else { cx }, if cy > cx { r } else { cy })
} else {
(cx, cy)
};
let pw = 2.0 + (-round).max(0.0) * 6.0;
let inner = 1.0 - mid * 0.8;
let edge0 = inner - feather * 0.5 * inner;
let edge1 = inner + feather * 0.5 * (1.4 - inner) + 0.02;
let Vignette { amount: amt, highlights: hl, style, centre: [cx, cy], axes: [ax, ay], power: pw, edges: [edge0, edge1] } = Vignette::new(w, h, p);
par_rows(px, w, 1, |y, row| {
for (x, q) in row.iter_mut().enumerate() {
let (dx, dy) = (((x as f32 + 0.5 - cx) / ax).abs(), ((y as f32 + 0.5 - cy) / ay).abs());
@@ -951,7 +1074,7 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) {
}
let l = luma([q[0], q[1], q[2]]).clamp(0.0, 1.0);
match style {
"paintOverlay" => {
VignetteStyle::PaintOverlay => {
let target = if amt < 0.0 { 0.0 } else { 1.0 };
let k = m * amt.abs();
for c in 0..3 {
@@ -965,7 +1088,7 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) {
let protect = smoothstep(0.6, 1.0, l) * hl;
k += (1.0 - k) * protect;
}
if style == "colorPriority" {
if style == VignetteStyle::ColorPriority {
let nl = l * k;
for c in 0..3 {
q[c] += nl - l;
@@ -985,6 +1108,37 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) {
mod tests {
use super::*;
/// #9273: Dehaze clears the haze evenly over a sky, right up to a dark building, instead of
/// leaving a bright halo of uncorrected haze around it. Synthetic scene: a clear sky and a
/// dark tower, hazed with `I = J·t + A·(1 − t)`.
#[test]
fn dehaze_leaves_no_halo_beside_dark_objects() {
let (w, h) = (600usize, 400usize);
let tower = |x: usize, y: usize| (250..350).contains(&x) && y >= 120;
let (t, air) = (0.55f32, [0.9f32, 0.9, 0.9]);
let mut px: Vec<[f32; 4]> = (0..w * h)
.map(|i| {
let (x, y) = (i % w, i / w);
let j = if tower(x, y) { [0.06, 0.06, 0.07] } else { [0.35 + 0.1 * y as f32 / h as f32, 0.5, 0.8] };
let c: [f32; 3] = std::array::from_fn(|k| j[k] * t + air[k] * (1.0 - t));
[c[0], c[1], c[2], 1.0]
})
.collect();
dehaze(&mut px, w, h, 1.0);
let sky = |xs: std::ops::Range<usize>| {
let v: Vec<f32> =
(150..390).flat_map(|y| xs.clone().map(move |x| (x, y))).map(|(x, y)| luma([px[y * w + x][0], px[y * w + x][1], px[y * w + x][2]])).collect();
v.iter().sum::<f32>() / v.len() as f32
};
// Sky 1–8 px from the tower against open sky: the haze left beside it, in 8-bit levels.
let halo = (sky(242..250) - sky(20..80)) * 255.0;
// 6.4 levels before the dark channel was opened, under 1 after.
assert!(halo < 3.0, "a {halo:.1}-level halo beside the tower");
// The open sky is still cleared: darker than the hazy input.
let hazy = luma(std::array::from_fn(|k| [0.4, 0.5, 0.8][k] * t + air[k] * (1.0 - t)));
assert!(sky(20..80) < hazy - 0.02, "{} vs hazy {hazy}", sky(20..80));
}
#[test]
fn new_curves_are_validated_but_saved_curves_still_load_and_render() {
for field in ["pointCurve", "pointCurveRed", "pointCurveGreen", "pointCurveBlue"] {
@@ -1144,6 +1298,105 @@ mod tests {
assert!(v[0][0] < 0.35 && (v[(h / 2) * w + w / 2][0] - 0.5).abs() < 0.01, "{:?}", v[0]);
}
#[test]
fn calibration_keeps_the_develop_off_the_per_pixel_gpu_stages() {
// The per-pixel stages (the Camera Raw dialog's GPU preview) don't run Calibration, so
// settings with it must fall back to the CPU develop instead of previewing without it.
for p in [
CameraRaw { shadow_tint: 40.0, ..Default::default() },
CameraRaw { calibration_hue: [0.0, 0.0, 30.0], whites: 20.0, ..Default::default() },
CameraRaw { calibration_sat: [-50.0, 0.0, 0.0], exposure: 0.5, ..Default::default() },
] {
assert!(!p.spatial());
assert!(per_pixel(&p, 2, 2, false, Some((0.0, 1.0))).is_none(), "{p:?}");
}
assert!(per_pixel(&CameraRaw { exposure: 0.5, ..Default::default() }, 2, 2, false, None).is_some());
}
#[test]
fn calibration_turns_primaries_keeps_neutrals_and_tints_shadows() {
let w = calibration_weights(0.0);
assert!((w[0] - 1.0).abs() < 1e-6, "pure red is all red primary: {w:?}");
let w = calibration_weights(60.0);
assert!((w[0] - 0.5).abs() < 1e-6 && (w[1] - 0.5).abs() < 1e-6, "yellow is half red, half green: {w:?}");
let w = calibration_weights(300.0);
assert!((w[2] - 0.5).abs() < 1e-6 && (w[0] - 0.5).abs() < 1e-6, "magenta wraps round: {w:?}");
let (none, zero) = ([0.0f32; 3], [0.0f32; 3]);
let grey = calibrate([0.3, 0.3, 0.3], [100.0, -100.0, 50.0], [100.0, -100.0, 100.0]);
assert!(grey.iter().all(|v| (v - 0.3).abs() < 1e-5), "greys are untouched: {grey:?}");
// Red turned towards green: it gains green, keeps its luminance.
let red = calibrate([0.5, 0.0, 0.0], [100.0, 0.0, 0.0], zero);
assert!(red[1] > 0.05 && (red[1] / red[0] - 0.5).abs() < 0.02 && red[2] < 1e-6, "red turns 30° towards green (orange): {red:?}");
assert!((luma(red) - luma([0.5, 0.0, 0.0])).abs() < 1e-4, "luminance kept: {red:?}");
// Blue at saturation −100 is grey; green at −50 is paler.
let blue = calibrate([0.0, 0.0, 0.8], none, [0.0, 0.0, -100.0]);
assert!((blue[0] - blue[1]).abs() < 1e-5 && (blue[1] - blue[2]).abs() < 1e-5, "blue at −100 saturation is grey: {blue:?}");
let green = calibrate([0.2, 0.8, 0.2], none, [0.0, -50.0, 0.0]);
assert!(green[0] > 0.2 && green[1] < 0.8, "green at −50 is paler: {green:?}");
// A primary's sliders leave the other primaries alone.
let unchanged = calibrate([0.0, 0.0, 0.8], [100.0, 100.0, 0.0], [100.0, 100.0, 0.0]);
assert!(unchanged.iter().zip([0.0, 0.0, 0.8]).all(|(a, b)| (a - b).abs() < 1e-5), "{unchanged:?}");
// Shadows tint: magenta lifts red and blue over green in a dark pixel, keeps luminance,
// and leaves white alone.
let dark = shadow_tint([0.02, 0.02, 0.02], 1.0);
assert!(dark[0] > dark[1] && dark[2] > dark[1], "{dark:?}");
assert!((luma(dark) - 0.02).abs() < 1e-4, "luminance kept: {dark:?}");
let green = shadow_tint([0.02, 0.02, 0.02], -1.0);
assert!(green[1] > green[0], "{green:?}");
let white = shadow_tint([1.0, 1.0, 1.0], 1.0);
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-3), "white is not tinted: {white:?}");
// Through develop: neutral settings are an identity, a tint changes dark pixels only.
let mut p = CameraRaw::default();
assert!(p.is_identity());
p.shadow_tint = 100.0;
assert!(!p.is_identity());
let mut px = vec![[0.1, 0.1, 0.1, 1.0], [0.95, 0.95, 0.95, 1.0]];
develop(&mut px, 2, 1, &p, false);
assert!(px[0][0] > px[0][1], "dark grey turns magenta: {:?}", px[0]);
assert!((px[1][0] - px[1][1]).abs() < 0.01, "near-white stays neutral: {:?}", px[1]);
p = CameraRaw::default();
p.calibration_hue[2] = 100.0;
let mut px = vec![[0.0, 0.0, 0.8, 1.0]];
develop(&mut px, 1, 1, &p, false);
assert!(px[0][0] > 0.05, "blue turned towards red: {:?}", px[0]);
}
#[test]
fn auto_tone_brightens_dark_and_darkens_bright_images() {
let mut p = CameraRaw::default();
auto_tone(&[], &mut p);
assert_eq!(p, CameraRaw::default(), "no pixels: nothing changes");
let dark: Vec<[f32; 4]> = (0..4096)
.map(|i| {
let v = 0.05 + (i % 64) as f32 / 64.0 * 0.15;
[v, v, v, 1.0]
})
.collect();
auto_tone(&dark, &mut p);
assert!(p.exposure > 0.5, "a dark image gets exposure: {}", p.exposure);
assert!(p.shadows > 0.0, "and lifted shadows: {}", p.shadows);
let mut p = CameraRaw::default();
let bright: Vec<[f32; 4]> = (0..4096)
.map(|i| {
let v = 0.8 + (i % 64) as f32 / 64.0 * 0.19;
[v, v, v, 1.0]
})
.collect();
auto_tone(&bright, &mut p);
assert!(p.exposure < -0.5, "a bright image loses exposure: {}", p.exposure);
assert!(p.highlights < 0.0, "and its highlights come down: {}", p.highlights);
let mut p = CameraRaw::default();
let good: Vec<[f32; 4]> = (0..4096)
.map(|i| {
let v = 0.04 + (i % 64) as f32 / 63.0 * 0.92;
[v, v, v, 1.0]
})
.collect();
auto_tone(&good, &mut p);
assert!(p.exposure.abs() < 0.3 && p.blacks.abs() < 10.0 && p.whites.abs() < 10.0, "a full-range image is left nearly alone: {p:?}");
assert!(p.saturation == 0.0 && p.hsl_neutral(), "other panels untouched");
}
#[test]
fn float_documents_keep_overrange() {
let mut a = vec![[2.0f32, 1.5, 0.5, 1.0]; 16];
+240
View File
@@ -0,0 +1,240 @@
//! Develop's per-pixel stages resolved for one image: every constant and table they use, so
//! another implementation of them (the GPU preview, `photocraft_gpu::CameraRawDevelop`) runs the
//! same maths. [`develop`](super::develop) builds its own constants with the same functions.
use super::{CameraRaw, Encoding, Wheel, curve_lut, hsv_to_rgb, is_linear, luma, parametric_lut, white_balance_gains, whites_blacks};
/// Entries of the tone curve tables (parametric and point curves).
pub const CURVE_LUT: usize = 1024;
/// Samples of a Whites or Blacks curve row: grey levels 0, 1/32, …, 1.
pub const WB_STEPS: usize = whites_blacks::STEPS;
/// White balance and exposure: channel gains in linear light, when the stage is on.
pub(crate) fn gains(p: &CameraRaw) -> Option<[f32; 3]> {
if p.wb_neutral() {
return None;
}
let g = white_balance_gains(p.temperature, p.tint);
let norm = luma(g);
Some(g.map(|v| v / norm * 2f32.powf(p.exposure)))
}
/// Contrast's strength, when Contrast is set.
fn contrast(p: &CameraRaw) -> Option<f32> {
(p.contrast != 0.0).then_some(p.contrast / 100.0 * 0.6)
}
/// Whites and Blacks as two curve rows for the image's amounts.
#[derive(Clone, Debug, PartialEq)]
pub struct WhitesBlacks {
/// The Whites curve over grey levels 0, 1/32, …, 1, when Whites is set.
pub whites: Option<[f32; WB_STEPS]>,
/// The Blacks curve, applied after Whites.
pub blacks: Option<[f32; WB_STEPS]>,
/// The clipping directions (Whites up, Blacks down) curve each channel; the others scale the
/// pixel by the change of its luminance.
pub per_channel: bool,
}
/// Color Grading's wheels resolved.
#[derive(Clone, Debug, PartialEq)]
pub struct Grade {
/// Colour offsets of the shadows, midtones, highlights and global wheels.
pub tints: [[f32; 3]; 4],
/// Luminance of the same wheels (slider units).
pub lums: [f32; 4],
pub blend: f32,
pub balance: f32,
}
impl Grade {
pub(crate) fn new(p: &CameraRaw) -> Self {
let tint = |wh: &Wheel| -> [f32; 3] {
let c = hsv_to_rgb(wh.hue, 1.0, 1.0);
let l = luma(c);
[c[0] - l, c[1] - l, c[2] - l].map(|v| v * wh.sat / 100.0 * 0.3)
};
Grade {
tints: [tint(&p.grade_shadows), tint(&p.grade_midtones), tint(&p.grade_highlights), tint(&p.grade_global)],
lums: [p.grade_shadows.lum, p.grade_midtones.lum, p.grade_highlights.lum, p.grade_global.lum],
blend: 0.15 + p.grade_blending.clamp(0.0, 100.0) / 100.0 * 0.35,
balance: p.grade_balance.clamp(-100.0, 100.0) / 100.0 * 0.25,
}
}
}
/// The vignette's `vignette_style`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VignetteStyle {
HighlightPriority,
ColorPriority,
PaintOverlay,
}
/// The post-crop vignette's shape on a `w × h` frame.
#[derive(Clone, Debug, PartialEq)]
pub struct Vignette {
/// −1 … 1.
pub amount: f32,
/// 0 … 1.
pub highlights: f32,
pub style: VignetteStyle,
/// Frame centre (pixels).
pub centre: [f32; 2],
/// Half axes of the shape (pixels).
pub axes: [f32; 2],
/// Exponent of the superellipse.
pub power: f32,
/// The mask's smoothstep edges in normalised radius.
pub edges: [f32; 2],
}
impl Vignette {
pub(crate) fn new(w: usize, h: usize, p: &CameraRaw) -> Self {
let mid = p.vignette_midpoint.clamp(0.0, 100.0) / 100.0;
let feather = p.vignette_feather.clamp(0.0, 100.0) / 100.0;
let round = p.vignette_roundness.clamp(-100.0, 100.0) / 100.0;
let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0);
// Roundness: 0 follows the frame's aspect, +1 a circle, −1 a rounded rectangle.
let (ax, ay) = if round >= 0.0 {
let r = cx.min(cy) + (cx.max(cy) - cx.min(cy)) * (1.0 - round);
(if cx >= cy { r } else { cx }, if cy > cx { r } else { cy })
} else {
(cx, cy)
};
let inner = 1.0 - mid * 0.8;
Vignette {
amount: p.vignette_amount / 100.0,
highlights: p.vignette_highlights.clamp(0.0, 100.0) / 100.0,
style: match p.vignette_style.as_str() {
"paintOverlay" => VignetteStyle::PaintOverlay,
"colorPriority" => VignetteStyle::ColorPriority,
_ => VignetteStyle::HighlightPriority,
},
centre: [cx, cy],
axes: [ax, ay],
power: 2.0 + (-round).max(0.0) * 6.0,
edges: [inner - feather * 0.5 * inner, inner + feather * 0.5 * (1.4 - inner) + 0.02],
}
}
}
/// Grain: value noise sampled in full-resolution coordinates.
#[derive(Clone, Debug, PartialEq)]
pub struct Grain {
/// Noise cell size (full-resolution pixels).
pub cell: f32,
/// Weight of the finer octave.
pub roughness: f32,
pub amount: f32,
pub seed: u64,
/// Full-resolution pixels per pixel (`1 / pixel_scale`).
pub scale: f32,
}
impl Grain {
pub(crate) fn new(p: &CameraRaw) -> Self {
Grain {
cell: 1.0 + p.grain_size / 25.0,
roughness: p.grain_roughness / 100.0,
amount: p.grain_amount / 100.0 * 0.12,
seed: u64::from(p.seed),
scale: 1.0 / p.pixel_scale.clamp(0.01, 1.0),
}
}
}
/// Develop's per-pixel stages for one image and settings, in order. `None` stages are off.
#[derive(Clone, Debug, PartialEq)]
pub struct PerPixel {
/// 1. White balance and exposure gains in linear light (sRGB-decoded values).
pub gains: Option<[f32; 3]>,
/// 3. Contrast's strength (Contrast without Texture or Clarity is per pixel).
pub contrast: Option<f32>,
/// 3b. Whites and Blacks, in the document's encoding.
pub whites_blacks: Option<WhitesBlacks>,
/// The document's transfer curve: 0 for sRGB's, else a power-law gamma.
pub encoding: f32,
/// 4. Vibrance and Saturation (slider / 100).
pub presence: Option<[f32; 2]>,
/// 5. The parametric tone curve, [`CURVE_LUT`] entries over 0 … 1.
pub parametric: Option<Vec<f32>>,
/// 5. Point curves: master, red, green, blue.
pub point: [Option<Vec<f32>>; 4],
/// 6. Color Mixer hue, saturation and luminance per band.
pub hsl: Option<[[f32; 8]; 3]>,
/// 7. Color Grading.
pub grade: Option<Grade>,
/// 9. Vignette.
pub vignette: Option<Vignette>,
/// 9. Grain.
pub grain: Option<Grain>,
/// Clip RGB to 0 … 1 at the end (all but 32-bit documents).
pub clamp: bool,
}
impl CameraRaw {
/// Whether developing needs a stage that reads neighbouring pixels: Dehaze, Shadows and
/// Highlights, Texture, Clarity, sharpening or noise reduction.
pub fn spatial(&self) -> bool {
self.dehaze != 0.0
|| [self.highlights, self.shadows, self.texture, self.clarity].iter().any(|v| *v != 0.0)
|| self.sharpen_amount > 0.0
|| self.noise_luminance > 0.0
|| self.noise_color > 0.0
}
}
/// The image's black and white points that Whites and Blacks adapt to, for `px` (`w × h`,
/// display-encoded) developed per pixel with `p`: what [`develop`](super::develop) measures
/// after white balance and Contrast. `None` when Whites and Blacks are 0 or `p` needs a
/// spatial stage.
pub fn white_black_points(px: &[[f32; 4]], w: usize, h: usize, p: &CameraRaw) -> Option<(f32, f32)> {
if (p.whites == 0.0 && p.blacks == 0.0) || p.spatial() || px.len() != w.saturating_mul(h) {
return None;
}
let enc = Encoding::of(p.encoding_gamma);
let (g, k_con) = (gains(p), contrast(p));
Some(super::luminance_points(px, w, h, enc, |q| {
let q = g.map_or(q, |g| super::balance(q, g));
k_con.map_or(q, |k| super::contrast(q, k))
}))
}
/// Develop's stages for settings without spatial stages, on a `w × h` image; `points` are the
/// image's [`white_black_points`]. `None` when `p` needs a spatial stage or Calibration (these
/// stages don't run it), or Whites or Blacks is set without `points`. The points themselves
/// leave Calibration out, so a caller may cache them across Calibration changes.
pub fn per_pixel(p: &CameraRaw, w: usize, h: usize, float: bool, points: Option<(f32, f32)>) -> Option<PerPixel> {
if p.spatial() || !p.calibration_neutral() {
return None;
}
let identity = p.is_identity();
let whites_blacks = if p.whites != 0.0 || p.blacks != 0.0 {
let (bp, wp) = points?;
let (xw, xb) = whites_blacks::amounts(p.whites.clamp(-100.0, 100.0), p.blacks.clamp(-100.0, 100.0), bp, wp);
(xw != 0.0 || xb != 0.0).then(|| WhitesBlacks {
whites: (xw != 0.0).then(|| whites_blacks::whites_row(xw)),
blacks: (xb != 0.0).then(|| whites_blacks::blacks_row(xb)),
per_channel: xw > 0.0 || xb < 0.0,
})
} else {
None
};
let parametric_on = !(p.curve_highlights == 0.0 && p.curve_lights == 0.0 && p.curve_darks == 0.0 && p.curve_shadows == 0.0);
let pts = |c: &Vec<[f32; 2]>| (!is_linear(c)).then(|| curve_lut(c, CURVE_LUT));
Some(PerPixel {
gains: gains(p),
contrast: contrast(p),
whites_blacks,
encoding: Encoding::of(p.encoding_gamma).0,
presence: (p.vibrance != 0.0 || p.saturation != 0.0).then_some([p.vibrance / 100.0, p.saturation / 100.0]),
parametric: parametric_on.then(|| parametric_lut(p, CURVE_LUT)),
point: [pts(&p.point_curve), pts(&p.point_curve_red), pts(&p.point_curve_green), pts(&p.point_curve_blue)],
hsl: (!p.hsl_neutral()).then_some([p.hsl_hue, p.hsl_sat, p.hsl_lum]),
grade: (!p.grade_neutral()).then(|| Grade::new(p)),
vignette: (p.vignette_amount != 0.0).then(|| Vignette::new(w, h, p)),
grain: (p.grain_amount > 0.0).then(|| Grain::new(p)),
clamp: !float && !identity,
})
}
+20 -1
View File
@@ -24,7 +24,7 @@ use super::Encoding;
use crate::photo_util::par_rows;
/// Grey levels the curves are sampled at: 0, 1/32, …, 1 (sRGB-encoded).
const STEPS: usize = 33;
pub(crate) const STEPS: usize = 33;
/// The first row's internal amount; rows are 10 apart.
const WHITES_FROM: f32 = -100.0;
const BLACKS_FROM: f32 = -100.0;
@@ -315,6 +315,25 @@ fn curve(rows: &[[f32; STEPS]], from: f32, x: f32, v: f32) -> f32 {
at(&rows[i]) * (1.0 - t) + at(&rows[i + 1]) * t
}
/// The curve at a fractional internal amount `x` as one row: `curve(rows, from, x, v)` is this
/// row interpolated at `v`, because both interpolations are linear.
fn row_at(rows: &[[f32; STEPS]], from: f32, x: f32) -> [f32; STEPS] {
let r = ((x - from) / 10.0).clamp(0.0, (rows.len() - 1) as f32);
let i = (r as usize).min(rows.len() - 2);
let t = r - i as f32;
std::array::from_fn(|j| rows[i][j] * (1.0 - t) + rows[i + 1][j] * t)
}
/// The Whites curve at internal amount `x` as a row over grey levels 0, 1/32, …, 1.
pub(crate) fn whites_row(x: f32) -> [f32; STEPS] {
row_at(&WHITES, WHITES_FROM, x)
}
/// The Blacks curve at internal amount `x` as a row over grey levels 0, 1/32, …, 1.
pub(crate) fn blacks_row(x: f32) -> [f32; STEPS] {
row_at(&BLACKS, BLACKS_FROM, x)
}
/// Whites at internal amount `x` (−100 … 270) of grey level `v`.
pub(crate) fn whites(v: f32, x: f32) -> f32 {
curve(&WHITES, WHITES_FROM, x, v)
+60 -17
View File
@@ -114,6 +114,21 @@ struct Variant {
ok: Vec<bool>,
}
/// Most pixels the mosaic of the image and its variants may span. It is one dense rectangle, so
/// thin images turned to different angles leave it mostly empty: the cost grows with the square
/// of their length, not with their pixels.
const MAX_MOSAIC_PIXELS: usize = 1 << 25;
/// Size of the mosaic of an `w`×`h` image and `extra` variant sizes separated by `gap`, or `None`
/// when it spans more than [`MAX_MOSAIC_PIXELS`] (or overflows).
fn mosaic_size(w: usize, h: usize, gap: usize, extra: &[(usize, usize)]) -> Option<(usize, usize)> {
let col_w = extra.iter().map(|v| v.0).max().unwrap_or(0);
let col_h = extra.iter().try_fold(0usize, |n, v| n.checked_add(v.1)?.checked_add(gap))?;
let mw = w.checked_add(gap)?.checked_add(col_w)?;
let mh = h.max(col_h);
(mw.checked_mul(mh)? <= MAX_MOSAIC_PIXELS).then_some((mw, mh))
}
/// A source buffer and which of its pixels may be sampled.
#[derive(Clone, Copy)]
struct Src<'a> {
@@ -149,13 +164,20 @@ fn sample(src: Src, x: f32, y: f32, out: &mut [f32]) -> bool {
/// Transformed copy of the source: rotation (degrees) and scale about the centre, optional mirror.
/// `Err` when cancelled.
fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result<Variant, Cancelled> {
let Src { w, h, ch, .. } = src;
/// Dimensions of the [`variant`] copy: the same bounding-box maths without building its pixels.
fn variant_dims(w: usize, h: usize, deg: f32, scale: f32) -> (usize, usize) {
let (s, c) = (-deg.to_radians()).sin_cos();
// Output size: the rotated, scaled bounding box.
let (fw, fh) = (w as f32 * scale, h as f32 * scale);
let ow = ((fw * c.abs() + fh * s.abs()).round() as usize).max(1);
let oh = ((fw * s.abs() + fh * c.abs()).round() as usize).max(1);
(ow, oh)
}
fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result<Variant, Cancelled> {
let Src { w, h, ch, .. } = src;
// Output size: the rotated, scaled bounding box.
let (ow, oh) = variant_dims(w, h, deg, scale);
let (s, c) = (-deg.to_radians()).sin_cos();
let (icx, icy) = ((w as f32 - 1.0) / 2.0, (h as f32 - 1.0) / 2.0);
let (ocx, ocy) = ((ow as f32 - 1.0) / 2.0, (oh as f32 - 1.0) / 2.0);
let mut out = Variant { w: ow, h: oh, img: vec![0.0; ow * oh * ch], ok: vec![false; ow * oh] };
@@ -243,17 +265,27 @@ fn fill_cow(
let gap = 2 * params.patch_radius + 2;
let ok: Vec<bool> = par::map(w * h, true, |i| !hole[i] && source[i]);
let src = Src { w, h, ch, img: &img, ok: &ok };
// Variants: the identity (which holds the hole) first, then the adaptations.
let mut extra: Vec<Variant> = Vec::new();
// Variants: the identity (which holds the hole) first, then the adaptations. Their pixels are
// expensive, so the mosaic budget is settled on their dimensions before any variant is built;
// when the variants would not fit, the image itself is completed without them.
let mut specs: Vec<(f32, f32, bool)> = Vec::new();
if opts.mirror {
extra.push(variant(src, 0.0, 1.0, true, ctl)?);
specs.push((0.0, 1.0, true));
}
for &deg in &opts.rotations {
extra.push(variant(src, deg, 1.0, false, ctl)?);
specs.push((deg, 1.0, false));
}
if opts.scale {
extra.push(variant(src, 0.0, 0.8, false, ctl)?);
extra.push(variant(src, 0.0, 1.25, false, ctl)?);
specs.push((0.0, 0.8, false));
specs.push((0.0, 1.25, false));
}
let dims: Vec<(usize, usize)> = specs.iter().map(|&(deg, scale, _)| variant_dims(w, h, deg, scale)).collect();
let size = if specs.is_empty() { None } else { mosaic_size(w, h, gap, &dims) };
let mut extra: Vec<Variant> = Vec::new();
if size.is_some() {
for &(deg, scale, mirror) in &specs {
extra.push(variant(src, deg, scale, mirror, ctl)?);
}
}
let role = |s: usize| {
if hole[s] {
@@ -265,14 +297,9 @@ fn fill_cow(
}
};
// Mosaic: identity at the left, variants in a column to its right, gaps that are no image.
// Without variants it is the image itself.
let (mw, mh, mosaic, roles): (usize, usize, Option<Vec<f32>>, Vec<Px>) = if extra.is_empty() {
(w, h, None, par::map(w * h, true, role))
} else {
let col_w = extra.iter().map(|v| v.w).max().unwrap_or(0);
let col_h: usize = extra.iter().map(|v| v.h + gap).sum();
let mw = w + gap + col_w;
let mh = h.max(col_h);
// Without variants it is the image itself; so it is when the variants would not fit
// (`size` above is then None and `extra` is empty).
let (mw, mh, mosaic, roles): (usize, usize, Option<Vec<f32>>, Vec<Px>) = if let Some((mw, mh)) = size {
let mut mimg = vec![0.0f32; mw * mh * ch];
let mut roles = vec![Px::Void; mw * mh];
for y in 0..h {
@@ -297,6 +324,8 @@ fn fill_cow(
oy += v.h + gap;
}
(mw, mh, Some(mimg), roles)
} else {
(w, h, None, par::map(w * h, true, role))
};
drop((extra, ok));
ctl.check()?;
@@ -695,6 +724,20 @@ pub fn default_brush_size(hole: Rect) -> f64 {
#[cfg(test)]
mod tests {
use super::mosaic_size;
fn blank(w: usize, h: usize) -> (usize, usize) {
(w, h)
}
#[test]
fn a_thin_image_with_turned_variants_does_not_get_a_quadratic_mosaic() {
// 40000×1 plus its 90° copy (1×40000): about 1.6 billion mosaic cells for 80000 pixels.
assert_eq!(mosaic_size(40_000, 1, 8, &[blank(1, 40_000)]), None);
assert_eq!(mosaic_size(usize::MAX, 1, 8, &[blank(1, 1)]), None);
assert_eq!(mosaic_size(100, 100, 8, &[blank(100, 100)]), Some((208, 108)));
}
use super::*;
/// Vertical stripes (period 6) in grey, with a hole in the middle.
+6 -1
View File
@@ -112,6 +112,10 @@ pub(crate) fn spherize(src: &Image, out: Rect, ctx: &Ctx, amount: f32, mode: Sph
})
}
/// The most Wave generators the renderer models: it clamps the setting to this, so a file asking
/// for more (the command allows up to 999) would render differently from how it was authored.
pub const MAX_WAVE_GENERATORS: u32 = 64;
/// Wave generator settings.
pub(crate) struct WaveSpec {
pub generators: u32,
@@ -137,8 +141,9 @@ fn shape(t: WaveType, phase: f32) -> f32 {
}
}
/// Wave: displace `src` over `out` by the average of `w.generators` seeded waves (at most `MAX_WAVE_GENERATORS`) along each axis.
pub(crate) fn wave(src: &Image, out: Rect, ctx: &Ctx, w: WaveSpec) -> Vec<f32> {
let n = w.generators.clamp(1, 64);
let n = w.generators.clamp(1, MAX_WAVE_GENERATORS);
let lerp = |(a, b): (f32, f32), t: f32| a + (b - a).max(0.0) * t;
let gens: Vec<(f32, f32, f32, f32)> = (0..n)
.map(|i| {
+52 -4
View File
@@ -87,6 +87,8 @@ impl Tiff<'_> {
}
}
fn text(&self, typ: u16, count: u32, field: usize) -> Option<String> {
#[cfg(test)]
TEXT_DECODES.with(|c| c.set(c.get() + 1));
if typ != 2 {
return None;
}
@@ -97,6 +99,9 @@ impl Tiff<'_> {
}
}
#[cfg(test)]
thread_local!(static TEXT_DECODES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) });
/// Reads [`CameraInfo`] from a TIFF-structured EXIF payload (an `Exif\0\0` prefix is skipped).
pub fn read(exif: &[u8]) -> CameraInfo {
let b = exif.strip_prefix(b"Exif\0\0").unwrap_or(exif);
@@ -117,15 +122,17 @@ pub fn read(exif: &[u8]) -> CameraInfo {
seen.push(off);
for (tag, typ, count, field) in t.ifd(off) {
match tag {
0x010F => info.make = t.text(typ, count, field),
0x0110 => info.model = t.text(typ, count, field),
// Duplicate TIFF text tags can point at the same large payload. Keep the first
// recognized value and do not repeatedly decode/copy later duplicates.
0x010F if info.make.is_none() => info.make = t.text(typ, count, field),
0x0110 if info.model.is_none() => info.model = t.text(typ, count, field),
0x829A => info.exposure_time = t.number(typ, count, field),
0x829D => info.f_number = t.number(typ, count, field),
0x8827 => info.iso = t.number(typ, count, field),
0x920A => info.focal_length = t.number(typ, count, field),
0xA405 => info.focal_length_35mm = t.number(typ, count, field).filter(|v| *v > 0.0),
0xA434 => info.lens = t.text(typ, count, field),
0x9003 => info.date_taken = t.text(typ, count, field),
0xA434 if info.lens.is_none() => info.lens = t.text(typ, count, field),
0x9003 if info.date_taken.is_none() => info.date_taken = t.text(typ, count, field),
0x8769 => {
if let Some(p) = t.u32(field) {
queue.push(p as usize);
@@ -252,6 +259,47 @@ mod tests {
/// Hostile payloads: huge counts, offsets near `u32::MAX`, self-referencing IFDs and random
/// bytes read as `None` fields instead of panicking or overflowing (usize is 32 bits on wasm).
#[test]
fn duplicate_text_tags_keep_the_first_value_without_copying_large_duplicates() {
let value = vec![b'X'; 2 * 1024 * 1024];
let mut b = b"II*\0".to_vec();
b.extend_from_slice(&8u32.to_le_bytes());
// IFD0: Make="M", a duplicate Make pointing at a 2 MiB payload, and the Exif IFD link.
b.extend_from_slice(&3u16.to_le_bytes());
b.extend_from_slice(&0x010Fu16.to_le_bytes());
b.extend_from_slice(&2u16.to_le_bytes());
b.extend_from_slice(&2u32.to_le_bytes());
b.extend_from_slice(b"M\0\0\0");
b.extend_from_slice(&0x010Fu16.to_le_bytes());
b.extend_from_slice(&2u16.to_le_bytes());
b.extend_from_slice(&(value.len() as u32).to_le_bytes());
b.extend_from_slice(&80u32.to_le_bytes());
b.extend_from_slice(&0x8769u16.to_le_bytes());
b.extend_from_slice(&4u16.to_le_bytes());
b.extend_from_slice(&1u32.to_le_bytes());
b.extend_from_slice(&50u32.to_le_bytes());
b.extend_from_slice(&0u32.to_le_bytes());
// Exif IFD at 50: DateTimeOriginal="D" and the same large duplicate payload at offset 80.
b.extend_from_slice(&2u16.to_le_bytes());
b.extend_from_slice(&0x9003u16.to_le_bytes());
b.extend_from_slice(&2u16.to_le_bytes());
b.extend_from_slice(&2u32.to_le_bytes());
b.extend_from_slice(b"D\0\0\0");
b.extend_from_slice(&0x9003u16.to_le_bytes());
b.extend_from_slice(&2u16.to_le_bytes());
b.extend_from_slice(&(value.len() as u32).to_le_bytes());
b.extend_from_slice(&80u32.to_le_bytes());
b.extend_from_slice(&0u32.to_le_bytes());
b.extend_from_slice(&value);
TEXT_DECODES.with(|c| c.set(0));
let info = read(&b);
assert_eq!(info.make.as_deref(), Some("M"));
assert_eq!(info.date_taken.as_deref(), Some("D"));
// One decode per tag: duplicates must be skipped before `text` copies their payload.
assert_eq!(TEXT_DECODES.with(|c| c.get()), 2);
}
#[test]
fn hostile_payloads_never_panic() {
let entry = |tag: u16, typ: u16, count: u32, value: u32| {
+57
View File
@@ -28,6 +28,18 @@ impl RgbHistogram {
/// Each visible pixel counts once, including partially transparent pixels. Non-finite
/// pixels are rejected as a whole; transparent RGB cannot contribute phantom peaks.
pub fn from_rgba(pixels: &[[f32; 4]]) -> Self {
// Camera Raw counts its whole proxy (~450k pixels) on every slider step, so the count
// runs in blocks across cores (#211, P36). Integer sums don't depend on the order the
// blocks finish in, so the result is the same as one pass over all pixels.
#[cfg(not(target_arch = "wasm32"))]
if pixels.len() >= PARALLEL_PIXELS {
use rayon::prelude::*;
return pixels.par_chunks(PARALLEL_PIXELS / 2).map(Self::count).reduce(Self::default, Self::merge);
}
Self::count(pixels)
}
fn count(pixels: &[[f32; 4]]) -> Self {
let mut h = Self::default();
for pixel in pixels {
if !pixel[3].is_finite() {
@@ -68,8 +80,29 @@ impl RgbHistogram {
}
h
}
/// Sum of two block counts. Saturating like the counters themselves.
#[cfg(not(target_arch = "wasm32"))]
fn merge(mut self, other: Self) -> Self {
let add = |a: &mut [u64], b: &[u64]| a.iter_mut().zip(b).for_each(|(a, b)| *a = a.saturating_add(*b));
for (a, b) in self.channels.iter_mut().zip(&other.channels) {
add(a, b);
}
add(&mut self.underflow, &other.underflow);
add(&mut self.overflow, &other.overflow);
add(&mut self.shadows, &other.shadows);
add(&mut self.highlights, &other.highlights);
self.samples = self.samples.saturating_add(other.samples);
self.transparent = self.transparent.saturating_add(other.transparent);
self.invalid = self.invalid.saturating_add(other.invalid);
self
}
}
/// Below this many pixels one pass is faster than splitting the count across cores.
#[cfg(not(target_arch = "wasm32"))]
const PARALLEL_PIXELS: usize = 64 * 1024;
/// Clipped channels of one visible straight RGBA pixel as bit masks (bit 0=R, 1=G, 2=B):
/// `(shadows, highlights)` at exact endpoints ≤0 / ≥1, not rounded edge bins.
/// Transparent and non-finite pixels clip nothing.
@@ -94,6 +127,30 @@ pub fn clipping(p: [f32; 4]) -> (u8, u8) {
mod tests {
use super::*;
// The parallel path, and its threshold, exist only off wasm32.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn matches_the_single_pass_on_every_domain_and_across_block_edges() {
// Every special value at every channel, plus a dense ramp through each bin's rounding
// edge; lengths around the parallel threshold and its block size.
let special = [0.0, -0.0, 1.0, -1e-7, 1.0 + 1e-7, 0.5 / 255.0, 254.5 / 255.0, -3.0, 4.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MIN_POSITIVE];
let mut pool: Vec<[f32; 4]> = Vec::new();
for (i, &v) in special.iter().enumerate() {
for alpha in [1.0, 0.5, 0.0, -1.0, f32::NAN] {
pool.push([v, special[(i + 3) % special.len()], 0.25, alpha]);
pool.push([0.75, v, special[(i + 5) % special.len()], alpha]);
}
}
pool.extend((0..4096).map(|i| {
let v = i as f32 / 4095.0 * 1.2 - 0.1;
[v, 1.0 - v, (v * 7.0).fract(), 1.0]
}));
for len in [0, 1, PARALLEL_PIXELS / 2 + 1, PARALLEL_PIXELS - 1, PARALLEL_PIXELS, 3 * PARALLEL_PIXELS + 17] {
let pixels: Vec<_> = (0..len).map(|i| pool[(i * 7919) % pool.len()]).collect();
assert_eq!(RgbHistogram::from_rgba(&pixels), RgbHistogram::count(&pixels), "{len} pixels");
}
}
#[test]
fn exact_bins_and_channel_totals() {
let pixels: Vec<_> = (0..BINS).map(|i| [i as f32 / 255.0, (255 - i) as f32 / 255.0, 0.5, 0.25]).collect();
+12
View File
@@ -319,6 +319,10 @@ fn peel_affordable(w: usize, px: &[Px], hb: Bounds, sources: usize) -> bool {
/// Bounds of the hole pixels of a role grid.
fn hole_bounds(w: usize, h: usize, px: &[Px]) -> Option<Bounds> {
// A zero-width grid holds no pixels whatever its height; don't size a row table by it.
if w == 0 || h == 0 {
return None;
}
// Per row the first and last hole column; the union of the rows' spans.
let rows: Vec<Option<(usize, usize)>> = par::map(h, w * h >= PAR_MIN, |y| {
let row = px.get(y * w..(y + 1) * w)?;
@@ -2047,6 +2051,14 @@ mod tests {
assert!((a.gain.0..=a.gain.1).contains(&g) && b.abs() <= a.bias + 1e-6);
}
/// A zero-width image has no pixels however tall it is claimed to be: no per-row table.
#[test]
fn zero_width_images_complete_without_sizing_by_height() {
let p = CompleteParams::default();
assert_eq!(complete(0, 1 << 40, 3, &[], &[], &p), Some(Vec::new()));
assert_eq!(complete(1 << 40, 0, 3, &[], &[], &p), Some(Vec::new()));
}
/// Smooth gradient plus deterministic fine noise.
fn texture(w: usize, h: usize) -> Vec<f32> {
let mut rng = Rng::new(7);
+68 -19
View File
@@ -18,6 +18,7 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod art_history;
mod artistic;
mod artistic_fx;
mod blur;
@@ -86,6 +87,7 @@ pub mod warp;
pub mod wideangle;
pub use artistic::{GALLERY_CATEGORIES, GalleryEffect, GalleryFilter, GalleryParam, GalleryParamKind};
pub use distort::MAX_WAVE_GENERATORS;
pub use image::{Edge, Image};
pub use params_ext::*;
@@ -560,7 +562,7 @@ pub enum FilterParams {
impl FilterParams {
/// Pixels read outside an output tile.
pub fn halo(&self) -> Halo {
let g = |sigma: f32| Halo::Radius((sigma.max(0.0) * 3.0).ceil() as i32 + 1);
let g = |sigma: f32| Halo::Radius(((sigma.max(0.0) * 3.0).ceil() as i32).saturating_add(1));
match self {
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius),
FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)),
@@ -570,9 +572,9 @@ impl FilterParams {
| FilterParams::Median { radius }
| FilterParams::DustAndScratches { radius, .. }
| FilterParams::Minimum { radius, .. }
| FilterParams::Maximum { radius, .. } => Halo::Radius(radius.max(0.0).ceil() as i32 + 1),
| FilterParams::Maximum { radius, .. } => Halo::Radius((radius.max(0.0).ceil() as i32).saturating_add(1)),
FilterParams::MotionBlur { distance, .. } => Halo::Radius((distance.abs() / 2.0).ceil() as i32 + 2),
FilterParams::Mosaic { cell_size } => Halo::Radius(cell_size.max(1.0).ceil() as i32 + 1),
FilterParams::Mosaic { cell_size } => Halo::Radius((cell_size.max(1.0).ceil() as i32).saturating_add(1)),
FilterParams::Emboss { height, .. } => Halo::Radius(height.abs().ceil() as i32 + 2),
FilterParams::FindEdges => Halo::Radius(1),
FilterParams::AddNoise { .. } | FilterParams::Solarize | FilterParams::Invert | FilterParams::Desaturate => Halo::Radius(0),
@@ -697,7 +699,7 @@ impl FilterParams {
/// Halos of the second filter batch.
fn halo_ext(p: &FilterParams) -> Halo {
let r = |v: f32| Halo::Radius(v.max(0.0).ceil() as i32 + 1);
let r = |v: f32| Halo::Radius((v.max(0.0).ceil() as i32).saturating_add(1));
match p {
FilterParams::ColorHalftone { max_radius, .. } => r(max_radius * 3.0),
FilterParams::Crystallize { cell_size, .. } => r(cell_size.max(1.0) * 3.0),
@@ -718,8 +720,9 @@ fn halo_ext(p: &FilterParams) -> Halo {
FilterParams::Relight { intensity, .. } if *intensity == 0.0 => Halo::Radius(0),
FilterParams::Relight { .. } => Halo::Radius(relight::halo_radius_max()),
FilterParams::ReduceNoise { .. } => r(denoise::reach()),
FilterParams::SmartBlur { radius, .. } => r(*radius),
FilterParams::LensBlur { radius, .. } => r(*radius),
// The kernels clamp the radius to 100 px: size the source window by what they use.
FilterParams::SmartBlur { radius, .. } => r(radius.min(100.0)),
FilterParams::LensBlur { radius, .. } => r(radius.min(100.0)),
FilterParams::ShapeBlur { radius, .. } => r(shape_blur::radius(*radius)),
FilterParams::TiltShift { blur, .. } => r(gallery::reach(*blur)),
FilterParams::IrisBlur { pins } => r(gallery::reach(pins.iter().map(|p| p.blur).fold(0.0, f32::max))),
@@ -998,10 +1001,18 @@ pub fn apply_in_with(
}
/// Results do not depend on the tiling, so wide-halo filters use bigger tiles to keep the
/// re-read margin (and its cost) below ~2× the tile area.
/// re-read margin (and its cost) below ~2× the tile area. The radius is input-derived, so the
/// halo arithmetic is done in `i64` and the result capped: an `i32::MAX` radius must not
/// overflow in debug builds, and must not produce a tile bigger than the hard cap.
fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 {
match params.halo_for(bounds) {
Halo::Radius(r) => TILE.max((2 * r + 63) / 64 * 64).min(2048),
// The radius is input-derived: widen to i64 before the arithmetic and cap the
// conversion, so an i32::MAX radius cannot overflow (debug panic) and cannot make a
// tile bigger than the hard cap below (131_072 = 2048 * 64, one tile row).
Halo::Radius(r) => {
let halo = ((2 * i64::from(r) + 63) / 64 * 64).min(131_072);
TILE.max(halo as i32).min(2048)
}
Halo::Bounds => TILE,
}
}
@@ -1095,14 +1106,16 @@ 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 };
// Every windowed filter: a Filter Gallery stack sums its effects' reach, and a radius is
// caller-supplied, so a short parameter set can ask for a huge source window per tile.
if let Halo::Radius(reach) = halo {
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 {
let too_many_tiles = matches!(params, FilterParams::UnsharpMask { .. }) && tile_count > 1_048_576;
if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::<f32>())? > isize::MAX as usize || too_many_tiles {
return None;
}
}
@@ -1130,16 +1143,43 @@ fn apply_tiled_with_impl(
// Tiles finished so far, for progress reported per tile (from any worker thread).
let finished = std::sync::atomic::AtomicUsize::new(0);
let total = tiles.len().max(1);
let run = |t: &Rect| -> (Rect, Vec<f32>) {
enum TileData {
Samples(Vec<f32>),
Encoded(Vec<u8>),
}
// Cell filters become writeback-bound once their geometry is reused. Encode
// on the tile workers; committing the group then only copies native bytes.
let finish = |data: Vec<f32>| {
if matches!(params, FilterParams::Crystallize { .. } | FilterParams::Pointillize { .. })
&& let Some(bytes) = pixelate::cells::encode(fmt, &data)
{
TileData::Encoded(bytes)
} else {
TileData::Samples(data)
}
};
let run = |t: &Rect| -> (Rect, TileData) {
// Cancelled: skip the remaining tiles of the group (the result is discarded).
if ctl.cancelled() {
return (*t, Vec::new());
return (*t, TileData::Samples(Vec::new()));
}
let tick = || {
let n = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
// The last few percent are the writes after each group.
ctl.progress(0.98 * n as f32 / total as f32);
};
let sparse = match *params {
FilterParams::Crystallize { cell_size, seed } => pixelate::cells::tile(surface, *t, &ctx, cell_size, seed, None, extent),
FilterParams::Pointillize { cell_size, seed, background } => pixelate::cells::tile(surface, *t, &ctx, cell_size, seed, Some(background), extent),
_ => None,
};
if let Some((src, mut data)) = sparse {
if let Some(sel) = selection {
mix_selection(&mut data, *t, sel, &src, fmt.alpha);
}
tick();
return (*t, finish(data));
}
let owned;
let src = match (&shared, halo) {
(Some(s), _) => s,
@@ -1168,13 +1208,13 @@ fn apply_tiled_with_impl(
_ => Some(kernel(params, src, *t, &ctx)),
},
}) else {
return (*t, Vec::new());
return (*t, TileData::Samples(Vec::new()));
};
if let Some(sel) = selection {
mix_selection(&mut data, *t, sel, src, fmt.alpha);
}
tick();
(*t, data)
(*t, finish(data))
};
// Tiles are filtered in groups of about RESULT_BUDGET bytes (at least one per core) and each
// group is written before the next starts, so a huge layer never holds all its results as
@@ -1190,24 +1230,33 @@ fn apply_tiled_with_impl(
return None;
}
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<(Rect, Vec<f32>)> = {
let results: Vec<(Rect, TileData)> = {
use rayon::prelude::*;
chunk.par_iter().map(run).collect()
};
#[cfg(target_arch = "wasm32")]
let results: Vec<(Rect, Vec<f32>)> = chunk.iter().map(run).collect();
let results: Vec<(Rect, TileData)> = chunk.iter().map(run).collect();
if ctl.cancelled() {
return None;
}
// Checked filters 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())
&& results.iter().any(|(t, data)| {
let pixels = t.width() as usize * t.height() as usize;
match data {
TileData::Samples(v) => v.len() != pixels * fmt.channels(),
TileData::Encoded(v) => v.len() != pixels * fmt.bytes_per_pixel(),
}
})
{
return None;
}
for (t, data) in results {
out.write_region(t, &data);
match data {
TileData::Samples(data) => out.write_region(t, &data),
TileData::Encoded(data) => out.write_interleaved(t, &data),
}
}
}
out.prune();
+88 -25
View File
@@ -191,6 +191,9 @@ struct StrokeCursor {
/// 125,000); smaller ones are not worth the hand-off.
const PARALLEL_DAB_NODES: usize = 16_384;
/// Bound completed output buffers independently of the document's size.
const RENDER_BATCH_TILES: usize = 64;
/// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a
/// drag is many small pushes and its edge stays smooth.
fn dab_spacing(r: f64) -> f64 {
@@ -209,11 +212,20 @@ pub fn auto_cell(bounds: Rect) -> f64 {
if bounds.size().area() <= 4_200_000 { 2.0 } else { 4.0 }
}
/// Most nodes of a field's displacement grid (12 bytes each with the freeze mask): 192 MB.
const MAX_FIELD_NODES: usize = 1 << 24;
impl LiquifyField {
pub fn new(bounds: Rect, cell: f64) -> Self {
let cell = cell.clamp(1.0, 64.0);
let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2;
let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2;
let mut cell = cell.clamp(1.0, 64.0);
let nodes = |cell: f64| ((f64::from(bounds.width()) / cell).ceil() as usize + 2, (f64::from(bounds.height()) / cell).ceil() as usize + 2);
let (mut w, mut h) = nodes(cell);
// The extent is the document canvas, which a file declares: past the node budget the
// field gets coarser (it is interpolated anyway) instead of asking for gigabytes.
while w.saturating_mul(h) > MAX_FIELD_NODES {
cell *= 2.0;
(w, h) = nodes(cell);
}
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None }
}
@@ -584,14 +596,11 @@ impl LiquifyField {
}
/// Catmull-Rom (bicubic) displacement at grid coordinates.
fn sample_cubic(&self, gx: f64, gy: f64, wx: &[f64; 4], wy: &[f64; 4]) -> [f64; 2] {
let (ix, iy) = (gx.floor() as isize, gy.floor() as isize);
fn sample_cubic(&self, xs: &[(usize, f64); 4], ys: &[(usize, f64); 4]) -> [f64; 2] {
let mut acc = [0.0f64; 2];
for (jj, wyj) in wy.iter().enumerate() {
let y = (iy - 1 + jj as isize).clamp(0, self.h as isize - 1) as usize;
for &(y, wyj) in ys {
let row = &self.d[y * self.w..(y + 1) * self.w];
for (ii, wxi) in wx.iter().enumerate() {
let x = (ix - 1 + ii as isize).clamp(0, self.w as isize - 1) as usize;
for &(x, wxi) in xs {
let k = wxi * wyj;
acc[0] += f64::from(row[x][0]) * k;
acc[1] += f64::from(row[x][1]) * k;
@@ -651,6 +660,13 @@ fn catmull_rom(t: f64) -> [f64; 4] {
[-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2]
}
/// A field axis's clamped nodes and weights, reusable across a tile's rows or columns.
fn cubic_taps(g: f64, len: usize) -> [(usize, f64); 4] {
let i = g.floor() as isize;
let weights = catmull_rom(g - g.floor());
std::array::from_fn(|k| ((i - 1 + k as isize).clamp(0, len as isize - 1) as usize, weights[k]))
}
/// Resamples `src` through the field: inside `field.bounds` each pixel shows the source at
/// `p + d(p)` (bicubic field, bicubic premultiplied source, edge pixels repeat past the field
/// bounds); everything outside the bounds is kept. Same format as `src`.
@@ -671,19 +687,24 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
let work = |t: &Rect| -> Option<(Rect, Vec<f32>)> {
let w = t.width() as usize;
let h = t.height() as usize;
// Field per pixel (bicubic), rows share their y weights.
// Field per pixel (bicubic), with axis taps shared across rows and columns.
let mut disp = vec![[0.0f64; 2]; w * h];
let (mut fx0, mut fy0, mut fx1, mut fy1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
let mut moved = false;
let columns: Vec<_> = (0..w)
.map(|x| {
let px = f64::from(t.x0) + x as f64 + 0.5;
let gx = (px - f64::from(b.x0) - 0.5) / field.cell;
(px, cubic_taps(gx, field.w))
})
.collect();
for y in 0..h {
let py = f64::from(t.y0) + y as f64 + 0.5;
let gy = (py - f64::from(b.y0) - 0.5) / field.cell;
let wy = catmull_rom(gy - gy.floor());
for x in 0..w {
let px = f64::from(t.x0) + x as f64 + 0.5;
let gx = (px - f64::from(b.x0) - 0.5) / field.cell;
let wx = catmull_rom(gx - gx.floor());
let mut d = field.sample_cubic(gx, gy, &wx, &wy);
let ys = cubic_taps(gy, field.h);
for (x, (px, xs)) in columns.iter().enumerate() {
let px = *px;
let mut d = field.sample_cubic(xs, &ys);
if has_freeze {
let mask = f64::from(field.freeze_at(px, py)).clamp(0.0, 1.0);
if mask > 0.0 {
@@ -765,15 +786,10 @@ pub fn apply_liquify(src: &Surface, field: &LiquifyField) -> Surface {
}
Some((*t, outp))
};
#[cfg(not(target_arch = "wasm32"))]
let done: Vec<(Rect, Vec<f32>)> = {
use rayon::prelude::*;
tiles.par_iter().filter_map(work).collect()
};
#[cfg(target_arch = "wasm32")]
let done: Vec<(Rect, Vec<f32>)> = tiles.iter().filter_map(work).collect();
for (r, v) in done {
out.write_region(r, &v);
for batch in tiles.chunks(RENDER_BATCH_TILES) {
for (r, v) in par::map(batch.len(), true, |i| batch.get(i).and_then(work)).into_iter().flatten() {
out.write_region(r, &v);
}
}
if alpha {
out.prune();
@@ -1122,6 +1138,15 @@ mod tests {
assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px");
}
#[test]
fn a_huge_canvas_gets_a_coarser_field_not_a_huge_one() {
// 400000² px at 2 px per node is 4·10¹⁰ nodes.
let f = LiquifyField::new(Rect::new(0, 0, 400_000, 400_000), 2.0);
assert!(f.w * f.h <= MAX_FIELD_NODES && f.d.len() == f.w * f.h && f.freeze.len() == f.d.len());
assert!(f.cell > 64.0);
assert_eq!(LiquifyField::new(Rect::new(0, 0, 100, 100), 2.0).cell, 2.0);
}
#[test]
fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() {
// A segment that does not start where the last one ended begins a new run of dabs there.
@@ -1300,6 +1325,44 @@ mod tests {
}
}
#[test]
fn a_wide_integer_translation_keeps_channels_and_tile_edges_at_every_format() {
let b = Rect::new(13, -1, 13 + 70 * 256, 1);
let w = b.width() as usize;
for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk] {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let format = PixelFormat::new(mode, depth, true);
let n = format.channels();
let mut data = Vec::new();
for i in 0..w * 2 {
let alpha = if i % 13 == 0 { 0.0 } else { 0.5 };
for c in 0..mode.color_channels() {
data.push(if alpha == 0.0 { 0.0 } else { ((i * 19 + c * 43) % 251 + 2) as f32 / 255.0 });
}
data.push(alpha);
}
let mut source = Surface::new(format);
source.write_region(b, &data);
source.write_pixel(b.x0 - 1, b.y0 - 1, &vec![0.75; n]);
let before = source.read_region(b);
let mut field = LiquifyField::new(b, 2.5);
field.d.fill([-1.0, 0.0]);
let output = apply_liquify(&source, &field);
let after = output.read_region(b);
let tolerance = if depth == SampleType::F32 { 4.0 * f32::EPSILON } else { 0.0 };
for (original, moved) in before.chunks_exact(w * n).zip(after.chunks_exact(w * n)) {
for (x, pixel) in moved.chunks_exact(n).enumerate() {
let offset = x.saturating_sub(1) * n;
for (actual, expected) in pixel.iter().zip(&original[offset..offset + n]) {
assert!((actual - expected).abs() <= tolerance, "{mode:?}/{depth:?} at column {x}");
}
}
}
assert_eq!(output.pixel(b.x0 - 1, b.y0 - 1), source.pixel(b.x0 - 1, b.y0 - 1));
}
}
}
#[test]
fn no_strokes_is_identity_at_every_depth() {
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
+5 -6
View File
@@ -512,12 +512,11 @@ fn direction_cost(tp: [f32; 2], tq: [f32; 2], dx: i32, dy: i32, len: f32) -> f32
if tp == [0.0; 2] || tq == [0.0; 2] {
return NEUTRAL_DIRECTION;
}
let mut l = [dx as f32 / len, dy as f32 / len];
if tp[0] * l[0] + tp[1] * l[1] < 0.0 {
l = [-l[0], -l[1]];
}
let dp = (tp[0] * l[0] + tp[1] * l[1]).clamp(-1.0, 1.0);
let dq = (l[0] * tq[0] + l[1] * tq[1]).clamp(-1.0, 1.0);
// An edge tangent is an axis, not a directed vector: reversing the strongest channel's
// gradient must not make an otherwise straight edge expensive to follow.
let l = [dx as f32 / len, dy as f32 / len];
let dp = (tp[0] * l[0] + tp[1] * l[1]).abs().clamp(0.0, 1.0);
let dq = (l[0] * tq[0] + l[1] * tq[1]).abs().clamp(0.0, 1.0);
2.0 / (3.0 * PI) * (dp.acos() + dq.acos())
}
+33
View File
@@ -55,6 +55,39 @@ fn snaps_to_the_most_prominent_edge() {
assert!((p[0] - 18.0).abs() < 0.01, "{p:?}");
}
#[test]
fn gradient_polarity_does_not_make_a_straight_edge_look_like_a_turn() {
// A gradient can reverse across adjacent pixels when another colour channel becomes the
// strongest one. The edge tangent is an axis, so its sign must not change the path cost.
let forward = direction_cost([1.0, 0.0], [1.0, 0.0], 1, 0, 1.0);
let reversed = direction_cost([1.0, 0.0], [-1.0, 0.0], 1, 0, 1.0);
assert!(forward < 1e-6, "aligned edge: {forward}");
assert!(reversed < 1e-6, "same edge with reversed gradient polarity: {reversed}");
}
#[test]
fn traces_a_straight_edge_through_a_gradient_polarity_change() {
let read = Cell::new(0);
// The contrast stays high, but the edge changes from dark-to-light to light-to-dark halfway
// down. The horizontal seam intersects it; the guide stays on the vertical contour.
let mut f = image(
|x, y| {
let left = x < 32;
let top = y < 32;
let white = left != top;
grey(if white { 255 } else { 0 })
},
&read,
);
let mut t = Tracer::new(Rect::new(0, 0, 64, 64));
let path = t.trace(&mut f, [32.0, 5.0], [32.0, 59.0], &[[32.0, 32.0]], Settings::default());
assert_eq!(path.first(), Some(&[32.0, 5.0]));
assert_eq!(path.last(), Some(&[32.0, 59.0]));
for p in &path {
assert!((p[0] - 32.0).abs() < 1.0, "left the high-contrast edge: {p:?}; {path:?}");
}
}
#[test]
fn trace_follows_a_curved_edge() {
let read = Cell::new(0);
+19 -4
View File
@@ -238,7 +238,7 @@ pub fn morph(src: &[f32], w: usize, h: usize, r: usize, grow: bool) -> Vec<f32>
mod brush;
mod morphology;
pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, swept_area};
pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, brush_surface, painted_tiles, stroke_area, swept_area};
/// Running max (or min) over a window of radius `r` (edge-clipped), van Herk / Gil–Werman:
/// three comparisons per sample whatever the radius.
@@ -413,6 +413,22 @@ where
lo.into_iter().zip(hi).map(|(a, b)| a + (b - a) * frac).collect()
}
/// Most pixels one refine should cover. Its result is a dense [`Region`] over [`refine_area`]
/// (about two bytes a pixel while it is assembled), so callers refuse a larger refine up front
/// rather than allocate it: 2³⁰ pixels, a 32k × 32k selection.
pub const MAX_REFINE_AREA: u64 = 1 << 30;
/// The area [`refine_mask`] and [`refine_mask_brushed`] compute and allocate their result over:
/// `content` grown by how far `p` can spread it, plus where `brush` adds, within `canvas`. Empty
/// when `content` misses the canvas.
pub fn refine_area(content: Rect, canvas: Rect, p: &RefineParams, brush: Option<&EdgeBrush>) -> Rect {
let content = content.intersect(&canvas);
if content.is_empty() {
return Rect::EMPTY;
}
content.inflate(p.spread()).union(&brush.map_or(Rect::EMPTY, EdgeBrush::added_bounds)).intersect(&canvas)
}
/// 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.
@@ -446,11 +462,10 @@ fn refine_mask_with<F>(
where
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy + Sync + Send,
{
let content = content.intersect(&canvas);
if content.is_empty() {
let bbox = refine_area(content, canvas, p, brush);
if bbox.is_empty() {
return None;
}
let bbox = content.inflate(p.spread()).union(&brush.map_or(Rect::EMPTY, EdgeBrush::added_bounds)).intersect(&canvas);
let halo = p.halo() + brush.map_or(0, |b| i32::try_from(b.window() * 4).unwrap_or(i32::MAX));
let mut tiles = Vec::new();
let mut y = bbox.y0;
+259 -82
View File
@@ -10,10 +10,17 @@
//! size), the colour-line model of A. Levin, D. Lischinski and Y. Weiss, "A Closed-Form Solution
//! to Natural Image Matting" (CVPR 2006), with one foreground and one background colour per window.
use photocraft_geom::Rect;
use std::collections::{BTreeMap, BTreeSet};
use photocraft_color::PixelFormat;
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
use photocraft_raster::Surface;
use super::box_mean;
use crate::segment::RgbImage;
use crate::selection::{MAX_PAINTED_TILES, clip_segment};
const TS: usize = TILE_SIZE as usize;
/// One Refine Edge Brush stroke, in document pixels.
#[derive(Clone, Debug, PartialEq)]
@@ -29,26 +36,42 @@ pub struct EdgeStroke {
/// Brush diameters the strokes may use.
pub const SIZE_RANGE: std::ops::RangeInclusive<f32> = 1.0..=5000.0;
/// The painted area: signed coverage (`+1` refined, `−1` restored, `0` untouched) over `bbox`.
/// The painted area: signed coverage (`+1` refined, `−1` restored, `0` untouched), kept only for
/// the tiles the strokes paint, so a thin stroke across a huge canvas costs those tiles, not its
/// bounding box (#8783).
#[derive(Clone, Debug, PartialEq)]
pub struct EdgeBrush {
/// Where the strokes can paint.
bbox: Rect,
/// Coverage × 127, row-major over `bbox`.
v: Vec<i8>,
/// Coverage × 127 per painted tile, row-major over the tile.
tiles: BTreeMap<TileCoord, Vec<i8>>,
/// Bounds of the added (positive) coverage.
added: Rect,
/// Box radius of the colour estimate (pixels).
window: usize,
}
/// Coverage (0–1) of a round brush of radius `r` swept from `a` to `b`, at pixel centre `p`.
fn capsule(p: (f32, f32), a: (f32, f32), b: (f32, f32), r: f32) -> f32 {
/// Distance from pixel centre `p` to the segment from `a` to `b`.
fn segment_distance(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let len2 = dx * dx + dy * dy;
let t = if len2 > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };
let (cx, cy) = (a.0 + t * dx, a.1 + t * dy);
let d = ((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt();
(r + 0.5 - d).clamp(0.0, 1.0)
((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt()
}
/// Coverage (0–1) of a round brush of radius `r` swept from `a` to `b`, at pixel centre `p`.
fn capsule(p: (f32, f32), a: (f32, f32), b: (f32, f32), r: f32) -> f32 {
(r + 0.5 - segment_distance(p, a, b)).clamp(0.0, 1.0)
}
/// Coverage (0–1) at distance `d` from a brush's path, for radius `r` and a solid core of radius
/// `core` (hardness × `r`): full inside the core, then a smooth fall to zero half a pixel past `r`
/// (a hard brush keeps just the one-pixel anti-aliased rim).
fn soft_coverage(d: f32, r: f32, core: f32) -> f32 {
let ramp = r + 0.5 - core;
let t = ((r + 0.5 - d) / ramp.max(1.0)).clamp(0.0, 1.0);
if ramp > 1.0 { t * t * (3.0 - 2.0 * t) } else { t }
}
/// The pixels a disc of radius `r` at `p` can touch, as a (possibly empty) rectangle.
@@ -64,27 +87,113 @@ pub const MAX_SWEPT_AREA: f64 = (1u64 << 28) as f64;
/// The area `strokes` sweep (see [`MAX_SWEPT_AREA`]); non-finite strokes count as infinite.
pub fn swept_area(strokes: &[EdgeStroke]) -> f64 {
strokes
.iter()
.map(|s| {
let size = f64::from(s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()));
let len: f64 = s.points.windows(2).filter_map(|w| Some(f64::from((w.get(1)?.0 - w.first()?.0).hypot(w.get(1)?.1 - w.first()?.1)))).sum();
if len.is_finite() { len * size + size * size } else { f64::INFINITY }
})
.sum()
strokes.iter().map(|s| stroke_area(&s.points, s.size)).sum()
}
/// The stroke's path as short segments: points closer than a quarter of the radius to the last
/// kept one are dropped, and long segments are split at most `r` long, so each segment's box
/// stays close to the area it paints.
fn segments(points: &[(f32, f32)], r: f32) -> Vec<((f32, f32), (f32, f32))> {
let Some(&first) = points.first() else { return Vec::new() };
/// The area one stroke of `points` sweeps with a brush `size` wide: length × size + size²;
/// non-finite strokes count as infinite.
pub fn stroke_area(points: &[(f32, f32)], size: f32) -> f64 {
let size = f64::from(size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()));
let len: f64 = points.windows(2).filter_map(|w| Some(f64::from((w.get(1)?.0 - w.first()?.0).hypot(w.get(1)?.1 - w.first()?.1)))).sum();
if len.is_finite() { len * size + size * size } else { f64::INFINITY }
}
/// The tiles of `canvas` a round brush `size` px wide along `points` can paint, in order; `None`
/// when that is more than `limit` (a thin stroke sweeps little but touches a whole tile wherever
/// it passes, so this, not [`MAX_SWEPT_AREA`], bounds what painting it allocates; #8783).
pub fn painted_tiles(points: &[(f32, f32)], size: f32, canvas: Rect, limit: usize) -> Option<BTreeSet<TileCoord>> {
let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
let mut set = BTreeSet::new();
// Consecutive pieces mostly fall in the same tiles.
let mut last: Option<Rect> = None;
for (a, b) in segments(points, r, canvas) {
let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&canvas);
// The tile coordinates `seg` spans (inclusive).
let span =
Rect::new(seg.x0.div_euclid(TILE_SIZE), seg.y0.div_euclid(TILE_SIZE), (seg.x1 - 1).div_euclid(TILE_SIZE), (seg.y1 - 1).div_euclid(TILE_SIZE));
if seg.is_empty() || last == Some(span) {
continue;
}
last = Some(span);
for tc in seg.tiles() {
set.insert(tc);
if set.len() > limit {
return None;
}
}
}
Some(set)
}
/// One stroke's coverage (0–255) per tile of `canvas`: the most `cov(pixel centre, a, b)` gives
/// over the path's pieces, so a stroke never paints past full. Only painted tiles are kept;
/// `None` past `limit` tiles (see [`painted_tiles`]).
fn paint_tiles(
points: &[(f32, f32)],
size: f32,
canvas: Rect,
limit: usize,
cov: impl Fn((f32, f32), (f32, f32), (f32, f32)) -> f32,
) -> Option<BTreeMap<TileCoord, Vec<u8>>> {
let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
let mut tiles: BTreeMap<TileCoord, Vec<u8>> = painted_tiles(points, size, canvas, limit)?.into_iter().map(|tc| (tc, vec![0u8; TS * TS])).collect();
for (a, b) in segments(points, r, canvas) {
let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&canvas);
for tc in seg.tiles() {
let o = tc.rect();
let Some(buf) = tiles.get_mut(&tc) else { continue };
let tr = o.intersect(&seg);
for y in tr.y0..tr.y1 {
for x in tr.x0..tr.x1 {
let c = (cov((x as f32 + 0.5, y as f32 + 0.5), a, b).clamp(0.0, 1.0) * 255.0).round() as u8;
if let Some(slot) = buf.get_mut((y - o.y0) as usize * TS + (x - o.x0) as usize)
&& c > *slot
{
*slot = c;
}
}
}
}
}
tiles.retain(|_, b| b.iter().any(|v| *v > 0));
Some(tiles)
}
/// Select and Mask's Brush: the coverage one stroke of a round brush `size` px wide with
/// `hardness` (0–100) paints along `points`, clipped to `canvas`, as a GRAY8 selection holding
/// only the tiles it paints. Overlapping dabs keep the most coverage any of them gives, so a
/// stroke never paints past full. `None` when it paints nothing, has non-finite points, sweeps
/// more than [`MAX_SWEPT_AREA`] or paints more than [`MAX_PAINTED_TILES`] tiles.
pub fn brush_surface(points: &[(f32, f32)], size: f32, hardness: f32, canvas: Rect) -> Option<Surface> {
if points.is_empty() || !size.is_finite() || !hardness.is_finite() || points.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) {
return None;
}
if stroke_area(points, size) > MAX_SWEPT_AREA {
return None;
}
let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
let core = r * hardness.clamp(0.0, 100.0) / 100.0;
let tiles = paint_tiles(points, size, canvas, MAX_PAINTED_TILES, |p, a, b| soft_coverage(segment_distance(p, a, b), r, core))?;
let mut s = Surface::new(PixelFormat::GRAY8);
for (tc, bytes) in &tiles {
s.write_interleaved(tc.rect(), bytes);
}
(s.tile_count() > 0).then_some(s)
}
/// The stroke's path as short segments within reach of `clip`: points closer than a quarter of
/// the radius to the last kept one are dropped, each segment is cut to `clip` grown by the brush
/// (what lies beyond can't paint inside it), and long ones are split at most `r` long, so each
/// piece's box stays close to the area it paints. The pieces are made as they are painted, never
/// collected, and only where they can paint: a 1 px stroke zigzagging across 2 million px is over
/// 100 million pieces in all, within [`MAX_SWEPT_AREA`] (the #8783 review).
fn segments(points: &[(f32, f32)], r: f32, clip: Rect) -> impl Iterator<Item = ((f32, f32), (f32, f32))> {
let min_step = (r / 4.0).max(0.5);
let max_step = r.max(2.0);
let mut kept = vec![first];
for p in points.iter().skip(1) {
let last = kept.last().copied().unwrap_or(first);
if (p.0 - last.0).hypot(p.1 - last.1) >= min_step {
let reach = clip.inflate((r + 2.0).ceil().clamp(0.0, 1e7) as i32);
let mut kept: Vec<(f32, f32)> = Vec::new();
for p in points {
if kept.last().is_none_or(|l| (p.0 - l.0).hypot(p.1 - l.1) >= min_step) {
kept.push(*p);
}
}
@@ -93,25 +202,22 @@ fn segments(points: &[(f32, f32)], r: f32) -> Vec<((f32, f32), (f32, f32))> {
{
kept.push(end);
}
if kept.len() == 1 {
return vec![(first, first)];
}
let mut out = Vec::new();
for w in kept.windows(2) {
let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue };
// A single point is one dab.
let dab = if kept.len() == 1 { kept.first().map(|p| (*p, *p)) } else { None };
let pairs: Vec<((f32, f32), (f32, f32))> = kept.windows(2).filter_map(|w| clip_segment(*w.first()?, *w.get(1)?, reach)).collect();
dab.into_iter().chain(pairs.into_iter().flat_map(move |(a, b)| {
let pieces = ((b.0 - a.0).hypot(b.1 - a.1) / max_step).ceil().clamp(1.0, 1e6) as usize;
let at = |k: usize| {
let at = move |k: usize| {
let t = k as f32 / pieces as f32;
(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)
};
out.extend((0..pieces).map(|k| (at(k), at(k + 1))));
}
out
(0..pieces).map(move |k| (at(k), at(k + 1)))
}))
}
impl EdgeBrush {
/// Rasterises `strokes` (clipped to `canvas`). `None` when they paint nothing or sweep more
/// than [`MAX_SWEPT_AREA`].
/// Rasterises `strokes` (clipped to `canvas`), tile by tile. `None` when they paint nothing,
/// sweep more than [`MAX_SWEPT_AREA`] or paint more than [`MAX_PAINTED_TILES`] tiles.
pub fn new(strokes: &[EdgeStroke], canvas: Rect) -> Option<EdgeBrush> {
let usable = |s: &&EdgeStroke| s.size.is_finite() && !s.points.is_empty() && s.points.iter().all(|p| p.0.is_finite() && p.1.is_finite());
if swept_area(strokes) > MAX_SWEPT_AREA {
@@ -126,61 +232,43 @@ impl EdgeBrush {
if bbox.is_empty() {
return None;
}
let bw = bbox.width() as usize;
let mut v = vec![0i8; bw.checked_mul(bbox.height() as usize)?];
let mut cov: Vec<f32> = Vec::new();
let mut tiles: BTreeMap<TileCoord, Vec<i8>> = BTreeMap::new();
let mut max_add = 0.0f32;
for s in strokes {
let sb = stroke_bounds(s);
if sb.is_empty() {
continue;
}
let r = s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
if s.add {
max_add = max_add.max(2.0 * r);
}
let sw = sb.width() as usize;
cov.clear();
cov.resize(sw * sb.height() as usize, 0.0);
// The stroke's coverage: the most any of its segments gives (a single dab for one point).
for (a, b) in segments(&s.points, r) {
let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&sb);
for y in seg.y0..seg.y1 {
for x in seg.x0..seg.x1 {
let c = capsule((x as f32 + 0.5, y as f32 + 0.5), a, b, r);
if let Some(slot) = cov.get_mut((y - sb.y0) as usize * sw + (x - sb.x0) as usize)
&& c > *slot
{
*slot = c;
}
}
}
}
let cov = paint_tiles(&s.points, s.size, canvas, MAX_PAINTED_TILES, |p, a, b| capsule(p, a, b, r))?;
let target = if s.add { 1.0 } else { -1.0 };
for y in sb.y0..sb.y1 {
for x in sb.x0..sb.x1 {
let c = cov.get((y - sb.y0) as usize * sw + (x - sb.x0) as usize).copied().unwrap_or(0.0);
if c <= 0.0 {
continue;
}
if let Some(slot) = v.get_mut((y - bbox.y0) as usize * bw + (x - bbox.x0) as usize) {
let cur = f32::from(*slot) / 127.0;
for (tc, c) in cov {
let v = tiles.entry(tc).or_insert_with(|| vec![0i8; TS * TS]);
for (slot, c) in v.iter_mut().zip(c) {
if c > 0 {
let (cur, c) = (f32::from(*slot) / 127.0, f32::from(c) / 255.0);
*slot = ((cur + (target - cur) * c) * 127.0).round().clamp(-127.0, 127.0) as i8;
}
}
}
if tiles.len() > MAX_PAINTED_TILES {
return None;
}
}
if v.iter().all(|c| *c == 0) {
tiles.retain(|_, v| v.iter().any(|c| *c != 0));
if tiles.is_empty() {
return None;
}
let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
for (row, line) in v.chunks_exact(bw).enumerate() {
let (Some(first), Some(last)) = (line.iter().position(|c| *c > 0), line.iter().rposition(|c| *c > 0)) else { continue };
let y = bbox.y0 + row as i32;
(x0, x1, y0, y1) = (x0.min(bbox.x0 + first as i32), x1.max(bbox.x0 + last as i32 + 1), y0.min(y), y1.max(y + 1));
let mut added = Rect::EMPTY;
for (tc, v) in &tiles {
let o = tc.rect();
for (row, line) in v.as_chunks::<TS>().0.iter().enumerate() {
let (Some(first), Some(last)) = (line.iter().position(|c| *c > 0), line.iter().rposition(|c| *c > 0)) else { continue };
let y = o.y0 + row as i32;
added = added.union(&Rect::new(o.x0 + first as i32, y, o.x0 + last as i32 + 1, y + 1));
}
}
let added = if x0 < x1 { Rect::new(x0, y0, x1, y1) } else { Rect::EMPTY };
Some(EdgeBrush { bbox, v, added, window: max_add.ceil().clamp(4.0, 64.0) as usize })
Some(EdgeBrush { bbox, tiles, added, window: max_add.ceil().clamp(4.0, 64.0) as usize })
}
/// Where the strokes paint (added or restored).
@@ -200,16 +288,32 @@ impl EdgeBrush {
/// Signed coverage at document pixel `(x, y)` (zero outside the painted area).
pub fn at(&self, x: i32, y: i32) -> f32 {
if !self.bbox.contains(x, y) {
return 0.0;
}
let i = (y - self.bbox.y0) as usize * self.bbox.width() as usize + (x - self.bbox.x0) as usize;
self.v.get(i).map_or(0.0, |c| f32::from(*c) / 127.0)
let (tc, o) = (TileCoord::containing(x, y), (y.rem_euclid(TILE_SIZE) as usize) * TS + x.rem_euclid(TILE_SIZE) as usize);
self.tiles.get(&tc).and_then(|v| v.get(o)).map_or(0.0, |c| f32::from(*c) / 127.0)
}
/// Signed coverage over `r` (row-major).
pub fn read(&self, r: Rect) -> Vec<f32> {
(r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| self.at(x, y))).collect()
let w = r.width() as usize;
let mut out = vec![0.0f32; w * r.height() as usize];
for tc in r.tiles() {
let Some(v) = self.tiles.get(&tc) else { continue };
let (o, tr) = (tc.rect(), tc.rect().intersect(&r));
for y in tr.y0..tr.y1 {
let src = (y - o.y0) as usize * TS;
let dst = (y - r.y0) as usize * w;
let (Some(src), Some(dst)) = (
v.get(src + (tr.x0 - o.x0) as usize..src + (tr.x1 - o.x0) as usize),
out.get_mut(dst + (tr.x0 - r.x0) as usize..dst + (tr.x1 - r.x0) as usize),
) else {
continue;
};
for (d, c) in dst.iter_mut().zip(src) {
*d = f32::from(*c) / 127.0;
}
}
}
out
}
}
@@ -276,6 +380,10 @@ mod tests {
use crate::segment::ImageSampler;
use crate::selection::Region;
fn at(s: &Surface, x: i32, y: i32) -> f32 {
s.pixel(x, y).first().copied().unwrap_or(0.0)
}
/// Strands of the subject's colour (every 4th column from x = 60) beyond the selection's
/// edge at x = 40: brushing over them adds them, Alt-brushing the edge keeps it as it was.
#[test]
@@ -330,4 +438,73 @@ mod tests {
let empty = EdgeStroke { points: vec![], size: 10.0, add: true };
assert!(EdgeBrush::new(&[off, nan, empty], canvas).is_none());
}
#[test]
fn brush_surface_paints_hard_and_soft_strokes() {
let canvas = Rect::new(0, 0, 100, 50);
let hard = brush_surface(&[(10.0, 25.0), (90.0, 25.0)], 20.0, 100.0, canvas).expect("painted");
assert!(at(&hard, 50, 25) > 0.99 && at(&hard, 50, 33) > 0.99, "solid inside the radius");
assert!(at(&hard, 50, 36) == 0.0 && at(&hard, 50, 10) == 0.0, "nothing past it");
// A soft brush fades from the centre line to the rim.
let soft = brush_surface(&[(10.0, 25.0), (90.0, 25.0)], 20.0, 0.0, canvas).expect("painted");
let (centre, mid, rim) = (at(&soft, 50, 25), at(&soft, 50, 30), at(&soft, 50, 34));
assert!(centre > 0.95 && mid > 0.2 && mid < 0.8 && rim < 0.1, "{centre} {mid} {rim}");
// A single point is one dab; clipped to the canvas.
let dab = brush_surface(&[(0.0, 0.0)], 10.0, 100.0, canvas).expect("painted");
let b = dab.content_bounds();
assert_eq!((b.x0, b.y0), (0, 0));
assert!(at(&dab, 1, 1) > 0.99);
}
#[test]
fn brush_surface_paints_nothing_for_hostile_strokes() {
let canvas = Rect::new(0, 0, 100, 50);
assert!(brush_surface(&[], 10.0, 100.0, canvas).is_none());
assert!(brush_surface(&[(f32::NAN, 1.0)], 10.0, 100.0, canvas).is_none());
assert!(brush_surface(&[(1.0, 1.0)], f32::INFINITY, 100.0, canvas).is_none());
assert!(brush_surface(&[(1.0, 1.0)], 10.0, f32::NAN, canvas).is_none());
assert!(brush_surface(&[(-500.0, -500.0)], 10.0, 100.0, canvas).is_none(), "off the canvas");
assert!(brush_surface(&[(0.0, 0.0), (1e7, 1e7)], 5000.0, 100.0, canvas).is_none(), "sweeps too much");
// Sizes and hardness outside their ranges are clamped, not rejected.
assert!(brush_surface(&[(50.0, 25.0)], 0.0, 500.0, canvas).is_some());
}
/// A 1 px stroke zigzagging across 2 million px stays within [`MAX_SWEPT_AREA`] yet splits
/// into over 100 million pieces: painting it must not collect them all first (it allocated
/// about 2 GB up front; the #8783 review).
#[test]
fn a_long_thin_zigzag_paints_without_collecting_its_pieces() {
let canvas = Rect::new(0, 0, 64, 64);
let points: Vec<(f32, f32)> = (0..135).map(|i| (if i % 2 == 0 { 0.0 } else { 2_000_000.0 }, 32.0 + (i % 3) as f32)).collect();
assert!(stroke_area(&points, 1.0) <= MAX_SWEPT_AREA);
// Only the pieces within reach of the canvas are made.
assert!(segments(&points, 0.5, canvas).count() < 134 * 40, "{}", segments(&points, 0.5, canvas).count());
let r = brush_surface(&points, 1.0, 100.0, canvas).expect("it paints its row");
assert!(at(&r, 10, 32) > 0.5);
let strokes = [EdgeStroke { points, size: 1.0, add: true }];
assert!(EdgeBrush::new(&strokes, canvas).is_some_and(|b| b.at(10, 32) > 0.5));
}
/// A 1 px diagonal across a 100,000 px canvas sweeps about 141,000 px, well within
/// [`MAX_SWEPT_AREA`], but its bounding box is 10 billion pixels: painting it must cost the
/// tiles it crosses, not that box (it allocated 40 GB; the #8783 review).
#[test]
fn a_thin_diagonal_across_a_huge_canvas_paints_only_its_tiles() {
let canvas = Rect::new(0, 0, 100_000, 100_000);
let diagonal = [(0.0, 0.0), (99_999.0, 99_999.0)];
assert!(stroke_area(&diagonal, 1.0) <= MAX_SWEPT_AREA);
let tiles = painted_tiles(&diagonal, 1.0, canvas, MAX_PAINTED_TILES).expect("within the limit");
assert!(tiles.len() < 1500, "{}", tiles.len());
let s = brush_surface(&diagonal, 1.0, 100.0, canvas).expect("painted");
assert!(s.tile_count() <= tiles.len());
assert!(at(&s, 50_000, 50_000) > 0.5 && at(&s, 50_000, 40_000) == 0.0);
let strokes = [EdgeStroke { points: diagonal.to_vec(), size: 1.0, add: true }];
let b = EdgeBrush::new(&strokes, canvas).expect("painted");
assert!(b.at(50_000, 50_000) > 0.5 && b.at(50_000, 40_000) == 0.0);
assert!(b.read(Rect::new(49_990, 49_990, 50_010, 50_010)).iter().any(|v| *v > 0.5));
// A zigzag crossing more tiles than the limit is refused rather than allocated.
let zigzag: Vec<(f32, f32)> = (0..20).map(|i| (if i % 2 == 0 { 0.0 } else { 99_999.0 }, i as f32 * 5000.0)).collect();
assert!(stroke_area(&zigzag, 1.0) <= MAX_SWEPT_AREA);
assert!(painted_tiles(&zigzag, 1.0, canvas, MAX_PAINTED_TILES).is_none());
assert!(brush_surface(&zigzag, 1.0, 100.0, canvas).is_none());
assert!(EdgeBrush::new(&[EdgeStroke { points: zigzag, size: 1.0, add: true }], canvas).is_none());
}
}
+77 -3
View File
@@ -29,6 +29,24 @@ pub(crate) struct OilSpec {
fn stroke_len(stylization: f32) -> f32 {
1.5 + stylization.clamp(0.1, 10.0) * 2.0
}
/// `f32::round` (half away from zero) as an integer, without the `roundf` call `f32::round`
/// makes on the baseline x86-64 target. The sum is taken in `f64`, where `v ± 0.5` is exact for
/// every `f32` the cast does not saturate (an `f32` sum is not: `0.49999997 + 0.5` rounds up to
/// `1.0`), so the result equals `v.round() as i32` for every `f32`, NaN (0) and the saturating
/// ends included.
#[inline]
fn round_i32(v: f32) -> i32 {
let v = f64::from(v);
(v + 0.5f64.copysign(v)) as i32
}
/// [`round_i32`] clamped into `0..n`.
#[inline]
fn round_index(v: f32, n: usize) -> usize {
round_i32(v).clamp(0, n as i32 - 1) as usize
}
fn tensor_sigma(scale: f32) -> f32 {
0.8 + scale.clamp(0.1, 10.0) * 0.6
}
@@ -93,7 +111,7 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve
let sample = |x: f32, y: f32, acc: &mut [f32], k: f32| {
let x = x.clamp(0.0, (ww - 1) as f32);
let y = y.clamp(0.0, (wh - 1) as f32);
let (x0, y0) = (x.floor() as usize, y.floor() as usize);
let (x0, y0) = (x as usize, y as usize);
let (x1, y1) = ((x0 + 1).min(ww - 1), (y0 + 1).min(wh - 1));
let (ax, ay) = (x - x0 as f32, y - y0 as f32);
let w00 = (1.0 - ax) * (1.0 - ay) * k;
@@ -119,7 +137,7 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve
let (mut qx, mut qy) = (dx, dy);
let (mut px, mut py) = (0.0f32, 0.0f32);
for k in 1..=steps {
let ti = (qy.round().clamp(0.0, (wh - 1) as f32) as usize) * ww + qx.round().clamp(0.0, (ww - 1) as f32) as usize;
let ti = round_index(qy, wh) * ww + round_index(qx, ww);
let (mut tx, mut ty) = tangent[ti];
if k == 1 {
tx *= dir;
@@ -135,7 +153,7 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve
let wk = 1.0 - k as f32 / (steps + 1) as f32;
sample(qx, qy, &mut acc[..n], wk);
wsum += wk;
nsum += wk * hash01(win.x0 + qx.round() as i32, win.y0 + qy.round() as i32, 71, 0);
nsum += wk * hash01(win.x0 + round_i32(qx), win.y0 + round_i32(qy), 71, 0);
}
}
let o = (ry * rw + rx) * n;
@@ -197,3 +215,59 @@ pub(crate) fn oil_paint(src: &Image, out: Rect, ctx: &Ctx, spec: &OilSpec) -> Ve
}
res
}
#[cfg(test)]
mod tests {
use super::{round_i32, round_index};
/// The LIC looks up tangents and bristle noise by rounded sample position; the replacements
/// for `f32::round` must agree with it over every position a tile can produce, and agree with
/// `f32::clamp` at the ends, including non-finite input.
#[test]
fn rounding_matches_the_standard_library() {
let mut v = -40.0f32;
while v <= 2100.0 {
assert_eq!(round_i32(v), v.round() as i32, "round_i32({v})");
for n in [1usize, 2, 33, 256, 2048] {
let want = (v.round().clamp(0.0, (n - 1) as f32)) as usize;
assert_eq!(round_index(v, n), want, "round_index({v}, {n})");
}
v += 0.25;
}
// The binade crossings where an `f32` sum `v + 0.5` rounds up (0.5 - 2^-25), the ties, and
// the largest non-integer `f32`.
let below_half = f32::from_bits(0x3eff_ffff);
for v in [below_half, -below_half, 0.5, -0.5, 1.5, -1.5, 2.5, -2.5, 8_388_607.5, -8_388_607.5, 8_388_608.0, 3.0e9, -3.0e9] {
assert_eq!(round_i32(v), v.round() as i32, "round_i32({v:e})");
for n in [1usize, 2, 33] {
let want = (v.round().clamp(0.0, (n - 1) as f32)) as usize;
assert_eq!(round_index(v, n), want, "round_index({v:e}, {n})");
}
}
assert_eq!(round_i32(f32::NAN), f32::NAN.round() as i32);
for n in [1usize, 8] {
assert_eq!(round_index(f32::NEG_INFINITY, n), (f32::NEG_INFINITY.clamp(0.0, (n - 1) as f32)) as usize);
assert_eq!(round_index(f32::INFINITY, n), ((n - 1) as f32) as usize);
}
}
/// Every `f32` bit pattern in [-4096, 4096] (both signs, about 2.3e9 values, split across
/// threads) rounds exactly like `f32::round`.
#[test]
fn rounding_matches_the_standard_library_for_every_f32_up_to_4096() {
use rayon::prelude::*;
let last = 4096.0f32.to_bits();
let bad = (0..=last >> 20)
.into_par_iter()
.filter_map(|hi| {
let lo = hi << 20;
let hi_end = (lo | 0xf_ffff).min(last);
(lo..=hi_end).find_map(|bits| {
let v = f32::from_bits(bits);
[v, -v].into_iter().find(|v| round_i32(*v) != v.round() as i32)
})
})
.min_by(|a, b| a.abs().total_cmp(&b.abs()));
assert_eq!(bad, None, "round_i32 differs from f32::round");
}
}
+48 -18
View File
@@ -5,6 +5,14 @@ use photocraft_geom::Rect;
use photocraft_raster::{Surface, from_rgba_into};
use serde::{Deserialize, Serialize};
/// How a fill lays down its colour: blended with a mode, or Clear, which erases what it covers
/// (the Paint Bucket's Mode › Clear).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FillMode {
Blend(BlendMode),
Clear,
}
/// Gradient tool shape.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -59,7 +67,7 @@ pub fn sample_stops(stops: &[(f32, [f32; 4])], t: f32) -> [f32; 4] {
fn composite_area(
s: &mut Surface,
area: Rect,
blend_mode: BlendMode,
mode: FillMode,
coverage: impl Fn(i32, i32) -> f32 + Sync,
color: impl Fn(i32, i32) -> [f32; 4] + Sync,
lock_transparency: bool,
@@ -89,12 +97,18 @@ fn composite_area(
let i = ((y - r.y0) as usize * w + (x - r.x0) as usize) * n;
let px = &mut region[i..i + n];
let d = photocraft_raster::to_rgba(&fmt, px);
let o = if lock_transparency { blend::composite_atop(blend_mode, d, color(x, y), k) } else { blend::composite(blend_mode, d, color(x, y), k) };
let o = match mode {
FillMode::Blend(m) if lock_transparency => blend::composite_atop(m, d, color(x, y), k),
FillMode::Blend(m) => blend::composite(m, d, color(x, y), k),
// Locked transparency keeps the alpha, so Clear leaves the pixel as it is.
FillMode::Clear if lock_transparency => d,
FillMode::Clear => [d[0], d[1], d[2], d[3] * (1.0 - k.min(1.0))],
};
from_rgba_into(&fmt, o, &mut enc);
// A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is.
if let Some(i) = inks
&& fmt.mode == photocraft_color::ColorMode::Cmyk
&& blend_mode == BlendMode::Normal
&& mode == FillMode::Blend(BlendMode::Normal)
&& k >= 1.0
{
enc[..4].copy_from_slice(&i);
@@ -135,7 +149,7 @@ pub fn paint_gradient(
composite_area(
s,
area,
blend_mode,
FillMode::Blend(blend_mode),
|x, y| opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)),
|x, y| {
let t = tool_gradient_t(shape, from, to, x as f32 + 0.5, y as f32 + 0.5);
@@ -187,11 +201,12 @@ pub fn bucket_fill_src(
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
) -> bool {
bucket_fill_inks(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, src, None)
bucket_fill_inks(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, src, None, FillMode::Blend(BlendMode::Normal))
}
/// [`bucket_fill_src`] where `inks` (CMYK, 0..1) are what a fully covered pixel of a CMYK target
/// gets instead of `src`'s sRGB stand-in (a colour that was picked as CMYK).
/// gets instead of `src`'s sRGB stand-in (a colour that was picked as CMYK), laid down with `mode`
/// (the options bar's Mode).
#[allow(clippy::too_many_arguments)]
pub fn bucket_fill_inks(
s: &mut Surface,
@@ -205,6 +220,7 @@ pub fn bucket_fill_inks(
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
inks: Option<[f32; 4]>,
mode: FillMode,
) -> bool {
if !area.contains(seed.0, seed.1) {
return false;
@@ -212,7 +228,7 @@ pub fn bucket_fill_inks(
let img = crate::selection::rgba8_image(s, area);
let Some(region) = crate::selection::wand_region(&img, area, seed, tolerance, contiguous, anti_alias) else { return false };
drop(img);
bucket_fill_region_inks(s, &region, opacity, selection, lock_transparency, src, inks);
bucket_fill_region_inks(s, &region, opacity, selection, lock_transparency, src, inks, mode);
true
}
@@ -226,10 +242,11 @@ pub fn bucket_fill_region_src(
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
) {
bucket_fill_region_inks(s, region, opacity, selection, lock_transparency, src, None);
bucket_fill_region_inks(s, region, opacity, selection, lock_transparency, src, None, FillMode::Blend(BlendMode::Normal));
}
/// [`bucket_fill_region_src`] with the CMYK `inks` of [`bucket_fill_inks`].
/// [`bucket_fill_region_src`] with the CMYK `inks` and the `mode` of [`bucket_fill_inks`].
#[allow(clippy::too_many_arguments)]
pub fn bucket_fill_region_inks(
s: &mut Surface,
region: &crate::selection::Region,
@@ -238,18 +255,11 @@ pub fn bucket_fill_region_inks(
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
inks: Option<[f32; 4]>,
mode: FillMode,
) {
// Composite only over the filled region's box.
let b = region.bbox;
composite_area(
s,
b,
BlendMode::Normal,
|x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)),
src,
lock_transparency,
inks,
);
composite_area(s, b, mode, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, lock_transparency, inks);
}
#[cfg(test)]
@@ -333,4 +343,24 @@ mod tests {
assert_eq!(s.rgba(-1, 4), [0.0; 4], "outside the selection");
assert_eq!(s.rgba(-4, 4), [0.0; 4], "outside the region");
}
/// The Paint Bucket's Mode: Multiply darkens what's there, Clear erases it (half at 50 %).
#[test]
fn bucket_mode_blends_or_clears() {
let a = Rect::new(0, 0, 4, 1);
let grey = [0.5, 0.5, 0.5, 1.0];
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
let mut s = Surface::new(fmt);
s.fill_rect(a, &grey);
let red = |_, _| [1.0, 0.0, 0.0, 1.0];
assert!(bucket_fill_inks(&mut s, a, (0, 0), 0.0, true, false, 1.0, None, false, red, None, FillMode::Blend(BlendMode::Multiply)));
let px = s.rgba(1, 0);
assert!((px[0] - 0.5).abs() < 0.01 && px[1] < 0.01 && (px[3] - 1.0).abs() < 1e-3, "{fmt:?} multiply: {px:?}");
assert!(bucket_fill_inks(&mut s, a, (0, 0), 0.0, true, false, 0.5, None, false, red, None, FillMode::Clear));
assert!((s.rgba(1, 0)[3] - 0.5).abs() < 0.01, "{fmt:?} clear at 50 %");
// Locked transparency keeps the alpha, so Clear does nothing.
assert!(bucket_fill_inks(&mut s, a, (0, 0), 0.0, true, false, 1.0, None, true, red, None, FillMode::Clear));
assert!((s.rgba(1, 0)[3] - 0.5).abs() < 0.01, "{fmt:?} locked");
}
}
}
+11 -2
View File
@@ -5,6 +5,7 @@
use photocraft_geom::Rect;
pub(crate) mod cells;
mod facet;
#[cfg(test)]
mod reference;
@@ -120,7 +121,7 @@ pub(crate) fn color_halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32,
/// being distressed) keeps hard edges and its weight instead of greying where cells straddle
/// an edge. A cell whose point falls outside the image takes the (premultiplied) average
/// colour of its pixels.
pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec<f32> {
fn crystallize_direct(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec<f32> {
let n = src.ch;
let cell = cell_size.max(2.0);
let b = ctx.bounds;
@@ -320,7 +321,7 @@ pub(crate) fn mezzotint(src: &Image, out: Rect, ctx: &Ctx, kind: MezzotintType,
/// Pointillize: randomly sized dots, coloured from the image at their centre,
/// on a canvas of the background colour.
pub(crate) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Vec<f32> {
fn pointillize_direct(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Vec<f32> {
let n = src.ch;
let cell = cell_size.max(2.0);
let b = ctx.bounds;
@@ -374,3 +375,11 @@ pub(crate) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, see
}
res
}
pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec<f32> {
cells::crystallize(src, out, ctx, cell_size, seed).unwrap_or_else(|| crystallize_direct(src, out, ctx, cell_size, seed))
}
pub(crate) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Vec<f32> {
cells::pointillize(src, out, ctx, cell_size, seed, background).unwrap_or_else(|| pointillize_direct(src, out, ctx, cell_size, seed, background))
}
+571
View File
@@ -0,0 +1,571 @@
//! Reuse the jittered lattice in cell space. Raster work stays in the original
//! row/candidate order, including f32 ties and boundary-cell average additions.
use super::*;
#[derive(Clone, Copy)]
struct Site {
point: (f32, f32),
color: [f32; MAXC],
radius: f32,
sampled: bool,
}
struct Grid {
x0: i32,
y0: i32,
width: usize,
sites: Vec<Site>,
}
fn key(p: i32, origin: f32, cell: f32) -> i32 {
((p as f32 + 0.5 - origin) / cell).floor() as i32
}
impl Grid {
fn new(src: &Image, area: Rect, ctx: &Ctx, cell: f32, seed: u32, dots: bool) -> Option<Self> {
if src.data.len() != (src.rect.width() as usize).checked_mul(src.rect.height() as usize)?.checked_mul(src.ch)? {
return None;
}
Self::new_with(area, ctx, src.ch, cell, seed, dots, |sx, sy, color| {
for (c, value) in color.iter_mut().enumerate() {
*value = src.get(sx, sy, c);
}
src.rect.contains(sx, sy) && ctx.bounds.contains(sx, sy)
})
}
#[allow(clippy::too_many_arguments)]
fn new_with(area: Rect, ctx: &Ctx, n: usize, cell: f32, seed: u32, dots: bool, sample: impl Fn(i32, i32, &mut [f32]) -> bool) -> Option<Self> {
if area.is_empty() || !cell.is_finite() || cell < 2.0 || n == 0 || n > MAXC {
return None;
}
let origin = (ctx.bounds.x0 as f32, ctx.bounds.y0 as f32);
let x0 = key(area.x0, origin.0, cell).checked_sub(1)?;
let y0 = key(area.y0, origin.1, cell).checked_sub(1)?;
let x1 = key(area.x1.checked_sub(1)?, origin.0, cell).checked_add(1)?;
let y1 = key(area.y1.checked_sub(1)?, origin.1, cell).checked_add(1)?;
let width = usize::try_from(x1.checked_sub(x0)?.checked_add(1)?).ok()?;
let height = usize::try_from(y1.checked_sub(y0)?.checked_add(1)?).ok()?;
let len = width.checked_mul(height)?;
// Optional tile-local scratch, at most 16 MiB even for a direct kernel call.
if len.checked_mul(std::mem::size_of::<Site>())? > 16 * 1024 * 1024 {
return None;
}
let mut sites = Vec::new();
sites.try_reserve_exact(len).ok()?;
let cc = ncol(ctx, n);
let sub = subtractive(ctx);
for gy in y0..=y1 {
for gx in x0..=x1 {
let point = cell_point(gx, gy, cell, seed, origin);
let (sx, sy) = (point.0.floor() as i32, point.1.floor() as i32);
let mut color = [0.0; MAXC];
let sampled = sample(sx, sy, color.get_mut(..n)?);
let radius = if dots {
let j = (hash01(gx, gy, 17, seed) - 0.5) * 0.08;
for value in color.iter_mut().take(cc) {
*value = (*value + if sub { -j } else { j }).clamp(0.0, 1.0);
}
cell * (0.45 + 0.35 * hash01(gx, gy, 13, seed))
} else {
0.0
};
sites.push(Site { point, color, radius, sampled });
}
}
Some(Self { x0, y0, width, sites })
}
fn neighbours(&self, cx: i32, cy: i32) -> Option<[usize; 9]> {
let x = usize::try_from(cx.checked_sub(1)?.checked_sub(self.x0)?).ok()?;
let y = usize::try_from(cy.checked_sub(1)?.checked_sub(self.y0)?).ok()?;
if x.checked_add(2)? >= self.width {
return None;
}
let start = y.checked_mul(self.width)?.checked_add(x)?;
let last = start.checked_add(2 * self.width)?.checked_add(2)?;
self.sites.get(last)?;
Some(std::array::from_fn(|i| start + i / 3 * self.width + i % 3))
}
}
/// Contiguous x runs with the same base cell. Evaluate the old f32 mapping once
/// per column, rather than use an inverse mapping that could round differently.
fn runs(area: Rect, origin: f32, cell: f32) -> Vec<(i32, i32, i32)> {
let mut runs: Vec<(i32, i32, i32)> = Vec::new();
for x in area.x0..area.x1 {
let cx = key(x, origin, cell);
if let Some(last) = runs.last_mut()
&& last.0 == cx
{
last.2 = x + 1;
} else {
runs.push((cx, x, x + 1));
}
}
runs
}
fn nearest(grid: &Grid, neighbours: &[usize; 9], x: i32, y: i32) -> Option<usize> {
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
let mut best = (f32::MAX, *neighbours.get(4)?);
for &i in neighbours {
let p = grid.sites.get(i)?.point;
let d = (p.0 - fx).powi(2) + (p.1 - fy).powi(2);
if d < best.0 {
best = (d, i);
}
}
Some(best.1)
}
/// Bounds on the *same f32 score* used by the scalar comparison. Along a
/// scanline, squared x distance has its maximum at an endpoint and its lower
/// bound at the clamped site centre. IEEE subtraction, square, addition and
/// sqrt are monotone on these nonnegative inputs, so these bounds also cover
/// rounding. No estimated bisectors or epsilon decide ownership.
fn score_range<const DOTS: bool>(site: &Site, x0: i32, x1: i32, y: i32) -> (f32, f32) {
let left = x0 as f32 + 0.5;
let right = (x1 - 1) as f32 + 0.5;
let dy2 = (site.point.1 - (y as f32 + 0.5)).powi(2);
let near = (site.point.0 - site.point.0.clamp(left, right)).powi(2) + dy2;
let far = (site.point.0 - left).powi(2).max((site.point.0 - right).powi(2)) + dy2;
if DOTS { (site.radius - far.sqrt(), site.radius - near.sqrt()) } else { (-far, -near) }
}
fn score<const DOTS: bool>(site: &Site, x: i32, y: i32) -> f32 {
let d = (site.point.0 - (x as f32 + 0.5)).powi(2) + (site.point.1 - (y as f32 + 0.5)).powi(2);
if DOTS { site.radius - d.sqrt() } else { -d }
}
/// Certify a whole run when one candidate's worst score beats every other's
/// best. Ambiguous runs split down to scalar pixels. Depth is bounded by the
/// output tile width (at most 11 splits for a 2048-pixel application tile).
fn certified_span<const DOTS: bool>(grid: &Grid, ids: &[usize; 9], x0: i32, y: i32, owners: &mut [u32], depths: &mut [f32]) -> Option<()> {
let len = owners.len();
if len == 0 {
return Some(());
}
let x1 = x0.checked_add(i32::try_from(len).ok()?)?;
let middle = x0 + (len / 2) as i32;
let mut best = (if DOTS { -0.5 } else { -f32::MAX }, None);
for &i in ids {
let value = score::<DOTS>(grid.sites.get(i)?, middle, y);
if value > best.0 {
best = (value, Some(i));
}
}
if let Some(i) = best.1 {
let site = grid.sites.get(i)?;
let lower = score_range::<DOTS>(site, x0, x1, y).0;
let certified = (!DOTS || lower > -0.5)
&& ids.iter().filter(|&&j| j != i).all(|&j| grid.sites.get(j).is_some_and(|other| lower > score_range::<DOTS>(other, x0, x1, y).1));
if len == 1 || certified {
owners.fill(i as u32);
if DOTS {
if lower >= 0.5 {
depths.fill(0.5);
} else {
for (offset, depth) in depths.iter_mut().enumerate() {
*depth = score::<true>(site, x0 + offset as i32, y);
}
}
}
return Some(());
}
} else if len == 1 || ids.iter().all(|&i| grid.sites.get(i).is_some_and(|site| score_range::<DOTS>(site, x0, x1, y).1 <= -0.5)) {
owners.fill(u32::MAX);
return Some(());
}
let half = len / 2;
let (left, right) = owners.split_at_mut(half);
let (ld, rd) = depths.split_at_mut(half);
certified_span::<DOTS>(grid, ids, x0, y, left, ld)?;
certified_span::<DOTS>(grid, ids, x0 + half as i32, y, right, rd)
}
pub(super) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Option<Vec<f32>> {
let cell = cell_size.max(2.0);
let n = src.ch;
let b = ctx.bounds;
// Match the scalar averaging window, including floor/ceil at negative origins.
// Decline extreme geometry before its i32 arithmetic; the scalar fallback owns it.
let c0 = (
(((i64::from(out.x0) - i64::from(b.x0)) as f32 / cell).floor() as i32).checked_sub(2)?,
(((i64::from(out.y0) - i64::from(b.y0)) as f32 / cell).floor() as i32).checked_sub(2)?,
);
let c1 = (
(((i64::from(out.x1) - i64::from(b.x0)) as f32 / cell).floor() as i32).checked_add(2)?,
(((i64::from(out.y1) - i64::from(b.y0)) as f32 / cell).floor() as i32).checked_add(2)?,
);
let win = Rect::new(
b.x0.checked_add((c0.0 as f32 * cell).floor() as i32)?,
b.y0.checked_add((c0.1 as f32 * cell).floor() as i32)?,
b.x0.checked_add((c1.0.checked_add(1)? as f32 * cell).ceil() as i32)?,
b.y0.checked_add((c1.1.checked_add(1)? as f32 * cell).ceil() as i32)?,
)
.intersect(&src.rect)
.intersect(&b);
let mut grid = Grid::new(src, out.union(&win), ctx, cell, seed, false)?;
let origin = (b.x0 as f32, b.y0 as f32);
if grid.sites.iter().any(|s| !s.sampled) {
let mut acc = Vec::new();
let count = grid.sites.len().checked_mul(n.checked_add(1)?)?;
acc.try_reserve_exact(count).ok()?;
acc.resize(count, 0.0f32);
let columns = runs(win, origin.0, cell);
for y in win.y0..win.y1 {
let cy = key(y, origin.1, cell);
for &(cx, x0, x1) in &columns {
let neighbours = grid.neighbours(cx, cy)?;
// An interior cell's average is never used. Skip the entire run if
// all nine possible winners have an on-image colour sample.
if neighbours.iter().all(|&i| grid.sites.get(i).is_some_and(|s| s.sampled)) {
continue;
}
for x in x0..x1 {
let i = nearest(&grid, &neighbours, x, y)?;
if grid.sites.get(i)?.sampled {
continue;
}
let gx = grid.x0.checked_add((i % grid.width) as i32)?;
let gy = grid.y0.checked_add((i / grid.width) as i32)?;
if gx < c0.0 || gy < c0.1 || gx > c1.0 || gy > c1.1 {
continue;
}
let sum = acc.get_mut(i.checked_mul(n + 1)?..i.checked_add(1)?.checked_mul(n + 1)?)?;
let alpha = if ctx.alpha { src.get(x, y, n - 1) } else { 1.0 };
for (c, value) in sum.iter_mut().enumerate().take(n) {
let s = src.get(x, y, c);
*value += if ctx.alpha && c + 1 < n { s * alpha } else { s };
}
*sum.get_mut(n)? += 1.0;
}
}
}
for (site, sum) in grid.sites.iter_mut().zip(acc.chunks_exact(n + 1)) {
let count = *sum.get(n)?;
if !site.sampled && count > 0.0 {
for (dst, value) in site.color.iter_mut().zip(sum).take(n) {
*dst = *value / count;
}
crate::fxutil::unpremul_px(site.color.get_mut(..n)?, ctx.alpha);
site.sampled = true;
}
}
}
crystallize_grid(src, out, ctx, cell, &grid)
}
fn crystallize_grid(src: &Image, out: Rect, ctx: &Ctx, cell: f32, grid: &Grid) -> Option<Vec<f32>> {
let n = src.ch;
let b = ctx.bounds;
let origin = (b.x0 as f32, b.y0 as f32);
let mut result = if src.rect == out { src.data.clone() } else { src.crop(out) };
let width = out.width() as usize;
let stride = width.checked_mul(n)?;
let columns = runs(out, origin.0, cell);
let mut distances = vec![f32::MAX; width];
let mut owners = vec![0u32; width];
for (row, pixels) in result.chunks_exact_mut(stride).enumerate() {
let y = out.y0.checked_add(i32::try_from(row).ok()?)?;
let fy = y as f32 + 0.5;
let cy = key(y, origin.1, cell);
distances.fill(f32::MAX);
for &(cx, x0, x1) in &columns {
let neighbours = grid.neighbours(cx, cy)?;
let start = usize::try_from(x0.checked_sub(out.x0)?).ok()?;
let end = usize::try_from(x1.checked_sub(out.x0)?).ok()?;
if cell >= 32.0 && out.width() <= 2048 {
certified_span::<false>(grid, &neighbours, x0, y, owners.get_mut(start..end)?, distances.get_mut(start..end)?)?;
continue;
}
owners.get_mut(start..end)?.fill(*neighbours.get(4)? as u32);
for &i in &neighbours {
let site = grid.sites.get(i)?;
let dy2 = (site.point.1 - fy).powi(2);
for (offset, (distance, owner)) in distances.get_mut(start..end)?.iter_mut().zip(owners.get_mut(start..end)?).enumerate() {
let fx = (x0 + offset as i32) as f32 + 0.5;
let d = (site.point.0 - fx).powi(2) + dy2;
if d < *distance {
*distance = d;
*owner = i as u32;
}
}
}
}
for (x, (pixel, &owner)) in pixels.chunks_exact_mut(n).zip(&owners).enumerate() {
if !b.contains(out.x0 + x as i32, y) {
continue;
}
let site = grid.sites.get(owner as usize)?;
if site.sampled {
pixel.copy_from_slice(site.color.get(..n)?);
}
}
}
Some(result)
}
/// A site may only compete in its own base column and its two neighbours.
/// Derive the inclusive pixel runs from the exact forward f32 cell mapping.
fn influence_columns(out: Rect, origin: f32, cell: f32, grid: &Grid) -> Option<Vec<(i32, i32)>> {
let columns = runs(out, origin, cell);
(0..grid.width)
.map(|column| {
let gx = grid.x0.checked_add(i32::try_from(column).ok()?)?;
let lo = columns.partition_point(|&(cx, _, _)| cx < gx - 1);
let hi = columns.partition_point(|&(cx, _, _)| cx <= gx + 1);
if lo == hi {
return Some((out.x0, out.x0));
}
Some((columns.get(lo)?.1, columns.get(hi - 1)?.2))
})
.collect()
}
pub(super) fn pointillize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32, background: [f32; 4]) -> Option<Vec<f32>> {
let cell = cell_size.max(2.0);
// This path uses integer pixel centres and conservative one-pixel support
// margins. Beyond exact half-integer f32 coordinates use the scalar kernel.
if cell > 4096.0 || [out.x0, out.y0, out.x1, out.y1].iter().any(|v| v.unsigned_abs() > 4_000_000) {
return None;
}
let grid = Grid::new(src, out, ctx, cell, seed, true)?;
pointillize_grid(src, out, ctx, cell, background, &grid)
}
fn pointillize_grid(src: &Image, out: Rect, ctx: &Ctx, cell: f32, background: [f32; 4], grid: &Grid) -> Option<Vec<f32>> {
let origin = (ctx.bounds.x0 as f32, ctx.bounds.y0 as f32);
let bg = native(ctx, background);
let n = src.ch;
let mut result = if src.rect == out { src.data.clone() } else { src.crop(out) };
let columns = influence_columns(out, origin.0, cell, grid)?;
let base_runs = runs(out, origin.0, cell);
let width = out.width() as usize;
let stride = width.checked_mul(n)?;
let mut depths = vec![-0.5f32; width];
let mut owners = vec![u32::MAX; width];
for (row, pixels) in result.chunks_exact_mut(stride).enumerate() {
let y = out.y0.checked_add(i32::try_from(row).ok()?)?;
let fy = y as f32 + 0.5;
let cy = key(y, origin.1, cell);
depths.fill(-0.5);
owners.fill(u32::MAX);
if cell >= 32.0 && out.width() <= 2048 {
for &(cx, x0, x1) in &base_runs {
let ids = grid.neighbours(cx, cy)?;
let start = usize::try_from(x0.checked_sub(out.x0)?).ok()?;
let end = usize::try_from(x1.checked_sub(out.x0)?).ok()?;
certified_span::<true>(grid, &ids, x0, y, owners.get_mut(start..end)?, depths.get_mut(start..end)?)?;
}
} else {
for gy in cy.checked_sub(1)?..=cy.checked_add(1)? {
let row = usize::try_from(gy.checked_sub(grid.y0)?).ok()?.checked_mul(grid.width)?;
// Global gy/gx order is the original dy/dx tie order at every pixel.
for (column, &(x0, x1)) in columns.iter().enumerate() {
if x0 >= x1 {
continue;
}
let i = row.checked_add(column)?;
let site = grid.sites.get(i)?;
let dy2 = (site.point.1 - fy).powi(2);
let reach = f64::from(site.radius) + 1.5;
let square = reach * reach - f64::from(dy2);
if square < 0.0 {
continue;
}
// The extra pixel covers subtraction/square/sqrt rounding for
// the bounded coordinates/radii above. The exact old depth
// comparison still decides every candidate that survives.
let reach_x = square.max(0.0).sqrt() + 1.0;
let lo = ((f64::from(site.point.0) - 0.5 - reach_x).floor() as i32).max(x0);
let hi = ((f64::from(site.point.0) - 0.5 + reach_x).ceil() as i32).min(x1);
if lo >= hi {
continue;
}
let start = usize::try_from(lo.checked_sub(out.x0)?).ok()?;
let end = usize::try_from(hi.checked_sub(out.x0)?).ok()?;
for (offset, (depth, owner)) in depths.get_mut(start..end)?.iter_mut().zip(owners.get_mut(start..end)?).enumerate() {
let fx = (lo + offset as i32) as f32 + 0.5;
let d = ((site.point.0 - fx).powi(2) + dy2).sqrt();
let candidate = site.radius - d;
if candidate > *depth {
*depth = candidate;
*owner = i as u32;
}
}
}
}
}
for ((pixel, &depth), &owner) in pixels.chunks_exact_mut(n).zip(&depths).zip(&owners) {
let a_orig = if ctx.alpha { *pixel.get(n - 1)? } else { 1.0 };
let mut canvas = bg;
if ctx.alpha {
*canvas.get_mut(n - 1)? = a_orig.max(bg.get(n - 1)?.min(1.0));
}
if owner != u32::MAX {
let i = owner as usize;
let k = (depth + 0.5).clamp(0.0, 1.0);
let dot = grid.sites.get(i)?;
for (c, value) in canvas.iter_mut().enumerate().take(n) {
*value += (*dot.color.get(c)? - *value) * k;
}
}
pixel.copy_from_slice(canvas.get(..n)?);
}
}
Some(result)
}
/// The filters sample cell centres, not a dense neighbourhood. On ordinary
/// tiles read those sparse samples directly; keep only the original output
/// pixels for alpha and selection mixing. Crystallize boundary tiles still
/// use the dense path, whose row-order f32 averages are part of its result.
#[allow(clippy::too_many_arguments)]
pub(crate) fn tile(
surface: &photocraft_raster::Surface,
out: Rect,
ctx: &Ctx,
cell: f32,
seed: u32,
background: Option<[f32; 4]>,
extent: Option<Rect>,
) -> Option<(Image, Vec<f32>)> {
if !cell.is_finite()
|| !(2.0..=300.0).contains(&cell)
|| out.is_empty()
|| out.width() > 2048
|| out.height() > 2048
|| [out.x0, out.y0, out.x1, out.y1].iter().any(|v| v.unsigned_abs() > 4_000_000)
|| extent.is_some_and(|e| e.is_empty() || out.intersect(&e) != out)
{
return None;
}
let dots = background.is_some();
let grid = Grid::new_with(out, ctx, surface.channels(), cell, seed, dots, |sx, sy, color| {
let (x, y) = match extent {
Some(e) => (sx.clamp(e.x0, e.x1 - 1), sy.clamp(e.y0, e.y1 - 1)),
None => (sx, sy),
};
surface.read_pixel(x, y, color);
ctx.bounds.contains(sx, sy)
})?;
if !dots && grid.sites.iter().any(|s| !s.sampled) {
return None;
}
let original = Image::read(surface, out);
let data = match background {
Some(bg) => pointillize_grid(&original, out, ctx, cell, bg, &grid)?,
None => crystallize_grid(&original, out, ctx, cell, &grid)?,
};
Some((original, data))
}
/// Same rounding and native byte order as Surface::write_region, before the
/// worker hands its result back. Bounds the allocation and retains float data
/// if an optional encode buffer cannot be reserved.
pub(crate) fn encode(format: photocraft_color::PixelFormat, samples: &[f32]) -> Option<Vec<u8>> {
use photocraft_color::SampleType;
let mut bytes = Vec::new();
let len = samples.len().checked_mul(format.sample.bytes())?;
bytes.try_reserve_exact(len).ok()?;
bytes.resize(len, 0);
match format.sample {
SampleType::U8 => {
for (dst, value) in bytes.iter_mut().zip(samples) {
*dst = (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
}
SampleType::U16 => {
for (dst, value) in bytes.as_chunks_mut::<2>().0.iter_mut().zip(samples) {
dst.copy_from_slice(&((value.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).to_ne_bytes());
}
}
SampleType::F32 => {
for (dst, value) in bytes.as_chunks_mut::<4>().0.iter_mut().zip(samples) {
dst.copy_from_slice(&value.to_ne_bytes());
}
}
}
Some(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::Surface;
#[test]
fn span_certificates_keep_discrete_winners_and_antialias_coverage() {
for origin in [-16_777_210, -3_999_000, -37, 0, 3_999_000, 16_777_210] {
let out = Rect::new(origin, -3, origin + 601, 4);
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true };
let src = Image::new(out, 4);
for cell in [32.0, 33.25, 100.0, 300.0, 4096.0] {
for seed in 0..32 {
let grid = Grid::new(&src, out, &ctx, cell, seed, true).unwrap();
for y in out.y0..out.y1 {
let cy = key(y, ctx.bounds.y0 as f32, cell);
for (cx, x0, x1) in runs(out, ctx.bounds.x0 as f32, cell) {
let ids = grid.neighbours(cx, cy).unwrap();
for dots in [false, true] {
let mut owners = vec![u32::MAX; (x1 - x0) as usize];
let mut depths = vec![-0.5; owners.len()];
if dots {
certified_span::<true>(&grid, &ids, x0, y, &mut owners, &mut depths).unwrap();
} else {
certified_span::<false>(&grid, &ids, x0, y, &mut owners, &mut depths).unwrap();
}
for (offset, (&owner, &depth)) in owners.iter().zip(&depths).enumerate() {
let x = x0 + offset as i32;
let mut best = (if dots { -0.5 } else { -f32::MAX }, u32::MAX);
for &i in &ids {
let value = if dots { score::<true>(&grid.sites[i], x, y) } else { score::<false>(&grid.sites[i], x, y) };
if value > best.0 {
best = (value, i as u32);
}
}
assert_eq!(owner, best.1, "x={x} y={y} cell={cell} seed={seed}");
if dots && owner != u32::MAX {
assert_eq!((depth + 0.5).clamp(0.0, 1.0).to_bits(), (best.0 + 0.5).clamp(0.0, 1.0).to_bits());
}
}
}
}
}
}
}
}
}
#[test]
fn worker_encoding_is_surface_write_region_at_every_depth() {
let values = [f32::NAN, f32::NEG_INFINITY, -100.0, -0.0, f32::from_bits(1), 0.5 / 65535.0, 0.5 / 255.0, 0.5, 1.0, 1.1, f32::INFINITY];
let area = Rect::new(-17, -9, 281, 2);
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
for alpha in [false, true] {
let format = PixelFormat::new(mode, sample, alpha);
let data: Vec<_> = (0..area.width() as usize * area.height() as usize * format.channels()).map(|i| values[i % values.len()]).collect();
let mut surface = Surface::new(format);
surface.write_region(area, &data);
assert_eq!(encode(format, &data).unwrap(), surface.to_interleaved(area));
}
}
}
}
#[test]
fn optional_grid_rejects_unbounded_or_incomplete_scratch() {
let area = Rect::new(0, 0, 100_000, 100_000);
let ctx = Ctx { bounds: area, mode: ColorMode::Rgb, alpha: true };
let image = Image { rect: area, ch: 4, data: Vec::new() };
assert!(Grid::new(&image, area, &ctx, 2.0, 0, true).is_none());
assert!(Grid::new_with(area, &ctx, 4, 2.0, 0, true, |_, _, _| false).is_none());
assert!(Grid::new_with(area, &ctx, 0, 2.0, 0, true, |_, _, _| false).is_none());
assert!(Grid::new_with(area, &ctx, MAXC + 1, 2.0, 0, true, |_, _, _| false).is_none());
assert!(Grid::new_with(area, &ctx, 4, f32::INFINITY, 0, true, |_, _, _| false).is_none());
}
}
+76
View File
@@ -191,3 +191,79 @@ fn facet_scratch_rejects_large_or_incomplete_storage() {
let src = Image { rect: out.inflate(3), ch: 4, data: Vec::new() };
assert!(facet::run(&src, out, &ctx).is_none());
}
#[test]
fn cell_filters_match_scalar_models_edges_and_fractional_cells() {
let bounds = Rect::new(-19, -13, 32, 29);
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Multichannel] {
for alpha in [false, true] {
let ctx = Ctx { bounds, mode, alpha };
for pattern in 0..5 {
for halo in [0, 7] {
let src = fixture(bounds.inflate(halo), &ctx, pattern);
for out in [bounds, Rect::new(-23, -15, 37, 31), Rect::new(-7, 2, 18, 13)] {
for cell in [2.0, 3.75, 10.0, 100.0, 300.0] {
for seed in [0, 37, u32::MAX] {
let got = cells::crystallize(&src, out, &ctx, cell, seed).unwrap();
assert_bits(&got, &crystallize_direct(&src, out, &ctx, cell, seed));
for bg in [[1.0; 4], [0.1, 0.8, 0.4, 0.25]] {
let got = cells::pointillize(&src, out, &ctx, cell, seed, bg).unwrap();
assert_bits(&got, &pointillize_direct(&src, out, &ctx, cell, seed, bg));
}
}
}
}
}
}
}
}
}
#[test]
fn cell_filters_tiled_storage_matches_scalar_with_soft_selection() {
use crate::{FilterParams, apply_tiled};
let bounds = Rect::new(-17, -9, 86, 78);
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for alpha in [false, true] {
let ctx = Ctx { bounds, mode, alpha };
let src = fixture(bounds, &ctx, 2);
let mut surface = Surface::new(PixelFormat::new(mode, depth, alpha));
surface.write_region(bounds, &src.data);
let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false));
let coverage: Vec<_> = (0..bounds.width() * bounds.height()).map(|i| (i % 7) as f32 / 6.0).collect();
selection.write_region(bounds, &coverage);
for params in [
FilterParams::Crystallize { cell_size: 3.75, seed: 37 },
FilterParams::Crystallize { cell_size: 10.0, seed: 37 },
FilterParams::Pointillize { cell_size: 3.75, seed: 37, background: [1.0; 4] },
FilterParams::Pointillize { cell_size: 10.0, seed: 37, background: [0.1, 0.8, 0.4, 0.25] },
] {
for extent in [None, Some(bounds)] {
assert!(matches!(params.halo(), crate::Halo::Radius(_)));
let halo = match params.halo() {
crate::Halo::Radius(r) => r,
_ => 0,
};
let image = match extent {
Some(e) => Image::read_clamped(&surface, bounds.inflate(halo), e),
None => Image::read(&surface, bounds.inflate(halo)),
};
let mut expected_data = match params {
FilterParams::Crystallize { cell_size, seed } => crystallize_direct(&image, bounds, &ctx, cell_size, seed),
FilterParams::Pointillize { cell_size, seed, background } => pointillize_direct(&image, bounds, &ctx, cell_size, seed, background),
_ => Vec::new(),
};
crate::mix_selection(&mut expected_data, bounds, &selection, &image, ctx.alpha);
let mut expected = surface.clone();
expected.write_region(bounds, &expected_data);
for tile in [1, 7, 17, 256] {
let got = apply_tiled(&surface, &params, bounds, bounds, Some(&selection), tile, extent);
assert_bits(&got.read_region(bounds), &expected.read_region(bounds));
}
}
}
}
}
}
}
+61 -12
View File
@@ -159,8 +159,37 @@ fn cover(o: i32, scale: f64) -> (i32, i32) {
(a, ((o + 1) as f64 / scale + 1e-9).floor().max(a as f64 + 1.0) as i32)
}
/// Unclamped weights of output sample `o` over source samples `lo..lo + len`.
fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32, Vec<f64>) {
/// The source samples worth a weight each: the edge, or the content with one sample of slack on
/// each side to carry the weight of everything beyond it (read as the default pixel).
fn keep_range(edge: Option<(i32, i32)>, content: (i32, i32)) -> (i64, i64) {
let (a, b) = edge.unwrap_or((content.0.saturating_sub(1), content.1.saturating_add(1)));
(i64::from(a), i64::from(b.max(a)))
}
/// Adds `v` spread evenly over the source samples `[a, b)` to `w`, which holds the samples
/// from `lo`: samples beyond either end of it add to that end slot instead of needing a slot.
fn spread(w: &mut [f64], lo: i64, a: i64, b: i64, v: f64) {
let Some(last) = w.len().checked_sub(1) else { return };
let each = v / (b - a).max(1) as f64;
let end = lo + w.len() as i64;
let (ia, ib) = (a.max(lo), b.min(end));
if ia < ib {
for slot in w.get_mut((ia - lo) as usize..(ib - lo) as usize).into_iter().flatten() {
*slot += each;
}
}
if a < lo {
w[0] += each * (b.min(lo) - a) as f64;
}
if b > end {
w[last] += each * (b - a.max(end)) as f64;
}
}
/// Weights of output sample `o` over source samples `lo..lo + len`, unclamped except that
/// reductions fold what lies beyond the edge (or the `content`) onto its ends, so a tiny scale
/// doesn't cost a weight per source sample it covers.
fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>, content: (i32, i32)) -> (i32, Vec<f64>) {
match kernel {
Kernel::Nearest => {
let u = (o as f64 + 0.5) / scale - 0.5;
@@ -168,7 +197,11 @@ fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32
}
Kernel::Bilinear if scale < 0.5 => {
let (a, b) = cover(o, scale);
(a, vec![1.0 / (b - a) as f64; (b - a) as usize])
let (ka, kb) = keep_range(edge, content);
let (lo, hi) = (i64::from(a).clamp(ka, kb), (i64::from(b) - 1).clamp(ka, kb));
let mut w = vec![0.0; (hi - lo + 1) as usize];
spread(&mut w, lo, i64::from(a), i64::from(b), 1.0);
(lo as i32, w)
}
Kernel::Bilinear => {
let u = position(o, scale, edge);
@@ -179,18 +212,18 @@ fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32
// Averaged down to four times the target first (pixels of `inner`), then filtered.
let inner = 4.0 * scale;
let inner_edge = edge.map(|(a, b)| ((a as f64 * inner - 1e-9).ceil() as i32, ((b + 1) as f64 * inner - 1e-9).ceil() as i32 - 1));
let (k0, kw) = weights(o, 0.25, Kernel::Keys { a, stretch }, inner_edge);
let (k0, kw) = weights(o, 0.25, Kernel::Keys { a, stretch }, inner_edge, (i32::MIN / 4, i32::MAX / 4));
let clamp = |k: i32| inner_edge.map_or(k, |(a, b)| k.clamp(a, b.max(a)));
let boxes: Vec<(i32, i32)> = (k0..).zip(&kw).map(|(k, _)| cover(clamp(k), inner)).collect();
let lo = boxes.iter().map(|b| b.0).min().unwrap_or(0);
let hi = boxes.iter().map(|b| b.1).max().unwrap_or(lo);
let mut w = vec![0.0; (hi - lo).max(0) as usize];
let (ka, kb) = keep_range(edge, content);
let lo = i64::from(boxes.iter().map(|b| b.0).min().unwrap_or(0));
let hi = i64::from(boxes.iter().map(|b| b.1).max().unwrap_or(lo as i32));
let (klo, khi) = (lo.clamp(ka, kb), (hi - 1).clamp(ka, kb));
let mut w = vec![0.0; (khi - klo + 1) as usize];
for ((a, b), v) in boxes.into_iter().zip(kw) {
for slot in w.get_mut((a - lo) as usize..(b - lo) as usize).into_iter().flatten() {
*slot += v / (b - a) as f64;
}
spread(&mut w, klo, i64::from(a), i64::from(b), v);
}
(lo, w)
(klo as i32, w)
}
Kernel::Keys { a, stretch } => {
let u = position(o, scale, edge);
@@ -225,7 +258,7 @@ fn taps(dst0: i32, dst1: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32
let kernel = if scale == 1.0 { Kernel::Nearest } else { kernel };
(dst0..dst1)
.map(|o| {
let (lo, w) = weights(o, scale, kernel, edge);
let (lo, w) = weights(o, scale, kernel, edge, content);
let hi = lo + w.len() as i32 - 1;
// Fold the weights of taps beyond the edge onto the edge sample.
let start = clamp(lo);
@@ -724,6 +757,22 @@ mod tests {
assert!((p[0] - 0.5).abs() < 0.05, "{p:?}");
}
/// A reduction by a tiny scale covers millions of source samples per output sample; only the
/// ones in the content get a weight, the rest fold onto its ends or into `outside`.
#[test]
fn tiny_scales_do_not_get_a_weight_per_covered_sample() {
for kernel in [Kernel::Bilinear, Kernel::Keys { a: -0.5, stretch: 1.0 }] {
for edge in [None, Some((0, 99))] {
let t = taps(0, 3, 1e-6, kernel, edge, (0, 99));
for t in &t {
assert!(t.weights.len() <= 100, "{}", t.weights.len());
let total = t.weights.iter().sum::<f32>() + t.outside;
assert!((total - 1.0).abs() < 1e-3, "{total}");
}
}
}
}
#[test]
fn transparent_edges_do_not_darken() {
let mut s = Surface::new(PixelFormat::RGBA32F);
+620 -171
View File
File diff suppressed because it is too large Load Diff
+37 -15
View File
@@ -524,23 +524,24 @@ fn components4(mask: &[bool], w: usize, h: usize) -> (Vec<u32>, Vec<usize>) {
/// rule as the magic wand's anti-aliasing).
pub fn antialias_u8(mask: &mut [u8], w: usize, h: usize) {
let src = mask.to_vec();
let at = |x: i32, y: i32, v: u8| if x < 0 || y < 0 || x >= w as i32 || y >= h as i32 { v } else { src[y as usize * w + x as usize] };
// A span whose 3×3 neighbourhood holds one value has no edge pixel, so it is only compared:
// most of a full-resolution mask is far from its boundary. 64 pixels keep the three row
// compares cheap per pixel, and a span that does reach the boundary costs few extra pixels.
const SPAN: usize = 64;
let uniform = |y: usize, x0: usize, x1: usize| {
let (a, b, v) = (x0.saturating_sub(1), (x1 + 1).min(w), src[y * w + x0]);
(y.saturating_sub(1)..(y + 2).min(h)).all(|ny| src[ny * w + a..ny * w + b].iter().all(|n| *n == v))
};
let row = |y: usize, out: &mut [u8]| {
for (x, o) in out.iter_mut().enumerate() {
let v = src[y * w + x];
let mut sum = 0u32;
let mut edge = false;
for dy in -1..=1 {
for dx in -1..=1 {
let n = at(x as i32 + dx, y as i32 + dy, v);
edge |= n != v;
sum += n as u32;
}
for (c, span) in out.chunks_mut(SPAN).enumerate() {
let x0 = c * SPAN;
if uniform(y, x0, x0 + span.len()) {
continue;
}
if edge {
let avg = sum as f32 / (9.0 * 255.0);
let f = if v > 127 { 0.5 + 0.5 * avg } else { 0.5 * avg };
*o = (f * 255.0).round() as u8;
for (i, o) in span.iter_mut().enumerate() {
if let Some(v) = edge_value(&src, w, h, x0 + i, y) {
*o = v;
}
}
}
};
@@ -553,6 +554,27 @@ pub fn antialias_u8(mask: &mut [u8], w: usize, h: usize) {
mask.chunks_mut(w).enumerate().for_each(|(y, r)| row(y, r));
}
/// The anti-aliased value of pixel `(x, y)` of `src` (`w` × `h`), or `None` when every neighbour
/// in its 3×3 neighbourhood has its value (neighbours outside the mask count as the pixel itself).
fn edge_value(src: &[u8], w: usize, h: usize, x: usize, y: usize) -> Option<u8> {
let v = src[y * w + x];
let mut sum = 0u32;
let mut edge = false;
for dy in -1..=1 {
for dx in -1..=1 {
let (nx, ny) = (x as i32 + dx, y as i32 + dy);
let n = if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 { v } else { src[ny as usize * w + nx as usize] };
edge |= n != v;
sum += n as u32;
}
}
edge.then(|| {
let avg = sum as f32 / (9.0 * 255.0);
let f = if v > 127 { 0.5 + 0.5 * avg } else { 0.5 * avg };
(f * 255.0).round() as u8
})
}
/// Shrinks a region's box to its non-zero pixels (`None` if empty).
pub fn trim_region(r: Region) -> Option<Region> {
let w = r.bbox.width() as usize;
+44
View File
@@ -903,6 +903,50 @@ fn select_subject_leaves_a_background_coloured_gap_open() {
assert!(v > 0.9, "IoU {v}");
}
#[test]
fn antialias_blends_edge_pixels_with_their_neighbourhood() {
// A lone selected pixel keeps most of its coverage and its neighbours get half of a ninth;
// neighbours outside the mask count as the pixel itself.
let mut m = vec![0, 0, 0, 0, 255, 0, 0, 0, 0];
antialias_u8(&mut m, 3, 3);
assert_eq!(m, [14, 14, 14, 14, 142, 14, 14, 14, 14]);
}
#[test]
fn antialias_skips_only_spans_without_edge_pixels() {
// Every pixel through the per-pixel rule, no span skipped.
let every_pixel = |mask: &mut [u8], w: usize, h: usize| {
let src = mask.to_vec();
for y in 0..h {
for x in 0..w {
if let Some(v) = edge_value(&src, w, h, x, y) {
mask[y * w + x] = v;
}
}
}
};
let mut rng = Rng::new(11);
// Widths around the 64-pixel span, single rows and columns, discs, noise and stripes.
for (w, h) in [(1usize, 1usize), (1, 9), (9, 1), (63, 4), (64, 3), (65, 5), (127, 2), (129, 70), (300, 200)] {
let (cx, cy, r) = (w as f32 * 0.6, h as f32 * 0.4, w.min(h) as f32 / 3.0);
let masks: Vec<Vec<u8>> = vec![
vec![255; w * h],
vec![0; w * h],
(0..w * h).map(|i| if ((i % w) as f32 - cx).hypot((i / w) as f32 - cy) < r { 255 } else { 0 }).collect(),
(0..w * h).map(|_| if rng.f32() < 0.02 { 255 } else { 0 }).collect(),
(0..w * h).map(|_| if rng.f32() < 0.5 { 255 } else { 0 }).collect(),
(0..w * h).map(|i| if (i % w) / 64 % 2 == 0 { 255 } else { 0 }).collect(),
(0..w * h).map(|i| if i % w == w - 1 || i / w == h / 2 { 255 } else { 0 }).collect(),
];
for (k, m) in masks.into_iter().enumerate() {
let (mut fast, mut slow) = (m.clone(), m);
antialias_u8(&mut fast, w, h);
every_pixel(&mut slow, w, h);
assert!(fast == slow, "{w}×{h} mask {k}");
}
}
}
/// Timing on a 6 MP figure, for before/after numbers (`cargo test --release -- --ignored select_subject_timing --nocapture`).
#[test]
#[ignore = "timing only"]
+4 -1
View File
@@ -761,10 +761,13 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec<f32> {
}
mod distance;
mod lasso;
mod marquee;
pub use distance::edt;
pub use marquee::{combine_surfaces, ellipse_surface};
pub(crate) use lasso::clip_segment;
pub use lasso::{MAX_PAINTED_TILES, polygon_edge_tiles, polygon_surface};
pub use marquee::{combine_sparse, combine_surfaces, ellipse_surface};
/// Grows the selection by `r` pixels.
pub fn expand(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
+291
View File
@@ -0,0 +1,291 @@
//! Lasso shapes built straight into tiles (#8783): only the tiles an edge crosses are rasterised,
//! and every tile wholly inside the polygon shares one solid tile, so a thin lasso across a huge
//! canvas costs the tiles along its edges, not its bounding box. The bytes are those
//! [`super::polygon`] gives over the canvas.
use std::collections::BTreeSet;
use std::sync::Arc;
use photocraft_color::PixelFormat;
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
use photocraft_raster::{Surface, Tile};
use crate::photo_util::par_map;
const TS: usize = TILE_SIZE as usize;
/// Most partly covered tiles one painted shape (a lasso, a brush stroke) may hold: 2²⁸ pixels'
/// worth, 256 MB as GRAY8. A thin shape touches a whole tile wherever it passes, so this, not
/// its area, bounds what painting it allocates.
pub const MAX_PAINTED_TILES: usize = (1 << 28) / (TS * TS);
/// The part of the segment `a`–`b` inside `r` (Liang–Barsky), or `None` when it misses it.
pub(crate) fn clip_segment(a: (f32, f32), b: (f32, f32), r: Rect) -> Option<((f32, f32), (f32, f32))> {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let (mut t0, mut t1) = (0.0f32, 1.0f32);
for (p, q) in [(-dx, a.0 - r.x0 as f32), (dx, r.x1 as f32 - a.0), (-dy, a.1 - r.y0 as f32), (dy, r.y1 as f32 - a.1)] {
if p == 0.0 {
if q < 0.0 {
return None;
}
} else {
let t = q / p;
if p < 0.0 {
t0 = t0.max(t);
} else {
t1 = t1.min(t);
}
}
}
(t0 <= t1 && t0.is_finite() && t1.is_finite()).then_some(((a.0 + dx * t0, a.1 + dy * t0), (a.0 + dx * t1, a.1 + dy * t1)))
}
/// The pixels the polygon through `points` can touch within `clip` (its bounding box, grown by a
/// pixel, as the Lasso has always rasterised it).
fn polygon_area(points: &[(f32, f32)], clip: Rect) -> Rect {
let (mut x0, mut y0, mut x1, mut y1) = (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
for p in points {
(x0, y0, x1, y1) = (x0.min(p.0), y0.min(p.1), x1.max(p.0), y1.max(p.1));
}
if points.len() < 3 || !(x0 <= x1 && y0 <= y1) {
return Rect::EMPTY;
}
let clamp = |v: f32| v.clamp(-1e7, 1e7) as i32;
Rect::new(clamp(x0.floor()) - 1, clamp(y0.floor()) - 1, clamp(x1.ceil()) + 1, clamp(y1.ceil()) + 1).intersect(&clip)
}
/// The tiles of `clip` the edges of the polygon through `points` cross (with a pixel's margin),
/// in order: the only ones whose pixels can differ. `None` when there are more than `limit`.
pub fn polygon_edge_tiles(points: &[(f32, f32)], clip: Rect, limit: usize) -> Option<BTreeSet<TileCoord>> {
let area = polygon_area(points, clip);
let mut set = BTreeSet::new();
if area.is_empty() {
return Some(set);
}
let step = TILE_SIZE as f32 / 4.0;
for (&a, &b) in points.iter().zip(points.iter().cycle().skip(1)) {
let Some((a, b)) = clip_segment(a, b, area.inflate(2)) else { continue };
let pieces = ((b.0 - a.0).hypot(b.1 - a.1) / step).ceil().clamp(1.0, 1e6) as usize;
let at = |k: usize| {
let t = k as f32 / pieces as f32;
(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)
};
for k in 0..pieces {
let (p, q) = (at(k), at(k + 1));
let lo = |u: f32, v: f32| u.min(v).floor() as i32 - 1;
let hi = |u: f32, v: f32| u.max(v).floor() as i32 + 2;
for tc in Rect::new(lo(p.0, q.0), lo(p.1, q.1), hi(p.0, q.0), hi(p.1, q.1)).intersect(&area).tiles() {
set.insert(tc);
if set.len() > limit {
return None;
}
}
}
}
Some(set)
}
/// One polygon edge, as [`super::polygon`] keeps it.
struct Edge {
top: f32,
bottom: f32,
a: (f32, f32),
b: (f32, f32),
}
/// The anti-aliased (even-odd) polygon through `points`, cut to `clip`, as a GRAY8 selection: the
/// bytes of [`super::polygon`] over `clip`, quantised. Edge tiles are rasterised, tiles wholly
/// inside share one solid tile. `None` when the edges cross more than [`MAX_PAINTED_TILES`] tiles.
pub fn polygon_surface(points: &[(f32, f32)], clip: Rect) -> Option<Surface> {
let mut s = Surface::new(PixelFormat::GRAY8);
let area = polygon_area(points, clip);
if area.is_empty() {
return Some(s);
}
let edge_tiles = polygon_edge_tiles(points, clip, MAX_PAINTED_TILES)?;
const SUB: usize = 4;
// Sample rows as `polygon` over `clip` takes them, so the bytes match.
let sample_y = |y: i32, k: usize| clip.y0 as f32 + (y - clip.y0) as f32 + (k as f32 + 0.5) / SUB as f32;
let mut edges: Vec<Edge> = points
.iter()
.zip(points.iter().cycle().skip(1))
.filter_map(|(&a, &b)| {
if a.1 < b.1 {
Some(Edge { top: a.1, bottom: b.1, a, b })
} else if b.1 < a.1 {
Some(Edge { top: b.1, bottom: a.1, a, b })
} else {
None
}
})
.collect();
edges.sort_by(|p, q| p.top.total_cmp(&q.top));
let crossings = |e: &[&Edge], y: f32, xs: &mut Vec<f32>| {
xs.clear();
xs.extend(e.iter().map(|&&Edge { a, b, .. }| a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0)));
xs.sort_by(f32::total_cmp);
};
let full = s.solid_tile(&[1.0]);
let blank = s.solid_tile(&[0.0]);
let bands: Vec<i32> = Rect::new(area.x0, area.y0, area.x0 + 1, area.y1).tiles().map(|tc| tc.ty).collect();
let built = par_map(bands.len(), |bi| -> Vec<(TileCoord, Arc<Tile>)> {
let Some(&ty) = bands.get(bi) else { return Vec::new() };
let rows = Rect::new(area.x0, ty * TILE_SIZE, area.x1, (ty + 1) * TILE_SIZE).intersect(&area);
// In `x` order (`TileCoord` orders by `tx` first).
let cols: Vec<TileCoord> = edge_tiles.iter().filter(|tc| tc.ty == ty).copied().collect();
let mut out = Vec::new();
let (first, last) = (sample_y(rows.y0, 0), sample_y(rows.y1 - 1, SUB - 1));
let reach = edges.partition_point(|e| e.top <= last);
let band_edges = edges.get(..reach).unwrap_or_default();
let mut xs: Vec<f32> = Vec::new();
// Tiles no edge crosses are wholly in or out: one sample row decides them all.
let y_mid = sample_y(rows.y0 + rows.height() as i32 / 2, 0);
let mid: Vec<&Edge> = band_edges.iter().filter(|e| e.top <= y_mid && e.bottom > y_mid).collect();
crossings(&mid, y_mid, &mut xs);
for tc in rows.tiles() {
if cols.binary_search(&tc).is_ok() {
continue;
}
let tr = tc.rect().intersect(&rows);
let cx = (tr.x0 + tr.x1) as f32 / 2.0;
if xs.partition_point(|x| *x <= cx) % 2 == 0 {
continue;
}
if tr == tc.rect() {
out.push((tc, Arc::clone(&full)));
} else {
let mut tile = Tile::clone(&blank);
fill(&mut tile, tc, tr, u8::MAX);
out.push((tc, Arc::new(tile)));
}
}
if cols.is_empty() {
return out;
}
// The edge tiles: `polygon`'s scanline, accumulated only over their columns.
let x_of = |tc: &TileCoord| tc.rect().intersect(&rows);
let spans: Vec<Rect> = cols.iter().map(x_of).collect();
let mut acc = vec![0.0f32; cols.len() * TS];
let mut tiles: Vec<Tile> = cols.iter().map(|_| Tile::clone(&blank)).collect();
let mut pending = band_edges.iter().filter(|e| e.bottom > first).peekable();
let mut active: Vec<&Edge> = Vec::new();
let origin = clip.x0 as f32;
for y in rows.y0..rows.y1 {
acc.fill(0.0);
for k in 0..SUB {
let sy = sample_y(y, k);
while let Some(e) = pending.next_if(|e| e.top <= sy) {
active.push(e);
}
active.retain(|e| e.bottom > sy);
if active.is_empty() {
continue;
}
crossings(&active, sy, &mut xs);
for pair in xs.as_chunks::<2>().0 {
let (x0, x1) = ((pair[0] - origin).max(0.0), (pair[1] - origin).min(clip.width() as f32));
if x1 <= x0 {
continue;
}
let (c0, c1) = (clip.x0 + x0.floor() as i32, clip.x0 + x1.ceil() as i32);
let start = spans.partition_point(|r| r.x1 <= c0);
for (ci, r) in spans.iter().enumerate().skip(start).take_while(|(_, r)| r.x0 < c1) {
for x in c0.max(r.x0)..c1.min(r.x1) {
let cx = (x - clip.x0) as f32;
let cov = (cx + 1.0).min(x1) - cx.max(x0);
if let Some(v) = acc.get_mut(ci * TS + (x - r.x0) as usize) {
*v += cov.max(0.0) / SUB as f32;
}
}
}
}
}
for ((r, tile), row) in spans.iter().zip(&mut tiles).zip(acc.as_chunks::<TS>().0) {
let base = y.rem_euclid(TILE_SIZE) as usize * TS + r.x0.rem_euclid(TILE_SIZE) as usize;
let Some(dst) = tile.bytes_mut().get_mut(base..base + r.width() as usize) else { continue };
for (d, v) in dst.iter_mut().zip(row) {
*d = (v.min(1.0).clamp(0.0, 1.0) * 255.0).round() as u8;
}
}
}
for (tc, tile) in cols.into_iter().zip(tiles) {
let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
if hi > 0 {
out.push((tc, if lo == u8::MAX { Arc::clone(&full) } else { Arc::new(tile) }));
}
}
out
});
s.put_tiles(built.into_iter().flatten());
Some(s)
}
/// Sets `tr` (within tile `tc`) to `v`.
fn fill(tile: &mut Tile, tc: TileCoord, tr: Rect, v: u8) {
let o = tc.rect();
for y in tr.y0..tr.y1 {
let base = (y - o.y0) as usize * TS;
if let Some(row) = tile.bytes_mut().get_mut(base + (tr.x0 - o.x0) as usize..base + (tr.x1 - o.x0) as usize) {
row.fill(v);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The dense rasteriser over `clip`, quantised as the Lasso stores it.
fn dense(points: &[(f32, f32)], clip: Rect) -> Surface {
let m: Vec<u8> = super::super::polygon(points, clip, true).iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8).collect();
let mut s = Surface::from_interleaved(PixelFormat::GRAY8, clip, &m);
s.prune();
s
}
#[test]
fn matches_the_dense_polygon() {
let clip = Rect::new(-40, 10, 1300, 900);
let shapes: [&[(f32, f32)]; 6] = [
&[(10.5, 20.25), (1200.0, 40.0), (600.0, 880.7)],
// Whole tiles inside, edges exactly on tile boundaries.
&[(0.0, 256.0), (768.0, 256.0), (768.0, 768.0), (0.0, 768.0)],
// Self-intersecting (even-odd), past the clip.
&[(-300.0, -50.0), (1500.0, 950.0), (1500.0, -50.0), (-300.0, 950.0)],
&[(100.0, 100.0), (101.0, 100.0), (100.5, 700.0)],
&[(5.0, 15.0), (5.0, 15.0), (5.0, 15.0)],
&[(2000.0, 2000.0), (2100.0, 2000.0), (2000.0, 2100.0)],
];
for pts in shapes {
let s = polygon_surface(pts, clip).expect("within the tile limit");
let d = dense(pts, clip);
let area = clip.union(&s.tile_bounds());
assert!(s.read_region(area) == d.read_region(area), "{pts:?}");
}
}
/// A thin sliver across a 100,000 px canvas: only the tiles along it are built (the dense
/// rasteriser allocated its 10-billion-pixel bounding box; the #8783 review).
#[test]
fn a_thin_sliver_across_a_huge_canvas_costs_its_edge_tiles() {
let clip = Rect::new(0, 0, 100_000, 100_000);
let sliver = [(0.0, 0.0), (99_999.0, 99_999.0), (0.0, 1.5)];
let tiles = polygon_edge_tiles(&sliver, clip, MAX_PAINTED_TILES).expect("within the limit");
assert!(tiles.len() < 2000, "{}", tiles.len());
let s = polygon_surface(&sliver, clip).expect("within the limit");
assert!(s.tile_count() <= tiles.len());
assert!(s.pixel(50_000, 50_000)[0] > 0.0 && s.pixel(50_000, 40_000)[0] == 0.0);
// A zigzag that crosses more tiles than the limit is refused.
let zigzag: Vec<(f32, f32)> = (0..200).map(|i| (if i % 2 == 0 { 0.0 } else { 99_999.0 }, i as f32 * 500.0)).collect();
assert!(polygon_edge_tiles(&zigzag, clip, MAX_PAINTED_TILES).is_none());
assert!(polygon_surface(&zigzag, clip).is_none());
}
#[test]
fn a_huge_polygon_shares_one_tile_inside() {
let clip = Rect::new(0, 0, 51_200, 51_200);
let s = polygon_surface(&[(-10.0, -10.0), (60_000.0, -10.0), (60_000.0, 60_000.0), (-10.0, 60_000.0)], clip).expect("no edge tiles");
let shared = s.tiles().map(|(_, t)| Arc::as_ptr(t)).collect::<BTreeSet<_>>();
assert!(s.tile_count() == 200 * 200 && shared.len() == 1, "{} tiles, {} distinct", s.tile_count(), shared.len());
}
}
+80
View File
@@ -236,6 +236,60 @@ pub fn combine_surfaces(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f3
out
}
/// [`combine_surfaces`] for an `f` that keeps `a` wherever `b` reads 0 (Add, Subtract), visiting
/// only `b`'s tiles within `area`: the rest of `a` (its other tiles and its untouched value) is
/// kept as it is, so painting a thin stroke across a huge canvas costs the tiles it paints, not
/// the canvas (#8783). Falls back to [`combine_surfaces`] over `area` for other inputs.
pub fn combine_sparse(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32) -> f32) -> Surface {
let gray = PixelFormat::GRAY8;
let lut: Vec<u8> = (0..=u16::MAX).map(|i| encode8(f(decode8((i >> 8) as u8), decode8(i as u8)))).collect();
let pair = |x: u8, y: u8| lut.get(usize::from(x) << 8 | usize::from(y)).copied().unwrap_or(0);
if a.format() != gray || b.format() != gray || b.default_bytes() != [0] || !(0..=255u8).all(|v| pair(v, 0) == v) {
return combine_surfaces(a, b, area, f);
}
let da = a.default_bytes().first().copied().unwrap_or(0);
let mut out = a.clone();
let (full, untouched) = (out.solid_tile(&[1.0]), out.solid_tile(&[decode8(da)]));
let coords: Vec<TileCoord> = b.tiles().map(|(c, _)| *c).filter(|c| !c.rect().intersect(&area).is_empty()).collect();
// Each tile: `Some(tile)` to store, `None` when it reads as `a`'s untouched value.
let built = par_map(coords.len(), |k| {
let tc = *coords.get(k)?;
let rect = tc.rect();
let tr = rect.intersect(&area);
let tb = b.tile(tc)?;
let mut tile = Tile::clone(a.tile(tc).unwrap_or(&untouched));
let data = tile.bytes_mut();
let (x0, x1) = ((tr.x0 - rect.x0) as usize, (tr.x1 - rect.x0) as usize);
for ly in (tr.y0 - rect.y0) as usize..(tr.y1 - rect.y0) as usize {
let (s, e) = (ly * TS + x0, ly * TS + x1);
let (Some(dst), Some(src)) = (data.get_mut(s..e), tb.bytes().get(s..e)) else { continue };
for (d, y) in dst.iter_mut().zip(src) {
*d = pair(*d, *y);
}
}
let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
Some((
tc,
if lo == da && hi == da {
None
} else if lo == u8::MAX {
Some(Arc::clone(&full))
} else {
Some(Arc::new(tile))
},
))
});
for (tc, tile) in built.into_iter().flatten() {
match tile {
Some(t) => out.put_tiles([(tc, t)]),
// `take_tiles` only allocates for a tile that is missing; this one is there.
None if out.tile(tc).is_some() => drop(out.take_tiles(tc.rect())),
None => {}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
@@ -311,4 +365,30 @@ mod tests {
}
}
}
/// Add and Subtract through only the new shape's tiles give the bytes of the whole-area
/// combination, keep the old selection's other tiles shared, and keep an untouched value
/// (a layer mask's reveal-all) outside them.
#[test]
fn sparse_combination_matches_the_whole_area_one() {
let area = Rect::new(0, 0, 1100, 700);
let mut rect = Surface::new(PixelFormat::GRAY8);
rect.fill_rect(Rect::from_xywh(200, 100, 600, 520), &[1.0]);
let ell = old_ellipse(Rect::from_xywh(-60, 250, 700, 500), area, true);
let far = ellipse_surface(Rect::from_xywh(900, 20, 150, 150), area, true);
let ops: [fn(f32, f32) -> f32; 2] = [|a, b| a.max(b), |a, b| (a - b).max(0.0)];
for (a, b) in [(&rect, &ell), (&ell, &rect), (&ell, &ell), (&rect, &far)] {
for f in ops {
let sparse = combine_sparse(a, b, area, f);
assert!(sparse.read_region(area) == old_combine(a, b, area, f).read_region(area));
assert!(sparse.tiles().all(|(_, t)| t.bytes().iter().any(|v| *v != 0)), "no empty tiles kept");
}
}
let kept = combine_sparse(&rect, &far, area, |a, b| a.max(b));
let tc = TileCoord::new(1, 1);
assert!(Arc::ptr_eq(kept.tile(tc).expect("kept"), rect.tile(tc).expect("there")), "untouched tiles stay shared");
let reveal = Surface::with_default(PixelFormat::GRAY8, &[1.0]);
let cut = combine_sparse(&reveal, &far, area, |a, b| (a - b).max(0.0));
assert!(cut.pixel(5000, 5000)[0] == 1.0 && cut.pixel(975, 95)[0] == 0.0);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
//! The pre-#7921 Smart Blur (every neighbour read per channel through `Image::get`) stays as an
//! exact oracle: the offset fast path must give the same bits for every mode, depth and layout.
use super::*;
use super::{BlurQuality, Ctx, Image, MAXC, Rect, SmartBlurMode, native, ncol, smart_blur, unpremul_px, xy};
use photocraft_color::ColorMode;
#[allow(clippy::too_many_arguments)]
+12 -1
View File
@@ -29,14 +29,25 @@ const MODELS: [ColorMode; 8] =
#[test]
fn swept_blocks_match_the_pixel_search() {
let bounds = Rect::new(-13, -9, 31, 26);
// Unless the heights are level-based, the colour mode reaches extrude only through the channel
// count and whether the samples are CMYK ink, so modes that agree on both repeat those cases.
// A run takes a quarter of the rest, a different quarter each day.
let mut seen = std::collections::HashSet::new();
let sweep = photocraft_testkit::Sweep::new(4);
let mut case = 0;
for mode in MODELS {
for alpha in [false, true] {
let n = PixelFormat::new(mode, SampleType::F32, alpha).channels();
let ctx = Ctx { bounds, mode, alpha };
let repeat = !seen.insert((n, alpha, mode == ColorMode::Cmyk));
for kind in 0..5 {
let src = image(bounds.inflate(20), n, kind);
for (size, depth) in [(2.0, 0.0), (2.0, 255.0), (3.5, 190.0), (8.0, 64.0), (22.0, 255.0), (255.0, 100.0)] {
for flags in 0..8 {
for flags in (0..8).filter(|f| !repeat || f & 1 != 0) {
case += 1;
if !sweep.includes(case) {
continue;
}
let spec = ExtrudeSpec {
kind: ExtrudeType::Blocks,
size,
+9
View File
@@ -640,3 +640,12 @@ fn mix_selection_reads_the_selection_once_per_tile() {
assert!(a.iter().zip(&b).all(|(x, y)| x.to_bits() == y.to_bits()), "alpha {alpha}: results differ");
}
}
#[test]
fn blur_halos_follow_the_clamped_radius_and_never_overflow() {
let smart = |radius| FilterParams::SmartBlur { radius, threshold: 25.0, quality: BlurQuality::default(), mode: SmartBlurMode::default() };
assert_eq!(smart(1e9).halo(), Halo::Radius(101));
assert_eq!(smart(f32::INFINITY).halo(), Halo::Radius(101));
let gaussian = FilterParams::GaussianBlur { radius: f32::INFINITY };
assert_eq!(gaussian.halo(), Halo::Radius(i32::MAX));
}
+16
View File
@@ -52,11 +52,19 @@ fn tilt_shift_matches_frozen_kernel_at_band_boundaries_and_edges() {
}
cases.push(params(80.0, (0.5, 0.5), 0.0, 5.0, 0.1));
cases.push(params(80.0, (-5.0, -5.0), 27.0, 0.1, 0.15));
// The channel count and alpha are independent of the band geometry: a run takes a quarter of
// their combinations, a different quarter each day.
let sweep = photocraft_testkit::Sweep::new(4);
let mut case = 0;
for ch in 1..=6 {
let src = image(bounds.inflate(12), ch);
for alpha in [false, true] {
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha };
for p in &cases {
case += 1;
if !sweep.includes(case) {
continue;
}
check(&src, bounds, &ctx, p);
check(&src, Rect::new(-7, 17, 15, 39), &ctx, p);
}
@@ -146,6 +154,10 @@ fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() {
selection.fill_rect(bounds, &[0.4]);
selection.fill_rect(Rect::new(3, 19, 35, 44), &[1.0]);
selection.fill_rect(Rect::new(10, 20, 20, 30), &[0.0]);
// Pixel formats and tile layouts are independent: a run takes a quarter of their combinations,
// a different quarter each day.
let sweep = photocraft_testkit::Sweep::new(4);
let mut case = 0;
for mode in [
ColorMode::Rgb,
ColorMode::Grayscale,
@@ -167,6 +179,10 @@ fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() {
assert_eq!(p.halo_for(bounds), crate::Halo::Radius(radius));
for extent in [None, Some(bounds)] {
for tile in [16, 47, 128] {
case += 1;
if !sweep.includes(case) {
continue;
}
let got = crate::apply_tiled(&surface, &p, area, bounds, Some(&selection), tile, extent);
let mut want = surface.clone();
for y in (area.y0..area.y1).step_by(tile as usize) {
+147 -18
View File
@@ -324,33 +324,114 @@ pub fn paste(scene: &Scene, i: usize, src: &Surface, src_rect: Rect, at: [f64; 2
Ok(Some(out))
}
/// The fixed frame of one clone stroke: the source and destination planes, the source offset in
/// plane-metric units and the brush radius, all set at the stroke's anchor (its first dab).
struct CloneFrame {
hs: Homography,
ls: (f64, f64),
hd: Homography,
hd_inv: Homography,
ld: (f64, f64),
offset: [f64; 2],
r_m: f64,
}
impl CloneFrame {
fn new(scene: &Scene, source: [f64; 2], anchor: [f64; 2], size: f64) -> Option<CloneFrame> {
let (ps, pd) = (scene.plane_at(source)?, scene.plane_at(anchor)?);
let ((hs, lus, lvs), (hd, lud, lvd)) = (scene.frame(ps)?, scene.frame(pd)?);
let (hs_inv, hd_inv) = (hs.inverse()?, hd.inverse()?);
let ms = metric(&hs_inv, (lus, lvs), source);
let md0 = metric(&hd_inv, (lud, lvd), anchor);
// Brush radius in metric units (from the image size at the anchor).
let probe = metric(&hd_inv, (lud, lvd), [anchor[0] + size / 2.0, anchor[1]]);
let r_m = ((probe[0] - md0[0]).hypot(probe[1] - md0[1])).max(1e-9);
let offset = [ms[0] - md0[0], ms[1] - md0[1]];
(offset.iter().all(|v| v.is_finite()) && r_m.is_finite()).then_some(CloneFrame { hs, ls: (lus, lvs), hd, hd_inv, ld: (lud, lvd), offset, r_m })
}
/// The source point (image px) that destination `p` copies from.
fn source_of(&self, p: [f64; 2]) -> [f64; 2] {
let m = metric(&self.hd_inv, self.ld, p);
let (x, y) = self.hs.apply((m[0] + self.offset[0]) / self.ls.0, (m[1] + self.offset[1]) / self.ls.1);
[x, y]
}
}
/// Image point → plane-metric coordinates through the plane's inverse homography.
fn metric(inv: &Homography, l: (f64, f64), p: [f64; 2]) -> [f64; 2] {
let (u, v) = inv.apply(p[0], p[1]);
[u * l.0, v * l.1]
}
/// Perspective clone stamp: one stroke of dabs at image points `points` on the destination
/// plane, copying from `source` (image px) with the offset fixed in plane-metric units at the
/// first dab. `size` is the brush diameter in px at the first dab; `hardness` 0..=1.
pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points: &[[f64; 2]], size: f64, hardness: f64, opacity: f32) -> usize {
let Some(first) = points.first() else { return 0 };
let (Some(ps), Some(pd)) = (scene.plane_at(source), scene.plane_at(*first)) else { return 0 };
let (Some((hs, lus, lvs)), Some((hd, lud, lvd))) = (scene.frame(ps), scene.frame(pd)) else { return 0 };
let (Some(hs_inv), Some(hd_inv)) = (hs.inverse(), hd.inverse()) else { return 0 };
let metric = |inv: &Homography, lu: f64, lv: f64, p: [f64; 2]| {
let (u, v) = inv.apply(p[0], p[1]);
[u * lu, v * lv]
};
let ms = metric(&hs_inv, lus, lvs, source);
let md0 = metric(&hd_inv, lud, lvd, *first);
let offset = [ms[0] - md0[0], ms[1] - md0[1]];
// Brush radius in metric units (from the image size at the first dab).
let probe = metric(&hd_inv, lud, lvd, [first[0] + size / 2.0, first[1]]);
let r_m = ((probe[0] - md0[0]).hypot(probe[1] - md0[1])).max(1e-9);
clone_dabs(scene, surf, source, *first, points, size, hardness, opacity, None).0
}
/// Where a clone stroke that starts at `anchor` copying from `source` samples for destination
/// `p`: the same plane-metric offset. A live preview paints a stroke in pieces with it, and
/// Aligned strokes keep the first stroke's offset. `None` when `source` or `anchor` is off the
/// planes.
pub fn clone_source(scene: &Scene, source: [f64; 2], anchor: [f64; 2], p: [f64; 2]) -> Option<[f64; 2]> {
let s = CloneFrame::new(scene, source, anchor, 1.0)?.source_of(p);
s.iter().all(|v| v.is_finite()).then_some(s)
}
/// [`clone_stroke`]'s dabs at `points` for a stroke anchored at `anchor` (its first dab, which
/// fixes the offset and the brush's metric size). Samples a snapshot taken at this call.
/// For incremental painting use [`clone_dabs_from`] with one snapshot per stroke.
/// With `clip`, every dab is cut to it before any read or write (a document passes its canvas,
/// so far off-canvas points allocate no tiles). Returns the dabs painted and the area they changed.
#[allow(clippy::too_many_arguments)]
pub fn clone_dabs(
scene: &Scene,
surf: &mut Surface,
source: [f64; 2],
anchor: [f64; 2],
points: &[[f64; 2]],
size: f64,
hardness: f64,
opacity: f32,
clip: Option<Rect>,
) -> (usize, Rect) {
let src_copy = surf.clone();
clone_dabs_from(scene, surf, &src_copy, source, anchor, points, size, hardness, opacity, clip)
}
/// Paint a batch of one stroke using the same immutable source snapshot for every batch.
/// Source and destination must have the same pixel format; otherwise no dabs are painted.
/// `clip` as in [`clone_dabs`].
#[allow(clippy::too_many_arguments)]
pub fn clone_dabs_from(
scene: &Scene,
surf: &mut Surface,
src_copy: &Surface,
source: [f64; 2],
anchor: [f64; 2],
points: &[[f64; 2]],
size: f64,
hardness: f64,
opacity: f32,
clip: Option<Rect>,
) -> (usize, Rect) {
if surf.format() != src_copy.format() {
return (0, Rect::EMPTY);
}
let Some(f) = CloneFrame::new(scene, source, anchor, size) else { return (0, Rect::EMPTY) };
let CloneFrame { hs, ls: (lus, lvs), hd, hd_inv, ld: (lud, lvd), offset, r_m } = f;
let fmt = surf.format();
let n = fmt.channels();
// Scratch for `sample`: the dab's source pixels, the four bilinear corners, the result.
let mut win = Window { area: Rect::new(0, 0, 0, 0), data: Vec::new() };
let (mut corners, mut s) = (vec![0.0f32; 4 * n], vec![0.0f32; n]);
let mut dabs = 0;
let mut changed_area = Rect::EMPTY;
for p in points {
let mc = metric(&hd_inv, lud, lvd, *p);
let mc = metric(&hd_inv, (lud, lvd), *p);
// Image-space footprint: map a metric circle's bounding points.
let footprint = |h: &Homography, lu: f64, lv: f64, c: [f64; 2]| {
let mut bb = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
@@ -359,12 +440,19 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
let (x, y) = h.apply((c[0] + r_m * a.cos()) / lu, (c[1] + r_m * a.sin()) / lv);
bb = [bb[0].min(x), bb[1].min(y), bb[2].max(x), bb[3].max(y)];
}
if bb.iter().any(|v| !v.is_finite()) {
return Rect::EMPTY;
}
Rect::new(bb[0].floor() as i32, bb[1].floor() as i32, (bb[2].ceil() as i32).saturating_add(1), (bb[3].ceil() as i32).saturating_add(1))
};
let area = footprint(&hd, lud, lvd, mc);
if area.is_empty() || area.width() > 4096 || area.height() > 4096 {
continue;
}
let area = clip.map_or(area, |c| area.intersect(&c));
if area.is_empty() {
continue;
}
// The source footprint, read once; `sample` reads any pixel outside it from the surface.
// Skipped when it dwarfs the destination (cloning from near to far in strong perspective):
// decoding it would cost more than the few per-pixel reads it saves.
@@ -385,9 +473,9 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
let mut changed = false;
for (i, px) in data.chunks_exact_mut(n).enumerate() {
let (x, y) = (area.x0 as f64 + (i % w) as f64 + 0.5, area.y0 as f64 + (i / w) as f64 + 0.5);
let m = metric(&hd_inv, lud, lvd, [x, y]);
let m = metric(&hd_inv, (lud, lvd), [x, y]);
let d = (m[0] - mc[0]).hypot(m[1] - mc[1]) / r_m;
if d >= 1.0 {
if d >= 1.0 || d.is_nan() {
continue;
}
let hard = hardness.clamp(0.0, 0.99);
@@ -396,7 +484,7 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
// Source pixel at the same metric offset.
let sm = [m[0] + offset[0], m[1] + offset[1]];
let (sx, sy) = hs.apply(sm[0] / lus, sm[1] / lvs);
sample(&src_copy, &win, sx - 0.5, sy - 0.5, &mut corners, &mut s);
sample(src_copy, &win, sx - 0.5, sy - 0.5, &mut corners, &mut s);
for c in 0..n {
px[c] += (s[c] - px[c]) * k;
}
@@ -405,9 +493,10 @@ pub fn clone_stroke(scene: &Scene, surf: &mut Surface, source: [f64; 2], points:
if changed {
surf.write_region(area, &data);
dabs += 1;
changed_area = if changed_area.is_empty() { area } else { changed_area.union(&area) };
}
}
dabs
(dabs, changed_area)
}
/// Pixels of `area` read from the source surface (`read_region` layout).
@@ -421,6 +510,10 @@ struct Window {
/// read from `s`.
fn sample(s: &Surface, win: &Window, x: f64, y: f64, corners: &mut Vec<f32>, out: &mut [f32]) {
let n = out.len();
if !x.is_finite() || !y.is_finite() {
out.fill(0.0);
return;
}
let (x0, y0) = (x.floor() as i32, y.floor() as i32);
let (tx, ty) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32);
let (x1, y1) = (x0.saturating_add(1), y0.saturating_add(1));
@@ -537,6 +630,42 @@ mod tests {
let n = clone_stroke(&sc, &mut s, [sx, sy], &[[dx, dy]], 10.0, 0.8, 1.0);
assert_eq!(n, 1);
assert!(s.rgba(dx as i32, dy as i32)[0] > 0.8, "{:?}", s.rgba(dx as i32, dy as i32));
// The stroke's anchor copies from the source itself.
let back = clone_source(&sc, [sx, sy], [dx, dy], [dx, dy]).unwrap();
assert!((back[0] - sx).abs() < 1e-6 && (back[1] - sy).abs() < 1e-6, "{back:?}");
}
#[test]
fn incremental_clone_uses_one_source_snapshot_per_stroke() {
let sc = Scene::new(VpPlane { corners: [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]] }, [50.0, 50.0], None, 800.0);
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
let mut original = Surface::new(fmt);
for x in 0..100 {
original.fill_rect(Rect::new(x, 0, x + 1, 100), &[x as f32 / 100.0, (x % 7) as f32 / 6.0, 0.3, 1.0]);
}
let points = [[40.0, 50.0], [44.0, 50.0], [48.0, 50.0], [52.0, 50.0]];
let mut whole = original.clone();
assert_eq!(clone_stroke(&sc, &mut whole, [36.0, 50.0], &points, 12.0, 0.8, 1.0), 4);
let mut pieces = original.clone();
for part in points.chunks(2) {
clone_dabs_from(&sc, &mut pieces, &original, [36.0, 50.0], points[0], part, 12.0, 0.8, 1.0, None);
}
assert_eq!(whole.read_region(Rect::new(0, 0, 100, 100)), pieces.read_region(Rect::new(0, 0, 100, 100)));
}
}
/// A clip cuts every dab before it reads or writes: dabs far outside it allocate no tiles.
#[test]
fn clipped_clone_allocates_no_tiles_outside_the_clip() {
let sc = Scene::new(VpPlane { corners: [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]] }, [50.0, 50.0], None, 800.0);
let clip = Rect::new(0, 0, 100, 100);
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(clip, &[0.5, 0.5, 0.5, 1.0]);
let tiles = s.tiles().count();
let points = [[50.0, 50.0], [-40.0, 50.0], [5000.0, 5000.0], [-90_000.0, 70_000.0]];
let (dabs, area) = clone_dabs(&sc, &mut s, [40.0, 50.0], points[0], &points, 20.0, 0.8, 1.0, Some(clip));
assert_eq!(dabs, 1);
assert!(clip.contains_rect(&area), "{area:?}");
assert_eq!(s.tiles().count(), tiles);
}
#[test]
+28 -1
View File
@@ -164,6 +164,11 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
/// [`warp_triangles`], reporting progress per destination tile to `ctl`. A cancelled `ctl` skips
/// the remaining tiles; the caller discards the result.
/// Destination tiles one triangle render may keep bookkeeping for (a 1M-tile render is 69 Gpx).
const MAX_DST_TILES: u64 = 1 << 20;
/// Source pixels one destination tile may read densely.
const MAX_FOOTPRINT_PIXELS: u64 = 1 << 25;
fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp, ctl: &Interrupt) -> Surface {
let mut fmt = src.format();
let converted;
@@ -190,6 +195,12 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris
y1 = y1.max(v.0[1]);
}
let dst = Rect::new(x0.max(-lim).floor() as i32 - 1, y0.max(-lim).floor() as i32 - 1, x1.min(lim).ceil() as i32 + 1, y1.min(lim).ceil() as i32 + 1);
// Bookkeeping is kept per destination tile, so a mesh flung across the whole coordinate range
// would ask for millions of entries before drawing anything: draw nothing instead.
let ts_px = photocraft_geom::TILE_SIZE as u64;
if u64::from(dst.width()).div_ceil(ts_px).saturating_add(1).saturating_mul(u64::from(dst.height()).div_ceil(ts_px).saturating_add(1)) > MAX_DST_TILES {
return out;
}
// Triangles: (vertex indices), binned per destination tile.
let tiles: Vec<Rect> = dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()).collect();
let mut bins: Vec<Vec<[usize; 3]>> = vec![Vec::new(); tiles.len()];
@@ -304,7 +315,11 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris
return None;
}
let foot = Rect::new(fx0.floor() as i32 - 3, fy0.floor() as i32 - 3, fx1.ceil() as i32 + 3, fy1.ceil() as i32 + 3).intersect(&src_rect);
if foot.is_empty() || !src.has_tiles_in(foot) {
// Sparse components far apart in the source can land in one tile: the dense window
// between them is not read (the tile stays empty). The pixel-area cap is checked first:
// `has_tiles_in` walks the source tile grid, so an oversized footprint must return
// without paying for that scan.
if foot.is_empty() || foot.size().area() > MAX_FOOTPRINT_PIXELS || !src.has_tiles_in(foot) {
return None;
}
let mut px = src.read_region(foot);
@@ -510,6 +525,18 @@ mod tests {
surf
}
#[test]
fn a_mesh_flung_across_the_coordinate_range_gets_no_per_tile_bookkeeping() {
// About 8000 × 8000 destination tiles from three vertices.
let src = sample(SampleType::U8);
let far = 1_000_000.0;
let verts = [([-far, -far], [0.0, 0.0]), ([far, -far], [40.0, 0.0]), ([0.0, far], [0.0, 24.0])];
let t = std::time::Instant::now();
let out = warp_triangles(&src, Rect::new(0, 0, 40, 24), &verts, &[[0, 1, 2]], Interp::Nearest);
assert!(out.content_bounds().is_empty());
assert!(t.elapsed().as_secs() < 5);
}
#[test]
fn a_thin_layer_stretched_into_a_curve_keeps_the_curve() {
// #5793: a 3 px wide column stretched 100x and bent into an arch. The grid was sized in

Some files were not shown because too many files have changed in this diff Show More