mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 14:27:35 -06:00
Merge main into the TIFF extra-samples branch
Resolve the contributors/people.toml append conflict by keeping both sides: main's three new entries and this branch's entry.
This commit is contained in:
+150
-185
@@ -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,82 @@ 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: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
# 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"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# 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
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
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 +192,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.149
|
||||
|
||||
# `--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.149
|
||||
|
||||
# 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 +281,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 +291,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 +300,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 +311,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 +371,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,7 +382,9 @@ jobs:
|
||||
- { crate: affinity, name: import_preview, args: "--features io" }
|
||||
- { crate: format, name: load }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
@@ -433,7 +398,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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -11,7 +11,8 @@ 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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -88,6 +88,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)):
|
||||
|
||||
Generated
+203
-35
@@ -241,12 +241,56 @@ dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
@@ -993,6 +1037,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1001,8 +1046,31 @@ version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_complete"
|
||||
version = "4.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f"
|
||||
dependencies = [
|
||||
"clap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1011,6 +1079,16 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "clap_mangen"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"roff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -1079,6 +1157,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "2.2.0"
|
||||
@@ -3066,6 +3150,12 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.5"
|
||||
@@ -4403,6 +4493,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
@@ -4638,11 +4734,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"async-io",
|
||||
"chrono",
|
||||
"clap",
|
||||
"core-graphics2",
|
||||
"dispatch2",
|
||||
"eframe",
|
||||
@@ -4673,6 +4770,8 @@ dependencies = [
|
||||
"ron",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"ureq",
|
||||
"winit",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
@@ -4684,7 +4783,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-affinity"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4694,11 +4793,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-algo"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"photocraft-raster",
|
||||
"photocraft-testkit",
|
||||
"rayon",
|
||||
"rustfft",
|
||||
"serde",
|
||||
@@ -4707,10 +4807,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-automation"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"cap-std",
|
||||
"clap",
|
||||
"getrandom 0.3.4",
|
||||
"photocraft-codecs",
|
||||
"photocraft-compose",
|
||||
@@ -4730,8 +4831,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"clap_mangen",
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
"photocraft-engine",
|
||||
@@ -4739,13 +4843,14 @@ dependencies = [
|
||||
"photocraft-io",
|
||||
"photocraft-raw",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cms"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"moxcms",
|
||||
"rayon",
|
||||
@@ -4753,7 +4858,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-codecs"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"exr",
|
||||
"flate2",
|
||||
@@ -4774,7 +4879,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-color"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"serde",
|
||||
@@ -4782,9 +4887,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-compose"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-cms",
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
"photocraft-geom",
|
||||
@@ -4797,7 +4903,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-doc"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4808,7 +4914,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"chrono",
|
||||
@@ -4832,11 +4938,13 @@ dependencies = [
|
||||
"photocraft-psd",
|
||||
"photocraft-raster",
|
||||
"photocraft-raw",
|
||||
"photocraft-testkit",
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"rayon",
|
||||
"roxmltree 0.20.0",
|
||||
"roxmltree 0.21.1",
|
||||
"same-file",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
@@ -4847,7 +4955,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-format"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc32fast",
|
||||
@@ -4870,14 +4978,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-geom"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-gpu"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"half",
|
||||
"log",
|
||||
@@ -4895,14 +5003,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-heif"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"craft-docpath",
|
||||
@@ -4921,29 +5029,30 @@ dependencies = [
|
||||
"photocraft-psd",
|
||||
"photocraft-raster",
|
||||
"photocraft-raw",
|
||||
"photocraft-testkit",
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"proptest",
|
||||
"resvg",
|
||||
"roxmltree 0.20.0",
|
||||
"roxmltree 0.21.1",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ipp"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ops"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"photocraft-doc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-paint"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4954,12 +5063,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-plugins"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"photocraft-raster",
|
||||
"rayon",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -4969,7 +5079,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-psd"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"photocraft-psd",
|
||||
@@ -4980,7 +5090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raster"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4991,7 +5101,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raw"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -5006,7 +5116,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-tablet"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
@@ -5018,7 +5128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"serde_json",
|
||||
@@ -5026,7 +5136,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-text"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"parley",
|
||||
@@ -5041,7 +5151,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-print",
|
||||
@@ -5084,7 +5194,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-vector"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
@@ -5095,7 +5205,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5117,7 +5227,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web-boot"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"js-sys",
|
||||
@@ -5951,6 +6061,12 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roff"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
|
||||
|
||||
[[package]]
|
||||
name = "ron"
|
||||
version = "0.12.2"
|
||||
@@ -6053,12 +6169,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
@@ -7454,6 +7572,35 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ureq-proto"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -7493,12 +7640,24 @@ dependencies = [
|
||||
"xmlwriter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.26.1"
|
||||
@@ -7932,6 +8091,15 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -8713,7 +8881,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
|
||||
+10
-4
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.0"
|
||||
version = "0.15.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -44,6 +44,9 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
|
||||
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"] }
|
||||
@@ -72,15 +75,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
//! 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>`)
|
||||
#[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).
|
||||
#[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,
|
||||
}
|
||||
|
||||
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).
|
||||
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) {
|
||||
// Help where a flag's value should be (`--out --help`) is still help: clap reports the
|
||||
// missing value first.
|
||||
Err(e) if e.kind() == ErrorKind::InvalidValue && 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(Cli::command().try_get_matches_from(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");
|
||||
// The variable holds a port, as for the desktop app's --control: only the flag takes
|
||||
// host:port, which `bridge` lets through.
|
||||
if a.bridge_from_env
|
||||
&& let Some(Bridge::Addr(v)) = &a.bridge
|
||||
{
|
||||
return Err(cmd.error(ErrorKind::ValueValidation, format!("invalid value '{v}' for 'PHOTOCRAFT_CONTROL_PORT': {}", ValueError::Bridge)));
|
||||
}
|
||||
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.
|
||||
if a.bridge_from_env {
|
||||
conflict(sub, m, "automation_read_root", "bridge")?;
|
||||
conflict(sub, m, "automation_write_root", "bridge")?;
|
||||
}
|
||||
}
|
||||
check_server(cmd.find_subcommand_mut(name), m, "bridge")?;
|
||||
}
|
||||
(Command::Serve(a), Some((name, m))) => {
|
||||
a.port_from_env = from_env(m, "port");
|
||||
// Only the TCP server reads a token, so a stale exported pair doesn't stop stdio.
|
||||
if a.port.is_some()
|
||||
&& let Some(sub) = cmd.find_subcommand_mut(name)
|
||||
{
|
||||
conflict(sub, m, "control_token", "control_token_file")?;
|
||||
}
|
||||
check_server(cmd.find_subcommand_mut(name), m, "port")?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(cli)
|
||||
}
|
||||
|
||||
/// Refuse `mcp` and `serve` launch options that would otherwise be silently ignored (#8035): a
|
||||
/// token flag without the bridge or port that reads it (flag or variable; a token variable alone
|
||||
/// may be a stale export), and bridge roots, which belong to the desktop process: accepting them
|
||||
/// here promises authority the bridge cannot set.
|
||||
fn check_server(sub: Option<&mut clap::Command>, m: &ArgMatches, transport: &str) -> Result<(), clap::Error> {
|
||||
let Some(sub) = sub else { return Ok(()) };
|
||||
for (id, flag) in [("control_token", "--control-token"), ("control_token_file", "--control-token-file")] {
|
||||
if m.value_source(id) == Some(ValueSource::CommandLine) && !m.contains_id(transport) {
|
||||
return Err(sub.error(ErrorKind::MissingRequiredArgument, format!("{flag} requires --{transport}")));
|
||||
}
|
||||
}
|
||||
if transport == "bridge" && m.contains_id(transport) && (m.contains_id("automation_read_root") || m.contains_id("automation_write_root")) {
|
||||
let message = "configure automation roots on the desktop app; --automation-read-root and --automation-write-root require headless MCP mode";
|
||||
return Err(sub.error(ErrorKind::ArgumentConflict, message));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -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())?)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
¶ms,
|
||||
"--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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
¶ms,
|
||||
"--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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}")?)
|
||||
}
|
||||
+527
-106
@@ -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(env!("CARGO_BIN_EXE_photocraft-cli"));
|
||||
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,15 @@ 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`.
|
||||
for args in [&["run", "x.png", "--out", "--help"][..], &["commands", "--filter", "-h"], &["mcp", "--bridge", "-h"], &["serve", "--port", "--help"]] {
|
||||
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 +96,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 +109,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 +140,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!("'{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!("'{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 +240,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 +249,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 +285,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 +306,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 +359,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 +381,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 +427,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 +464,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 +550,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 +567,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 +588,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 +605,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 +615,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 +649,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 +675,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 +743,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 +785,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 +827,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 +850,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 +860,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 +881,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 +964,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 +980,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 +992,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 +1018,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 +1072,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 +1082,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 +1126,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 +1140,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 +1189,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 +1217,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 +1236,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}");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! The crate's integration tests, linked into one test binary.
|
||||
|
||||
mod cli;
|
||||
mod man_and_completions;
|
||||
mod orientation;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -34,7 +34,7 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "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 }
|
||||
|
||||
@@ -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";
|
||||
@@ -233,20 +233,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()));
|
||||
|
||||
@@ -34,6 +34,8 @@ egui = { 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]
|
||||
rfd = { workspace = true }
|
||||
@@ -41,6 +43,8 @@ arboard = { version = "3.6", 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.
|
||||
ureq = { version = "3.4", default-features = false, features = ["rustls", "platform-verifier"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Safe CoreGraphics bindings, compatible with the existing macOS 11 deployment target.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+29
-362
@@ -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;
|
||||
@@ -147,360 +143,30 @@ 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),
|
||||
}
|
||||
}
|
||||
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,
|
||||
// `--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 +182,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
|
||||
@@ -597,7 +263,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
|
||||
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 +278,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 +367,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.
|
||||
@@ -779,7 +445,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 +603,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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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"]),
|
||||
];
|
||||
@@ -560,6 +561,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 +631,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 +911,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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Native HTTPS transport for release checks. Only public release metadata is requested.
|
||||
|
||||
use photocraft_engine::updates_cmds::{CheckFn, CheckResult, parse_release};
|
||||
use std::time::Duration;
|
||||
|
||||
const LATEST: &str = "https://api.github.com/repos/storytold/photocraft/releases/latest";
|
||||
const MAX_RESPONSE: u64 = 1024 * 1024;
|
||||
|
||||
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 = fetch(LATEST, Duration::from_secs(15));
|
||||
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 fetch(url: &str, timeout: Duration) -> CheckResult {
|
||||
// Respect the OS trust store (including managed-network certificates), while still
|
||||
// validating the certificate chain and server name.
|
||||
let agent = ureq::Agent::config_builder()
|
||||
.tls_config(ureq::tls::TlsConfig::builder().root_certs(ureq::tls::RootCerts::PlatformVerifier).build())
|
||||
.timeout_global(Some(timeout))
|
||||
.max_redirects(0)
|
||||
.build()
|
||||
.new_agent();
|
||||
let mut response = match agent
|
||||
.get(url)
|
||||
.header("User-Agent", "PhotoCraft-update-check")
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("X-GitHub-Api-Version", "2026-03-10")
|
||||
.call()
|
||||
{
|
||||
Ok(response) => response,
|
||||
// The repository may not have published a stable release yet.
|
||||
Err(ureq::Error::StatusCode(404)) => return Ok(None),
|
||||
Err(ureq::Error::StatusCode(403 | 429)) => return Err("GitHub is limiting update checks. Please try again later.".into()),
|
||||
Err(e) => return Err(format!("Could not contact the release server: {e}")),
|
||||
};
|
||||
if response.status() != 200 {
|
||||
return Err(format!("Unexpected release server status: {}", response.status()));
|
||||
}
|
||||
let bytes = response.body_mut().with_config().limit(MAX_RESPONSE).read_to_vec().map_err(|e| format!("Could not read release response: {e}"))?;
|
||||
parse_release(&bytes, photocraft_engine::build_info::VERSION)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
fn server(status: &str, body: &str, extra: &str) -> (String, std::thread::JoinHandle<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}/latest", listener.local_addr().unwrap());
|
||||
let response = format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\n{extra}Connection: close\r\n\r\n{body}", body.len());
|
||||
let worker = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut request = Vec::new();
|
||||
let mut chunk = [0; 1024];
|
||||
while !request.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
let n = stream.read(&mut chunk).unwrap();
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&chunk[..n]);
|
||||
}
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
String::from_utf8(request).unwrap()
|
||||
});
|
||||
(url, worker)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetches_release_metadata_with_generic_headers() {
|
||||
let (url, worker) = server("200 OK", r#"{"tag_name":"v99.0.0","draft":false,"prerelease":false}"#, "");
|
||||
let result = fetch(&url, Duration::from_secs(5)).unwrap().unwrap();
|
||||
assert_eq!(result.version, "99.0.0");
|
||||
let request = worker.join().unwrap().to_ascii_lowercase();
|
||||
assert!(request.contains("user-agent: photocraft-update-check\r\n"));
|
||||
assert!(request.contains("accept: application/vnd.github+json"));
|
||||
assert!(!request.contains("authorization:") && !request.contains("cookie:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_missing_release_rate_limits_and_bad_responses() {
|
||||
for (status, body, extra, expected_none) in [
|
||||
("404 Not Found", "", "", true),
|
||||
("403 Forbidden", "", "", false),
|
||||
("429 Too Many Requests", "", "", false),
|
||||
("500 Internal Server Error", "", "", false),
|
||||
("200 OK", "invalid JSON", "", false),
|
||||
("302 Found", "", "Location: https://untrusted.example/\r\n", false),
|
||||
] {
|
||||
let (url, worker) = server(status, body, extra);
|
||||
let result = fetch(&url, Duration::from_secs(5));
|
||||
if expected_none {
|
||||
assert_eq!(result, Ok(None));
|
||||
} else {
|
||||
assert!(result.is_err(), "{status}");
|
||||
}
|
||||
worker.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_response_size_and_times_out() {
|
||||
let (url, worker) = server("200 OK", &" ".repeat(MAX_RESPONSE as usize + 1), "");
|
||||
assert!(fetch(&url, Duration::from_secs(5)).is_err());
|
||||
worker.join().unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}/latest", listener.local_addr().unwrap());
|
||||
let start = std::time::Instant::now();
|
||||
assert!(fetch(&url, Duration::from_millis(100)).is_err());
|
||||
assert!(start.elapsed() < Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
@@ -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(env!("CARGO_BIN_EXE_photocraft"));
|
||||
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"]);
|
||||
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("`nope` is not a valid port"), "stderr: {stderr}");
|
||||
assert!(!stderr.contains("unknown option"), "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}");
|
||||
}
|
||||
|
||||
@@ -9,10 +9,21 @@
|
||||
/// 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()));
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -20,6 +20,15 @@ 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` 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -252,6 +255,16 @@ 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.jasallen]
|
||||
real_name = "Jason DeBoever"
|
||||
sync_github_name = true
|
||||
|
||||
@@ -15,6 +15,8 @@ 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;
|
||||
/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes.
|
||||
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
|
||||
|
||||
@@ -164,7 +166,7 @@ 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 seen = HashSet::new();
|
||||
let mut next = header.fat_offset;
|
||||
@@ -222,7 +224,7 @@ 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")),
|
||||
@@ -235,6 +237,10 @@ impl<'a> Archive<'a> {
|
||||
// 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 +254,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 })
|
||||
|
||||
@@ -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)));
|
||||
@@ -152,6 +162,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 +272,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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +288,8 @@ 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;
|
||||
|
||||
/// Read a document: archive, `doc.dat` and the model.
|
||||
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
|
||||
@@ -304,6 +306,7 @@ pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
warnings: BTreeMap<String, usize>,
|
||||
nodes: usize,
|
||||
child_slots: usize,
|
||||
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 +323,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
warnings: BTreeMap::new(),
|
||||
nodes: 0,
|
||||
child_slots: 0,
|
||||
name_bytes: 0,
|
||||
active: HashSet::new(),
|
||||
geometry_budget: crate::geometry::Budget::default(),
|
||||
pixels_left: limits.max_pixel_bytes,
|
||||
@@ -394,10 +398,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 +420,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
Ok((out, mask))
|
||||
}
|
||||
|
||||
/// Counts raw array slots (null ones included) against [`MAX_CHILD_SLOTS`].
|
||||
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 +447,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 +476,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…)");
|
||||
}
|
||||
|
||||
@@ -181,24 +181,30 @@ 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 });
|
||||
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;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -302,6 +302,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
|
||||
|
||||
@@ -26,11 +26,17 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
|
||||
None => {
|
||||
// Inline objects (page numbers, anchors) each take one index before the segment text.
|
||||
let mut v = Vec::new();
|
||||
// One past the allowance is enough to trip the check below; shared segments
|
||||
// repeated by reference must not expand beyond it first.
|
||||
let room = MAX_CHARS.saturating_sub(total).saturating_add(1);
|
||||
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));
|
||||
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());
|
||||
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(room.saturating_sub(v.len())));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
@@ -21,4 +21,5 @@ rustfft = "6.4.1"
|
||||
rayon = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
photocraft-testkit = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 ° 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.
|
||||
|
||||
@@ -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
@@ -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| {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
+67
-19
@@ -86,6 +86,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 +561,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 +571,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 +698,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 +719,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 +1000,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 +1105,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 +1142,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 +1207,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 +1229,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
@@ -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] {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms, bounds, bounds, Some(&selection), tile, extent);
|
||||
assert_bits(&got.read_region(bounds), &expected.read_region(bounds));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+61
-12
@@ -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);
|
||||
|
||||
+63
-11
@@ -359,19 +359,18 @@ fn carve_width_proxy_height(w: usize, h: usize, ch: usize, img: &[f32], protect:
|
||||
});
|
||||
|
||||
let seam_count = w - new_w;
|
||||
let mut carver = Carver::new(w, proxy_h, ch, proxy, proxy_protect, true);
|
||||
let mut removed = Vec::with_capacity(seam_count);
|
||||
// Removing a seam shifts the panel left, so a panel column is not an original one. Tracking
|
||||
// the original column per pixel costs a third buffer that every seam has to shift (24 MB a
|
||||
// seam at 24 MP); counting the columns already taken from that row is exact and costs a
|
||||
// binary search per removed column.
|
||||
let mut carver = Carver::new(w, proxy_h, ch, proxy, proxy_protect, false);
|
||||
let mut removed_by_row: Vec<Vec<usize>> = vec![Vec::new(); proxy_h];
|
||||
let mut taken = 0usize;
|
||||
while carver.cw > new_w {
|
||||
ctl.check()?;
|
||||
removed.push(carver.remove_seam());
|
||||
ctl.progress(removed.len() as f32 / seam_count as f32);
|
||||
}
|
||||
|
||||
let mut removed_by_row = Vec::with_capacity(proxy_h);
|
||||
for proxy_y in 0..proxy_h {
|
||||
let mut columns: Vec<usize> = removed.iter().map(|path| path[proxy_y]).collect();
|
||||
columns.sort_unstable();
|
||||
removed_by_row.push(columns);
|
||||
record_removed(&mut removed_by_row, &carver.remove_seam());
|
||||
taken += 1;
|
||||
ctl.progress(taken as f32 / seam_count as f32);
|
||||
}
|
||||
let mut out = Vec::with_capacity(new_w * h * ch);
|
||||
let mut columns = vec![0; seam_count];
|
||||
@@ -403,6 +402,32 @@ fn carve_width_proxy_height(w: usize, h: usize, ch: usize, img: &[f32], protect:
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Add one seam, given in panel columns, to each row's sorted list of removed original columns.
|
||||
/// For the `i`-th column already taken from a row, `o - i` is the panel column it occupied, and
|
||||
/// that never decreases along the list, so the taken columns at or left of panel column `x` are a
|
||||
/// prefix found by binary search. `x` plus that prefix's length is the original column, and every
|
||||
/// removal the search passed is left of it, so it inserts where the search stopped. The rows are
|
||||
/// independent, so they update in parallel like the pixel shift.
|
||||
fn record_removed(removed_by_row: &mut [Vec<usize>], seam: &[usize]) {
|
||||
// The entries are the original columns still taken from one row, sorted, so entry `i` is at
|
||||
// least `i` and `o - i` cannot underflow.
|
||||
let add = |(row, &x): (&mut Vec<usize>, &usize)| {
|
||||
let (mut lo, mut hi) = (0, row.len());
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
if row[mid] - mid <= x { lo = mid + 1 } else { hi = mid }
|
||||
}
|
||||
row.insert(lo, x + lo);
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
removed_by_row.par_iter_mut().zip(seam.par_iter()).for_each(add);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
removed_by_row.iter_mut().zip(seam.iter()).for_each(add);
|
||||
}
|
||||
|
||||
/// Content-aware resize of `img` to `new_w × new_h` (width first, then height by transposing).
|
||||
pub fn carve(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, new_h: usize) -> Vec<f32> {
|
||||
// Never cancelled; the fallback is unreachable.
|
||||
@@ -761,6 +786,33 @@ mod tests {
|
||||
assert_eq!(carve_width_with(w, h, ch, &img, None, 64, &Interrupt::cancel_only(&cancelled)), Err(Cancelled));
|
||||
}
|
||||
|
||||
/// The proxy path counts each removed original column instead of shifting an index map, so a
|
||||
/// tracking and a counting carver must agree: run both side by side down to one column and
|
||||
/// check every row's list after every seam (tie-heavy noise gives rows different histories;
|
||||
/// 1-4 channels, with and without protection).
|
||||
#[test]
|
||||
fn counted_removed_columns_match_the_tracked_map() {
|
||||
let cases: [(usize, usize, usize, u32, bool); 5] =
|
||||
[(37, 9, 1, 2, false), (40, 12, 2, 3, true), (53, 7, 3, 255, false), (29, 16, 4, 1000, true), (64, 5, 3, 2, true)];
|
||||
for (i, &(w, h, ch, levels, prot)) in cases.iter().enumerate() {
|
||||
let img = noise(w * h * ch, levels, 31 + i as u32);
|
||||
let protect = prot.then(|| noise(w * h, 3, 77 + i as u32));
|
||||
let mut tracked = Carver::new(w, h, ch, img.clone(), protect.clone(), true);
|
||||
let mut counted = Carver::new(w, h, ch, img, protect, false);
|
||||
let mut expected: Vec<Vec<usize>> = vec![Vec::new(); h];
|
||||
let mut got: Vec<Vec<usize>> = vec![Vec::new(); h];
|
||||
while tracked.cw > 1 {
|
||||
let seam = tracked.remove_seam();
|
||||
for (row, &x) in expected.iter_mut().zip(seam.iter()) {
|
||||
let at = row.partition_point(|&o| o < x);
|
||||
row.insert(at, x);
|
||||
}
|
||||
record_removed(&mut got, &counted.remove_seam());
|
||||
assert_eq!(got, expected, "case {i} after {} seams", w - tracked.cw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enlarging_duplicates_flat_areas() {
|
||||
let (w, h) = (30, 8);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(|a, b| a.total_cmp(b));
|
||||
};
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+28
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Final-render coverage for #7989, extending the boundary fix in #8012.
|
||||
use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, LiquifyTool, apply_liquify};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
fn bounds() -> Rect {
|
||||
// Cross the tile boundary at x=256, with a nonzero origin on both axes.
|
||||
Rect::new(192, -32, 320, 96)
|
||||
}
|
||||
|
||||
fn source(mode: ColorMode, sample: SampleType) -> Surface {
|
||||
let format = PixelFormat::new(mode, sample, true);
|
||||
let mut pixels = Vec::with_capacity(128 * 128 * format.channels());
|
||||
for y in 0..128 {
|
||||
for x in 0..128 {
|
||||
let alpha = if (48..52).contains(&x) { 0.0 } else { ((x + 3 * y) % 5 + 1) as f32 / 5.0 };
|
||||
for channel in 0..mode.color_channels() {
|
||||
pixels.push(if alpha == 0.0 { 0.0 } else { ((x * 19 + y * 7 + channel * 43) % 251 + 2) as f32 / 255.0 });
|
||||
}
|
||||
pixels.push(alpha);
|
||||
}
|
||||
}
|
||||
let mut source = Surface::new(format);
|
||||
source.write_region(bounds(), &pixels);
|
||||
source
|
||||
}
|
||||
|
||||
fn warp(points: &[[f64; 2]], size: f64, pressure: f64) -> LiquifyStroke {
|
||||
let mut stroke = LiquifyStroke::new(LiquifyTool::ForwardWarp, size);
|
||||
stroke.pressure = pressure;
|
||||
stroke.points = points.iter().map(|point| point.to_vec()).collect();
|
||||
stroke
|
||||
}
|
||||
|
||||
fn freeze_left() -> LiquifyStroke {
|
||||
let mut stroke = LiquifyStroke::new(LiquifyTool::LassoMask, 1.0);
|
||||
stroke.amount = Some(1.0);
|
||||
stroke.points = vec![vec![192.0, -32.0], vec![256.0, -32.0], vec![256.0, 96.0], vec![192.0, 96.0]];
|
||||
stroke
|
||||
}
|
||||
|
||||
fn check_frozen_output(prior_warp: bool) {
|
||||
for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk] {
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let source = source(mode, sample);
|
||||
for cell in [2.5, 4.0] {
|
||||
let mut strokes = Vec::new();
|
||||
if prior_warp {
|
||||
strokes.push(warp(&[[238.0, 20.0], [261.0, 28.0]], 64.0, 55.0));
|
||||
}
|
||||
strokes.push(freeze_left());
|
||||
let mut field = LiquifyField::from_strokes(bounds(), cell, &strokes);
|
||||
// #8012 establishes the bilinear mapping when the mask is applied.
|
||||
// This test checks later edits, not exact pre-mask cubic preservation.
|
||||
let before = apply_liquify(&source, &field);
|
||||
assert_eq!(before.format(), source.format());
|
||||
assert_eq!(before == source, !prior_warp, "the fixture must retain an earlier warp and keep a fresh mask-only operation unchanged");
|
||||
strokes.push(warp(&[[268.0, 32.0], [252.0, 32.0]], 48.0, 80.0));
|
||||
field.apply_stroke(&strokes[strokes.len() - 1]);
|
||||
let after = apply_liquify(&source, &field);
|
||||
assert_eq!(after.format(), source.format());
|
||||
|
||||
let (mut protected, mut fractional_alpha, mut unfrozen_changes) = (0, 0, 0);
|
||||
let mut frozen_warped = 0;
|
||||
for y in bounds().y0..bounds().y1 {
|
||||
for x in bounds().x0..bounds().x1 {
|
||||
let pixel = before.pixel(x, y);
|
||||
if field.freeze_at(f64::from(x) + 0.5, f64::from(y) + 0.5) == 1.0 {
|
||||
protected += 1;
|
||||
frozen_warped += usize::from(pixel != source.pixel(x, y));
|
||||
let alpha = pixel[mode.color_channels()];
|
||||
fractional_alpha += usize::from(alpha > 0.0 && alpha < 1.0);
|
||||
// An unchanged F32 pixel can round on premultiply/unpremultiply.
|
||||
// Integer-depth pixels must remain exactly equal.
|
||||
let tolerance = if sample == SampleType::F32 { 4.0 * f32::EPSILON } else { 0.0 };
|
||||
for (channel, (actual, expected)) in after.pixel(x, y).iter().zip(&pixel).enumerate() {
|
||||
assert!(
|
||||
(actual - expected).abs() <= tolerance,
|
||||
"{mode:?}/{sample:?}, mesh {cell}, prior warp {prior_warp}: frozen channel {channel} changed at {x},{y}: {expected} -> {actual}"
|
||||
);
|
||||
}
|
||||
} else if field.freeze_at(f64::from(x) + 0.5, f64::from(y) + 0.5) == 0.0 {
|
||||
unfrozen_changes += usize::from(after.pixel(x, y) != pixel);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(protected > 7_000 && fractional_alpha > 3_000, "the fixture must cover frozen pixels with fractional alpha");
|
||||
assert_eq!(frozen_warped > 0, prior_warp, "the fixture must retain the earlier warp inside the fully frozen region");
|
||||
assert!(unfrozen_changes > 0, "the adjacent unfrozen region must still move");
|
||||
let replay = LiquifyField::from_strokes(bounds(), cell, &strokes);
|
||||
assert!(field == replay, "stroke replay must reproduce the field exactly");
|
||||
assert!(apply_liquify(&source, &replay) == after, "stroke replay must reproduce the final pixels exactly");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_frozen_output_preserves_color_and_alpha_across_formats() {
|
||||
check_frozen_output(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_frozen_mapping_survives_adjacent_warps_across_formats() {
|
||||
check_frozen_output(true);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The crate's integration tests, linked into one test binary.
|
||||
|
||||
mod liquify_freeze_formats;
|
||||
mod liquify_source_footprint;
|
||||
mod retouch_blur_exact;
|
||||
mod warp_clipped;
|
||||
|
||||
@@ -25,6 +25,7 @@ base64 = "0.22"
|
||||
schemars = "1"
|
||||
getrandom = { workspace = true }
|
||||
cap-std = "4"
|
||||
clap = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Synthetic camera raws (#7789).
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! The control and automation launch options, as clap types: `photocraft-cli mcp` and `serve`
|
||||
//! flatten them, and so does the desktop app (#7071), so each flag and its environment variable
|
||||
//! are declared once. A flag wins over its variable.
|
||||
//!
|
||||
//! Parse with [`blank_env_is_unset`] applied to the arguments, so an empty variable counts as unset,
|
||||
//! and pass a parse error through [`name_env_vars`], so a bad value names the variable it came from.
|
||||
//! Check the token pair with [`conflict`] only where a token is used, so a stale exported pair
|
||||
//! doesn't stop a launch that needs none.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::builder::NonEmptyStringValueParser;
|
||||
use clap::error::{ContextKind, ContextValue, ErrorKind};
|
||||
use clap::parser::ValueSource;
|
||||
use clap::{Arg, ArgMatches, Args, Command};
|
||||
|
||||
use crate::{AuthorizedWorkspace, AutomationError};
|
||||
|
||||
/// The control token, for a server or for a client of one.
|
||||
#[derive(Args)]
|
||||
pub struct ControlArgs {
|
||||
/// The control token (64 hex digits)
|
||||
#[arg(long, value_name = "64-HEX", env = "PHOTOCRAFT_CONTROL_TOKEN", hide_env_values = true, value_parser = NonEmptyStringValueParser::new())]
|
||||
pub control_token: Option<String>,
|
||||
/// A file holding the control token, used instead of --control-token (a server creates it
|
||||
/// when missing)
|
||||
#[arg(long, value_name = "PATH", env = "PHOTOCRAFT_CONTROL_TOKEN_FILE")]
|
||||
pub control_token_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// The folders automation may touch. Without a root, that direction of access is denied. No
|
||||
/// variable stands in for them here: one exported for the desktop app must not widen headless
|
||||
/// `photocraft-cli` access (#1551); the desktop app adds its own.
|
||||
#[derive(Args)]
|
||||
pub struct AutomationArgs {
|
||||
/// The folder automation may read from (none without it)
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub automation_read_root: Option<PathBuf>,
|
||||
/// The folder automation may write to (none without it)
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub automation_write_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
/// The roots, opened as capabilities.
|
||||
pub fn workspace(&self) -> Result<AuthorizedWorkspace, AutomationError> {
|
||||
AuthorizedWorkspace::new(self.automation_read_root.as_deref(), self.automation_write_root.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// A control port, for clap's `value_parser`: `photocraft --control`, `photocraft-cli serve --port`
|
||||
/// and PHOTOCRAFT_CONTROL_PORT, which may carry spaces.
|
||||
pub fn control_port(s: &str) -> Result<u16, NotAPort> {
|
||||
s.trim().parse().map_err(|_| NotAPort)
|
||||
}
|
||||
|
||||
/// A [`control_port`] value that isn't a port.
|
||||
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("expected a port, 0-65535")]
|
||||
pub struct NotAPort;
|
||||
|
||||
/// For `Command::mut_args`: an empty or blank variable counts as unset, so `PHOTOCRAFT_CONTROL_PORT=`
|
||||
/// turns a fallback off without `unset` (#2491). clap would pass the empty value to the parser.
|
||||
pub fn blank_env_is_unset(arg: Arg) -> Arg {
|
||||
let blank = arg.get_env().and_then(std::env::var_os).is_some_and(|v| v.to_str().is_some_and(|v| v.trim().is_empty()));
|
||||
if blank { arg.env(None::<&str>) } else { arg }
|
||||
}
|
||||
|
||||
/// clap names the flag when a value from its variable is bad or conflicts with another, as if the
|
||||
/// flag had been given: name the variable instead when the flag isn't among `args` (without the
|
||||
/// binary name; a first one naming a subcommand selects whose arguments to look in) (#2491).
|
||||
pub fn name_env_vars(mut error: clap::Error, cmd: &Command, args: &[OsString]) -> clap::Error {
|
||||
// `--flag value` or `--flag=value`.
|
||||
let given = |long: &str| args.iter().filter_map(|a| a.to_str()?.strip_prefix("--")?.strip_prefix(long)).any(|r| r.is_empty() || r.starts_with('='));
|
||||
let sub = args.first().and_then(|name| cmd.find_subcommand(name)).unwrap_or(cmd);
|
||||
for kind in [ContextKind::InvalidArg, ContextKind::PriorArg] {
|
||||
let Some(ContextValue::String(shown)) = error.get(kind) else { continue };
|
||||
let Some(arg) = sub.get_arguments().find(|a| a.to_string() == *shown) else { continue };
|
||||
let (Some(long), Some(var)) = (arg.get_long(), arg.get_env()) else { continue };
|
||||
if !given(long) {
|
||||
error.insert(kind, ContextValue::String(var.to_string_lossy().into_owned()));
|
||||
}
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
/// clap's either/or usage error when `m`, parsed by `cmd`, has both `a` and `b`, each named as it was
|
||||
/// given: the flag, or its variable. For a conflict that depends on the mode, such as the token pair
|
||||
/// (only a server or bridge reads a token), which clap's `conflicts_with` can't express.
|
||||
pub fn conflict(cmd: &mut Command, m: &ArgMatches, a: &str, b: &str) -> Result<(), clap::Error> {
|
||||
if m.value_source(a).is_none() || m.value_source(b).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let shown = |id: &str| {
|
||||
let arg = cmd.get_arguments().find(|arg| arg.get_id() == id);
|
||||
match (m.value_source(id), arg.and_then(Arg::get_env)) {
|
||||
(Some(ValueSource::EnvVariable), Some(var)) => var.to_string_lossy().into_owned(),
|
||||
_ => arg.map_or_else(|| id.to_owned(), |arg| arg.to_string()),
|
||||
}
|
||||
};
|
||||
let message = format!("the argument '{}' cannot be used with '{}'", shown(a), shown(b));
|
||||
Err(cmd.error(ErrorKind::ArgumentConflict, message))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn control_port_accepts_valid_numbers() {
|
||||
assert_eq!(control_port("50494"), Ok(50494));
|
||||
assert_eq!(control_port(" 8080 "), Ok(8080));
|
||||
assert_eq!(control_port("0"), Ok(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_port_rejects_anything_else() {
|
||||
for bad in ["78787", "-1", "", "x"] {
|
||||
assert_eq!(control_port(bad), Err(NotAPort), "{bad:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,28 @@ impl Headless {
|
||||
})
|
||||
}
|
||||
|
||||
/// Replace a smart object's contents with a file from the read root: the capability-scoped
|
||||
/// `layer.smartObjects.replaceContents`, which takes an ambient `path` and is refused in
|
||||
/// automation (#8061). `params` is the command's own (`layer`).
|
||||
pub fn replace_smart_object(&mut self, index: Option<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
|
||||
self.on_doc(index, |this| {
|
||||
let mut opts = params.as_object().cloned().unwrap_or_default();
|
||||
opts.insert("path".into(), json!(path));
|
||||
if matches!(&this.filesystem, Filesystem::TrustedLocal) {
|
||||
return this.command_run("layer.smartObjects.replaceContents", Value::Object(opts));
|
||||
}
|
||||
let bytes = match &this.filesystem {
|
||||
Filesystem::Workspace(workspace) => workspace.read(path)?,
|
||||
_ => {
|
||||
return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into()));
|
||||
}
|
||||
};
|
||||
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path);
|
||||
this.sync_jobs();
|
||||
Ok(photocraft_engine::smart_cmds::replace_contents_from_bytes(&mut this.session, &Value::Object(opts), name, bytes)?)
|
||||
})
|
||||
}
|
||||
|
||||
/// Export each exported layer of a document as its own file beneath the write root: the
|
||||
/// capability-scoped `file.export.layersToFiles`, which takes an ambient `dir` and is
|
||||
/// therefore refused in automation (#3216). The bytes come from the command's own core, so
|
||||
@@ -171,10 +193,16 @@ impl Headless {
|
||||
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
|
||||
};
|
||||
let mut outputs = workspace.export_sequence(dir)?;
|
||||
let written = file_cmds::export_layers_with(&this.session, params, |name, bytes| {
|
||||
let report = file_cmds::export_layers_report_with(&this.session, params, |name, bytes| {
|
||||
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
|
||||
});
|
||||
exported(dir, written, &mut outputs)
|
||||
let (written, skipped) = match report {
|
||||
Ok(report) => (Ok(report.files), report.skipped),
|
||||
Err(error) => (Err(error), Vec::new()),
|
||||
};
|
||||
let mut result = exported(dir, written, &mut outputs)?;
|
||||
result["skipped"] = json!(skipped);
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod args;
|
||||
pub mod bridge;
|
||||
pub mod budgets;
|
||||
pub mod files;
|
||||
|
||||
+228
-20
@@ -10,6 +10,8 @@
|
||||
//! - `session.list`, `doc.open {path}`, `doc.new {…file.new params}`,
|
||||
//! `doc.place {path, scale?, fit?, center?, index?}` (#2810: the read root is the scope,
|
||||
//! the counterpart of the refused ambient `file.placeEmbedded`),
|
||||
//! `doc.replaceSmartObject {path, layer?, index?}` (#8061: the same scope for
|
||||
//! `layer.smartObjects.replaceContents`),
|
||||
//! `doc.exportLayers {dir, format?, prefix?, visibleOnly?, quality?, index?}`,
|
||||
//! `doc.exportDataSets {dir, format?, dataSets?, naming?, index?}`,
|
||||
//! `doc.importDataSets {path, delimiter?, index?}` (#3216: the same treatment for the export
|
||||
@@ -22,7 +24,9 @@
|
||||
use std::io::{BufRead, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde_json::{Value, json};
|
||||
@@ -43,6 +47,7 @@ pub const METHODS: &[&str] = &[
|
||||
"session.list",
|
||||
"doc.open",
|
||||
"doc.place",
|
||||
"doc.replaceSmartObject",
|
||||
"doc.new",
|
||||
"doc.save",
|
||||
"doc.exportLayers",
|
||||
@@ -108,6 +113,13 @@ impl Headless {
|
||||
opts.remove("index");
|
||||
self.place(index_of(&p)?, path, &Value::Object(opts))
|
||||
}
|
||||
"doc.replaceSmartObject" => {
|
||||
let path = str_of(&p, "path").ok_or_else(|| bad("doc.replaceSmartObject needs `path`"))?;
|
||||
let mut opts = p.as_object().cloned().unwrap_or_default();
|
||||
opts.remove("path");
|
||||
opts.remove("index");
|
||||
self.replace_smart_object(index_of(&p)?, path, &Value::Object(opts))
|
||||
}
|
||||
"doc.new" => self.command_run("file.new", p),
|
||||
"doc.save" => {
|
||||
let tga_bits = photocraft_engine::file_cmds::tga_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
|
||||
@@ -315,32 +327,67 @@ fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token:
|
||||
}
|
||||
}
|
||||
|
||||
/// Headless TCP daemons with no clients should eventually release their listening port (#3682).
|
||||
const TCP_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
/// Serve authenticated JSON lines on a loopback TCP address with one shared session.
|
||||
/// Refuses non-loopback addresses and caps active connections and request bytes.
|
||||
/// Refuses non-loopback addresses, caps connections and request bytes, and closes the listener
|
||||
/// after an idle period with no connections. Active clients are never evicted by this timer.
|
||||
pub fn serve_tcp(addr: &str, h: Arc<Mutex<Headless>>, token: String, ready: impl FnOnce(std::net::SocketAddr)) -> Result<(), AutomationError> {
|
||||
serve_tcp_until_idle(addr, h, token, ready, TCP_IDLE_TIMEOUT)
|
||||
}
|
||||
|
||||
fn serve_tcp_until_idle(
|
||||
addr: &str,
|
||||
h: Arc<Mutex<Headless>>,
|
||||
token: String,
|
||||
ready: impl FnOnce(std::net::SocketAddr),
|
||||
idle: Duration,
|
||||
) -> Result<(), AutomationError> {
|
||||
let listener = TcpListener::bind(addr).map_err(|e| AutomationError::Io(format!("bind {addr}: {e}")))?;
|
||||
let local = listener.local_addr().map_err(|e| AutomationError::Io(e.to_string()))?;
|
||||
if !local.ip().is_loopback() {
|
||||
return Err(bad(format!("{addr} is not a loopback address")));
|
||||
}
|
||||
listener.set_nonblocking(true).map_err(|e| AutomationError::Io(e.to_string()))?;
|
||||
ready(local);
|
||||
let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
|
||||
let token = Arc::new(token);
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut stream) = stream else { continue };
|
||||
let Some(permit) = limiter.try_acquire() else {
|
||||
let _ = configure_stream(&stream);
|
||||
let _ = writeln!(stream, "{}", json!({"id": null, "ok": false, "error": "connection limit reached"}));
|
||||
continue;
|
||||
};
|
||||
let h = h.clone();
|
||||
let token = Arc::clone(&token);
|
||||
std::thread::spawn(move || {
|
||||
let _permit = permit;
|
||||
let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE);
|
||||
});
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
let mut last_active = Instant::now();
|
||||
loop {
|
||||
match listener.accept() {
|
||||
Ok((mut stream, _)) => {
|
||||
last_active = Instant::now();
|
||||
let Some(permit) = limiter.try_acquire() else {
|
||||
let _ = configure_stream(&stream);
|
||||
let _ = writeln!(stream, "{}", json!({"id": null, "ok": false, "error": "connection limit reached"}));
|
||||
continue;
|
||||
};
|
||||
// The listener is nonblocking; individual requests still use blocking socket I/O.
|
||||
let _ = stream.set_nonblocking(false);
|
||||
let h = h.clone();
|
||||
let token = Arc::clone(&token);
|
||||
let active = Arc::clone(&active);
|
||||
active.fetch_add(1, Ordering::AcqRel);
|
||||
std::thread::spawn(move || {
|
||||
let _permit = permit;
|
||||
let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE);
|
||||
active.fetch_sub(1, Ordering::AcqRel);
|
||||
});
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
if active.load(Ordering::Acquire) != 0 {
|
||||
last_active = Instant::now();
|
||||
} else if last_active.elapsed() >= idle {
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
Err(e) if matches!(e.kind(), std::io::ErrorKind::Interrupted | std::io::ErrorKind::ConnectionAborted) => {}
|
||||
Err(e) => return Err(AutomationError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -724,6 +771,129 @@ mod tests {
|
||||
std::fs::remove_dir_all(base).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_replace_smart_object_reads_through_the_read_root_and_never_outside_it() {
|
||||
let base = std::env::temp_dir().join(format!("pc-doc-replace-so-{}", std::process::id()));
|
||||
let root = base.join("workspace");
|
||||
let outside = base.join("outside");
|
||||
for dir in [&root, &outside] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
let png = |w: u32, h: u32, v: u8| {
|
||||
let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgb, vec![v; (w * h * 3) as usize]).unwrap();
|
||||
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()
|
||||
};
|
||||
std::fs::write(root.join("logo.png"), png(8, 6, 42)).unwrap();
|
||||
std::fs::write(root.join("red.png"), png(8, 6, 200)).unwrap();
|
||||
std::fs::write(outside.join("red.png"), png(8, 6, 200)).unwrap();
|
||||
let absolute = outside.join("red.png").to_string_lossy().into_owned();
|
||||
|
||||
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
|
||||
h.handle("doc.new", json!({"width": 40, "height": 30})).unwrap();
|
||||
let layer = h.handle("doc.place", json!({"path": "logo.png"})).unwrap()["layer"].clone();
|
||||
// The smart object's file name and the composite at its centre: what Replace Contents changes.
|
||||
let contents = |h: &mut Headless| {
|
||||
let inspected = h.handle("doc.inspect", json!({})).unwrap();
|
||||
let layers = inspected["layers"].as_array().unwrap();
|
||||
let so = layers.iter().find(|l| l["id"] == layer).unwrap_or_else(|| panic!("{inspected}"));
|
||||
let name = so["smartSource"]["fileName"].as_str().unwrap_or_default().to_owned();
|
||||
let rendered = h.handle("doc.render", json!({"maxSide": 0})).unwrap();
|
||||
let png = base64::engine::general_purpose::STANDARD.decode(rendered["base64"].as_str().unwrap()).unwrap();
|
||||
let img = photocraft_codecs::decode(&png).unwrap();
|
||||
let (w, ht) = img.dimensions();
|
||||
let data = img.as_u8().unwrap();
|
||||
let channels = data.len() / (w * ht) as usize;
|
||||
(name, data[((ht / 2 * w + w / 2) as usize) * channels])
|
||||
};
|
||||
assert_eq!(contents(&mut h), ("logo.png".to_owned(), 42), "the placed contents");
|
||||
let replaced = h.handle("doc.replaceSmartObject", json!({"path": "red.png", "layer": layer})).unwrap();
|
||||
assert_eq!(replaced["layer"], layer, "{replaced}");
|
||||
assert_eq!(contents(&mut h), ("red.png".to_owned(), 200), "the contents were replaced");
|
||||
h.handle("engine.execute", json!({"command": "edit.undo"})).unwrap();
|
||||
assert_eq!(contents(&mut h), ("logo.png".to_owned(), 42), "one undo step restores the old contents");
|
||||
h.handle("engine.execute", json!({"command": "edit.redo"})).unwrap();
|
||||
assert_eq!(contents(&mut h), ("red.png".to_owned(), 200), "redo replaces them again");
|
||||
// Outside the read root, a bad layer, a missing path or a file that is no image: refused, nothing changed.
|
||||
for (params, reason) in [
|
||||
(json!({"path": "../outside/red.png"}), "parent traversal"),
|
||||
(json!({"path": absolute}), "must be inside the automation root"),
|
||||
(json!({"path": "logo.png", "layer": 999_999}), "no such layer"),
|
||||
(json!({}), "needs `path`"),
|
||||
] {
|
||||
let refused = h.handle("doc.replaceSmartObject", params).unwrap_err().to_string();
|
||||
assert!(refused.contains(reason), "{refused}");
|
||||
assert_eq!(contents(&mut h), ("red.png".to_owned(), 200), "nothing changed: {refused}");
|
||||
}
|
||||
std::fs::write(root.join("junk.png"), b"not an image").unwrap();
|
||||
assert!(h.handle("doc.replaceSmartObject", json!({"path": "junk.png", "layer": layer})).is_err());
|
||||
assert_eq!(contents(&mut h), ("red.png".to_owned(), 200), "a file that is no image changes nothing");
|
||||
// The ambient command keeps its refusal.
|
||||
let ambient = h.handle("engine.execute", json!({"command": "layer.smartObjects.replaceContents", "params": {"path": "red.png"}}));
|
||||
assert!(ambient.unwrap_err().to_string().contains("ambient filesystem paths"));
|
||||
// Trusted local keeps the ambient command.
|
||||
let mut t = Headless::trusted_local();
|
||||
t.handle("doc.new", json!({"width": 40, "height": 30})).unwrap();
|
||||
let tl = t.handle("doc.place", json!({"path": root.join("logo.png").to_string_lossy()})).unwrap()["layer"].clone();
|
||||
let trusted = t.handle("doc.replaceSmartObject", json!({"path": root.join("red.png").to_string_lossy(), "layer": tl})).unwrap();
|
||||
assert_eq!(trusted["layer"], tl, "{trusted}");
|
||||
drop(t);
|
||||
drop(h);
|
||||
std::fs::remove_dir_all(base).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_export_layers_reports_skips_in_workspace_and_trusted_sessions() {
|
||||
for trusted in [false, true] {
|
||||
let base = std::env::temp_dir().join(format!("pc-export-skips-{}-{trusted}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
let mut h =
|
||||
if trusted { Headless::trusted_local() } else { Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&base), Some(&base)).unwrap()) };
|
||||
h.handle("doc.new", json!({"width": 4, "height": 4, "background": "white", "name": "card"})).unwrap();
|
||||
let adjustment = h.handle("engine.execute", json!({"command": "layer.newAdjustmentLayer.levels"})).unwrap()["layer"].clone();
|
||||
h.handle("engine.execute", json!({"command": "layer.setProps", "params": {"name": "Levels"}})).unwrap();
|
||||
let hidden = h.handle("engine.execute", json!({"command": "layer.new.layer", "params": {"name": "Hidden"}})).unwrap()["layer"].clone();
|
||||
h.handle("engine.execute", json!({"command": "layer.setProps", "params": {"visible": false}})).unwrap();
|
||||
// Exercise the actual JSON-lines serve envelope, not only direct dispatch.
|
||||
let h = Mutex::new(h);
|
||||
let out_dir = base.join("out").to_string_lossy().into_owned();
|
||||
let request = json!({"id": 8491, "method": "doc.exportLayers", "params": {"dir": out_dir}}).to_string() + "\n";
|
||||
let mut output = Vec::new();
|
||||
serve_lines(&h, request.as_bytes(), &mut output).unwrap();
|
||||
let reply: Value = serde_json::from_slice(&output).unwrap();
|
||||
assert_eq!(reply["id"], 8491);
|
||||
assert_eq!(reply["ok"], true, "{reply}");
|
||||
assert_eq!(reply["result"]["files"], json!([format!("{out_dir}/card_0001_Background.png")]));
|
||||
if !trusted {
|
||||
assert_eq!(reply["result"]["count"], 1);
|
||||
}
|
||||
assert_eq!(
|
||||
reply["result"]["skipped"],
|
||||
json!([
|
||||
{"layer": hidden, "name": "Hidden", "reason": "hidden by visibleOnly"},
|
||||
{"layer": adjustment, "name": "Levels", "reason": "adjustment layer has no pixels of its own"}
|
||||
])
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(base.join("out")).unwrap().count(), 1);
|
||||
let mut h = h.into_inner().unwrap();
|
||||
let all_dir = base.join("all").to_string_lossy().into_owned();
|
||||
let all = h.export_layers(None, &all_dir, &json!({"visibleOnly": false})).unwrap();
|
||||
assert_eq!(all["files"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(all["skipped"].as_array().unwrap().len(), 1);
|
||||
// Workspace no-overwrite errors still fail and remove only newly created files.
|
||||
if !trusted {
|
||||
let collision = base.join("collision");
|
||||
std::fs::create_dir_all(&collision).unwrap();
|
||||
std::fs::write(collision.join("card_0002_Background.png"), b"keep").unwrap();
|
||||
assert!(h.export_layers(None, &collision.to_string_lossy(), &json!({"visibleOnly": false})).is_err());
|
||||
assert_eq!(std::fs::read_dir(&collision).unwrap().count(), 1);
|
||||
assert_eq!(std::fs::read(collision.join("card_0002_Background.png")).unwrap(), b"keep");
|
||||
}
|
||||
drop(h);
|
||||
std::fs::remove_dir_all(base).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_export_layers_writes_only_beneath_the_write_root() {
|
||||
let base = std::env::temp_dir().join(format!("pc-doc-export-layers-{}", std::process::id()));
|
||||
@@ -800,7 +970,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_place_and_data_set_methods_keep_the_active_document() {
|
||||
fn indexed_place_replace_and_data_set_methods_keep_the_active_document() {
|
||||
let base = std::env::temp_dir().join(format!("pc-doc-index-active-{}", std::process::id()));
|
||||
let root = base.join("workspace");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
@@ -817,6 +987,9 @@ mod tests {
|
||||
|
||||
let steps = [
|
||||
("doc.place", json!({"path": "logo.png", "index": 1}), true),
|
||||
// Document 1's active layer is now the placed Smart Object.
|
||||
("doc.replaceSmartObject", json!({"path": "logo.png", "index": 1}), true),
|
||||
("doc.replaceSmartObject", json!({"path": "missing.png", "index": 1}), false),
|
||||
("doc.importDataSets", json!({"path": "sets.csv", "index": 1}), true),
|
||||
("doc.exportLayers", json!({"dir": "layers", "index": 1}), true),
|
||||
("doc.exportDataSets", json!({"dir": "sets", "index": 1}), true),
|
||||
@@ -875,13 +1048,16 @@ mod tests {
|
||||
fn workspace_psd_saves_embed_no_file_a_linked_smart_object_names() {
|
||||
use photocraft_engine::doc::{LayerContent, SmartSource};
|
||||
|
||||
const MARKER: &[u8] = b"PHOTOCRAFT-LINKED-0001";
|
||||
let has_marker = |bytes: &[u8]| bytes.windows(MARKER.len()).any(|w| w == MARKER);
|
||||
// An image PhotoCraft can open (other linked files are never embedded): an embedded copy
|
||||
// carries these exact bytes, while pixels written for the layer don't.
|
||||
let image = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgb, (0..192u8).collect()).unwrap();
|
||||
let marker = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
let has_marker = |bytes: &[u8]| bytes.windows(marker.len()).any(|w| w == marker.as_slice());
|
||||
let base = std::env::temp_dir().join(format!("pc-psd-linked-embed-{}", std::process::id()));
|
||||
let root = base.join("workspace");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let outside = base.join("outside.txt");
|
||||
std::fs::write(&outside, MARKER).unwrap();
|
||||
let outside = base.join("outside.png");
|
||||
std::fs::write(&outside, &marker).unwrap();
|
||||
// A document whose smart object names a file outside the roots as its source.
|
||||
let mut session = photocraft_engine::Session::new();
|
||||
session.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
|
||||
@@ -1010,6 +1186,38 @@ mod tests {
|
||||
assert!(h.handle("methods", Value::Null).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_tcp_daemon_releases_its_port_but_keeps_active_clients() {
|
||||
use std::io::BufReader;
|
||||
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let h = Arc::new(Mutex::new(Headless::new()));
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
|
||||
let (done_tx, done_rx) = std::sync::mpsc::channel();
|
||||
let server = std::thread::spawn(move || {
|
||||
let result = serve_tcp_until_idle("127.0.0.1:0", h, TOKEN.to_owned(), move |addr| ready_tx.send(addr).unwrap(), Duration::from_millis(200));
|
||||
done_tx.send(result).unwrap();
|
||||
});
|
||||
let addr = ready_rx.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
let mut client = std::net::TcpStream::connect(addr).unwrap();
|
||||
client.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
|
||||
let mut reader = BufReader::new(client.try_clone().unwrap());
|
||||
writeln!(client, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
let auth: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(auth["result"]["authenticated"], true);
|
||||
// The port stays owned well past the idle limit while a client is connected.
|
||||
assert!(
|
||||
matches!(done_rx.recv_timeout(Duration::from_millis(450)), Err(std::sync::mpsc::RecvTimeoutError::Timeout)),
|
||||
"daemon exited with a live client"
|
||||
);
|
||||
drop(reader);
|
||||
drop(client);
|
||||
assert!(done_rx.recv_timeout(Duration::from_secs(3)).unwrap().is_ok(), "idle daemon did not exit cleanly");
|
||||
server.join().unwrap();
|
||||
assert!(TcpListener::bind(addr).is_ok(), "idle listener still owns its port");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_serves_loopback() {
|
||||
use std::io::{BufReader, Write as _};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{BufRead, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -308,13 +308,6 @@ impl Read for AuthDeadlineStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment-aware token inputs shared by the desktop app and CLI.
|
||||
pub fn token_inputs(supplied: Option<String>, token_file: Option<PathBuf>) -> (Option<String>, Option<PathBuf>) {
|
||||
let supplied = supplied.or_else(|| std::env::var("PHOTOCRAFT_CONTROL_TOKEN").ok());
|
||||
let token_file = token_file.or_else(|| std::env::var_os("PHOTOCRAFT_CONTROL_TOKEN_FILE").map(PathBuf::from));
|
||||
(supplied, token_file)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -71,6 +71,20 @@ pub struct PlaceParams {
|
||||
pub center: Option<[f64; 2]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ReplaceSmartObjectParams {
|
||||
/// Path beneath the configured automation read root: relative (forward slashes) or absolute.
|
||||
pub path: String,
|
||||
/// Headless mode only: document index from `session_list` (default: the active document).
|
||||
/// Bridge mode rejects `index`.
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
/// The Smart Object layer: an id or a name (default: the active layer).
|
||||
#[serde(default)]
|
||||
pub layer: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExportLayersParams {
|
||||
@@ -336,7 +350,8 @@ pub struct UiSetParams {
|
||||
/// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a
|
||||
/// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off":
|
||||
/// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not
|
||||
/// over this channel). Other fields are an error.
|
||||
/// over this channel). updatesOpen (bool, the Help › Check for Updates window). Other fields
|
||||
/// are an error.
|
||||
pub fields: Value,
|
||||
}
|
||||
|
||||
@@ -412,6 +427,14 @@ fn strict_node(v: &mut Value, defs: &serde_json::Map<String, Value>, depth: usiz
|
||||
}
|
||||
}
|
||||
|
||||
/// Both the bare engine list and older bridge wrappers own their rows already.
|
||||
fn into_command_rows(mut reply: Value) -> Vec<Value> {
|
||||
match reply {
|
||||
Value::Array(rows) => rows,
|
||||
_ => reply.get_mut("commands").and_then(Value::as_array_mut).map(std::mem::take).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ok_json(v: &Value) -> CallToolResult {
|
||||
let text = match json_bytes(v).and_then(|bytes| String::from_utf8(bytes).map_err(|error| AutomationError::Other(error.to_string()))) {
|
||||
Ok(text) => text,
|
||||
@@ -637,7 +660,33 @@ impl PhotocraftMcp {
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Export each exported layer of the document as its own file beneath the write root (the capability-scoped Layers to Files). A file that already exists is an error, not an overwrite."
|
||||
description = "Replace a Smart Object's contents with the file at `path` beneath the read root, keeping its transform and filters (the capability-scoped Replace Contents). `layer` is the Smart Object's id or name (default: the active layer)."
|
||||
)]
|
||||
async fn doc_replace_smart_object(&self, Parameters(p): Parameters<ReplaceSmartObjectParams>) -> Result<CallToolResult, McpError> {
|
||||
let mut opts = serde_json::Map::new();
|
||||
if let Some(v) = p.layer {
|
||||
opts.insert("layer".into(), v);
|
||||
}
|
||||
let opts = Value::Object(opts);
|
||||
// Bridge mode runs the app on this machine, which reads the path itself like any command.
|
||||
let mut bridge_params = opts.clone();
|
||||
if let Some(o) = bridge_params.as_object_mut() {
|
||||
o.insert("path".into(), Value::String(p.path.clone()));
|
||||
}
|
||||
let (path, index) = (p.path, p.index);
|
||||
if let Some(r) = self.headless_op(move |h| h.replace_smart_object(index, &path, &opts)).await {
|
||||
return to_result(r);
|
||||
}
|
||||
// The app's command runs on its active document: an `index` it cannot honour is refused
|
||||
// rather than dropped, or another document's contents would be replaced.
|
||||
if index.is_some() {
|
||||
return Ok(fail("`index` is only supported in headless mode; in bridge mode select the document in the app first"));
|
||||
}
|
||||
self.run_command("layer.smartObjects.replaceContents".into(), bridge_params, true).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Export top-level layers as files (groups merged). Returns files and skipped: [{layer, name, reason}] for hidden layers (visible_only) and adjustments with no pixels of their own. Workspace exports resolve beneath the write root, also return count of written files, and reject existing files instead of overwriting them. Trusted-local/bridge exports use the engine command's file access policy."
|
||||
)]
|
||||
async fn doc_export_layers(&self, Parameters(p): Parameters<ExportLayersParams>) -> Result<CallToolResult, McpError> {
|
||||
let mut opts = serde_json::Map::new();
|
||||
@@ -827,10 +876,7 @@ impl PhotocraftMcp {
|
||||
};
|
||||
let needle = p.filter.map(|f| f.to_lowercase());
|
||||
let enabled_only = p.enabled_only.unwrap_or(false);
|
||||
let items: Vec<Value> = all
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| all.get("commands").and_then(Value::as_array).cloned().unwrap_or_default())
|
||||
let items: Vec<Value> = into_command_rows(all)
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n))
|
||||
@@ -1083,6 +1129,29 @@ pub fn render_document_png(doc: &photocraft_doc::Document, max_side: u32) -> Res
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_rows_keep_bare_and_wrapped_replies_compatible() {
|
||||
let rows = json!([{"id":"first","params":{"nested":[1,2]}}, {"id":"second","enabled":false}]);
|
||||
assert_eq!(into_command_rows(rows.clone()), rows.as_array().unwrap().clone());
|
||||
assert_eq!(into_command_rows(json!({"commands":rows,"other":"ignored"})), rows.as_array().unwrap().clone());
|
||||
for reply in [json!(null), json!(false), json!(3), json!("invalid"), json!({}), json!({"commands":null}), json!({"commands":{}}), json!([])] {
|
||||
assert!(into_command_rows(reply).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_rows_reuse_the_owned_array_and_nested_strings() {
|
||||
for wrapped in [false, true] {
|
||||
let rows = json!([{"id":"first","params":"documentation".repeat(64)}]);
|
||||
let array = rows.as_array().unwrap().as_ptr();
|
||||
let text = rows[0]["params"].as_str().unwrap().as_ptr();
|
||||
let reply = if wrapped { Value::Object([("commands".into(), rows)].into_iter().collect()) } else { rows };
|
||||
let moved = into_command_rows(reply);
|
||||
assert_eq!(moved.as_ptr(), array, "wrapped={wrapped}: move the original allocation");
|
||||
assert_eq!(moved[0]["params"].as_str().unwrap().as_ptr(), text, "wrapped={wrapped}: retain nested allocations");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_schema_inlines_refs_and_stops_on_recursive_definitions() {
|
||||
// A definition that refers to itself: inlining stops at a depth instead of looping.
|
||||
|
||||
@@ -398,12 +398,13 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
|
||||
| "gradient.presets.importGrd"
|
||||
| "plugin.install"
|
||||
| "swatches.import"
|
||||
| "style.presets.importAsl"
|
||||
| "layer.layerStyle.contours.load" => {
|
||||
// `data` wins over `path` in these commands; any `path` without it reads the filesystem.
|
||||
params.get("data").is_none() && params.get("path").is_some()
|
||||
}
|
||||
// With a `path` the file is written there; without one the bytes come back as `data`.
|
||||
"actions.save" | "swatches.export" | "layer.layerStyle.contours.save" => params.get("path").is_some(),
|
||||
"actions.save" | "swatches.export" | "style.presets.exportAsl" | "layer.layerStyle.contours.save" => params.get("path").is_some(),
|
||||
// Shortcut sets: a .kys read or written by path, or a Summarize page written by path.
|
||||
"edit.keyboardShortcuts" => {
|
||||
params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some())
|
||||
@@ -845,6 +846,9 @@ mod tests {
|
||||
("swatches.export", serde_json::json!({"path": "/outside/set.ase"})),
|
||||
("actions.load", serde_json::json!({"path": "/outside/set.atn"})),
|
||||
("actions.save", serde_json::json!({"path": "/outside/set.atn"})),
|
||||
("style.presets.importAsl", serde_json::json!({"path": "/outside/set.asl"})),
|
||||
("style.presets.exportAsl", serde_json::json!({"path": "/outside/set.asl"})),
|
||||
("style.presets.exportAsl", serde_json::json!({"path": 5})),
|
||||
("layer.layerStyle.contours.load", serde_json::json!({"path": "/outside/set.shc"})),
|
||||
("layer.layerStyle.contours.save", serde_json::json!({"path": "/outside/set.shc"})),
|
||||
] {
|
||||
@@ -859,6 +863,8 @@ mod tests {
|
||||
("swatches.export", serde_json::json!({"format": "ase"})),
|
||||
("actions.load", serde_json::json!({"data": "QUJD"})),
|
||||
("actions.save", serde_json::json!({"set": "S"})),
|
||||
("style.presets.importAsl", serde_json::json!({"data": "QUJD"})),
|
||||
("style.presets.exportAsl", serde_json::json!({"group": "Basics"})),
|
||||
("layer.layerStyle.contours.load", serde_json::json!({"data": "QUJD"})),
|
||||
("layer.layerStyle.contours.save", serde_json::json!({"names": ["a"]})),
|
||||
] {
|
||||
@@ -947,6 +953,7 @@ mod tests {
|
||||
| "path.rename"
|
||||
| "path.stroke"
|
||||
| "type.attachToPath"
|
||||
| "type.fillPath"
|
||||
| "type.detachFromPath"
|
||||
| "select.toWorkPath"
|
||||
| "layer.vectorMask.add"
|
||||
@@ -993,6 +1000,8 @@ mod tests {
|
||||
("gradient.presets.importGrd", serde_json::json!({"path": "/outside/set.grd"})),
|
||||
("plugin.install", serde_json::json!({"path": "/outside/plugin.wasm"})),
|
||||
("plugin.reload", serde_json::json!({"path": "/outside/plugins"})),
|
||||
("style.presets.importAsl", serde_json::json!({"path": "/outside/set.asl"})),
|
||||
("style.presets.exportAsl", serde_json::json!({"path": "/outside/set.asl"})),
|
||||
("prefs.set", serde_json::json!({"path": "historyLog.filePath", "value": "/outside/log"})),
|
||||
("prefs.set", serde_json::json!({"values": {"interface.language": "fr", "historyLog.filePath": "/outside/log"}})),
|
||||
] {
|
||||
|
||||
@@ -80,6 +80,7 @@ async fn lists_expected_tools() {
|
||||
"doc_open",
|
||||
"doc_new",
|
||||
"doc_place",
|
||||
"doc_replace_smart_object",
|
||||
"doc_export_layers",
|
||||
"doc_export_data_sets",
|
||||
"doc_import_data_sets",
|
||||
@@ -706,6 +707,36 @@ async fn doc_place_is_capability_scoped() {
|
||||
cleanup(&base);
|
||||
}
|
||||
|
||||
/// #8061: Replace Contents reads from the read root through `doc_replace_smart_object`; the
|
||||
/// ambient `layer.smartObjects.replaceContents` stays refused.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn doc_replace_smart_object_is_capability_scoped() {
|
||||
let base = tmp("replace-so");
|
||||
let root = base.join("workspace");
|
||||
let outside = base.join("outside");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgb, vec![42; 144]).unwrap();
|
||||
let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
|
||||
std::fs::write(root.join("inside.png"), &bytes).unwrap();
|
||||
std::fs::write(outside.join("outside.png"), bytes).unwrap();
|
||||
let client = connect(headless_in(&root)).await;
|
||||
|
||||
json_of(&call(&client, "doc_new", json!({"width": 40, "height": 30})).await);
|
||||
let placed = json_of(&call(&client, "doc_place", json!({"path": "inside.png"})).await);
|
||||
let layer = placed["layer"].clone();
|
||||
let replaced = call(&client, "doc_replace_smart_object", json!({"path": "inside.png", "layer": layer})).await;
|
||||
assert_ne!(replaced.is_error, Some(true), "{}", text(&replaced));
|
||||
let refused = call(&client, "doc_replace_smart_object", json!({"path": outside.join("outside.png").to_string_lossy(), "layer": layer})).await;
|
||||
assert_eq!(refused.is_error, Some(true));
|
||||
assert!(text(&refused).contains("must be inside the automation root"), "{}", text(&refused));
|
||||
let ambient = call(&client, "command_run", json!({"id": "layer.smartObjects.replaceContents", "params": {"path": "inside.png", "layer": layer}})).await;
|
||||
assert_eq!(ambient.is_error, Some(true));
|
||||
assert!(text(&ambient).contains("ambient filesystem paths"), "{}", text(&ambient));
|
||||
client.cancel().await.unwrap();
|
||||
cleanup(&base);
|
||||
}
|
||||
|
||||
/// #3216: the export half of #2810. The write root scopes both exports, the read root the CSV,
|
||||
/// and the three ambient commands keep their refusal.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -768,6 +799,43 @@ async fn doc_exports_and_data_set_import_are_capability_scoped() {
|
||||
cleanup(&base);
|
||||
}
|
||||
|
||||
/// Skipped-layer details survive the MCP tool result, including the all-skipped case.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn doc_export_layers_reports_skipped_layers() {
|
||||
let base = tmp("skipped-layers");
|
||||
let client = connect(headless_in(&base)).await;
|
||||
json_of(&call(&client, "doc_new", json!({"width": 4, "height": 4, "background": "white", "name": "card"})).await);
|
||||
let background = json_of(&call(&client, "doc_inspect", json!({})).await)["layers"][0]["id"].clone();
|
||||
let adjustment = json_of(&call(&client, "command_run", json!({"id": "layer.newAdjustmentLayer.levels"})).await)["layer"].clone();
|
||||
json_of(&call(&client, "command_run", json!({"id": "layer.setProps", "params": {"name": "Levels/α"}})).await);
|
||||
let hidden = json_of(&call(&client, "command_run", json!({"id": "layer.new.layer", "params": {"name": "Hidden"}})).await)["layer"].clone();
|
||||
json_of(&call(&client, "command_run", json!({"id": "layer.setProps", "params": {"visible": false}})).await);
|
||||
let result = json_of(&call(&client, "doc_export_layers", json!({"dir": "layers"})).await);
|
||||
assert_eq!(result["count"], 1);
|
||||
assert_eq!(result["files"], json!(["layers/card_0001_Background.png"]));
|
||||
assert_eq!(
|
||||
result["skipped"],
|
||||
json!([
|
||||
{"layer": hidden, "name": "Hidden", "reason": "hidden by visibleOnly"},
|
||||
{"layer": adjustment, "name": "Levels/α", "reason": "adjustment layer has no pixels of its own"}
|
||||
])
|
||||
);
|
||||
let all = json_of(&call(&client, "doc_export_layers", json!({"dir": "all", "visible_only": false})).await);
|
||||
assert_eq!(all["count"], 2);
|
||||
assert_eq!(all["skipped"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(std::fs::read_dir(base.join("layers")).unwrap().count(), 1);
|
||||
assert_eq!(std::fs::read_dir(base.join("all")).unwrap().count(), 2);
|
||||
// Every top-level layer is still accounted for when none can be exported.
|
||||
json_of(&call(&client, "command_run", json!({"id": "layer.setProps", "params": {"layer": background, "visible": false}})).await);
|
||||
let none = json_of(&call(&client, "doc_export_layers", json!({"dir": "none"})).await);
|
||||
assert_eq!(none["files"], json!([]));
|
||||
assert_eq!(none["count"], 0);
|
||||
assert_eq!(none["skipped"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(std::fs::read_dir(base.join("none")).unwrap().count(), 0);
|
||||
client.cancel().await.unwrap();
|
||||
cleanup(&base);
|
||||
}
|
||||
|
||||
/// The template workflow stays in memory, including saving nested contents back to the parent.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn embedded_smart_objects_can_be_edited_saved_and_unpacked_without_file_authority() {
|
||||
@@ -1043,6 +1111,26 @@ async fn bridge_previews_reject_document_indices() {
|
||||
assert_eq!(schema.get("additionalProperties"), Some(&json!(false)));
|
||||
}
|
||||
|
||||
/// Bridge mode runs Replace Contents on the app's active document, so an `index` is refused
|
||||
/// before anything reaches the app rather than dropped (it would replace another document's
|
||||
/// contents); without one the command is forwarded as before.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bridge_replace_smart_object_rejects_a_document_index() {
|
||||
let (addr, app) = fake_app().await;
|
||||
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
|
||||
let refused = call(&client, "doc_replace_smart_object", json!({"path": "new.png", "index": 1})).await;
|
||||
assert_eq!(refused.is_error, Some(true), "{}", text(&refused));
|
||||
assert!(text(&refused).contains("index"), "{}", text(&refused));
|
||||
let forwarded = call(&client, "doc_replace_smart_object", json!({"path": "new.png", "layer": 7})).await;
|
||||
assert_ne!(forwarded.is_error, Some(true), "{}", text(&forwarded));
|
||||
client.cancel().await.unwrap();
|
||||
let seen = tokio::time::timeout(std::time::Duration::from_secs(5), app).await.unwrap().unwrap();
|
||||
let executed: Vec<&Value> = seen.iter().filter(|request| request["method"] == "engine.execute").collect();
|
||||
assert_eq!(executed.len(), 1, "the indexed call must not reach the app: {seen:?}");
|
||||
assert_eq!(executed[0]["params"]["command"], "layer.smartObjects.replaceContents");
|
||||
assert_eq!(executed[0]["params"]["params"], json!({"path": "new.png", "layer": 7}));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn headless_previews_preserve_document_indices() {
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
|
||||
@@ -27,7 +27,8 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
|
||||
* `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding.
|
||||
* `Profile::same_colors(&other)`: the same colour space whatever the encoding (every sample of
|
||||
a device grid within ΔE 0.5 through both), e.g. Photoshop's sRGB IEC61966-2.1 and the built-in
|
||||
sRGB. `content_hash()` compares bytes and suits cache keys only.
|
||||
sRGB. `same_bytes(&other)` compares the ICC bytes. `content_hash()` is a 64-bit hash of them
|
||||
that another profile can share: a cache key, confirmed with `same_bytes`.
|
||||
|
||||
### Evaluation
|
||||
|
||||
|
||||
+24
-2
@@ -293,15 +293,22 @@ fn table_inverse_with(t: &[f32], y: f32, increasing: bool, edge_start: f32, edge
|
||||
|
||||
/// For a flat run at the table start (`start = true`) the last index of the run, or for a flat
|
||||
/// run at the end the first index of it, as a normalised input.
|
||||
///
|
||||
/// A table that is inverted is monotonic, so the run is found by bisection, not by a scan: a long
|
||||
/// constant table is inverted once per pixel, and scanning it each time made the cost the curve's
|
||||
/// length times the image's.
|
||||
fn edge_index(t: &[f32], start: bool) -> f32 {
|
||||
let n = t.len();
|
||||
if start {
|
||||
let v = t[0];
|
||||
let i = t.iter().position(|x| (*x - v).abs() > 1e-7).map_or(n - 1, |i| i - 1);
|
||||
// The first value that differs, given they do so from there on.
|
||||
let first_other = t.partition_point(|x| (*x - v).abs() <= 1e-7);
|
||||
let i = if first_other >= n { n - 1 } else { first_other.saturating_sub(1) };
|
||||
i as f32 / (n - 1) as f32
|
||||
} else {
|
||||
let v = t[n - 1];
|
||||
let i = t.iter().rposition(|x| (*x - v).abs() > 1e-7).map_or(0, |i| i + 1);
|
||||
// The start of the trailing run equal to the last value.
|
||||
let i = t.partition_point(|x| (*x - v).abs() > 1e-7);
|
||||
i as f32 / (n - 1) as f32
|
||||
}
|
||||
}
|
||||
@@ -362,6 +369,21 @@ mod tests {
|
||||
assert!((g.eval64(-0.5) + 0.5f64.powf(2.2)).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverting_a_long_flat_table_does_not_rescan_it_for_every_pixel() {
|
||||
// Four million zeros and a one: a scan per call would be 8·10¹¹ steps for these calls.
|
||||
let mut t = vec![0.0f32; 4_000_000];
|
||||
t.push(1.0);
|
||||
let c = Curve::Table(t);
|
||||
let started = std::time::Instant::now();
|
||||
let mut last = 0.0;
|
||||
for _ in 0..200_000 {
|
||||
last = c.eval_inverse(0.0);
|
||||
}
|
||||
assert!(last > 0.99, "the flat start ends at the last zero: {last}");
|
||||
assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_table_matches_per_call_including_flat_runs() {
|
||||
// Batch inverse must equal calling eval_inverse64 per point. Include a table with flat
|
||||
|
||||
@@ -221,7 +221,14 @@ pub fn parse_look(text: &str) -> Result<LutFile, LutError> {
|
||||
};
|
||||
let size: usize = tag("size").and_then(|s| s.parse().ok()).ok_or_else(|| LutError("no <size> in .look".into()))?;
|
||||
let (entries, _) = checked_data_len(size)?;
|
||||
let hex: Vec<u8> = tag("data").ok_or_else(|| LutError("no <data> in .look".into()))?.bytes().filter(u8::is_ascii_hexdigit).collect();
|
||||
let hex: Vec<u8> = tag("data").ok_or_else(|| LutError("no <data> in .look".into()))?.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
|
||||
if hex.iter().any(|b| !b.is_ascii_hexdigit()) {
|
||||
return err("invalid hexadecimal <data> in .look");
|
||||
}
|
||||
// Each float needs eight hex digits; as_chunks would silently discard a partial value.
|
||||
if !hex.len().is_multiple_of(8) {
|
||||
return err("incomplete float in .look <data>");
|
||||
}
|
||||
let nib = |c: u8| (c as char).to_digit(16).unwrap_or(0) as u8;
|
||||
let bytes: Vec<u8> = hex.as_chunks::<2>().0.iter().map(|p| nib(p[0]) << 4 | nib(p[1])).collect();
|
||||
let floats: Vec<f32> = bytes.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
|
||||
@@ -456,6 +463,38 @@ DOMAIN_MAX 0.5 1 1
|
||||
assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn look_rgb_and_rgba_allow_whitespace_in_hex_data() {
|
||||
let id = LutFile::identity(2);
|
||||
for channels in [3, 4] {
|
||||
let mut hex = String::new();
|
||||
for rgb in id.data.as_chunks::<3>().0 {
|
||||
for value in rgb.iter().copied().chain((channels == 4).then_some(1.0)) {
|
||||
for byte in value.to_le_bytes() {
|
||||
hex.push_str(&format!("{byte:02x} \t\r\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let xml = format!("<look><size>2</size><data>\"{hex}\"</data></look>");
|
||||
assert_eq!(parse("valid.look", xml.as_bytes()).unwrap().data, id.data);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn look_rejects_partial_floats_and_invalid_hex() {
|
||||
let id = LutFile::identity(2);
|
||||
let hex: String = id.data.iter().flat_map(|v| v.to_le_bytes()).map(|b| format!("{b:02X}")).collect();
|
||||
for suffix in ["0", "00", "0000", "000000", "G", "é"] {
|
||||
let xml = format!("<look><size>2</size><data>{hex}{suffix}</data></look>");
|
||||
assert!(parse("bad.look", xml.as_bytes()).is_err(), "accepted malformed suffix {suffix:?}");
|
||||
}
|
||||
let invalid = hex.replacen('0', "G", 1);
|
||||
let xml = format!("<look><size>2</size><data>{invalid}</data></look>");
|
||||
assert!(parse("bad.look", xml.as_bytes()).unwrap_err().0.contains("invalid hexadecimal"));
|
||||
let xml = format!("<look><size>2</size><data>{hex}00</data></look>");
|
||||
assert!(parse("bad.look", xml.as_bytes()).unwrap_err().0.contains("incomplete float"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn look_3d_size_exponent_overflow_is_rejected() {
|
||||
let text = format!("<look><size>{}</size><data>00</data></look>", usize::MAX);
|
||||
|
||||
+102
-2
@@ -184,6 +184,7 @@ pub struct Profile {
|
||||
impl Profile {
|
||||
/// Parses ICC profile bytes.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Profile, CmsError> {
|
||||
CURVE_SAMPLES_LEFT.with(|left| left.set(MAX_CURVE_SAMPLES));
|
||||
let mut p = parse_profile(bytes)?;
|
||||
p.bytes = Some(Arc::new(bytes.to_vec()));
|
||||
Ok(p)
|
||||
@@ -211,7 +212,9 @@ impl Profile {
|
||||
self.color_space.channels()
|
||||
}
|
||||
|
||||
/// Hash of the profile's ICC bytes (stable within a process; used as a cache key).
|
||||
/// Hash of the profile's ICC bytes (stable within a process; used as a cache key). It is a
|
||||
/// fixed-key 64-bit hash, so a crafted profile can share another's: equal hashes only narrow
|
||||
/// the search, [`Profile::same_bytes`] decides.
|
||||
pub fn content_hash(&self) -> u64 {
|
||||
*self.hash.get_or_init(|| {
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -221,6 +224,16 @@ impl Profile {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `self` and `other` have the same ICC bytes.
|
||||
pub fn same_bytes(&self, other: &Profile) -> bool {
|
||||
if let (Some(a), Some(b)) = (&self.bytes, &other.bytes)
|
||||
&& Arc::ptr_eq(a, b)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
self.content_hash() == other.content_hash() && self.to_bytes() == other.to_bytes()
|
||||
}
|
||||
|
||||
/// Whether `self` and `other` describe the same colours: same colour space, and every sample
|
||||
/// of a device-value grid has the same colour through both (relative colorimetric, within
|
||||
/// [`SAME_COLORS_MAX_DELTA_E`]). Different encodings of one space are the same colours: the
|
||||
@@ -228,7 +241,7 @@ impl Profile {
|
||||
/// parametric curves) differ in every byte. Colours are compared rather than round-tripped
|
||||
/// because CMYK → PCS → CMYK is not an identity even through a single profile.
|
||||
pub fn same_colors(&self, other: &Profile) -> bool {
|
||||
if self.content_hash() == other.content_hash() {
|
||||
if self.same_bytes(other) {
|
||||
return true;
|
||||
}
|
||||
let n = self.channels();
|
||||
@@ -573,6 +586,24 @@ fn parse_profile(bytes: &[u8]) -> Result<Profile, CmsError> {
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Sampled-curve entries one profile may decode in all (64 MB of `f32`). Tags and curve groups can
|
||||
/// point at the same encoded bytes, and each reference decodes its own table.
|
||||
const MAX_CURVE_SAMPLES: usize = 16 << 20;
|
||||
|
||||
thread_local! {
|
||||
/// What is left of [`MAX_CURVE_SAMPLES`] for the profile being parsed on this thread.
|
||||
static CURVE_SAMPLES_LEFT: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_CURVE_SAMPLES) };
|
||||
}
|
||||
|
||||
/// Charges `n` decoded curve samples to the profile being parsed.
|
||||
fn charge_curve_samples(n: usize) -> Result<(), CmsError> {
|
||||
CURVE_SAMPLES_LEFT.with(|left| {
|
||||
let rest = left.get().checked_sub(n).ok_or_else(|| CmsError::Invalid("the profile's curves hold too many samples".into()))?;
|
||||
left.set(rest);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses a `curv` or `para` element; returns the curve and the element's byte length
|
||||
/// (unpadded).
|
||||
fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> {
|
||||
@@ -590,6 +621,7 @@ fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> {
|
||||
1 => Ok((Curve::Gamma(r.u16(12)? as f64 / 256.0), len)),
|
||||
_ => {
|
||||
let s = r.slice(12, n * 2)?;
|
||||
charge_curve_samples(n)?;
|
||||
let t = s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect();
|
||||
Ok((Curve::Table(t), len))
|
||||
}
|
||||
@@ -818,6 +850,7 @@ fn parse_text(d: &[u8]) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::builtin::Builtin;
|
||||
|
||||
fn be(v: u32) -> [u8; 4] {
|
||||
v.to_be_bytes()
|
||||
@@ -903,4 +936,71 @@ mod tests {
|
||||
d.extend(be(u32::MAX));
|
||||
assert_eq!(parse_text(&d), "");
|
||||
}
|
||||
|
||||
/// `p` with the content hash of `other`: the hash is a fixed-key 64-bit SipHash, so a
|
||||
/// crafted profile can have another's (#8613). Setting it stands in for finding one.
|
||||
fn with_hash_of(p: &Profile, other: &Profile) -> Profile {
|
||||
let p = Profile { hash: std::sync::OnceLock::from(other.content_hash()), ..p.clone() };
|
||||
assert_eq!(p.content_hash(), other.content_hash());
|
||||
p
|
||||
}
|
||||
|
||||
/// #8613: a profile that shares sRGB's hash is not taken for sRGB.
|
||||
#[test]
|
||||
fn a_hash_collision_is_not_the_same_colours() {
|
||||
let srgb = Builtin::Srgb.profile();
|
||||
let p3 = with_hash_of(Builtin::DisplayP3.profile(), srgb);
|
||||
assert!(!p3.same_bytes(srgb));
|
||||
assert!(!p3.same_colors(srgb));
|
||||
// Controls: equal bytes are the same, whatever object holds them.
|
||||
assert!(srgb.same_bytes(srgb));
|
||||
assert!(srgb.same_bytes(&Profile::parse(&srgb.to_bytes()).unwrap()));
|
||||
assert!(Builtin::DisplayP3.profile().same_colors(Builtin::DisplayP3.profile()));
|
||||
}
|
||||
|
||||
/// #8613: the transform cache does not hand a colliding profile the other's transform.
|
||||
#[test]
|
||||
fn a_hash_collision_does_not_get_the_cached_transform() {
|
||||
use crate::transform::{Transform, TransformOptions, cached};
|
||||
let (srgb, lab) = (Builtin::Srgb.profile(), Builtin::LabD50.profile());
|
||||
let p3 = with_hash_of(Builtin::DisplayP3.profile(), srgb);
|
||||
let opts = TransformOptions::default();
|
||||
let green = [0.0, 1.0, 0.0];
|
||||
let eval = |t: &Transform| {
|
||||
let mut out = [0f32; 3];
|
||||
t.eval(&green, &mut out);
|
||||
out
|
||||
};
|
||||
let want = eval(&Transform::with_options(Builtin::DisplayP3.profile(), lab, opts).unwrap());
|
||||
assert_ne!(want, eval(&Transform::with_options(srgb, lab, opts).unwrap()), "P3 green is not sRGB green");
|
||||
let sr = eval(&cached(srgb, lab, opts).unwrap());
|
||||
assert_eq!(eval(&cached(&p3, lab, opts).unwrap()), want);
|
||||
// Control: sRGB still gets its own transform after the colliding lookup.
|
||||
assert_eq!(eval(&cached(srgb, lab, opts).unwrap()), sr);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod curve_budget_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aliased_curves_are_charged_for_each_decoded_table() {
|
||||
// One 1M-sample `curv` element referenced seventeen times decodes 17M samples.
|
||||
let mut element = b"curv\0\0\0\0".to_vec();
|
||||
element.extend((1u32 << 20).to_be_bytes());
|
||||
element.extend(std::iter::repeat_n(0u8, 2 << 20));
|
||||
CURVE_SAMPLES_LEFT.with(|left| left.set(MAX_CURVE_SAMPLES));
|
||||
let mut failed_at = None;
|
||||
for i in 0..17 {
|
||||
if parse_curve(&element).is_err() {
|
||||
failed_at = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(failed_at, Some(16), "sixteen tables fit the budget, the seventeenth does not");
|
||||
// Each profile parse starts with a fresh budget.
|
||||
CURVE_SAMPLES_LEFT.with(|left| left.set(MAX_CURVE_SAMPLES));
|
||||
assert!(parse_curve(&element).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -703,20 +703,33 @@ pub fn black_point(p: &Profile, intent: Intent, _input: bool) -> Result<f64, Cms
|
||||
|
||||
type Key = (u64, u64, TransformOptions);
|
||||
|
||||
/// Process-wide cache of transforms keyed by profile content and options.
|
||||
/// A cached transform with the ICC bytes of the profiles it was built from.
|
||||
struct Entry {
|
||||
src: Arc<Vec<u8>>,
|
||||
dst: Arc<Vec<u8>>,
|
||||
transform: Arc<Transform>,
|
||||
}
|
||||
|
||||
/// Process-wide cache of transforms keyed by profile content and options. The key is the
|
||||
/// profiles' 64-bit hashes, which a crafted profile can share with another (#8613), so a hit
|
||||
/// counts only when the bytes match too; a colliding pair gets its own, uncached transform.
|
||||
pub fn cached(src: &Profile, dst: &Profile, opts: TransformOptions) -> Result<Arc<Transform>, CmsError> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<Key, Arc<Transform>>>> = OnceLock::new();
|
||||
static CACHE: OnceLock<Mutex<HashMap<Key, Entry>>> = OnceLock::new();
|
||||
let key = (src.content_hash(), dst.content_hash(), opts);
|
||||
let (src_bytes, dst_bytes) = (src.to_bytes(), dst.to_bytes());
|
||||
let cache = CACHE.get_or_init(Default::default);
|
||||
if let Some(t) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return Ok(t.clone());
|
||||
let hit = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key).map(|e| (e.src == src_bytes && e.dst == dst_bytes).then(|| e.transform.clone()));
|
||||
match hit {
|
||||
Some(Some(t)) => return Ok(t),
|
||||
Some(None) => return Ok(Arc::new(Transform::with_options(src, dst, opts)?)),
|
||||
None => {}
|
||||
}
|
||||
let t = Arc::new(Transform::with_options(src, dst, opts)?);
|
||||
let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.len() > 64 {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(key, t.clone());
|
||||
c.insert(key, Entry { src: src_bytes, dst: dst_bytes, transform: t.clone() });
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,9 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
|
||||
give `DecodeWarning::MoreElements`; the image orientation is applied on import. Writing is
|
||||
big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for
|
||||
U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma.
|
||||
`FidelityWarning::BitsReduced` reports a 10- or 12-bit save (and every Cineon save) that drops
|
||||
precision: always for float samples, for U16 ones unless they are already code values at that
|
||||
depth.
|
||||
Other descriptors, signed or run-length-encoded data and other bit depths return
|
||||
`CodecError::Unsupported` naming the field.
|
||||
|
||||
|
||||
@@ -537,6 +537,26 @@ pub(crate) fn bits_for(opt: Option<DpxBits>, sample: SampleType) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bits `format` stores below its U16 plan: DPX at 10 or 12 bits, Cineon's 10. `None` when
|
||||
/// the stored depth is the plan's sample type.
|
||||
pub(crate) fn packed_bits(format: Format, opts: &EncodeOptions) -> Option<u8> {
|
||||
match format {
|
||||
Format::Dpx => opts.dpx_bits.filter(|b| matches!(b, DpxBits::Bits10 | DpxBits::Bits12)).map(DpxBits::bits),
|
||||
Format::Cineon => Some(10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` when every sample of a U16 image is already a `bits`-bit code value, so writing it at
|
||||
/// `bits` and reading it back gives the same samples (a frame opened from such a file).
|
||||
pub(crate) fn keeps_u16_samples(img: &Image, bits: u8) -> bool {
|
||||
if img.sample_type() != SampleType::U16 || !(1..16).contains(&bits) {
|
||||
return false;
|
||||
}
|
||||
let max = (1u64 << bits) - 1;
|
||||
img.data().as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes(*c)).all(|v| widen(narrow(v, max), max) == v)
|
||||
}
|
||||
|
||||
/// The plan's sample type for the chosen bits: U8 for 8 bits, U16 for 10, 12 and 16.
|
||||
pub(crate) fn plan_sample(opt: Option<DpxBits>, src: SampleType) -> SampleType {
|
||||
match opt {
|
||||
|
||||
@@ -137,32 +137,39 @@ enum DataEnd {
|
||||
/// Walks the marker segments (skipped by their length) and the entropy-coded scan data
|
||||
/// (skipped up to the next marker that isn't a stuffed `FF 00`, a restart or a fill byte),
|
||||
/// so an embedded thumbnail's end marker or data after the end of the image don't count.
|
||||
fn data_end(b: &[u8]) -> DataEnd {
|
||||
/// Also returns how many scan-data bytes the walk crossed: the truncated-input budget is
|
||||
/// measured on these, not on the file length, which APP padding can inflate without
|
||||
/// adding image data.
|
||||
fn data_end(b: &[u8]) -> (DataEnd, u64) {
|
||||
// Where the bytes run out: inside the image data once a scan has held some.
|
||||
let cut = |scan_data: bool| if scan_data { DataEnd::Truncated } else { DataEnd::Empty };
|
||||
let cut = |scan_data: bool, scanned: u64| (if scan_data { DataEnd::Truncated } else { DataEnd::Empty }, scanned);
|
||||
let mut i = 2; // past SOI
|
||||
let mut scan_data = false;
|
||||
let mut scanned = 0u64;
|
||||
loop {
|
||||
let Some(&byte) = b.get(i) else { return cut(scan_data) };
|
||||
let Some(&byte) = b.get(i) else { return cut(scan_data, scanned) };
|
||||
if byte != 0xFF {
|
||||
// Not where a marker should be: too odd to judge.
|
||||
return DataEnd::Complete;
|
||||
return (DataEnd::Complete, scanned);
|
||||
}
|
||||
let Some(&code) = b.get(i + 1) else { return cut(scan_data) };
|
||||
let Some(&code) = b.get(i + 1) else { return cut(scan_data, scanned) };
|
||||
match code {
|
||||
0xD9 => return DataEnd::Complete,
|
||||
0xD9 => return (DataEnd::Complete, scanned),
|
||||
0xFF => i += 1,
|
||||
0x01 | 0xD0..=0xD8 => i += 2,
|
||||
_ => {
|
||||
let Some(len) = b.get(i + 2..i + 4).and_then(|s| <[u8; 2]>::try_from(s).ok()).map(u16::from_be_bytes) else {
|
||||
return cut(scan_data);
|
||||
return cut(scan_data, scanned);
|
||||
};
|
||||
i = i.saturating_add(2 + usize::from(len));
|
||||
if code == 0xDA {
|
||||
let start = i;
|
||||
loop {
|
||||
let Some(p) = b.get(i..).and_then(|r| r.iter().position(|&v| v == 0xFF)) else {
|
||||
return cut(scan_data || b.len() > start);
|
||||
// EOF inside this scan: everything from the scan start to the end
|
||||
// of the file is scan data (inside a declared segment it may not
|
||||
// be, so only count what remains).
|
||||
return cut(scan_data || b.len() > start, scanned + u64::try_from(b.len().saturating_sub(start)).unwrap_or(0));
|
||||
};
|
||||
i += p;
|
||||
match b.get(i + 1) {
|
||||
@@ -172,6 +179,7 @@ fn data_end(b: &[u8]) -> DataEnd {
|
||||
}
|
||||
}
|
||||
scan_data |= i > start;
|
||||
scanned += u64::try_from(i - start).unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,8 +190,12 @@ fn err(e: impl std::fmt::Display) -> CodecError {
|
||||
CodecError::malformed(F, e)
|
||||
}
|
||||
|
||||
/// Most pixels one byte of truncated scan data may account for. Even a flat image costs a few
|
||||
/// bits per 8×8 block, and truncated photographs are far above this.
|
||||
const MAX_TRUNCATED_PIXELS_PER_BYTE: u64 = 2048;
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
|
||||
let end = data_end(bytes);
|
||||
let (end, scan_bytes) = data_end(bytes);
|
||||
if end == DataEnd::Empty {
|
||||
return Err(err("the file ends before any image data"));
|
||||
}
|
||||
@@ -204,6 +216,13 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
|
||||
_ => (ColorSpace::RGB, ChannelLayout::Rgb),
|
||||
};
|
||||
limits.check(w, h, layout, SampleType::U8)?;
|
||||
// The backend fills whatever the scan no longer describes, so a short scan under a large
|
||||
// frame is a gigabyte of synthesized output from a few kilobytes of file. The budget is
|
||||
// measured on the scan data itself: APP padding can inflate `bytes.len()` arbitrarily
|
||||
// without describing one more pixel.
|
||||
if end == DataEnd::Truncated && u64::from(w) * u64::from(h) / MAX_TRUNCATED_PIXELS_PER_BYTE > scan_bytes {
|
||||
return Err(err("the truncated scan is too short for the frame it declares"));
|
||||
}
|
||||
dec.set_options(dec.options().jpeg_set_out_colorspace(out_cs));
|
||||
let mut px = dec.decode().map_err(err)?;
|
||||
let expected = w as usize * h as usize * layout.channels();
|
||||
@@ -435,6 +454,52 @@ mod tests {
|
||||
bytes.get(2..11) == Some(b"\xff\xe0\0\x10JFIF\0")
|
||||
}
|
||||
|
||||
/// A tiny truncated scan under a frame patched to 20000×20000 is refused before the backend
|
||||
/// fills 400 million pixels from it; the same file with its real size still recovers.
|
||||
#[test]
|
||||
fn a_short_truncated_scan_cannot_declare_a_huge_frame() {
|
||||
let img = Image::from_raw(16, 16, ChannelLayout::Rgb, SampleType::U8, vec![128; 16 * 16 * 3]).unwrap();
|
||||
let full = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
let cut = &full[..full.len() - 4];
|
||||
assert_eq!(data_end(cut).0, DataEnd::Truncated);
|
||||
assert!(crate::decode_as(Format::Jpeg, cut).is_ok());
|
||||
let sof = cut.windows(2).position(|w| w == [0xFF, 0xC0] || w == [0xFF, 0xC2]).unwrap();
|
||||
let mut huge = cut.to_vec();
|
||||
huge[sof + 5..sof + 9].copy_from_slice(&[0x4E, 0x20, 0x4E, 0x20]);
|
||||
let e = crate::decode_as(Format::Jpeg, &huge).unwrap_err();
|
||||
assert!(e.to_string().contains("too short"), "{e}");
|
||||
}
|
||||
|
||||
/// The truncated-scan budget counts scan data, not the file length: APP padding before the
|
||||
/// window cannot admit an oversized output. The same padded file with its real size still
|
||||
/// decodes (with the truncation warning).
|
||||
#[test]
|
||||
fn app_padding_cannot_admit_an_oversized_truncated_frame() {
|
||||
let img = Image::from_raw(16, 16, ChannelLayout::Rgb, SampleType::U8, vec![128; 16 * 16 * 3]).unwrap();
|
||||
let full = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
let cut = &full[..full.len() - 4];
|
||||
// Four full-length COMMENT segments (~256 KiB) stand where APP padding would; they are
|
||||
// complete, so the file otherwise parses normally.
|
||||
let fill = [b'p'; 65533];
|
||||
let mut padded = cut[..2].to_vec();
|
||||
for _ in 0..4 {
|
||||
padded.extend_from_slice(&[0xFF, 0xFE]);
|
||||
padded.extend_from_slice(&0xFFFFu16.to_be_bytes());
|
||||
padded.extend_from_slice(&fill);
|
||||
}
|
||||
padded.extend_from_slice(&cut[2..]);
|
||||
assert_eq!(data_end(&padded).0, DataEnd::Truncated);
|
||||
// Well above the frame's pixels / budget, yet far above the scan data: the old
|
||||
// file-length bound admitted this frame.
|
||||
assert!(padded.len() > (u64::from(20_000u32) * u64::from(20_000u32) / MAX_TRUNCATED_PIXELS_PER_BYTE) as usize, "padding too small");
|
||||
assert!(crate::decode_as(Format::Jpeg, &padded).is_ok());
|
||||
let sof = padded.windows(2).position(|w| w == [0xFF, 0xC0] || w == [0xFF, 0xC2]).unwrap();
|
||||
let mut huge = padded.clone();
|
||||
huge[sof + 5..sof + 9].copy_from_slice(&[0x4E, 0x20, 0x4E, 0x20]);
|
||||
let e = crate::decode_as(Format::Jpeg, &huge).unwrap_err();
|
||||
assert!(e.to_string().contains("too short"), "{e}");
|
||||
}
|
||||
|
||||
/// CMYK JPEG output must omit JFIF while retaining Adobe's CMYK marker.
|
||||
#[test]
|
||||
fn cmyk_jpeg_omits_jfif_but_keeps_adobe_cmyk_marker() {
|
||||
|
||||
@@ -85,7 +85,9 @@ pub(crate) fn decode_frames(bytes: &[u8], limits: &Limits) -> Result<Option<Vec<
|
||||
if out.len() >= MAX_GIF_FRAMES {
|
||||
return Err(CodecError::LimitExceeded(format!("more than {MAX_GIF_FRAMES} frames")));
|
||||
}
|
||||
if (size as u64).saturating_mul(out.len() as u64 + 1) > limits.max_alloc {
|
||||
// Every frame keeps its own copy of the file's ICC profile along with its pixels.
|
||||
let frame_bytes = (size as u64).saturating_add(icc.as_ref().map_or(0, |p| p.len() as u64));
|
||||
if frame_bytes.saturating_mul(out.len() as u64 + 1) > limits.max_alloc {
|
||||
return Err(CodecError::LimitExceeded(format!("{} frames of {w}x{h} exceed max_alloc {}", out.len() + 1, limits.max_alloc)));
|
||||
}
|
||||
let delay_ms = match dec.read_frame(&mut buf) {
|
||||
@@ -126,9 +128,8 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
|
||||
enc.set_icc_profile(icc.clone());
|
||||
}
|
||||
if opts.embed_metadata {
|
||||
if let Some(exif) = &img.meta.exif {
|
||||
// The pixels are written as they are shown: never let a viewer rotate them again.
|
||||
enc.set_exif_metadata(crate::resolution::export_exif(exif, img.meta.dpi).into_owned());
|
||||
if let Some(exif) = export_exif(&img) {
|
||||
enc.set_exif_metadata(exif);
|
||||
}
|
||||
if let Some(xmp) = &img.meta.xmp {
|
||||
enc.set_xmp_metadata(crate::resolution::export_xmp(xmp, img.meta.dpi).as_bytes().to_vec());
|
||||
@@ -160,11 +161,40 @@ fn encode_lossy(img: &Image, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError
|
||||
None
|
||||
};
|
||||
let icc = if opts.embed_icc { img.icc.as_deref() } else { None };
|
||||
let exif = if opts.embed_metadata { img.meta.exif.as_deref().map(|e| crate::resolution::export_exif(e, img.meta.dpi)) } else { None };
|
||||
let exif = if opts.embed_metadata { export_exif(img) } else { None };
|
||||
let xmp = if opts.embed_metadata { img.meta.xmp.as_deref().map(|x| crate::resolution::export_xmp(x, img.meta.dpi)) } else { None };
|
||||
Ok(riff(w, h, &frame, alpha.as_deref(), icc, exif.as_deref(), xmp.as_deref().map(str::as_bytes)))
|
||||
}
|
||||
|
||||
/// The EXIF to write: the image's, upright (the pixels are written as they are shown, so a viewer
|
||||
/// must never rotate them again), carrying the resolution. WebP has no resolution field of its own,
|
||||
/// so the resolution goes into EXIF, added when the image has none (#2399).
|
||||
pub(crate) fn export_exif(img: &Image) -> Option<Vec<u8>> {
|
||||
let exif = img.meta.exif.as_deref().unwrap_or_default();
|
||||
// A WebP without EXIF already reads as 72 ppi, so the default needs no block of its own (and
|
||||
// the extended container it would bring to every web export).
|
||||
if exif.is_empty() && img.meta.dpi.is_none_or(is_default_ppi) {
|
||||
return None;
|
||||
}
|
||||
let out = crate::resolution::export_exif_adding_missing_resolution(exif, img.meta.dpi).into_owned();
|
||||
(!out.is_empty()).then_some(out)
|
||||
}
|
||||
|
||||
/// Whether a WebP of `img` keeps its resolution: the EXIF written carries it. Not when the image's
|
||||
/// own EXIF can't be parsed: that block is written as it is rather than replaced, so the
|
||||
/// resolution is dropped (and reported) instead of the metadata.
|
||||
pub(crate) fn keeps_dpi(img: &Image) -> bool {
|
||||
let bare_default = img.meta.exif.as_deref().is_none_or(<[u8]>::is_empty) && img.meta.dpi.is_none_or(is_default_ppi);
|
||||
// The XMP written beside it carries the resolution too, and readers look there first.
|
||||
let in_xmp = img.meta.xmp.as_deref().is_some_and(|x| crate::resolution::xmp_resolution(&crate::resolution::export_xmp(x, img.meta.dpi)).is_some());
|
||||
bare_default || in_xmp || export_exif(img).is_some_and(|e| crate::resolution::exif_resolution(&e).is_some())
|
||||
}
|
||||
|
||||
/// 72 ppi each way: what a file without a resolution opens as.
|
||||
fn is_default_ppi((x, y): (f32, f32)) -> bool {
|
||||
(x - 72.0).abs() < 0.01 && (y - 72.0).abs() < 0.01
|
||||
}
|
||||
|
||||
/// A RIFF chunk: FourCC, little-endian size, payload, padded to an even length.
|
||||
fn chunk(out: &mut Vec<u8>, fourcc: &[u8; 4], payload: &[u8]) {
|
||||
out.extend_from_slice(fourcc);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::codecs::dpx;
|
||||
use crate::format::{Format, caps};
|
||||
use crate::image::{ChannelLayout, Image, SampleType};
|
||||
use crate::options::{DdsCompression, EncodeOptions};
|
||||
@@ -20,6 +21,12 @@ pub enum FidelityWarning {
|
||||
from: SampleType,
|
||||
to: SampleType,
|
||||
},
|
||||
/// Samples will be stored with fewer bits than their sample type holds: DPX at 10 or 12
|
||||
/// bits, Cineon at 10.
|
||||
BitsReduced {
|
||||
from: SampleType,
|
||||
bits: u8,
|
||||
},
|
||||
/// Float samples outside `[0, 1]` will be clipped by integer storage.
|
||||
RangeClipped,
|
||||
/// Non-opaque alpha will be discarded.
|
||||
@@ -82,6 +89,7 @@ impl fmt::Display for FidelityWarning {
|
||||
write!(f, "{} cannot be written in this build", format.name())
|
||||
}
|
||||
DepthReduced { from, to } => write!(f, "{} will be reduced to {}", depth_name(*from), depth_name(*to)),
|
||||
BitsReduced { from, bits } => write!(f, "{} will be reduced to {bits}-bit", depth_name(*from)),
|
||||
RangeClipped => write!(f, "HDR values outside 0..1 will be clipped"),
|
||||
AlphaDiscarded => write!(f, "alpha will be discarded"),
|
||||
AlphaBinarized => write!(f, "alpha will be reduced to on/off transparency"),
|
||||
@@ -161,7 +169,7 @@ pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan
|
||||
// The sample type follows the chosen bits (U8 for 8, U16 for 10, 12 and 16).
|
||||
Format::Dpx => Plan {
|
||||
layout: if layout == ChannelLayout::GrayA { ChannelLayout::Gray } else { pick_layout(layout, c.layouts, c.alpha) },
|
||||
sample: crate::codecs::dpx::plan_sample(opts.dpx_bits, sample),
|
||||
sample: dpx::plan_sample(opts.dpx_bits, sample),
|
||||
},
|
||||
// CIELab has no float form: float Lab is written with 16 bits.
|
||||
Format::Tiff if layout.is_lab() && sample.is_float() => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: SampleType::U16 },
|
||||
@@ -201,7 +209,13 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
|
||||
(SampleType::F16, SampleType::F32) => false,
|
||||
_ => true,
|
||||
};
|
||||
if lossy_depth {
|
||||
// DPX at 10 or 12 bits and Cineon keep fewer bits than their U16 plan: float samples always
|
||||
// lose precision there, 16-bit ones unless they are already code values at that depth.
|
||||
if let Some(bits) = dpx::packed_bits(format, opts).filter(|_| ss != SampleType::U8) {
|
||||
if ss.is_float() || !dpx::keeps_u16_samples(&image.converted(p.layout, SampleType::U16), bits) {
|
||||
w.push(W::BitsReduced { from: ss, bits });
|
||||
}
|
||||
} else if lossy_depth {
|
||||
w.push(W::DepthReduced { from: ss, to: p.sample });
|
||||
}
|
||||
// FITS stores floats as 0..1 (see `codecs::fits`), so out-of-range values are clipped too.
|
||||
@@ -254,7 +268,7 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
|
||||
w.push(W::MetadataTooLarge { what: "XMP", bytes: xmp.len() });
|
||||
}
|
||||
}
|
||||
if m.dpi.is_some() && !c.dpi {
|
||||
if m.dpi.is_some() && (!c.dpi || (format == Format::WebP && !crate::codecs::webp::keeps_dpi(image))) {
|
||||
w.push(W::DpiDropped);
|
||||
}
|
||||
// DPX keeps its own header fields, not free-form text.
|
||||
|
||||
@@ -256,7 +256,8 @@ pub fn caps(format: Format) -> FormatCaps {
|
||||
}
|
||||
Format::Jpeg => FormatCaps { layouts: &[L::Gray, L::Rgb, L::Cmyk], alpha: false, icc: true, exif: true, xmp: true, dpi: true, lossy: true, ..base },
|
||||
Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: TIFF_LAYOUTS, icc: true, exif: true, xmp: true, dpi: true, text: true, ..base },
|
||||
Format::WebP => FormatCaps { icc: true, exif: true, xmp: true, animation: true, ..base },
|
||||
// Resolution travels in EXIF (WebP has no field of its own).
|
||||
Format::WebP => FormatCaps { icc: true, exif: true, xmp: true, dpi: true, animation: true, ..base },
|
||||
Format::Gif => FormatCaps { layouts: &[L::Rgba], animation: true, lossy: true, ..base },
|
||||
Format::Bmp => base,
|
||||
Format::Tga => FormatCaps { layouts: RGB_GRAY, ..base },
|
||||
|
||||
@@ -14,7 +14,9 @@ pub struct Limits {
|
||||
/// Maximum bytes for the decoded pixel buffer (and passed on as the
|
||||
/// allocation budget to underlying decoders).
|
||||
/// PNG also applies this to the aggregate decoded text/XMP UTF-8 bytes,
|
||||
/// including keywords, separately from pixels. This is not a total RSS cap.
|
||||
/// including keywords, separately from pixels. Animated WebP charges
|
||||
/// every retained frame's full pixel copy plus its own copy of the
|
||||
/// file's ICC profile against this budget. This is not a total RSS cap.
|
||||
pub max_alloc: u64,
|
||||
}
|
||||
|
||||
|
||||
@@ -323,6 +323,49 @@ fn chosen_bit_depth_sets_the_header_and_packing() {
|
||||
assert!(fidelity_warnings(&float, Format::Dpx).contains(&FidelityWarning::DepthReduced { from: SampleType::F32, to: SampleType::U16 }));
|
||||
}
|
||||
|
||||
/// The depth warnings of `img` written as `format` with `o`.
|
||||
fn depth_warnings(img: &Image, format: Format, o: &EncodeOptions) -> Vec<FidelityWarning> {
|
||||
fidelity_warnings_with(img, format, o)
|
||||
.into_iter()
|
||||
.filter(|w| matches!(w, FidelityWarning::DepthReduced { .. } | FidelityWarning::BitsReduced { .. }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_sixteen_bits_at_ten_or_twelve_warns_with_the_stored_bits() {
|
||||
let img = Image::from_u16(5, 2, ChannelLayout::Rgb, &(0..30u16).map(|i| i * 2000 + 1).collect::<Vec<_>>()).unwrap();
|
||||
for (choice, bits) in [(DpxBits::Bits10, 10), (DpxBits::Bits12, 12)] {
|
||||
assert_eq!(depth_warnings(&img, Format::Dpx, &opts(Some(choice))), [FidelityWarning::BitsReduced { from: SampleType::U16, bits }]);
|
||||
assert_eq!(FidelityWarning::BitsReduced { from: SampleType::U16, bits }.to_string(), format!("16-bit will be reduced to {bits}-bit"));
|
||||
// Float samples name the stored bits too, not the 16-bit words they are packed in.
|
||||
let float = img.convert(ChannelLayout::Rgb, SampleType::F32);
|
||||
assert_eq!(depth_warnings(&float, Format::Dpx, &opts(Some(choice))), [FidelityWarning::BitsReduced { from: SampleType::F32, bits }]);
|
||||
// 8-bit samples widen into 10 and 12 bits without loss.
|
||||
let eight = img.convert(ChannelLayout::Rgb, SampleType::U8);
|
||||
assert_eq!(depth_warnings(&eight, Format::Dpx, &opts(Some(choice))), []);
|
||||
}
|
||||
assert_eq!(depth_warnings(&img, Format::Dpx, &opts(Some(DpxBits::Bits16))), []);
|
||||
assert_eq!(depth_warnings(&img, Format::Dpx, &opts(None)), []);
|
||||
assert_eq!(depth_warnings(&img, Format::Dpx, &opts(Some(DpxBits::Bits8))), [FidelityWarning::DepthReduced { from: SampleType::U16, to: SampleType::U8 }]);
|
||||
assert_eq!(depth_warnings(&img, Format::Cineon, &EncodeOptions::default()), [FidelityWarning::BitsReduced { from: SampleType::U16, bits: 10 }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_opened_at_ten_or_twelve_bits_saves_back_without_a_warning() {
|
||||
let img = Image::from_u16(5, 2, ChannelLayout::Rgb, &(0..30u16).map(|i| i * 2000 + 1).collect::<Vec<_>>()).unwrap();
|
||||
for (choice, bits) in [(DpxBits::Bits10, 10), (DpxBits::Bits12, 12)] {
|
||||
let opened = decode(&encode(&img, Format::Dpx, &opts(Some(choice))).unwrap()).unwrap();
|
||||
assert_eq!(depth_warnings(&opened, Format::Dpx, &opts(Some(choice))), [], "{bits}-bit");
|
||||
assert_eq!(decode(&encode(&opened, Format::Dpx, &opts(Some(choice))).unwrap()).unwrap(), opened, "{bits}-bit");
|
||||
}
|
||||
// A 12-bit frame saved at 10 bits does lose its two low bits.
|
||||
let twelve = decode(&encode(&img, Format::Dpx, &opts(Some(DpxBits::Bits12))).unwrap()).unwrap();
|
||||
assert_eq!(depth_warnings(&twelve, Format::Dpx, &opts(Some(DpxBits::Bits10))), [FidelityWarning::BitsReduced { from: SampleType::U16, bits: 10 }]);
|
||||
let cineon = decode(&cineon_bytes(3, 4, 2, &cineon_codes(24))).unwrap();
|
||||
assert_eq!(depth_warnings(&cineon, Format::Cineon, &EncodeOptions::default()), []);
|
||||
assert_eq!(depth_warnings(&cineon, Format::Dpx, &opts(Some(DpxBits::Bits10))), []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gray_with_alpha_is_written_as_luma_with_a_warning() {
|
||||
let img = Image::from_u8(2, 1, ChannelLayout::GrayA, vec![10, 128, 200, 255]).unwrap();
|
||||
|
||||
@@ -99,13 +99,14 @@ fn exif_xmp_dpi_text_dropped_for_qoi() {
|
||||
assert!(w.contains(&x), "{x:?}");
|
||||
}
|
||||
}
|
||||
/// WebP keeps the resolution in EXIF (#2399); only free-form text has nowhere to go.
|
||||
#[test]
|
||||
fn webp_drops_dpi_and_text_only() {
|
||||
fn webp_drops_text_only() {
|
||||
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
|
||||
img.meta =
|
||||
Metadata { exif: Some(sample_exif()), xmp: Some("x".into()), dpi: Some((72.0, 72.0)), text: vec![("a".into(), "b".into())], ..Default::default() };
|
||||
let w = warns(&img, Format::WebP);
|
||||
assert_eq!(w, vec![W::DpiDropped, W::TextDropped]);
|
||||
assert_eq!(w, vec![W::TextDropped]);
|
||||
}
|
||||
#[test]
|
||||
fn float_to_png_reduces_and_clips() {
|
||||
@@ -180,6 +181,7 @@ fn every_warning_has_a_message() {
|
||||
let all = [
|
||||
W::WriteUnsupported { format: Format::Avif },
|
||||
W::DepthReduced { from: SampleType::F32, to: SampleType::F16 },
|
||||
W::BitsReduced { from: SampleType::U16, bits: 10 },
|
||||
W::RangeClipped,
|
||||
W::AlphaDiscarded,
|
||||
W::AlphaBinarized,
|
||||
@@ -209,9 +211,19 @@ fn warnings_match_actual_roundtrip_behaviour() {
|
||||
let img = test_image(l, s);
|
||||
let w = warns(&img, f);
|
||||
let back = decode(&encode(&img, f, &EncodeOptions::default()).unwrap()).unwrap();
|
||||
let depth_reduced = w.iter().any(|w| matches!(w, W::DepthReduced { .. }));
|
||||
let depth_reduced = w.iter().any(|w| match w {
|
||||
W::DepthReduced { .. } => true,
|
||||
W::BitsReduced { from, .. } => from.is_float(),
|
||||
_ => false,
|
||||
});
|
||||
let lost_depth = back.sample_type() != s && back.sample_type() != SampleType::F32 && s != SampleType::U8;
|
||||
assert_eq!(depth_reduced, lost_depth, "{f:?} {l:?} {s:?} {w:?} -> {:?}", back.sample_type());
|
||||
// Cineon stores 10 bits: 16-bit samples come back changed exactly when warned.
|
||||
if f == Format::Cineon && s == SampleType::U16 && back.sample_type() == s {
|
||||
let bits_reduced = w.iter().any(|w| matches!(w, W::BitsReduced { .. }));
|
||||
let changed = back.to_u16_samples() != img.convert(back.layout(), s).to_u16_samples();
|
||||
assert_eq!(bits_reduced, changed, "{f:?} {l:?} {w:?}");
|
||||
}
|
||||
let alpha_lost = l.has_alpha() && !back.layout().has_alpha();
|
||||
assert_eq!(w.contains(&W::AlphaDiscarded), alpha_lost, "{f:?} {l:?} {s:?}");
|
||||
let cmyk_lost = l.is_cmyk() && !back.layout().is_cmyk();
|
||||
|
||||
@@ -38,6 +38,12 @@ fn check_format(format: Format) {
|
||||
// TIFF writes the block's directories as its own and rebuilds a block from them on
|
||||
// reading (tests/it/tiff_exif.rs); IFD0's orientation here is the image's.
|
||||
assert!(!warnings.contains(&FidelityWarning::ExifDropped), "{format:?}");
|
||||
} else if c.exif && format == Format::WebP {
|
||||
// WebP's resolution lives in EXIF, so the block comes back with resolution tags added.
|
||||
let back_exif = back.meta.exif.as_deref().unwrap_or_else(|| panic!("{format:?} lost exif"));
|
||||
assert_eq!(photocraft_codecs::resolution::exif_resolution(back_exif).map(|(x, y)| (x.round(), y.round())), Some((300.0, 150.0)));
|
||||
// The rest of the block survives too: the Orientation tag (0x0112, little-endian).
|
||||
assert!(back_exif.windows(2).any(|w| w == b"\x12\x01"), "{format:?} lost Orientation");
|
||||
} else if c.exif {
|
||||
assert_eq!(back.meta.exif, img.meta.exif, "{format:?} exif");
|
||||
} else {
|
||||
@@ -295,3 +301,40 @@ fn metadata_survives_depth_conversion() {
|
||||
assert_eq!(back.icc, img.icc);
|
||||
assert_eq!(back.meta.xmp, img.meta.xmp);
|
||||
}
|
||||
|
||||
/// #2399: a WebP export keeps the resolution even when the image has no EXIF (WebP has no
|
||||
/// resolution field of its own, so a minimal EXIF block carries it), lossless and lossy alike,
|
||||
/// and no longer warns that it is dropped.
|
||||
#[test]
|
||||
fn webp_keeps_dpi_without_source_exif() {
|
||||
let mut img = test_image(ChannelLayout::Rgb, SampleType::U8);
|
||||
img.meta = Metadata { dpi: Some((300.0, 150.0)), ..Default::default() };
|
||||
assert!(!fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped));
|
||||
for lossless in [true, false] {
|
||||
let opts = EncodeOptions { webp_lossless: lossless, ..Default::default() };
|
||||
let back = decode(&encode(&img, Format::WebP, &opts).unwrap()).unwrap();
|
||||
let (x, y) = back.meta.dpi.unwrap_or_else(|| panic!("lossless {lossless}: dpi lost"));
|
||||
assert!((x - 300.0).abs() < 0.05 && (y - 150.0).abs() < 0.05, "lossless {lossless}: {x},{y}");
|
||||
}
|
||||
// EXIF that can't be parsed is written as it is, so the resolution is reported as dropped.
|
||||
img.meta = Metadata { exif: Some(b"Exif\0\0garbage".to_vec()), dpi: Some((300.0, 150.0)), ..Default::default() };
|
||||
assert!(fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped));
|
||||
let back = decode(&encode(&img, Format::WebP, &EncodeOptions::default()).unwrap()).unwrap();
|
||||
assert!(back.meta.exif.is_some());
|
||||
// ...unless the XMP written beside it records the resolution, which readers look at first.
|
||||
img.meta.xmp = Some(r#"<x:xmpmeta><rdf:Description tiff:XResolution="300/1" tiff:YResolution="150/1" tiff:ResolutionUnit="2"/></x:xmpmeta>"#.into());
|
||||
assert!(!fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped));
|
||||
let back = decode(&encode(&img, Format::WebP, &EncodeOptions::default()).unwrap()).unwrap();
|
||||
let (x, y) = back.meta.dpi.expect("dpi from XMP");
|
||||
assert!((x - 300.0).abs() < 0.05 && (y - 150.0).abs() < 0.05, "{x},{y}");
|
||||
// The default 72 ppi needs no EXIF (a WebP without one opens at 72), so a plain export stays
|
||||
// a simple-format file; nor does an image without a resolution.
|
||||
img.meta = Metadata { dpi: Some((72.0, 72.0)), ..Default::default() };
|
||||
assert!(!fidelity_warnings(&img, Format::WebP).contains(&FidelityWarning::DpiDropped));
|
||||
let bytes = encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, ..Default::default() }).unwrap();
|
||||
assert_eq!(bytes.get(12..16), Some(&b"VP8 "[..]));
|
||||
// Without a resolution or EXIF there is still no EXIF chunk.
|
||||
img.meta = Metadata::default();
|
||||
let back = decode(&encode(&img, Format::WebP, &EncodeOptions::default()).unwrap()).unwrap();
|
||||
assert!(back.meta.exif.is_none());
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Photoshop and Paint.NET blend modes (scalar reference implementation).
|
||||
//!
|
||||
//! Formulas follow the public PDF / ISO 32000-2 and W3C Compositing definitions,
|
||||
//! with Photoshop's variants where they differ (Soft Light, Hard Mix, Darker/Lighter Color,
|
||||
//! with Photoshop's variants where they differ (Hard Mix, Darker/Lighter Color,
|
||||
//! Linear Burn/Dodge, Vivid/Linear/Pin Light, Subtract, Divide). This is the CPU reference
|
||||
//! that the GPU kernels must match.
|
||||
|
||||
@@ -323,10 +323,19 @@ fn hard_light(cb: f32, cs: f32) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop's Soft Light (differs from the W3C/PDF definition in the upper half).
|
||||
/// Photoshop's Soft Light: the W3C/PDF curve. In the upper half (`cs > 0.5`) the backdrop moves
|
||||
/// towards `D(cb)`, which is `sqrt(cb)` above a quarter and the cubic `((16·cb − 12)·cb + 4)·cb`
|
||||
/// below it; with `sqrt` throughout, dark backdrops came out up to 20 levels too light (#8804,
|
||||
/// measured on psd-tools `blend-modes/rgb-blend-modes.psd`).
|
||||
#[inline]
|
||||
fn soft_light_ps(cb: f32, cs: f32) -> f32 {
|
||||
if cs <= 0.5 { 2.0 * cb * cs + cb * cb * (1.0 - 2.0 * cs) } else { 2.0 * cb * (1.0 - cs) + cb.max(0.0).sqrt() * (2.0 * cs - 1.0) }
|
||||
if cs <= 0.5 {
|
||||
2.0 * cb * cs + cb * cb * (1.0 - 2.0 * cs)
|
||||
} else {
|
||||
let b = cb.max(0.0);
|
||||
let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() };
|
||||
2.0 * cb * (1.0 - cs) + d * (2.0 * cs - 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- non-separable helpers (W3C Compositing Level 1) ----
|
||||
@@ -572,6 +581,37 @@ mod tests {
|
||||
assert!(close(blend_channel(BlendMode::HardMix, 0.6, 0.3), 0.0));
|
||||
}
|
||||
|
||||
/// #8804: Photoshop's Soft Light over a dark backdrop (cb ≤ 0.25, cs > 0.5) follows the W3C
|
||||
/// curve `D(cb) = ((16·cb − 12)·cb + 4)·cb`, not `sqrt(cb)`. Measured on psd-tools
|
||||
/// `blend-modes/rgb-blend-modes.psd` (Photoshop's merged image), in 8-bit levels:
|
||||
/// (backdrop, source, Photoshop).
|
||||
#[test]
|
||||
fn soft_light_follows_photoshop_over_dark_backdrops() {
|
||||
for (cb, cs, ps) in [(4u8, 247u8, 11.0f32), (6, 254, 19.0), (9, 252, 25.0), (10, 237, 32.0), (14, 253, 46.0)] {
|
||||
let got = blend_channel(BlendMode::SoftLight, f32::from(cb) / 255.0, f32::from(cs) / 255.0) * 255.0;
|
||||
assert!((got - ps).abs() <= 7.0, "cb {cb} cs {cs}: {got} vs Photoshop {ps}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_light_is_the_w3c_formula() {
|
||||
let d = |cb: f32| if cb <= 0.25 { ((16.0 * cb - 12.0) * cb + 4.0) * cb } else { cb.sqrt() };
|
||||
let w3c = |cb: f32, cs: f32| if cs <= 0.5 { cb - (1.0 - 2.0 * cs) * cb * (1.0 - cb) } else { cb + (2.0 * cs - 1.0) * (d(cb) - cb) };
|
||||
for b in (0..=255u8).step_by(3) {
|
||||
for s in (0..=255u8).step_by(5) {
|
||||
let (cb, cs) = (f32::from(b) / 255.0, f32::from(s) / 255.0);
|
||||
let got = blend_channel(BlendMode::SoftLight, cb, cs);
|
||||
assert!((got - w3c(cb, cs)).abs() < 1e-5, "cb {b} cs {s}: {got} vs {}", w3c(cb, cs));
|
||||
}
|
||||
}
|
||||
// Continuous where the two halves of D meet (both give 0.5 at cb = 0.25).
|
||||
let below = blend_channel(BlendMode::SoftLight, 0.25, 1.0);
|
||||
let above = blend_channel(BlendMode::SoftLight, 0.25 + 1e-6, 1.0);
|
||||
assert!(close(below, 0.5) && (above - below).abs() < 1e-4, "{below} {above}");
|
||||
// A negative backdrop (32-bit documents) counts as black under a light source, as before.
|
||||
assert!(close(blend_channel(BlendMode::SoftLight, -0.1, 1.0), 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vivid_light_source_extremes_and_interior_values() {
|
||||
for cb in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user