Merge main and preserve original-format embedded vector save-back
@@ -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,51 +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, build scripts or the lockfile.
|
||||
# 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
|
||||
@@ -78,67 +107,100 @@ jobs:
|
||||
else
|
||||
echo "web=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
|
||||
echo "legs=$all" >> "$GITHUB_OUTPUT"
|
||||
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 "windows=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "legs=[$linux]" >> "$GITHUB_OUTPUT"
|
||||
echo "windows=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
test:
|
||||
timeout-minutes: 100
|
||||
name: test (${{ matrix.os }}${{ matrix.part != 'all' && format(', {0}', matrix.part) || '' }})
|
||||
# The Linux gate. Its rust-cache entry is the one `test (without heif)` restores.
|
||||
gate:
|
||||
if: github.event.action != 'labeled' || github.event.label.name == 'full-ci'
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# egui/winit/rfd.
|
||||
packages: >-
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
libgtk-3-dev
|
||||
# The tests build with the optional craft-fonts input (crates/text/build.rs), so the
|
||||
# Japanese font tests that skip without it run. Same commit as release.yml.
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
# Cargo.toml's dev profile already keeps line tables only.
|
||||
dev-debug: ""
|
||||
env: |
|
||||
PHOTOCRAFT_NO_PRINT=1
|
||||
LPDEST=photocraft-no-such-printer
|
||||
timeout-minutes: 100
|
||||
# fmt, clippy, layers, scorecard, tests, panic_hunt, doctests and the wasm32 check, with
|
||||
# `--locked --features heif`, then the missing-translations report (xtask/src/main.rs).
|
||||
command: cargo xtask ci
|
||||
|
||||
# Windows tests run as two legs in parallel: the unit tests and the integration tests. As one job
|
||||
# they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting the integration
|
||||
# tests over a third runner (nextest --partition) saved ~2 min of wall time but cost ~7
|
||||
# runner-minutes and one more Windows slot per PR. They stay outside the shared gate for sccache,
|
||||
# which needs its own action step.
|
||||
windows:
|
||||
needs: platforms
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: needs.platforms.outputs.windows == 'true'
|
||||
timeout-minutes: 100
|
||||
name: test (windows-latest, ${{ matrix.part }})
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include: ${{ fromJSON(needs.platforms.outputs.legs) }}
|
||||
part: [unit, integration]
|
||||
env:
|
||||
# No debuginfo on Windows: writing PDBs for ~200 test binaries cost 2.2 of ~21 build minutes.
|
||||
# A panic still names its file and line; only backtrace frames lose theirs (Linux keeps
|
||||
# line-tables-only, so a backtrace with lines is one OS away).
|
||||
CARGO_PROFILE_DEV_DEBUG: ${{ startsWith(matrix.os, 'windows') && '0' || 'line-tables-only' }}
|
||||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
# The all-language UI tests shape real Japanese, Chinese, Korean and Devanagari labels
|
||||
# with kittest's strict `MissingGlyphPolicy::Panic`. A hosted Windows image has no
|
||||
# guaranteed Japanese or Simplified Chinese system font, and egui 0.37 panics on a
|
||||
# missing glyph; the app's lazy system fallback runs after layout, too late for the
|
||||
# first headless frame. Use the same pinned central fonts as Linux and release, so every
|
||||
# language is drawn by a real embedded face rather than skipping. Same commit as the gate
|
||||
# above and release.yml.
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Unused toolchains in the image (Android SDK, .NET, GHC, CodeQL) are deleted in the
|
||||
# background: the space is free before the craft-fonts build needs it, and nothing waits.
|
||||
- name: Install Linux system dependencies (egui/winit/rfd)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \
|
||||
/opt/hostedtoolcache/CodeQL </dev/null >/dev/null 2>&1 &
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
|
||||
libgl1-mesa-dev libgtk-3-dev
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
path: craft-fonts
|
||||
persist-credentials: false
|
||||
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
components: clippy
|
||||
|
||||
# Only main saves caches; PRs restore main's. Every PR used to save its own set (up to
|
||||
# 3.8 GB), usable by that PR only, so the repo's 10 GB cache budget kept evicting main's.
|
||||
# Only main saves caches; PRs restore main's, so the repo's 10 GB cache budget keeps main's.
|
||||
# A failing test still saves: the cache holds compiled dependencies, not test results.
|
||||
# The Windows legs share one key (same dependencies); the integration leg saves it,
|
||||
# because it also runs Clippy and so leaves Clippy's dependency metadata in it.
|
||||
# Both legs share one key (same dependencies); the integration leg saves it, because it also
|
||||
# runs Clippy and so leaves Clippy's dependency metadata in it.
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }}
|
||||
save-if: ${{ github.ref == 'refs/heads/main' && matrix.part == 'integration' }}
|
||||
cache-on-failure: true
|
||||
|
||||
# sccache caches the workspace crates' compiled libraries, which rust-cache leaves out:
|
||||
# warm, each Windows leg built 4.5 min faster. Like rust-cache, only main writes (PRs read
|
||||
# main's entries); a full set is ~90 MB, so it barely touches the 10 GB budget (#2688).
|
||||
# Test executables can't be cached, so a PR still links its tests.
|
||||
- name: Install sccache (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
- name: Install sccache
|
||||
uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.18.0
|
||||
|
||||
- name: Use sccache (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
- name: Use sccache
|
||||
env:
|
||||
MAIN: ${{ github.ref == 'refs/heads/main' }}
|
||||
run: |
|
||||
@@ -147,149 +209,80 @@ jobs:
|
||||
if [ "$MAIN" != true ]; then echo "SCCACHE_GHA_RW_MODE=READ_ONLY" >> "$GITHUB_ENV"; fi
|
||||
shell: bash
|
||||
|
||||
- name: Format
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
# Keep formatting, strict Clippy, layering and scorecard checks ahead of tests.
|
||||
- name: Clippy
|
||||
if: matrix.part == 'all' || matrix.part == 'integration'
|
||||
if: matrix.part == 'integration'
|
||||
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
|
||||
|
||||
- name: Dependency layering
|
||||
if: matrix.part == 'all' || matrix.part == 'integration'
|
||||
if: matrix.part == 'integration'
|
||||
run: cargo xtask layers
|
||||
|
||||
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
|
||||
# prefs audit). Static: it reads committed files only, no benchmarks run here.
|
||||
- name: Scorecard up to date
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo xtask scorecard --check
|
||||
|
||||
# nextest runs the test binaries in parallel (cargo test runs them one at a time), with the
|
||||
# GPU tests capped by the `gpu` group in .config/nextest.toml (#2204).
|
||||
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
|
||||
- uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
|
||||
with:
|
||||
tool: cargo-nextest@0.9.146
|
||||
tool: cargo-nextest@0.9.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
|
||||
@@ -305,7 +298,9 @@ jobs:
|
||||
CARGO_PROFILE_RELEASE_LTO: "false"
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
@@ -313,7 +308,7 @@ jobs:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-on-failure: true
|
||||
- name: Restore test corpora
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: corpus/
|
||||
key: corpus-${{ hashFiles('xtask/src/corpus_pins.rs', 'xtask/*.sha256') }}
|
||||
@@ -322,23 +317,6 @@ jobs:
|
||||
- name: Corpus tests
|
||||
run: cargo xtask test-corpus
|
||||
|
||||
wasm:
|
||||
timeout-minutes: 30
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: wasm32
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-on-failure: true
|
||||
- name: Check wasm-safe crates
|
||||
run: cargo xtask wasm
|
||||
|
||||
web:
|
||||
timeout-minutes: 60
|
||||
name: web build and wasm size gate
|
||||
@@ -350,7 +328,9 @@ jobs:
|
||||
# Match release.yml so PR results reflect what would actually ship.
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
@@ -408,6 +388,7 @@ jobs:
|
||||
- { crate: psd, name: metadata }
|
||||
- { crate: psd, name: tagged }
|
||||
- { crate: psd, name: resources }
|
||||
- { crate: psd, name: asl }
|
||||
- { crate: codecs, name: decode_any }
|
||||
- { crate: codecs, name: decode_png }
|
||||
- { crate: codecs, name: decode_jpeg }
|
||||
@@ -418,8 +399,10 @@ jobs:
|
||||
- { crate: affinity, name: import_preview, args: "--features io" }
|
||||
- { crate: format, name: load }
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@2c76c034f9e32a5d6e2c104dc4df23a22476db04 # nightly
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # nightly
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
workspaces: crates/${{ matrix.target.crate }}/fuzz
|
||||
@@ -432,7 +415,7 @@ jobs:
|
||||
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time="$FUZZ_SECONDS"
|
||||
- name: Upload crash artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: fuzz-${{ matrix.target.crate }}-${{ matrix.target.name }}
|
||||
path: crates/${{ matrix.target.crate }}/fuzz/artifacts
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
name: mdBook build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
- name: Install mdBook
|
||||
run: cargo install mdbook --version 0.4.52 --locked
|
||||
|
||||
@@ -9,6 +9,8 @@ name: FreeBSD
|
||||
# or dependency change: a drifted dependency still shows up within a day. PRs that change the
|
||||
# FreeBSD packaging itself still run it.
|
||||
on:
|
||||
push:
|
||||
branches: [release]
|
||||
schedule:
|
||||
- cron: "17 2 * * *"
|
||||
pull_request:
|
||||
@@ -30,7 +32,7 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
cargo-build-extra-args: "--features heif"
|
||||
# The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a device at
|
||||
# once, so photocraft-gpu's tests run one at a time, like ui-egui's integration tests.
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
|
||||
- name: Upload results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: perf-results
|
||||
path: |
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
|
||||
- name: Upload baseline
|
||||
if: always() && inputs.update_baseline
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: perf-baseline
|
||||
path: perf/baseline.json
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
with:
|
||||
app-name: photocraft
|
||||
app-title: PhotoCraft
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -11,10 +11,14 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
# A push to main is the run that publishes coverage; superseded runs there would never finish.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
# Track shared CI improvements on main.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
with:
|
||||
timeout-minutes: 120
|
||||
fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
secrets: inherit
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -70,6 +70,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
|
||||
8. **Performance is a feature.** Benchmark heavy operations on a 24–36 MP image in release. Work per tile in parallel (rayon), skip empty tiles, never scan a full surface per frame (cache per revision), and record before/after timings in the dev log.
|
||||
|
||||
9. **Never panic on input** (see *Never crash* above). A command's `run` closure and anything it calls must return `Err`, never panic, for *any* params or document state: validate params, check bounds before indexing or dividing, and reject absurd sizes before allocating. The `panic_hunt` integration test fuzzes every command with adversarial params and must stay green.
|
||||
10. **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
|
||||
## 4. Picking work
|
||||
|
||||
|
||||
@@ -25,9 +25,11 @@ next to them.
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
@@ -36,9 +38,11 @@ next to them.
|
||||
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/text-warp.svg` | Create Warped Text glyph (Type tool options bar): a T above a bowed arc, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/align-edge-*.svg`, `assets/icons/distribute-*.svg` | Move tool options-bar glyphs: Align (six edges/centres) and Distribute (edges, centres, spacing), a guide line or edge lines with bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
@@ -87,6 +91,8 @@ The other built-in ICC profiles and the generated LUT looks are produced by code
|
||||
| `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/embedded-vector-editor-en.png` | Embedded AI/PDF/EPS conversion warning and editor chooser (no artwork) | Don Omondi | Offscreen `smart_svg_ui::tests::capture_embedded_vector_conversion_prompt` with authored bytes and mock services | MIT OR Apache-2.0 |
|
||||
| `docs/images/embedded-vector-tabs-linux.png` | Shared VectorCraft window after a portal editor save and ordinary document launch | Don Omondi | Local Linux GUI acceptance with authored orange rectangle/blue circle SVGs; no private artwork | 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)):
|
||||
@@ -112,7 +118,7 @@ next to the files:
|
||||
|
||||
| Path (fetched) | Title | Author | Source | License |
|
||||
|---|---|---|---|---|
|
||||
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
|
||||
| `corpus/photoshop/` (258 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
|
||||
| `corpus/gimp/` (28 files, 251 KB) | GIMP XCF oracles: 40×30 procedural layers written by GIMP 3.2.6 through the corpus generator `tools/gimp-xcf/xcf_fixtures.py`, each with GIMP's merged rendering as a PNG | PhotoCraft contributors (authored with GIMP 3.2.6 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`gimp/`, with its generator and README) | MIT OR Apache-2.0 |
|
||||
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
|
||||
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
|
||||
|
||||
@@ -4,13 +4,13 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.16.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["serde"] }
|
||||
# internal
|
||||
photocraft-geom = { path = "crates/geom" }
|
||||
photocraft-color = { path = "crates/color" }
|
||||
@@ -39,11 +39,16 @@ photocraft-tablet = { path = "crates/tablet" }
|
||||
photocraft-ipp = { path = "crates/ipp" }
|
||||
|
||||
# external
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = "1"
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
clap_complete = "4.6.11"
|
||||
clap_mangen = { version = "0.3.3", features = ["env"] }
|
||||
arc-swap = "1"
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
@@ -52,16 +57,20 @@ image = { version = "0.25", default-features = false }
|
||||
zune-core = "0.5"
|
||||
zune-jpeg = "0.5"
|
||||
proptest = "1"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
# Without `default_fonts`, which embeds egui's fonts uncompressed: ui-egui ships them deflated
|
||||
# (egui_fonts.rs; the web build's size gate).
|
||||
egui = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
egui = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
rfd = "0.17"
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
rayon = "1"
|
||||
criterion = { version = "0.5", default-features = false }
|
||||
criterion = { version = "0.8", default-features = false }
|
||||
getrandom = "0.3"
|
||||
# Monotonic clock on native and wasm (screenshot deadlines: a wall clock can jump).
|
||||
web-time = "1"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
@@ -70,15 +79,18 @@ unsafe_code = "forbid"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
|
||||
# Dependencies carry no debug info: their frames in a backtrace lose file:line, and the target dir
|
||||
# and link times shrink. The test profile inherits both settings.
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
# Line tables keep file:line in panic backtraces but drop variable info, which roughly halves link
|
||||
# time and PDB size for the ~260 test binaries (#2204). CI sets the same through
|
||||
# CARGO_PROFILE_DEV_DEBUG (and no debuginfo at all for the Windows tests). To inspect locals in a
|
||||
# debugger, build with CARGO_PROFILE_DEV_DEBUG=true.
|
||||
# time and PDB size for the test binaries (#2204). CI's Windows legs build with no debuginfo at all
|
||||
# (CARGO_PROFILE_DEV_DEBUG=0). To inspect locals in a debugger, build with
|
||||
# CARGO_PROFILE_DEV_DEBUG=true.
|
||||
debug = "line-tables-only"
|
||||
|
||||
# Test builds compile ui-egui without optimization: its library and unit tests were the biggest
|
||||
@@ -167,3 +179,20 @@ opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-paint]
|
||||
opt-level = 3
|
||||
|
||||
# Keep the complete egui family and native event bridge on reviewed fork commits.
|
||||
[patch.crates-io]
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
@@ -22,3 +22,20 @@ Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed
|
||||
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and
|
||||
BIZ UDMincho; not the web build), under the SIL Open Font License 1.1. Their licence texts
|
||||
ship with the package as OFL-<family>.txt.
|
||||
|
||||
The egui-icons font subset is embedded from epaint_default_fonts by crates/ui-egui/build.rs
|
||||
and registered in the native and web fallback stacks by crates/ui-egui/src/egui_fonts.rs.
|
||||
It is a subset of emoji-icon-font by John Slegers; the same upstream MIT license applies.
|
||||
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
Original: https://github.com/jslegers/emoji-icon-font
|
||||
The following notice is reproduced verbatim from emoji-icon-font-mit-license.txt:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
@@ -185,8 +185,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🧰 67 tools</h4>
|
||||
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
|
||||
<h4>🧰 68 tools</h4>
|
||||
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Art History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🖌️ A real brush engine</h4>
|
||||
@@ -221,7 +221,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
|
||||
PhotoCraft's PSD support is a standalone crate written from Adobe's public specification and tested against a corpus of real-world files.
|
||||
|
||||
- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/corpus.rs`).
|
||||
- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/it/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/it/corpus.rs`).
|
||||
- **Pixels that match:** a composite oracle compares our render with Photoshop's own merged image, covering gradient interpolation (Classic, Perceptual and Linear), layer effects, shape strokes, clipping and fill opacity.
|
||||
- **Large documents:** PSB, 16 and 32-bit files, and CMYK and Lab documents open natively.
|
||||
|
||||
@@ -343,7 +343,7 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi
|
||||
|
||||
**New to PhotoCraft?** Download it from the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). That's the easiest way to install it.
|
||||
|
||||
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) have earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
|
||||
### Windows
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
|
||||
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (Object Finder retains large enclosed foregrounds (#6548); Russian catalog covers every dynamic label; previously minor: oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from
|
||||
the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md)
|
||||
@@ -104,7 +104,7 @@ because engine messages are still English. Detail: [localization-parity](docs/lo
|
||||
| Korean | `ko` | 100% | partial |
|
||||
| Vietnamese | `vi` | 0% | none |
|
||||
|
||||
Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
|
||||
Also shipped: Traditional Chinese, Russian (every dynamic label, [notes](docs/localization-ru.md)), Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -122,6 +122,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
| Date | Entry |
|
||||
|---|---|
|
||||
| 2026-10-17 | Object Finder classifies background by boundary coverage, retaining large enclosed foregrounds and rejecting surrounding backdrop (#6548); learned segmentation remains open (#2788) |
|
||||
| 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized to the craftrules standard (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. |
|
||||
| 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus |
|
||||
| 2026-10-08 | 0.5.0. Affinity import; Magnetic Lasso, Pattern Stamp, Mixer Brush, Pencil, Patch, Vertical Type on the toolbar |
|
||||
@@ -135,7 +136,9 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
|
||||
| 2026-10-17 | minor | Object Finder retains large enclosed foregrounds (#6548) |
|
||||
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/it/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
|
||||
| 2026-10-15 | minor | Russian catalog covers every dynamic UI label the other catalogs translate |
|
||||
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
|
||||
| 2026-10-10 | minor | Readiness by audience with hours per tier |
|
||||
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
|
||||
|
||||
@@ -28,9 +28,19 @@ 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.
|
||||
winresource = "0.1"
|
||||
|
||||
@@ -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,428 @@
|
||||
//! The command line, as clap types. Values are parsed into their types here, so a bad one is a
|
||||
//! usage error naming its flag (exit 2) and the handlers never see it (#7071). A flag a
|
||||
//! subcommand doesn't take is a usage error too, so a typo such as `--fromat` can't be silently
|
||||
//! ignored (#423), and so is a repeated one: `--quality 80 --quality 60` is more likely a mistake
|
||||
//! than a choice. A value starting with `-` is written `--out=-image.png` (or `./-image.png`), so a
|
||||
//! value flag never takes the next flag as its value; negative numbers are kept where they mean
|
||||
//! something.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fmt;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::error::ErrorKind;
|
||||
use clap::parser::ValueSource;
|
||||
use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand};
|
||||
use photocraft_automation::args::{AutomationArgs, ControlArgs, blank_env_is_unset, conflict, control_port, name_env_vars};
|
||||
use photocraft_codecs::{JpegFormat, TiffCompression};
|
||||
use photocraft_io::{DdsCompression, DpxBits, TgaBits};
|
||||
use serde_json::Value;
|
||||
|
||||
/// photocraft-cli: headless PhotoCraft
|
||||
#[derive(Parser)]
|
||||
#[command(name = "photocraft-cli", version = photocraft_engine::build_info::long_version_static(), arg_required_else_help = true)]
|
||||
pub(crate) struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum Command {
|
||||
Convert(ConvertArgs),
|
||||
Info(InfoArgs),
|
||||
Run(RunArgs),
|
||||
Batch(BatchArgs),
|
||||
Droplet(DropletArgs),
|
||||
Commands(CommandsArgs),
|
||||
Mcp(McpArgs),
|
||||
Serve(ServeArgs),
|
||||
}
|
||||
|
||||
// How an image is written: shared by `convert`, `run` and `batch`.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct ExportArgs {
|
||||
/// Output format, overriding the output's extension (png, jpg, tif, webp, …)
|
||||
#[arg(long, value_name = "EXT")]
|
||||
pub(crate) format: Option<String>,
|
||||
/// JPEG or WebP quality, 1-100. A WebP written with a quality is lossy, without one lossless
|
||||
// allow_negative_numbers makes `--quality -1` an out-of-range value rather than an unexpected argument.
|
||||
#[arg(long, value_name = "1-100", value_parser = clap::value_parser!(u8).range(1..=100), allow_negative_numbers = true)]
|
||||
pub(crate) quality: Option<u8>,
|
||||
/// Keep the layers in TIFF output (Photoshop layer data); TIFF output is flat without it
|
||||
#[arg(long)]
|
||||
pub(crate) tiff_layers: bool,
|
||||
/// Compress TIFF output: none (the default), lzw or zip
|
||||
#[arg(long, value_name = "none|lzw|zip", value_parser = tiff_compression)]
|
||||
pub(crate) tiff_compression: Option<TiffCompression>,
|
||||
/// A TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its
|
||||
/// alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24
|
||||
#[arg(long, value_name = "24|32", value_parser = tga_bits)]
|
||||
pub(crate) tga_bits: Option<TgaBits>,
|
||||
/// A DPX's bits per sample. Without it: 8 for an 8-bit document, else 16
|
||||
#[arg(long, value_name = "8|10|12|16", value_parser = dpx_bits)]
|
||||
pub(crate) dpx_bits: Option<DpxBits>,
|
||||
/// A DDS texture's compression: none (uncompressed, the default), bc1 (DXT1: alpha on or
|
||||
/// off), bc2 (DXT3) or bc3 (DXT5)
|
||||
#[arg(long, value_name = "none|bc1|bc2|bc3", value_parser = dds_compression)]
|
||||
pub(crate) dds_compression: Option<DdsCompression>,
|
||||
/// Also write a DDS texture's full mip chain
|
||||
#[arg(long)]
|
||||
pub(crate) dds_mipmaps: bool,
|
||||
/// A JPEG's scans, as JPEG Options picks them: baseline (the default), optimized (Huffman
|
||||
/// tables fitted to the image; RGB is written 4:2:0 and keeps the standard tables, so only Gray
|
||||
/// and CMYK shrink) or progressive
|
||||
#[arg(long, value_name = "baseline|optimized|progressive", value_parser = jpeg_format)]
|
||||
pub(crate) jpeg_format: Option<JpegFormat>,
|
||||
}
|
||||
|
||||
/// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …)
|
||||
///
|
||||
/// An <OUT> that is the <IN> file is refused, as the result would replace the original;
|
||||
/// --in-place allows it.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct ConvertArgs {
|
||||
/// The file to read
|
||||
#[arg(value_name = "IN")]
|
||||
pub(crate) input: PathBuf,
|
||||
/// The file to write; its extension picks the format unless --format is given
|
||||
#[arg(value_name = "OUT")]
|
||||
pub(crate) output: PathBuf,
|
||||
/// Allow an <OUT> that is the <IN> file, replacing the original
|
||||
#[arg(long)]
|
||||
pub(crate) in_place: bool,
|
||||
#[command(flatten)]
|
||||
pub(crate) export: ExportArgs,
|
||||
}
|
||||
|
||||
/// Print the document as JSON (size, mode, depth, layer tree)
|
||||
#[derive(Args)]
|
||||
pub(crate) struct InfoArgs {
|
||||
pub(crate) file: PathBuf,
|
||||
/// One line of JSON instead of pretty-printed
|
||||
#[arg(long)]
|
||||
pub(crate) compact: bool,
|
||||
}
|
||||
|
||||
/// Open a file (or start a new document), run engine commands in order, save the result
|
||||
///
|
||||
/// Prints each command's JSON result. Give exactly one of <FILE> or --new, and at least one --cmd
|
||||
/// or --out. An --out that is the <FILE> is refused, as the result would replace the original;
|
||||
/// --in-place allows it.
|
||||
#[derive(Args)]
|
||||
#[command(group(ArgGroup::new("source").args(["file", "new"]).required(true)))]
|
||||
#[command(group(ArgGroup::new("work").args(["cmd", "out"]).required(true).multiple(true)))]
|
||||
pub(crate) struct RunArgs {
|
||||
/// The document to open
|
||||
pub(crate) file: Option<PathBuf>,
|
||||
/// Start from a new document with these JSON parameters instead of opening a file
|
||||
#[arg(long, value_name = "JSON", value_parser = json)]
|
||||
pub(crate) new: Option<Value>,
|
||||
/// An engine command to run; repeat for several, run in order
|
||||
#[arg(long, value_name = "ID", action = ArgAction::Append)]
|
||||
pub(crate) cmd: Vec<String>,
|
||||
/// JSON parameters for the --cmd before it (default {})
|
||||
#[arg(long, value_name = "JSON", action = ArgAction::Append, value_parser = json)]
|
||||
pub(crate) params: Vec<Value>,
|
||||
/// Save the result to this file
|
||||
#[arg(long, value_name = "FILE")]
|
||||
pub(crate) out: Option<PathBuf>,
|
||||
/// Allow an --out that is the <FILE>, replacing the original
|
||||
#[arg(long)]
|
||||
pub(crate) in_place: bool,
|
||||
#[command(flatten)]
|
||||
pub(crate) export: ExportArgs,
|
||||
/// `--cmd` and `--params` values in command-line order, set by [`parse`].
|
||||
#[arg(skip)]
|
||||
pub(crate) steps: Vec<Step>,
|
||||
}
|
||||
|
||||
/// One `--cmd` or `--params` of `run`, in command-line order.
|
||||
pub(crate) enum Step {
|
||||
Cmd(String),
|
||||
Params(Value),
|
||||
}
|
||||
|
||||
/// Apply an action list to every image in a directory
|
||||
///
|
||||
/// A Photoshop Actions file (.atn) is a set: its action --action names plays, as below. JSON steps
|
||||
/// are [id, params] pairs, {"command": id, "params": {…}} objects or bare ids, as a recorded
|
||||
/// action or droplet stores them (a list, or wrapped in {"actions": …}, {"steps": …} or a
|
||||
/// droplet). An Actions set (the Actions panel's actions.json, {"actions": [{"name", "steps"}…]})
|
||||
/// plays the action --action names (needed when the set holds more than one) with the whole set
|
||||
/// loaded, so its steps can play other actions of the set.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct BatchArgs {
|
||||
/// The action list: actions.json, a droplet, or a Photoshop Actions file (.atn)
|
||||
#[arg(long, value_name = "FILE")]
|
||||
pub(crate) actions: PathBuf,
|
||||
/// Which action of an Actions set to play
|
||||
#[arg(long, value_name = "NAME")]
|
||||
pub(crate) action: Option<String>,
|
||||
/// The folder of images to process
|
||||
#[arg(long = "in", value_name = "DIR")]
|
||||
pub(crate) in_dir: PathBuf,
|
||||
/// The folder to write the results to, as <stem>.<ext>
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub(crate) out: PathBuf,
|
||||
/// An --out folder that is the --in folder is refused, as the results would replace the
|
||||
/// originals; --in-place allows it
|
||||
#[arg(long)]
|
||||
pub(crate) in_place: bool,
|
||||
#[command(flatten)]
|
||||
pub(crate) export: ExportArgs,
|
||||
}
|
||||
|
||||
/// Run a droplet (File › Automate › Create Droplet) on images and folders
|
||||
#[derive(Args)]
|
||||
pub(crate) struct DropletArgs {
|
||||
/// The droplet to run
|
||||
#[arg(value_name = "FILE.pcdroplet")]
|
||||
pub(crate) file: String,
|
||||
/// Images and folders to run it on
|
||||
#[arg(value_name = "FILE-OR-DIR", required = true)]
|
||||
pub(crate) inputs: Vec<String>,
|
||||
/// The folder to write the results to
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub(crate) out: Option<String>,
|
||||
}
|
||||
|
||||
/// List the engine command registry
|
||||
#[derive(Args)]
|
||||
pub(crate) struct CommandsArgs {
|
||||
/// Print the entries as JSON
|
||||
#[arg(long)]
|
||||
pub(crate) json: bool,
|
||||
/// Only the commands matching this text
|
||||
#[arg(long, value_name = "TEXT", allow_negative_numbers = true)]
|
||||
pub(crate) filter: Option<String>,
|
||||
}
|
||||
|
||||
/// Run the MCP server on stdio (headless engine, or bridge to a running `photocraft --control <port>`)
|
||||
///
|
||||
/// Headless: each automation root grants that direction of file access; an omitted root denies it.
|
||||
/// Prefer absolute root paths in MCP client configurations. Bridge: set the roots on the desktop
|
||||
/// app; root flags are refused. The token flags require --bridge (or PHOTOCRAFT_CONTROL_PORT).
|
||||
#[derive(Args)]
|
||||
pub(crate) struct McpArgs {
|
||||
/// Bridge to the PhotoCraft app listening here instead of a headless engine; a bare port means
|
||||
/// 127.0.0.1:<PORT>
|
||||
#[arg(long, value_name = "127.0.0.1:PORT", env = "PHOTOCRAFT_CONTROL_PORT", value_parser = bridge)]
|
||||
pub(crate) bridge: Option<Bridge>,
|
||||
/// `--bridge` came from PHOTOCRAFT_CONTROL_PORT, set by [`parse`].
|
||||
#[arg(skip)]
|
||||
pub(crate) bridge_from_env: bool,
|
||||
#[command(flatten)]
|
||||
pub(crate) control: ControlArgs,
|
||||
#[command(flatten)]
|
||||
pub(crate) automation: AutomationArgs,
|
||||
}
|
||||
|
||||
/// Keep one headless session open and answer JSON lines on stdio, or on 127.0.0.1:<PORT>
|
||||
///
|
||||
/// Each request is a JSON line {"id", "method", "params"}. Methods: engine.execute,
|
||||
/// jobs.list/cancel, engine.commands,
|
||||
/// doc.open/new/place/save/exportLayers/exportDataSets/importDataSets/inspect/render/select/close,
|
||||
/// session.list, batch, methods (docs/control-protocol.md#headless-server). The token flags require
|
||||
/// --port (or PHOTOCRAFT_CONTROL_PORT); stdio needs no control token.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct ServeArgs {
|
||||
/// Listen on 127.0.0.1:<PORT> instead of stdio
|
||||
#[arg(long, value_name = "PORT", env = "PHOTOCRAFT_CONTROL_PORT", value_parser = control_port)]
|
||||
pub(crate) port: Option<u16>,
|
||||
/// `--port` came from PHOTOCRAFT_CONTROL_PORT, set by [`parse`].
|
||||
#[arg(skip)]
|
||||
pub(crate) port_from_env: bool,
|
||||
#[command(flatten)]
|
||||
pub(crate) control: ControlArgs,
|
||||
#[command(flatten)]
|
||||
pub(crate) automation: AutomationArgs,
|
||||
}
|
||||
|
||||
/// Why a value was refused: clap prints it after the flag and its value (exit 2).
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum ValueError {
|
||||
#[error("expected 24 or 32")]
|
||||
TgaBits,
|
||||
#[error("expected 8, 10, 12 or 16")]
|
||||
DpxBits,
|
||||
#[error("expected none, bc1, bc2 or bc3")]
|
||||
DdsCompression,
|
||||
#[error("expected none, lzw or zip")]
|
||||
TiffCompression,
|
||||
#[error("expected baseline, optimized or progressive")]
|
||||
JpegFormat,
|
||||
/// Why a `--bridge` value, or a PHOTOCRAFT_CONTROL_PORT value for `mcp`, is refused.
|
||||
#[error("expected a port, 0-65535 (--bridge also takes 127.0.0.1:<PORT>)")]
|
||||
Bridge,
|
||||
}
|
||||
|
||||
/// Why `parse` refuses `mcp` or `serve` options clap accepted: clap prints it as a usage error
|
||||
/// (exit 2).
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum ServerOptionError {
|
||||
/// PHOTOCRAFT_CONTROL_PORT holds a port, as for the desktop app's --control: only the flag
|
||||
/// takes host:port.
|
||||
#[error("invalid value '{0}' for 'PHOTOCRAFT_CONTROL_PORT': {bridge}", bridge = ValueError::Bridge)]
|
||||
BridgeVar(String),
|
||||
/// With --bridge the roots are the desktop process's to set: accepting them here promises
|
||||
/// authority the bridge can't give (#8035).
|
||||
#[error("configure automation roots on the desktop app; --automation-read-root and --automation-write-root require headless MCP mode")]
|
||||
BridgeRoots,
|
||||
/// A token flag without the bridge or port that reads it would be ignored (#8035).
|
||||
#[error("{flag} requires {transport}")]
|
||||
NeedsTransport { flag: &'static str, transport: &'static str },
|
||||
}
|
||||
|
||||
impl ServerOptionError {
|
||||
fn into_clap(self, cmd: &mut clap::Command) -> clap::Error {
|
||||
let kind = match self {
|
||||
Self::BridgeVar(_) => ErrorKind::ValueValidation,
|
||||
Self::BridgeRoots => ErrorKind::ArgumentConflict,
|
||||
Self::NeedsTransport { .. } => ErrorKind::MissingRequiredArgument,
|
||||
};
|
||||
cmd.error(kind, self)
|
||||
}
|
||||
}
|
||||
|
||||
fn tga_bits(s: &str) -> Result<TgaBits, ValueError> {
|
||||
s.parse().ok().and_then(TgaBits::from_bits).ok_or(ValueError::TgaBits)
|
||||
}
|
||||
|
||||
fn dpx_bits(s: &str) -> Result<DpxBits, ValueError> {
|
||||
s.parse().ok().and_then(DpxBits::from_bits).ok_or(ValueError::DpxBits)
|
||||
}
|
||||
|
||||
fn dds_compression(s: &str) -> Result<DdsCompression, ValueError> {
|
||||
DdsCompression::from_name(s).ok_or(ValueError::DdsCompression)
|
||||
}
|
||||
|
||||
fn tiff_compression(s: &str) -> Result<TiffCompression, ValueError> {
|
||||
match s {
|
||||
"none" => Ok(TiffCompression::None),
|
||||
"lzw" => Ok(TiffCompression::Lzw),
|
||||
"zip" => Ok(TiffCompression::Deflate),
|
||||
_ => Err(ValueError::TiffCompression),
|
||||
}
|
||||
}
|
||||
|
||||
fn jpeg_format(s: &str) -> Result<JpegFormat, ValueError> {
|
||||
JpegFormat::parse(s).ok_or(ValueError::JpegFormat)
|
||||
}
|
||||
|
||||
/// Where `mcp --bridge` connects. Shown as the address: `127.0.0.1:<PORT>` for a port.
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum Bridge {
|
||||
/// A bare port: the app on 127.0.0.1.
|
||||
Port(u16),
|
||||
/// `host:port`, as given. Whether the host is loopback is the bridge's to say.
|
||||
Addr(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Bridge {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Bridge::Port(port) => SocketAddr::from((Ipv4Addr::LOCALHOST, *port)).fmt(f),
|
||||
Bridge::Addr(addr) => f.write_str(addr),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `mcp --bridge`, or PHOTOCRAFT_CONTROL_PORT, which holds a port.
|
||||
fn bridge(s: &str) -> Result<Bridge, ValueError> {
|
||||
match control_port(s) {
|
||||
Ok(port) => Ok(Bridge::Port(port)),
|
||||
Err(_) if s.contains(':') => Ok(Bridge::Addr(s.to_owned())),
|
||||
Err(_) => Err(ValueError::Bridge),
|
||||
}
|
||||
}
|
||||
|
||||
/// `--new` and `--params`: bad JSON is a usage error, before any work. Whether the value is an
|
||||
/// object is the command's to say.
|
||||
fn json(s: &str) -> Result<Value, serde_json::Error> {
|
||||
serde_json::from_str(s)
|
||||
}
|
||||
|
||||
/// `--cmd` and `--params` values back in command-line order: clap keeps each flag's values apart,
|
||||
/// their indices say how they interleave.
|
||||
fn steps(m: &ArgMatches) -> Vec<Step> {
|
||||
let mut steps: Vec<(usize, Step)> = Vec::new();
|
||||
if let (Some(at), Ok(Some(ids))) = (m.indices_of("cmd"), m.try_get_many::<String>("cmd")) {
|
||||
steps.extend(at.zip(ids).map(|(i, id)| (i, Step::Cmd(id.clone()))));
|
||||
}
|
||||
if let (Some(at), Ok(Some(params))) = (m.indices_of("params"), m.try_get_many::<Value>("params")) {
|
||||
steps.extend(at.zip(params).map(|(i, p)| (i, Step::Params(p.clone()))));
|
||||
}
|
||||
steps.sort_by_key(|s| s.0);
|
||||
steps.into_iter().map(|(_, s)| s).collect()
|
||||
}
|
||||
|
||||
/// Parse the arguments (without the binary name). clap reads them as `OsString`s: a path need not
|
||||
/// be valid Unicode (#1108), text must. A flag left out falls back to its environment variable,
|
||||
/// unless that is empty, and a bad value from one is a usage error naming the variable (#2491). A
|
||||
/// bare `--help` or `-h` is help even after a mistake on the same line (`--out --help`, `mcp
|
||||
/// stray.psd --help`): clap reports the mistake first.
|
||||
pub(crate) fn parse(args: &[OsString]) -> Result<Cli, clap::Error> {
|
||||
let mut cmd = Cli::command().mut_subcommands(|sub| sub.mut_args(blank_env_is_unset));
|
||||
let argv = std::iter::once(OsString::from("photocraft-cli")).chain(args.iter().cloned());
|
||||
let matches = match cmd.try_get_matches_from_mut(argv) {
|
||||
Err(e) if args.iter().take_while(|a| *a != "--").any(|a| *a == "--help" || *a == "-h") => {
|
||||
let help = [OsString::from("photocraft-cli"), args.first().cloned().unwrap_or_default(), OsString::from("--help")];
|
||||
return Err(cmd.try_get_matches_from_mut(help).err().unwrap_or(e));
|
||||
}
|
||||
matches => matches.map_err(|e| name_env_vars(e, &cmd, args))?,
|
||||
};
|
||||
let mut cli = Cli::from_arg_matches(&matches)?;
|
||||
let from_env = |m: &ArgMatches, id: &str| m.value_source(id) == Some(ValueSource::EnvVariable);
|
||||
match (&mut cli.command, matches.subcommand()) {
|
||||
(Command::Run(r), Some((_, m))) => r.steps = steps(m),
|
||||
(Command::Mcp(a), Some((name, m))) => {
|
||||
a.bridge_from_env = from_env(m, "bridge");
|
||||
if a.bridge_from_env
|
||||
&& let Some(Bridge::Addr(v)) = &a.bridge
|
||||
{
|
||||
return Err(ServerOptionError::BridgeVar(v.clone()).into_clap(&mut cmd));
|
||||
}
|
||||
if let Some(sub) = cmd.find_subcommand_mut(name) {
|
||||
// Only the bridge reads a token, so a stale exported pair doesn't stop a headless
|
||||
// run.
|
||||
if a.bridge.is_some() {
|
||||
conflict(sub, m, "control_token", "control_token_file")?;
|
||||
}
|
||||
// The roots are the headless engine's: the variable switching to the bridge would
|
||||
// drop them, and the error names it.
|
||||
if a.bridge_from_env {
|
||||
conflict(sub, m, "automation_read_root", "bridge")?;
|
||||
conflict(sub, m, "automation_write_root", "bridge")?;
|
||||
}
|
||||
if a.bridge.is_some() && (a.automation.automation_read_root.is_some() || a.automation.automation_write_root.is_some()) {
|
||||
return Err(ServerOptionError::BridgeRoots.into_clap(sub));
|
||||
}
|
||||
check_server(sub, m, a.bridge.is_some(), "--bridge")?;
|
||||
}
|
||||
}
|
||||
(Command::Serve(a), Some((name, m))) => {
|
||||
a.port_from_env = from_env(m, "port");
|
||||
if let Some(sub) = cmd.find_subcommand_mut(name) {
|
||||
// Only the TCP server reads a token, so a stale exported pair doesn't stop stdio.
|
||||
if a.port.is_some() {
|
||||
conflict(sub, m, "control_token", "control_token_file")?;
|
||||
}
|
||||
check_server(sub, m, a.port.is_some(), "--port")?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(cli)
|
||||
}
|
||||
|
||||
/// Refuse a token flag without the bridge or port that reads it (as a flag or its variable). A
|
||||
/// token variable alone may be a stale export, so it isn't refused.
|
||||
fn check_server(sub: &mut clap::Command, m: &ArgMatches, has_transport: bool, transport: &'static str) -> Result<(), clap::Error> {
|
||||
for (id, flag) in [("control_token", "--control-token"), ("control_token_file", "--control-token-file")] {
|
||||
if m.value_source(id) == Some(ValueSource::CommandLine) && !has_transport {
|
||||
return Err(ServerOptionError::NeedsTransport { flag, transport }.into_clap(sub));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -3,204 +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>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
|
||||
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
|
||||
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
|
||||
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
|
||||
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
|
||||
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
|
||||
--dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16.
|
||||
--dds-compression sets a DDS texture's compression: none (uncompressed, the default), bc1
|
||||
(DXT1: alpha on or off), bc2 (DXT3) or bc3 (DXT5). --dds-mipmaps also writes the full mip chain.
|
||||
photocraft-cli info <file> [--compact]
|
||||
Print the document as JSON (size, mode, depth, layer tree).
|
||||
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
|
||||
Open a file, run engine commands in order, save the result. Each --params
|
||||
applies to the preceding --cmd. Prints each command's JSON result.
|
||||
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] [--dds-compression <none|bc1|bc2|bc3>] [--dds-mipmaps]
|
||||
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
|
||||
set: its action --action names plays, as below. JSON steps are [id, params] pairs,
|
||||
{\"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"],
|
||||
bare: &["--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"],
|
||||
bare: &["--tiff-layers", "--dds-mipmaps"],
|
||||
run: run_cmds,
|
||||
},
|
||||
Subcommand {
|
||||
name: "batch",
|
||||
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits", "--dds-compression"],
|
||||
bare: &["--in-place", "--tiff-layers", "--dds-mipmaps"],
|
||||
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}");
|
||||
@@ -209,612 +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("--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())
|
||||
}
|
||||
|
||||
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)?;
|
||||
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
|
||||
warn_all(err, &o.warnings);
|
||||
warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document));
|
||||
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}")?)
|
||||
}
|
||||
@@ -1,798 +0,0 @@
|
||||
//! Process-level tests of the `photocraft-cli` binary.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn bin() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_photocraft-cli"))
|
||||
}
|
||||
|
||||
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);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn write_png(path: &Path, w: u32, h: u32, seed: u8) {
|
||||
let data: Vec<u8> = (0..w * h * 4).map(|i| (i as u8).wrapping_mul(seed).wrapping_add(seed)).collect();
|
||||
let img = photocraft_codecs::Image::from_u8(w, h, photocraft_codecs::ChannelLayout::Rgba, data).unwrap();
|
||||
std::fs::write(path, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
fn ok(cmd: &mut Command) -> (String, String) {
|
||||
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)
|
||||
}
|
||||
|
||||
#[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"));
|
||||
let o = bin().arg("frobnicate").output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(2));
|
||||
let (out, _) = ok(bin().arg("--help"));
|
||||
assert!(out.contains("convert"));
|
||||
let (out, _) = ok(bin().arg("--version"));
|
||||
assert!(out.starts_with("photocraft-cli "));
|
||||
}
|
||||
|
||||
/// #423: `<subcommand> --help` and `-h` print usage and return at once (no inputs read, no files
|
||||
/// written, no server started), whatever else is on the line.
|
||||
#[test]
|
||||
fn every_subcommand_answers_help() {
|
||||
let d = tmp("help");
|
||||
for sub in ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"] {
|
||||
for help in ["--help", "-h"] {
|
||||
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_eq!(std::fs::read_dir(&d).unwrap().count(), 0, "help wrote nothing");
|
||||
}
|
||||
|
||||
/// #423: a flag the subcommand doesn't take is a usage error naming it, before any work.
|
||||
#[test]
|
||||
fn unknown_flags_are_usage_errors() {
|
||||
let d = tmp("flags");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
let actions = d.join("actions.json");
|
||||
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
|
||||
let out_dir = d.join("out");
|
||||
let fails = |cmd: &mut Command, flag: &str| {
|
||||
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}");
|
||||
};
|
||||
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");
|
||||
assert!(!out_dir.exists(), "nothing written");
|
||||
}
|
||||
let x = d.join("x.png");
|
||||
fails(bin().arg("run").arg(input.join("a.png")).args(["--cmd", "image.adjustments.invert", "--param", "{}", "--out"]).arg(&x), "--param");
|
||||
assert!(!x.exists());
|
||||
// A flag one subcommand takes is still unknown to another; a bare flag takes no value.
|
||||
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"));
|
||||
// Every flag still works in both forms.
|
||||
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
|
||||
assert!(out.contains("filter.blur.gaussianBlur"), "{out}");
|
||||
// The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060).
|
||||
for needle in ["ellipse", "marquee"] {
|
||||
let (out, _) = ok(bin().args(["commands", "--filter", needle]));
|
||||
assert!(out.contains("select.rect"), "{needle}: {out}");
|
||||
}
|
||||
ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([
|
||||
"--format=jpg",
|
||||
"--quality",
|
||||
"80",
|
||||
]));
|
||||
assert!(out_dir.join("a.jpg").exists());
|
||||
let (out, _) = ok(bin().arg("info").arg(input.join("a.png")).arg("--compact"));
|
||||
assert!(!out.trim().contains('\n'), "compact JSON is one line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_png_pcraft_png_is_lossless() {
|
||||
let d = tmp("convert");
|
||||
let (a, b, c) = (d.join("a.png"), d.join("b.pcraft"), d.join("c.png"));
|
||||
write_png(&a, 37, 23, 7);
|
||||
ok(bin().args(["convert"]).arg(&a).arg(&b));
|
||||
assert!(photocraft_format::is_pcraft(&std::fs::read(&b).unwrap()));
|
||||
ok(bin().args(["convert"]).arg(&b).arg(&c));
|
||||
let x = photocraft_codecs::decode(&std::fs::read(&a).unwrap()).unwrap();
|
||||
let y = photocraft_codecs::decode(&std::fs::read(&c).unwrap()).unwrap();
|
||||
assert_eq!(x.to_rgba8(), y.to_rgba8());
|
||||
// Format override and lossy warning path.
|
||||
ok(bin().args(["convert"]).arg(&a).arg(d.join("out.bin")).args(["--format", "jpg", "--quality", "60"]));
|
||||
assert!(photocraft_codecs::detect(&std::fs::read(d.join("out.bin")).unwrap()) == Some(photocraft_codecs::Format::Jpeg));
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_errors() {
|
||||
let d = tmp("converr");
|
||||
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));
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 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"));
|
||||
assert_eq!(out.trim().lines().count(), 1);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
/// #518, #523: a truncated JPEG and an animated GIF open, but `info` lists why the image is
|
||||
/// incomplete and `convert` prints it on stderr (and still succeeds); complete files stay quiet.
|
||||
#[test]
|
||||
fn info_and_convert_report_decode_warnings() {
|
||||
let d = tmp("decode-warnings");
|
||||
let noise: Vec<u8> = (0..64 * 48 * 3).map(|i: u32| (i.wrapping_mul(7919) % 251) as u8).collect();
|
||||
let img = photocraft_codecs::Image::from_u8(64, 48, photocraft_codecs::ChannelLayout::Rgb, noise).unwrap();
|
||||
let jpeg = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).unwrap();
|
||||
// Three 1x1 frames.
|
||||
let mut gif = b"GIF89a\x01\0\x01\0\x80\0\0\0\0\0\xFF\xFF\xFF".to_vec();
|
||||
for _ in 0..3 {
|
||||
gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
|
||||
}
|
||||
gif.push(0x3B);
|
||||
// A GIF opens in Indexed Color (#7886), so `convert` to PNG also notes the palette PNG it
|
||||
// writes, after the decode warning.
|
||||
let cases = [
|
||||
("whole.jpg", &jpeg[..], None, None),
|
||||
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing"), None),
|
||||
("anim.gif", &gif[..], Some("only the first of 3 frames was imported"), Some("written as an 8-bit palette PNG (2 colours)")),
|
||||
];
|
||||
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 v: Value = serde_json::from_str(&out).unwrap();
|
||||
assert_eq!(v["warnings"], json!(warning.into_iter().collect::<Vec<_>>()), "{name}");
|
||||
let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png"))));
|
||||
let expected: Vec<String> = warning.into_iter().chain(export).map(|w| format!("warning: {w}")).collect();
|
||||
assert_eq!(
|
||||
err.trim(),
|
||||
expected.join(
|
||||
"
|
||||
"
|
||||
),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_commands_and_save() {
|
||||
let d = tmp("run");
|
||||
let out_pc = d.join("r.pcraft");
|
||||
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));
|
||||
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 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");
|
||||
let names: Vec<&str> = v["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect();
|
||||
assert_eq!(names, ["Ink", "Background"]);
|
||||
// Continue from the saved file and export PNG.
|
||||
let png = d.join("r.png");
|
||||
ok(bin().arg("run").arg(&out_pc).args(["--cmd", "layer.new.layer"]).arg("--out").arg(&png));
|
||||
assert_eq!(photocraft_codecs::decode(&std::fs::read(&png).unwrap()).unwrap().dimensions(), (40, 30));
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_stops_after_failed_action_without_exporting() {
|
||||
let d = tmp("run-failed-action");
|
||||
let out = d.join("result.png");
|
||||
let result = bin()
|
||||
.args(["run", "--new", r#"{"width":8,"height":8}"#])
|
||||
.args(["--cmd", "layer.new.layer"])
|
||||
.args(["--cmd", "actions.record", "--params", r#"{"name":"Delete"}"#])
|
||||
.args(["--cmd", "layer.delete", "--cmd", "actions.stop"])
|
||||
// The recorded deletion succeeded with two layers; replay cannot delete the last one.
|
||||
.args(["--cmd", "actions.play", "--params", r#"{"action":"Delete"}"#])
|
||||
.args(["--cmd", "document.inspect", "--out"])
|
||||
.arg(&out)
|
||||
.output()
|
||||
.unwrap();
|
||||
let error = String::from_utf8_lossy(&result.stderr);
|
||||
assert_eq!(result.status.code(), Some(1), "stdout: {}\nstderr: {error}", String::from_utf8_lossy(&result.stdout));
|
||||
assert!(error.contains("actions.play") && error.contains("Delete") && error.contains("step 1"), "{error}");
|
||||
let rows: Vec<Value> = String::from_utf8_lossy(&result.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert!(rows.iter().all(|row| row["command"] != "document.inspect"), "later commands must not run");
|
||||
assert!(!out.exists(), "a failed action must not export a partial result");
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_continues_after_successful_action_and_exports() {
|
||||
let d = tmp("run-successful-action");
|
||||
let out = d.join("result.png");
|
||||
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));
|
||||
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);
|
||||
assert!(playback["result"].get("failed").is_none());
|
||||
assert!(rows.iter().any(|row| row["command"] == "document.inspect"));
|
||||
assert!(out.is_file());
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
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"));
|
||||
let o = bin().args(["run", "--new", "{}", "--params", "{}"]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
let o = bin().args(["run", "--new", "{", "--cmd", "x"]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
// 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));
|
||||
let e = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(e.contains("--new") && e.contains("must be a JSON object"), "{e}");
|
||||
let o = bin().args(["run", "--new", "{}", "--cmd", "layer.new.layer", "--params", "\"x\""]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
let e = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(e.contains("layer.new.layer") && e.contains("must be a JSON object"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_rejects_invalid_filter_parameters_without_exporting() {
|
||||
let d = tmp("invalid-filter-params");
|
||||
let out = d.join("result.png");
|
||||
for (params, key) in [(r#"{"radius":"big"}"#, "radius"), (r#"{"radus":3}"#, "radus"), (r#"{"radius":5000}"#, "radius"), (r#"{"radius":-4}"#, "radius")] {
|
||||
let result = bin()
|
||||
.args(["run", "--new", r#"{"width":8,"height":8}"#, "--cmd", "filter.blur.gaussianBlur", "--params", params, "--out"])
|
||||
.arg(&out)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(result.status.code(), Some(1));
|
||||
let error = String::from_utf8_lossy(&result.stderr);
|
||||
assert!(error.contains("filter.blur.gaussianBlur") && error.contains(key), "{error}");
|
||||
assert!(!out.exists());
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_applies_actions_to_directory() {
|
||||
let d = tmp("batch");
|
||||
let (inp, outp) = (d.join("in"), d.join("out"));
|
||||
std::fs::create_dir_all(&inp).unwrap();
|
||||
write_png(&inp.join("one.png"), 12, 8, 1);
|
||||
write_png(&inp.join("two.png"), 9, 9, 2);
|
||||
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"]));
|
||||
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 v: Value = serde_json::from_str(&info).unwrap();
|
||||
assert_eq!(v["layers"][0]["name"], "Batch");
|
||||
}
|
||||
// A failing action reports and exits 1.
|
||||
std::fs::write(&actions, r#"{"actions":[{"id":"no.such.command"}]}"#).unwrap();
|
||||
let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&inp).arg("--out").arg(&outp).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("FAIL"));
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_skips_recorded_view_and_save_steps() {
|
||||
let d = tmp("batch-shell-steps");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
let source = input.join("a.png");
|
||||
write_png(&source, 12, 8, 3);
|
||||
let original = std::fs::read(&source).unwrap();
|
||||
let recorded_save = d.join("recorded-save.psd");
|
||||
let mut cases = Vec::new();
|
||||
for (name, id) in [("zoom-in", "view.zoomIn"), ("zoom-out", "view.zoomOut"), ("fit", "view.fitOnScreen"), ("actual-pixels", "view.actualPixels")] {
|
||||
cases.push((name, json!([[id, {}], ["image.imageRotation.90cw", {}]]).to_string()));
|
||||
}
|
||||
cases.push(("save", json!([["file.save", {}], ["image.imageRotation.90cw", {}]]).to_string()));
|
||||
cases.push(("save-as", json!([["file.saveAs", {"path": recorded_save.to_string_lossy()}], ["image.imageRotation.90cw", {}]]).to_string()));
|
||||
|
||||
for (name, actions) in cases {
|
||||
let o = batch_with(&d, &input, name, &actions, &[]);
|
||||
let (stdout, stderr) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
|
||||
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: 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");
|
||||
assert!(!recorded_save.exists(), "{name} wrote the recorded Save As path");
|
||||
}
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_actions_formats() {
|
||||
let a = photocraft_cli::parse_actions(r#"[{"command":"a","params":{"x":1}},{"id":"b"}]"#).unwrap();
|
||||
assert_eq!(a, vec![("a".to_string(), json!({"x":1})), ("b".to_string(), json!({}))]);
|
||||
assert!(photocraft_cli::parse_actions(r#"{"actions":[{"command":"c"}]}"#).unwrap().len() == 1);
|
||||
assert!(photocraft_cli::parse_actions(r#"[{"params":{}}]"#).is_err());
|
||||
assert!(photocraft_cli::parse_actions("7").is_err());
|
||||
// #489: the step shapes a recorded action and a droplet store, bare or wrapped.
|
||||
let want = vec![("a".to_string(), json!({"x":1})), ("b".to_string(), json!({}))];
|
||||
for text in [
|
||||
r#"[["a",{"x":1}],["b"]]"#,
|
||||
r#"[["a",{"x":1}],"b"]"#,
|
||||
r#"{"steps":[["a",{"x":1}],["b",{}]]}"#,
|
||||
r#"{"photocraftDroplet":1,"action":{"steps":[["a",{"x":1}],{"command":"b"}]}}"#,
|
||||
r#"{"actions":[["a",{"x":1}],["b",{}]]}"#,
|
||||
] {
|
||||
assert_eq!(photocraft_cli::parse_actions(text).unwrap(), want, "{text}");
|
||||
}
|
||||
for text in ["[42]", "[[]]", "[[7,{}]]", r#"{"name":"x"}"#] {
|
||||
assert!(photocraft_cli::parse_actions(text).is_err(), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `batch` on `input` with `actions` (saved as `<name>.json`), writing to the folder `<d>/<name>`.
|
||||
fn batch_with(d: &Path, input: &Path, name: &str, actions: &str, extra: &[&str]) -> std::process::Output {
|
||||
let file = d.join(format!("{name}.json"));
|
||||
std::fs::write(&file, actions).unwrap();
|
||||
bin().args(["batch", "--actions"]).arg(&file).arg("--in").arg(input).arg("--out").arg(d.join(name)).args(extra).output().unwrap()
|
||||
}
|
||||
|
||||
/// #489: `batch --actions` takes `[id, params]` steps and a droplet's steps, with the same results
|
||||
/// as the object form; a malformed step fails before anything is written.
|
||||
#[test]
|
||||
fn batch_accepts_recorded_action_steps() {
|
||||
let d = tmp("batch-steps");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
write_png(&input.join("b.png"), 6, 5, 9);
|
||||
let shapes = [
|
||||
("objects", r#"[{"command":"image.adjustments.invert"}]"#),
|
||||
("pairs", r#"[["image.adjustments.invert",{}]]"#),
|
||||
("ids", r#"["image.adjustments.invert"]"#),
|
||||
("droplet", r#"{"photocraftDroplet":1,"action":{"steps":[["image.adjustments.invert",{}]]}}"#),
|
||||
];
|
||||
for (name, actions) in shapes {
|
||||
let o = batch_with(&d, &input, name, actions, &[]);
|
||||
assert!(o.status.success(), "{name}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(String::from_utf8_lossy(&o.stdout).contains("2 succeeded, 0 failed"), "{name}");
|
||||
for f in ["a.png", "b.png"] {
|
||||
assert_eq!(std::fs::read(d.join(name).join(f)).unwrap(), std::fs::read(d.join("objects").join(f)).unwrap(), "{name}/{f}");
|
||||
}
|
||||
}
|
||||
let o = batch_with(&d, &input, "bad", "[42]", &[]);
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
assert!(!d.join("bad").exists(), "nothing written");
|
||||
}
|
||||
|
||||
/// #490: a leading dot on `--format` doesn't double the dot in output names.
|
||||
#[test]
|
||||
fn batch_format_with_leading_dot() {
|
||||
let d = tmp("batch-dot");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
write_png(&input.join("b.png"), 6, 5, 9);
|
||||
let invert = r#"[{"command":"image.adjustments.invert"}]"#;
|
||||
for (name, format) in [("dot", ".jpg"), ("plain", "jpg")] {
|
||||
let o = batch_with(&d, &input, name, invert, &["--format", format]);
|
||||
assert!(o.status.success(), "{format}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
let mut names: Vec<String> = std::fs::read_dir(d.join(name)).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
assert_eq!(names, ["a.jpg", "b.jpg"], "{format}");
|
||||
assert_eq!(photocraft_codecs::detect(&std::fs::read(d.join(name).join("a.jpg")).unwrap()), Some(photocraft_codecs::Format::Jpeg));
|
||||
}
|
||||
}
|
||||
|
||||
/// #491: `--quality` outside 1-100 is an 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");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
let a = input.join("a.png");
|
||||
write_png(&a, 8, 4, 3);
|
||||
let invert = r#"[{"command":"image.adjustments.invert"}]"#;
|
||||
for q in ["0", "101", "255", "256", "1000", "-1", "abc", "50.5", ""] {
|
||||
let out = d.join("q.jpg");
|
||||
let (mut convert, mut run) = (bin(), bin());
|
||||
convert.arg("convert").arg(&a).arg(&out).args(["--quality", q]);
|
||||
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!(!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!(!d.join("qb").exists(), "batch --quality {q} wrote a folder");
|
||||
}
|
||||
// The documented range still encodes, lower quality making a smaller file.
|
||||
let sizes: Vec<usize> = ["1", "50", "100"]
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let out = d.join(format!("q{q}.jpg"));
|
||||
ok(bin().arg("convert").arg(&a).arg(&out).args(["--quality", q]));
|
||||
std::fs::read(out).unwrap().len()
|
||||
})
|
||||
.collect();
|
||||
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
|
||||
}
|
||||
|
||||
/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the
|
||||
/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049).
|
||||
#[test]
|
||||
fn dpx_converts_both_ways_with_the_bits_asked_for() {
|
||||
let d = tmp("dpx");
|
||||
let a = d.join("a.png");
|
||||
write_png(&a, 8, 4, 3);
|
||||
for (bits, want) in [(None, 8), (Some("10"), 10), (Some("12"), 12), (Some("16"), 16)] {
|
||||
let out = d.join("a.dpx");
|
||||
let mut convert = bin();
|
||||
convert.arg("convert").arg(&a).arg(&out);
|
||||
if let Some(b) = bits {
|
||||
convert.args(["--dpx-bits", b]);
|
||||
}
|
||||
ok(&mut convert);
|
||||
let bytes = std::fs::read(&out).unwrap();
|
||||
assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}");
|
||||
}
|
||||
let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact"));
|
||||
let v: Value = serde_json::from_str(&info).unwrap();
|
||||
assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}");
|
||||
assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}");
|
||||
let back = d.join("back.png");
|
||||
ok(bin().arg("convert").arg(d.join("a.dpx")).arg(&back));
|
||||
assert!(back.exists());
|
||||
for bad in ["24", "0", "abc"] {
|
||||
let out = d.join("bad.dpx");
|
||||
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}");
|
||||
assert!(!out.exists(), "--dpx-bits {bad} wrote a file");
|
||||
}
|
||||
}
|
||||
|
||||
/// DDS converts both ways; `--dds-compression` picks the FourCC (bytes 84–87; none writes
|
||||
/// uncompressed pixels) and `--dds-mipmaps` the mip count (bytes 28–31). A compression that
|
||||
/// isn't none, bc1, bc2 or bc3 is refused and nothing is written (#1327).
|
||||
#[test]
|
||||
fn dds_converts_both_ways_with_the_compression_asked_for() {
|
||||
let d = tmp("dds");
|
||||
let a = d.join("a.png");
|
||||
write_png(&a, 8, 4, 3);
|
||||
for (args, fourcc, mips) in [
|
||||
(&[][..], [0; 4], 1),
|
||||
(&["--dds-compression", "bc1"][..], *b"DXT1", 1),
|
||||
(&["--dds-compression", "DXT3", "--dds-mipmaps"][..], *b"DXT3", 4),
|
||||
(&["--dds-compression", "bc3"][..], *b"DXT5", 1),
|
||||
(&["--dds-mipmaps"][..], [0; 4], 4),
|
||||
] {
|
||||
let out = d.join("a.dds");
|
||||
ok(bin().arg("convert").arg(&a).arg(&out).args(args));
|
||||
let bytes = std::fs::read(&out).unwrap();
|
||||
let mip_count = u32::from_le_bytes(bytes[28..32].try_into().unwrap());
|
||||
assert_eq!((&bytes[..4], &bytes[84..88], mip_count), (&b"DDS "[..], &fourcc[..], mips), "{args:?}");
|
||||
let back = d.join("back.png");
|
||||
ok(bin().arg("convert").arg(&out).arg(&back));
|
||||
assert!(back.exists(), "{args:?}");
|
||||
}
|
||||
for bad in ["bc7", "dxt2", ""] {
|
||||
let out = d.join("bad.dds");
|
||||
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dds-compression", bad]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1), "--dds-compression {bad}");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dds-compression `{bad}`")), "--dds-compression {bad}");
|
||||
assert!(!out.exists(), "--dds-compression {bad} wrote a file");
|
||||
}
|
||||
}
|
||||
|
||||
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
|
||||
/// refused and nothing is written.
|
||||
#[test]
|
||||
fn tga_bits_choose_the_targa_depth() {
|
||||
let d = tmp("tga-bits");
|
||||
let a = d.join("a.png");
|
||||
write_png(&a, 8, 4, 3);
|
||||
for (bits, want) in [(None, 32), (Some("24"), 24), (Some("32"), 32)] {
|
||||
let out = d.join("a.tga");
|
||||
let mut convert = bin();
|
||||
convert.arg("convert").arg(&a).arg(&out);
|
||||
if let Some(b) = bits {
|
||||
convert.args(["--tga-bits", b]);
|
||||
}
|
||||
ok(&mut convert);
|
||||
assert_eq!(std::fs::read(&out).unwrap()[16], want, "--tga-bits {bits:?}");
|
||||
}
|
||||
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}");
|
||||
assert!(!out.exists(), "--tga-bits {bad} wrote a file");
|
||||
}
|
||||
}
|
||||
|
||||
/// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before
|
||||
/// anything is written, unless `--in-place` asks for it.
|
||||
#[test]
|
||||
fn batch_refuses_to_write_over_its_inputs() {
|
||||
let d = tmp("batch-in-place");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap();
|
||||
std::fs::write(input.join("a.tif"), photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &Default::default()).unwrap()).unwrap();
|
||||
let actions = d.join("actions.json");
|
||||
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
|
||||
let snapshot = || {
|
||||
let mut files: Vec<(PathBuf, Vec<u8>)> =
|
||||
std::fs::read_dir(&input).unwrap().map(|e| e.unwrap().path()).map(|p| (p.clone(), std::fs::read(p).unwrap())).collect();
|
||||
files.sort();
|
||||
files
|
||||
};
|
||||
let before = snapshot();
|
||||
let batch = |out: &Path, extra: &[&str]| {
|
||||
bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out"]).arg(out).args(extra).output().unwrap()
|
||||
};
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&input, d.join("link")).unwrap();
|
||||
let link = cfg!(unix).then(|| PathBuf::from("link"));
|
||||
for out in [PathBuf::from("in"), PathBuf::from("./in/"), input.clone(), input.join(".")].iter().chain(&link) {
|
||||
for extra in [&[][..], &["--format", "tif"][..]] {
|
||||
let o = batch(out, extra);
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
assert_eq!(o.status.code(), Some(1), "--out {} {extra:?}: {err}", out.display());
|
||||
assert!(err.contains("is the --in folder") && err.contains("--in-place"), "{err}");
|
||||
assert_eq!(snapshot(), before, "--out {} {extra:?} changed the originals", out.display());
|
||||
}
|
||||
}
|
||||
// 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"]));
|
||||
assert!(out.contains("2 succeeded, 0 failed"), "{out}");
|
||||
assert_eq!(snapshot(), before);
|
||||
// `--in-place` opts in: each result replaces its original.
|
||||
let o = batch(Path::new("in"), &["--in-place"]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let after = snapshot();
|
||||
assert_eq!(after.len(), 2);
|
||||
assert!(after.iter().zip(&before).all(|(a, b)| a.0 == b.0 && a.1 != b.1), "both inputs replaced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_listing() {
|
||||
let (out, _) = ok(bin().arg("commands"));
|
||||
assert!(out.contains("file.new"));
|
||||
let (out, _) = ok(bin().args(["commands", "--json", "--filter", "layer.new"]));
|
||||
let v: Value = serde_json::from_str(&out).unwrap();
|
||||
assert!(
|
||||
v.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|c| { c["id"].as_str().unwrap().contains("layer.new") || c["label"].as_str().unwrap().to_lowercase().contains("layer.new") })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
assert_eq!(code, 0);
|
||||
assert!(String::from_utf8(out).unwrap().contains("file.new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
send(json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
|
||||
"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}));
|
||||
let init = recv(1);
|
||||
assert_eq!(init["result"]["serverInfo"]["name"], "photocraft", "{init}");
|
||||
send(json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
|
||||
send(json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}));
|
||||
let tools = recv(2);
|
||||
let names: Vec<&str> = tools["result"]["tools"].as_array().unwrap().iter().filter_map(|t| t["name"].as_str()).collect();
|
||||
assert!(names.contains(&"command_run") && names.contains(&"doc_render_preview"), "{names:?}");
|
||||
send(json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"doc_new","arguments":{"width":8,"height":8}}}));
|
||||
let r = recv(3);
|
||||
assert_ne!(r["result"]["isError"], true, "{r}");
|
||||
send(json!({"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"doc_render_preview","arguments":{}}}));
|
||||
let r = recv(4);
|
||||
assert_eq!(r["result"]["content"][0]["type"], "image", "{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() {
|
||||
let d = tmp("batch-collide");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
// Same stem in other formats; `A.tif` differs only in case.
|
||||
let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap();
|
||||
for (name, format) in [("a.bmp", photocraft_codecs::Format::Bmp), ("A.tif", photocraft_codecs::Format::Tiff)] {
|
||||
std::fs::write(input.join(name), photocraft_codecs::encode(&img, format, &Default::default()).unwrap()).unwrap();
|
||||
}
|
||||
let actions = d.join("actions.json");
|
||||
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
|
||||
let out_dir = d.join("out");
|
||||
let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(["--format", "png"]).output().unwrap();
|
||||
let (out, err) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
|
||||
assert!(
|
||||
!o.status.success(),
|
||||
"a lost result must fail the run
|
||||
{out}
|
||||
{err}"
|
||||
);
|
||||
assert!(out.contains("1 succeeded, 2 failed"), "{out}");
|
||||
assert_eq!(err.matches("not written").count(), 2, "{err}");
|
||||
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));
|
||||
assert!(out.contains("3 succeeded, 0 failed"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn droplet_runs_an_action_on_files() {
|
||||
let d = tmp("droplet");
|
||||
write_png(&d.join("a.png"), 8, 4, 3);
|
||||
write_png(&d.join("b.png"), 6, 2, 5);
|
||||
let droplet = d.join("rot.pcdroplet");
|
||||
std::fs::write(
|
||||
&droplet,
|
||||
json!({"photocraftDroplet": 1, "name": "rot", "action": {"steps": [["image.imageRotation.90cw", {}]]}, "options": {"format": "png"}}).to_string(),
|
||||
)
|
||||
.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));
|
||||
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));
|
||||
let o = bin().arg("droplet").arg(d.join("a.png")).arg(d.join("b.png")).output().unwrap();
|
||||
assert!(!o.status.success(), "not a droplet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 v: Value = serde_json::from_str(&info).unwrap();
|
||||
v["layers"].as_array().unwrap().len()
|
||||
};
|
||||
for (name, flag, layers) in [("flat.tif", None, 1), ("layered.tif", Some("--tiff-layers"), 2)] {
|
||||
let out = d.join(name);
|
||||
let mut cmd = bin();
|
||||
cmd.args(["run", "--new", r#"{"width":16,"height":16}"#]).args(["--cmd", "layer.new.layer", "--params", r#"{"name":"Ink"}"#]);
|
||||
if let Some(flag) = flag {
|
||||
cmd.arg(flag);
|
||||
}
|
||||
ok(cmd.arg("--out").arg(&out));
|
||||
assert_eq!(layer_count(&out), layers, "{name}");
|
||||
}
|
||||
// convert honours the same flag.
|
||||
let src = d.join("layered.tif");
|
||||
let flat = d.join("converted.tif");
|
||||
ok(bin().arg("convert").arg(&src).arg(&flat));
|
||||
assert_eq!(layer_count(&flat), 1);
|
||||
let kept = d.join("kept.tif");
|
||||
ok(bin().arg("convert").arg(&src).arg(&kept).arg("--tiff-layers"));
|
||||
assert_eq!(layer_count(&kept), 2);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_non_unicode_argument_is_a_usage_error_not_a_panic() {
|
||||
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 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!(!err.contains("panicked"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `batch --actions set.atn`: a Photoshop Actions file plays like an Actions set, and its steps
|
||||
/// PhotoCraft can't play are warned about up front.
|
||||
#[test]
|
||||
fn batch_plays_a_photoshop_actions_file() {
|
||||
use photocraft_engine::actions_cmds::Action;
|
||||
let d = tmp("atn");
|
||||
let input = d.join("in");
|
||||
std::fs::create_dir_all(&input).unwrap();
|
||||
write_png(&input.join("a.png"), 8, 4, 3);
|
||||
let invert = Action::new("Negative", vec![("image.adjustments.invert".into(), json!({})), ("select.all".into(), json!({}))]);
|
||||
let odd = Action::new("Odd", vec![(photocraft_engine::atn_cmds::PHOTOSHOP_STEP.into(), json!({"event": "syntheticFill", "name": "Generative Fill"}))]);
|
||||
let (bytes, skipped) = photocraft_engine::atn_cmds::export("Set", &[&invert, &odd], &[], false).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
let atn = d.join("Set.atn");
|
||||
std::fs::write(&atn, bytes).unwrap();
|
||||
let out_dir = d.join("out");
|
||||
// Two actions: the set needs --action.
|
||||
let o = bin().args(["batch", "--actions"]).arg(&atn).arg("--in").arg(&input).arg("--out").arg(&out_dir).output().unwrap();
|
||||
assert!(!o.status.success());
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("--action"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let (_, err) = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir));
|
||||
assert!(out_dir.join("a.png").exists());
|
||||
assert!(!err.contains("warning"), "{err}");
|
||||
// The unsupported step is warned about, and fails the file as Photoshop would stop on it.
|
||||
let o = bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Odd", "--in"]).arg(&input).arg("--out").arg(d.join("out2")).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(err.contains("Generative Fill"), "{err}");
|
||||
assert!(!o.status.success());
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! The crate's integration tests, linked into one test binary.
|
||||
|
||||
mod cli;
|
||||
mod man_and_completions;
|
||||
mod orientation;
|
||||
|
||||
/// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid.
|
||||
fn binary() -> std::ffi::OsString {
|
||||
std::env::var_os("NEXTEST_BIN_EXE_photocraft_cli").unwrap_or_else(|| env!("CARGO_BIN_EXE_photocraft-cli").into())
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! The clap definition is consistent, and every shell's completions and every man page
|
||||
//! (`examples/man-and-completions.rs`) can be generated from it.
|
||||
|
||||
use clap::ValueEnum;
|
||||
use clap_complete::Shell;
|
||||
|
||||
#[test]
|
||||
fn command_is_consistent() {
|
||||
photocraft_cli::command().debug_assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completions_generate_for_every_shell() {
|
||||
for &shell in Shell::value_variants() {
|
||||
let mut out = Vec::new();
|
||||
clap_complete::generate(shell, &mut photocraft_cli::command(), "photocraft-cli", &mut out);
|
||||
assert!(!out.is_empty(), "{shell}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn man_pages_render_for_the_command_and_every_subcommand() {
|
||||
let cmd = photocraft_cli::command();
|
||||
for page in std::iter::once(&cmd).chain(cmd.get_subcommands()) {
|
||||
let mut out = Vec::new();
|
||||
clap_mangen::Man::new(page.clone()).render(&mut out).unwrap();
|
||||
assert!(!out.is_empty(), "{}", page.get_name());
|
||||
}
|
||||
}
|
||||
|
||||
/// The example's two `generate_to` calls, repeated here, write the five completion files under the
|
||||
/// names `packaging/linux/package.sh` and `packaging/freebsd/package.sh` install, `photocraft-cli.1`
|
||||
/// and a page per subcommand, and no pages for the `help` subcommand.
|
||||
#[test]
|
||||
fn the_example_writes_the_files_the_packages_install() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-cli-{}-man-and-completions", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut cmd = photocraft_cli::command();
|
||||
for &shell in Shell::value_variants() {
|
||||
clap_complete::generate_to(shell, &mut cmd, "photocraft-cli", &dir).unwrap();
|
||||
}
|
||||
clap_mangen::generate_to(photocraft_cli::command(), &dir).unwrap();
|
||||
|
||||
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
|
||||
written.sort();
|
||||
let completions = ["photocraft-cli.bash", "_photocraft-cli", "photocraft-cli.fish", "photocraft-cli.elv", "_photocraft-cli.ps1"];
|
||||
let subcommands = ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"];
|
||||
let mut expected: Vec<String> = completions.map(String::from).into();
|
||||
expected.push("photocraft-cli.1".into());
|
||||
expected.extend(subcommands.iter().map(|s| format!("photocraft-cli-{s}.1")));
|
||||
expected.sort();
|
||||
assert_eq!(written, expected);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use photocraft_codecs::{ChannelLayout, DecodeOptions, EncodeOptions, Format, Ima
|
||||
use serde_json::Value;
|
||||
|
||||
fn bin() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_photocraft-cli"))
|
||||
Command::new(crate::binary())
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
@@ -125,7 +125,8 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() {
|
||||
let (a, b) = (d.join("a.pcraft"), d.join("b.pcraft"));
|
||||
ok(bin().arg("convert").arg(&jpg).arg(&a));
|
||||
ok(bin().arg("convert").arg(&a).arg(&b));
|
||||
// Everything but the file's own path and saved document name must match.
|
||||
// Everything but the file's own path and saved document name (which the first snapshot is
|
||||
// named after) must match.
|
||||
let info = |p: &Path| {
|
||||
let mut v = serde_json::from_str::<Value>(&ok(bin().arg("info").arg(p))).unwrap();
|
||||
v.as_object_mut().unwrap().remove("file");
|
||||
@@ -136,8 +137,12 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() {
|
||||
assert_eq!((ia["width"].as_u64(), ia["height"].as_u64()), (Some(16), Some(32)));
|
||||
assert_eq!(ia["name"], "a.pcraft");
|
||||
assert_eq!(ib["name"], "b.pcraft");
|
||||
ia.as_object_mut().unwrap().remove("name");
|
||||
ib.as_object_mut().unwrap().remove("name");
|
||||
assert_eq!(ia["snapshots"][0]["name"], "a.pcraft");
|
||||
assert_eq!(ib["snapshots"][0]["name"], "b.pcraft");
|
||||
for v in [&mut ia, &mut ib] {
|
||||
v.as_object_mut().unwrap().remove("name");
|
||||
v["snapshots"][0].as_object_mut().unwrap().remove("name");
|
||||
}
|
||||
assert_eq!(ia, ib);
|
||||
let (pa, pb) = (d.join("a.png"), d.join("b.png"));
|
||||
ok(bin().arg("convert").arg(&a).arg(&pa));
|
||||
@@ -29,12 +29,12 @@ log = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||
eframe = { workspace = true, features = ["wgpu", "accesskit"] }
|
||||
eframe = { workspace = true, features = ["wgpu", "accesskit", "clipboard"] }
|
||||
rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "RequestMode", "Navigator"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -9,8 +9,8 @@ use photocraft_engine::{
|
||||
};
|
||||
|
||||
pub type Batch = BTreeMap<String, Option<Arc<[u8]>>>;
|
||||
/// Three sampled images per preset (tip, dual tip, texture), plus group/index/action records.
|
||||
pub const MAX_RECORDS: usize = MAX_GROUPS + MAX_PRESETS_PER_GROUP * 3 + 2;
|
||||
/// Three sampled images per preset (tip, dual tip, texture), plus group/index/action/pattern records.
|
||||
pub const MAX_RECORDS: usize = MAX_GROUPS + MAX_PRESETS_PER_GROUP * 3 + 3;
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
@@ -45,6 +45,7 @@ pub fn record_limit(name: &str) -> Result<u64, String> {
|
||||
match name {
|
||||
INDEX_FILE => Ok(MAX_INDEX_BYTES),
|
||||
ACTIONS_FILE => Ok(MAX_ACTIONS_BYTES),
|
||||
PATTERNS_FILE => Ok(MAX_PATTERNS_BYTES),
|
||||
_ if name.starts_with("tips/") && leaf.ends_with(".pctip") => Ok(MAX_TIP_BYTES),
|
||||
_ if !name.contains('/') && name.ends_with(".pcbrushes") => Ok(MAX_GROUP_BYTES),
|
||||
_ => Err("unknown preset record type".into()),
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::cell::Cell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image};
|
||||
use photocraft_doc::Document;
|
||||
@@ -17,11 +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", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef",
|
||||
"nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "act", "ase", "af", "afdesign", "afphoto", "afpub",
|
||||
"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", "asl", "svg", "svgz", "aco", "act", "ase", "af",
|
||||
"afdesign", "afphoto", "afpub",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
const CANVAS_ID: &str = "photocraft_canvas";
|
||||
@@ -79,6 +80,7 @@ pub fn start() {
|
||||
canvas,
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
PhotocraftApp::install_type_reset_hooks(&cc.egui_ctx);
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
|
||||
let inbox: Inbox = Arc::default();
|
||||
let place_inbox: Inbox = Arc::default();
|
||||
@@ -217,7 +219,7 @@ fn load_font_manifest(served: Served, ctx: egui::Context) {
|
||||
files.entry(f.family.clone()).or_default().push((f.file.clone(), f.integrity.clone()));
|
||||
}
|
||||
log::info!("photocraft-web: {} served font families", files.len());
|
||||
served.lock().unwrap_or_else(|e| e.into_inner()).files = files;
|
||||
served.lock().unwrap_or_else(PoisonError::into_inner).files = files;
|
||||
// The families for the font menus, and the script fallbacks (the manifest's `scripts`).
|
||||
served_fonts::add_fonts(&fonts);
|
||||
// Fetch now the fallbacks of the scripts the browser's languages use (an Arabic reader gets
|
||||
@@ -232,20 +234,21 @@ fn browser_languages() -> Vec<String> {
|
||||
web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|l| l.as_string()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// GET `url` (relative to the page) and read the whole body. With `integrity` (a Subresource
|
||||
/// Integrity value), the browser rejects a body whose hash differs.
|
||||
/// GET `url` (a relative path on the same origin) and read the whole body. With `integrity` (a
|
||||
/// Subresource Integrity value), the browser rejects a body whose hash differs.
|
||||
async fn fetch_bytes(url: &str, integrity: Option<&str>) -> Result<Vec<u8>, String> {
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
if !served_fonts::is_relative_path(url) {
|
||||
return Err("url must be a relative path".into());
|
||||
}
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let request = match integrity {
|
||||
Some(sri) => {
|
||||
let init = web_sys::RequestInit::new();
|
||||
init.set_integrity(sri);
|
||||
window.fetch_with_str_and_init(url, &init)
|
||||
}
|
||||
None => window.fetch_with_str(url),
|
||||
};
|
||||
let init = web_sys::RequestInit::new();
|
||||
init.set_mode(web_sys::RequestMode::SameOrigin);
|
||||
if let Some(sri) = integrity {
|
||||
init.set_integrity(sri);
|
||||
}
|
||||
let request = window.fetch_with_str_and_init(url, &init);
|
||||
let resp: web_sys::Response = JsFuture::from(request).await.map_err(js)?.dyn_into().map_err(|_| "not a Response")?;
|
||||
if !resp.ok() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
@@ -293,7 +296,7 @@ impl WebShell {
|
||||
fn serve_fonts(&mut self, ctx: &egui::Context) {
|
||||
let requested = served_fonts::take_requests();
|
||||
let (fetch, arrived) = {
|
||||
let mut s = self.served.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut s = self.served.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let mut fetch = Vec::new();
|
||||
for family in requested {
|
||||
if let Some(files) = s.files.get(&family).cloned()
|
||||
@@ -310,7 +313,7 @@ impl WebShell {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match fetch_bytes(&file, integrity.as_deref()).await {
|
||||
Ok(bytes) => {
|
||||
served.lock().unwrap_or_else(|e| e.into_inner()).arrived.push((family, bytes));
|
||||
served.lock().unwrap_or_else(PoisonError::into_inner).arrived.push((family, bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't fetch served font {file}: {e}"),
|
||||
@@ -326,6 +329,10 @@ impl eframe::App for WebShell {
|
||||
// A tap reaches eframe as Pointer Events and Touch Events: keep one press per tap, or the
|
||||
// twin press closes the menu the first one opened (#2095).
|
||||
photocraft_ui_egui::touch_twins::drop_twins(ctx, raw_input);
|
||||
// The browser build has no TCP control server, so `inject_synthetic` finds an empty
|
||||
// queue, but the clipboard key mapping (`Event::PasteImage`), legacy pinch, pen-hold and
|
||||
// titlebar normalization live in the app hook and must run here too.
|
||||
eframe::App::raw_input_hook(&mut self.app, ctx, raw_input);
|
||||
}
|
||||
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
@@ -345,7 +352,7 @@ impl eframe::App for WebShell {
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
let queue = if place { &place_inbox } else { &inbox };
|
||||
queue.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
queue.lock().unwrap_or_else(PoisonError::into_inner).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
@@ -354,8 +361,9 @@ impl eframe::App for WebShell {
|
||||
});
|
||||
}
|
||||
self.serve_fonts(ctx);
|
||||
self.app.logic(ctx, frame);
|
||||
self.app.logic_frame(ctx, frame);
|
||||
self.save_presets(ctx);
|
||||
self.app.finish_type_reset_logic(ctx);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
@@ -382,16 +390,17 @@ impl eframe::App for WebShell {
|
||||
});
|
||||
});
|
||||
}
|
||||
self.app.ui(ui, frame);
|
||||
self.app.ui_frame(ui, frame);
|
||||
self.save_presets(ui.ctx());
|
||||
self.app.finish_type_reset_pass(ui.ctx());
|
||||
}
|
||||
}
|
||||
|
||||
fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services {
|
||||
Services {
|
||||
screen_pick: screen_color_service(),
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
|
||||
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| {
|
||||
photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
|
||||
photocraft_io::export(doc, path, &settings.io_options()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
|
||||
|
||||
@@ -29,19 +29,26 @@ photocraft-automation = { workspace = true }
|
||||
photocraft-ui-egui = { workspace = true }
|
||||
# Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate).
|
||||
photocraft-tablet = { workspace = true }
|
||||
eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence"] }
|
||||
eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence", "clipboard"] }
|
||||
egui = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
blake3 = { version = "1.8", default-features = false, features = ["pure"] }
|
||||
chrono = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tempfile = "=3.27.0"
|
||||
roxmltree = "0.20"
|
||||
roxmltree = "0.21"
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { version = "3.6", features = ["wayland-data-control"] }
|
||||
arboard = { version = "3.7", features = ["wayland-data-control"] }
|
||||
open = "5"
|
||||
# Reads the saved window layout (eframe persistence) to drop values that would abort startup.
|
||||
ron = "0.12"
|
||||
# Background release checks; HTTPS with Rust TLS, no async runtime.
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Safe CoreGraphics bindings, compatible with the existing macOS 11 deployment target.
|
||||
@@ -52,22 +59,23 @@ core-graphics2 = { version = "=0.6.1", default-features = false, features = ["di
|
||||
# safe API. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
# Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs) and find where the
|
||||
# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position). Already in
|
||||
# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position), and play the
|
||||
# system alert sound for Beep When Done (services.rs, NSBeep). Already in
|
||||
# Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions.
|
||||
objc2 = "=0.6.4"
|
||||
objc2 = "=0.6.5"
|
||||
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] }
|
||||
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow"] }
|
||||
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSGraphics", "NSResponder", "NSView", "NSWindow"] }
|
||||
# The macOS menu bar (mac_menu.rs): a standalone native menu crate (from the tauri-apps org, but
|
||||
# not Tauri: no webview, no JS), AppKit menus behind a safe API (no GTK with default features off). Pinned exactly: review the diff before bumping.
|
||||
muda = { version = "=0.21.1", default-features = false }
|
||||
muda = { version = "=0.21.2", default-features = false }
|
||||
# Quit with `terminate:` from the main queue, outside winit's event handler (mac_window.rs).
|
||||
# Already in Cargo.lock (winit, objc2-app-kit); pinned exactly to the same version.
|
||||
dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
|
||||
|
||||
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
rustix = { version = "=1.1.5", features = ["fs"] }
|
||||
|
||||
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
photocraft-cms = { workspace = true }
|
||||
# The print portal's PDF: a private (0600) temporary file, removed when the dialog is done.
|
||||
@@ -108,3 +116,7 @@ harness = false
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for photocraft.exe; build.rs only uses it when targeting Windows.
|
||||
winresource = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
# Opt-in native save-back acceptance transports embedded sources through engine JSON.
|
||||
base64 = "=0.22.1"
|
||||
|
||||
@@ -390,7 +390,7 @@ fn stage_copy(from: &Path, to: &Path) -> std::io::Result<PathBuf> {
|
||||
fn remove_stale_staging(folder: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(folder) else { return };
|
||||
for entry in entries.flatten() {
|
||||
if !entry.file_name().to_str().is_some_and(|n| side_of(n, STAGED).is_some()) {
|
||||
if entry.file_name().to_str().is_none_or(|n| side_of(n, STAGED).is_none()) {
|
||||
continue;
|
||||
}
|
||||
let removed = match entry.file_type() {
|
||||
|
||||
@@ -25,7 +25,7 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRe
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
eprintln!("photocraft: control server listening on 127.0.0.1:{port}");
|
||||
eprintln!("photocraft: control server listening on {}", listening_addr(&listener, port));
|
||||
std::thread::spawn(move || {
|
||||
let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
|
||||
let token = Arc::new(token);
|
||||
@@ -47,6 +47,16 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRe
|
||||
rx
|
||||
}
|
||||
|
||||
/// The address `listener` is bound to: with `--control 0` the OS picks the port, and a client
|
||||
/// needs that one, not the requested 0 (#6427). Falls back to the requested port if the OS
|
||||
/// can't report it.
|
||||
fn listening_addr(listener: &TcpListener, requested_port: u16) -> String {
|
||||
match listener.local_addr() {
|
||||
Ok(addr) => addr.to_string(),
|
||||
Err(_) => format!("127.0.0.1:{requested_port}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE);
|
||||
}
|
||||
@@ -131,6 +141,17 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::BufRead;
|
||||
|
||||
/// #6427: with port 0 the log names the port the OS bound, which a client can connect to.
|
||||
#[test]
|
||||
fn port_zero_logs_the_bound_port() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let bound = listener.local_addr().unwrap();
|
||||
assert_ne!(bound.port(), 0);
|
||||
let logged = listening_addr(&listener, 0);
|
||||
assert_eq!(logged, format!("127.0.0.1:{}", bound.port()));
|
||||
TcpStream::connect(&logged).unwrap();
|
||||
}
|
||||
|
||||
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
|
||||
/// (releasing its connection permit), and nothing it sent is dispatched.
|
||||
#[test]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! The owned temporary SVG is watched off the UI thread. Closing the PhotoCraft session stops
|
||||
//! watching and removes its temporary directory; it does not terminate the external editor.
|
||||
|
||||
use photocraft_ui_egui::{ExternalSvgEditor, ExternalSvgSession};
|
||||
use photocraft_ui_egui::{ExternalSvgEditor, ExternalSvgSession, ExternalVectorSave};
|
||||
|
||||
/// Start an explicit native editor session; artwork cannot supply executable paths.
|
||||
pub(crate) fn open(editor: ExternalSvgEditor, source: photocraft_ui_egui::ExternalVectorSource) -> Result<Box<dyn ExternalSvgSession>, String> {
|
||||
@@ -34,7 +34,17 @@ mod native {
|
||||
let portal = matches!(&editor, ExternalSvgEditor::VectorCraft);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let portal = false;
|
||||
let vectorcraft = matches!(&editor, ExternalSvgEditor::VectorCraft);
|
||||
let executable = if portal && source.extension == "svg" { None } else { Some(resolve_editor(editor)?) };
|
||||
// Display conversion is always performed by VectorCraft's CLI, even when the user
|
||||
// chooses a different interactive editor. Explicit VectorCraft builds keep their matching CLI.
|
||||
let converter = if source.extension == "svg" {
|
||||
None
|
||||
} else if vectorcraft {
|
||||
executable.clone()
|
||||
} else {
|
||||
Some(resolve_converter(executable.as_deref())?)
|
||||
};
|
||||
if source.bytes.is_empty() || source.bytes.len() > MAX_BYTES || !matches!(source.extension.as_str(), "svg" | "pdf" | "ai" | "eps") {
|
||||
return Err("Unsupported embedded vector source or source exceeds 16 MiB".into());
|
||||
}
|
||||
@@ -45,7 +55,7 @@ mod native {
|
||||
thread::Builder::new()
|
||||
.name("photocraft-vector-editor".into())
|
||||
.spawn(move || {
|
||||
let prepared = match executable.as_deref() {
|
||||
let prepared = match converter.as_deref() {
|
||||
Some(executable) => super::vector_conversion::prepare(executable, &source, &stopped),
|
||||
None => OwnedSvg::create(&source.bytes).map(|owned| (owned, source.bytes.as_ref().clone())),
|
||||
};
|
||||
@@ -57,16 +67,18 @@ mod native {
|
||||
#[cfg(target_os = "linux")]
|
||||
if portal {
|
||||
match super::portal::open(&owned, &stopped) {
|
||||
Ok(()) => watch(owned, initial, None, stopped, send, POLL_INTERVAL, SAVE_SETTLE),
|
||||
Ok(()) => watch(owned, initial, None, stopped, send, (POLL_INTERVAL, SAVE_SETTLE), converter.as_deref().map(|e| (e, &source))),
|
||||
Err(error) => {
|
||||
let _ = send.try_send(Err(error));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(executable) = executable {
|
||||
if let Some(executable) = executable.as_ref() {
|
||||
match std::process::Command::new(executable).arg(&owned.file).spawn() {
|
||||
Ok(child) => watch(owned, initial, Some(child), stopped, send, POLL_INTERVAL, SAVE_SETTLE),
|
||||
Ok(child) => {
|
||||
watch(owned, initial, Some(child), stopped, send, (POLL_INTERVAL, SAVE_SETTLE), converter.as_deref().map(|e| (e, &source)))
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = send.try_send(Err(format!("Could not open the vector editor: {error}")));
|
||||
}
|
||||
@@ -82,6 +94,20 @@ mod native {
|
||||
Ok(Box::new(Session { stop, updates, finished: false }))
|
||||
}
|
||||
|
||||
/// An explicit VectorCraft build may carry its own CLI. Other editors use the known
|
||||
/// VectorCraft installation; document metadata never participates in discovery.
|
||||
fn resolve_converter(selected: Option<&Path>) -> Result<PathBuf, String> {
|
||||
let name = if cfg!(windows) { "vectorcraft-cli.exe" } else { "vectorcraft-cli" };
|
||||
if let Some(editor) = selected
|
||||
&& let Some(directory) = editor.parent()
|
||||
&& std::fs::symlink_metadata(directory.join(name)).is_ok_and(|m| m.is_file() && !m.file_type().is_symlink())
|
||||
{
|
||||
return Ok(editor.to_path_buf());
|
||||
}
|
||||
resolve_editor(ExternalSvgEditor::VectorCraft)
|
||||
.map_err(|_| "AI/PDF/EPS editing also needs an installed VectorCraft CLI to validate display previews".into())
|
||||
}
|
||||
|
||||
/// Discover only known installations or an explicitly chosen executable.
|
||||
#[cfg(unix)]
|
||||
fn resolve_editor(editor: ExternalSvgEditor) -> Result<PathBuf, String> {
|
||||
@@ -119,6 +145,8 @@ mod native {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Probe only fixed Program Files / LocalAppData install locations (one Adobe directory
|
||||
/// level, at most 64 entries) or validate an explicitly chosen executable.
|
||||
#[cfg(windows)]
|
||||
fn resolve_editor(editor: ExternalSvgEditor) -> Result<PathBuf, String> {
|
||||
match editor {
|
||||
@@ -162,6 +190,8 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
/// Require an absolute `.exe` that is a regular file, not a link or reparse point, and
|
||||
/// return its canonical path; scripts and command lines are rejected.
|
||||
#[cfg(windows)]
|
||||
fn validate_executable(path: &Path) -> Result<PathBuf, String> {
|
||||
if !path.is_absolute() || !path.extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("exe")) {
|
||||
@@ -181,6 +211,14 @@ mod native {
|
||||
impl OwnedSvg {
|
||||
/// Create a private working file from bounded source bytes.
|
||||
pub(super) fn create(svg: &[u8]) -> Result<Self, String> {
|
||||
Self::create_format(svg, "svg")
|
||||
}
|
||||
|
||||
/// Whitelisted extensions preserve the format; artwork cannot select a path.
|
||||
pub(super) fn create_format(svg: &[u8], extension: &str) -> Result<Self, String> {
|
||||
if !matches!(extension, "svg" | "ai" | "pdf" | "eps") {
|
||||
return Err("Unsupported vector working-copy format".into());
|
||||
}
|
||||
if svg.is_empty() || svg.len() > MAX_BYTES {
|
||||
return Err("The SVG must contain between 1 byte and 16 MiB".into());
|
||||
}
|
||||
@@ -208,7 +246,7 @@ mod native {
|
||||
}
|
||||
.map_err(|e| format!("Cannot create the SVG editing directory: {e}"))?;
|
||||
let root = directory.path().canonicalize().map_err(|e| format!("Cannot resolve the SVG editing directory: {e}"))?;
|
||||
let file = root.join("Artwork.svg");
|
||||
let file = root.join(format!("Artwork.{extension}"));
|
||||
let mut output = OpenOptions::new().write(true).create_new(true).open(&file).map_err(|e| format!("Cannot create the SVG editing file: {e}"))?;
|
||||
output.write_all(svg).and_then(|()| output.sync_all()).map_err(|e| format!("Cannot write the SVG editing file: {e}"))?;
|
||||
Ok(Self { directory, root, file })
|
||||
@@ -269,6 +307,7 @@ mod native {
|
||||
}
|
||||
|
||||
/// Write normalized SVG through a checked, no-follow handle before editor launch.
|
||||
#[cfg(test)]
|
||||
pub(super) fn overwrite(&self, bytes: &[u8]) -> Result<(), String> {
|
||||
if bytes.is_empty() || bytes.len() > MAX_BYTES {
|
||||
return Err("Prepared SVG exceeds the supported byte limit".into());
|
||||
@@ -300,6 +339,7 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject anything but a regular file, including symlinks and Windows reparse points.
|
||||
fn ensure_regular(metadata: &Metadata) -> Result<(), String> {
|
||||
if !metadata.is_file() || is_redirect(metadata) {
|
||||
return Err("The SVG or editor must be a regular file, not a link or reparse point".into());
|
||||
@@ -307,6 +347,8 @@ mod native {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True for a symlink, or on Windows any reparse point, whose target could be swapped.
|
||||
/// Expects `symlink_metadata`, which describes the link itself rather than its target.
|
||||
fn is_redirect(metadata: &Metadata) -> bool {
|
||||
if metadata.file_type().is_symlink() {
|
||||
return true;
|
||||
@@ -347,14 +389,20 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
struct Session {
|
||||
pub(super) struct Session {
|
||||
stop: Sender<()>,
|
||||
updates: Receiver<Result<Vec<u8>, String>>,
|
||||
updates: Receiver<Result<ExternalVectorSave, String>>,
|
||||
finished: bool,
|
||||
}
|
||||
|
||||
impl ExternalSvgSession for Session {
|
||||
/// Like [`Self::poll_vector`], keeping only the saved source bytes.
|
||||
fn poll(&mut self) -> Option<Result<Vec<u8>, String>> {
|
||||
self.poll_vector().map(|result| result.map(|saved| saved.bytes))
|
||||
}
|
||||
|
||||
/// Take one queued save or error without blocking; a closed queue marks the session finished.
|
||||
fn poll_vector(&mut self) -> Option<Result<ExternalVectorSave, String>> {
|
||||
match self.updates.try_recv() {
|
||||
Ok(update) => Some(update),
|
||||
Err(mpsc::TryRecvError::Empty) => None,
|
||||
@@ -365,6 +413,7 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
/// True once a poll has seen the watcher thread exit and drop its sender.
|
||||
fn finished(&self) -> bool {
|
||||
self.finished
|
||||
}
|
||||
@@ -385,24 +434,80 @@ mod native {
|
||||
let (send, updates) = mpsc::sync_channel(2);
|
||||
thread::Builder::new()
|
||||
.name("photocraft-svg-editor".into())
|
||||
.spawn(move || watch(owned, initial, child, stopped, send, interval, settle))
|
||||
.spawn(move || watch(owned, initial, child, stopped, send, (interval, settle), None))
|
||||
.map_err(|e| format!("Cannot watch SVG editor saves: {e}"))?;
|
||||
Ok(Session { stop, updates, finished: false })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn start_native_test(
|
||||
owned: OwnedSvg,
|
||||
initial: Vec<u8>,
|
||||
editor: PathBuf,
|
||||
source: photocraft_ui_egui::ExternalVectorSource,
|
||||
) -> Result<Session, String> {
|
||||
let (stop, stopped) = mpsc::channel();
|
||||
let (send, updates) = mpsc::sync_channel(2);
|
||||
thread::Builder::new()
|
||||
.name("native-vector-test-watcher".into())
|
||||
.spawn(move || watch(owned, initial, None, stopped, send, (Duration::from_millis(30), Duration::from_millis(100)), Some((&editor, &source))))
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Session { stop, updates, finished: false })
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Delivery {
|
||||
Sent,
|
||||
Changed,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
/// Keep either a converted save or its error under queue backpressure, without
|
||||
/// re-running conversion. A newer file supersedes either result; cancellation never waits.
|
||||
fn deliver(
|
||||
owned: &OwnedSvg,
|
||||
stamp: &Stamp,
|
||||
send: &SyncSender<Result<ExternalVectorSave, String>>,
|
||||
stopped: &Receiver<()>,
|
||||
mut update: Result<ExternalVectorSave, String>,
|
||||
interval: Duration,
|
||||
) -> Delivery {
|
||||
loop {
|
||||
if owned.stamp().ok().as_ref() != Some(stamp) {
|
||||
return Delivery::Changed;
|
||||
}
|
||||
match send.try_send(update) {
|
||||
Ok(()) => return Delivery::Sent,
|
||||
Err(TrySendError::Full(value)) => {
|
||||
update = value;
|
||||
if !matches!(stopped.recv_timeout(interval), Err(mpsc::RecvTimeoutError::Timeout)) {
|
||||
return Delivery::Stopped;
|
||||
}
|
||||
}
|
||||
Err(TrySendError::Disconnected(_)) => return Delivery::Stopped,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker loop: poll the working file each `interval`, read it only after its stamp stays
|
||||
/// unchanged for `settle`, and deliver changed bytes with an optional converted SVG preview
|
||||
/// for non-SVG sources. Repeated identical errors and known-failed sources are not re-sent.
|
||||
/// Returns when stopped or the UI drops its receiver, dropping the private working copy.
|
||||
fn watch(
|
||||
owned: OwnedSvg,
|
||||
mut last_bytes: Vec<u8>,
|
||||
mut child: Option<Child>,
|
||||
stopped: Receiver<()>,
|
||||
send: SyncSender<Result<Vec<u8>, String>>,
|
||||
interval: Duration,
|
||||
settle: Duration,
|
||||
send: SyncSender<Result<ExternalVectorSave, String>>,
|
||||
timing: (Duration, Duration),
|
||||
converter: Option<(&Path, &photocraft_ui_egui::ExternalVectorSource)>,
|
||||
) {
|
||||
let (interval, settle) = timing;
|
||||
// The editor may save before the watcher starts. First settled read must compare bytes.
|
||||
let mut last_stamp: Option<Stamp> = None;
|
||||
let mut pending: Option<(Stamp, Instant)> = None;
|
||||
let mut last_error: Option<String> = None;
|
||||
let mut failed_source: Option<[u8; 32]> = None;
|
||||
while let Err(mpsc::RecvTimeoutError::Timeout) = stopped.recv_timeout(interval) {
|
||||
if child.as_mut().is_some_and(|process| process.try_wait().ok().flatten().is_some()) {
|
||||
child = None;
|
||||
@@ -428,18 +533,52 @@ mod native {
|
||||
};
|
||||
match candidate {
|
||||
Ok(bytes) => {
|
||||
last_error = None;
|
||||
if bytes == last_bytes {
|
||||
if bytes == last_bytes || failed_source.is_some_and(|hash| hash == *blake3::hash(&bytes).as_bytes()) {
|
||||
last_stamp = pending.as_ref().map(|(stamp, _)| stamp.clone());
|
||||
continue;
|
||||
}
|
||||
match send.try_send(Ok(bytes.clone())) {
|
||||
Ok(()) => {
|
||||
last_bytes = bytes;
|
||||
last_stamp = pending.as_ref().map(|(stamp, _)| stamp.clone());
|
||||
let preview = match converter.filter(|(_, source)| source.extension != "svg") {
|
||||
Some((editor, source)) => {
|
||||
let snapshot = photocraft_ui_egui::ExternalVectorSource {
|
||||
bytes: std::sync::Arc::new(bytes.clone()),
|
||||
extension: source.extension.clone(),
|
||||
size: source.size,
|
||||
};
|
||||
match super::vector_conversion::render_preview(editor, &snapshot, &stopped) {
|
||||
Ok(svg) => Some(svg),
|
||||
Err(error) => {
|
||||
let Some((stamp, _)) = pending.as_ref() else { continue };
|
||||
if last_error.as_ref() == Some(&error) {
|
||||
failed_source = Some(*blake3::hash(&bytes).as_bytes());
|
||||
last_stamp = Some(stamp.clone());
|
||||
} else {
|
||||
match deliver(&owned, stamp, &send, &stopped, Err(error.clone()), interval) {
|
||||
Delivery::Sent => {
|
||||
last_error = Some(error);
|
||||
failed_source = Some(*blake3::hash(&bytes).as_bytes());
|
||||
last_stamp = Some(stamp.clone());
|
||||
}
|
||||
Delivery::Changed => {}
|
||||
Delivery::Stopped => return,
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(TrySendError::Full(_)) => {}
|
||||
Err(TrySendError::Disconnected(_)) => break,
|
||||
None => None,
|
||||
};
|
||||
let Some((stamp, _)) = pending.as_ref() else { continue };
|
||||
let update = Ok(ExternalVectorSave { bytes: bytes.clone(), preview_svg: preview });
|
||||
match deliver(&owned, stamp, &send, &stopped, update, interval) {
|
||||
Delivery::Sent => {
|
||||
last_error = None;
|
||||
failed_source = None;
|
||||
last_bytes = bytes;
|
||||
last_stamp = Some(stamp.clone());
|
||||
}
|
||||
Delivery::Changed => {}
|
||||
Delivery::Stopped => return,
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -461,6 +600,29 @@ mod native {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn conversion_errors_wait_for_queue_capacity_without_disappearing() {
|
||||
let owned = OwnedSvg::create(b"<svg/>").unwrap();
|
||||
let stamp = owned.stamp().unwrap();
|
||||
let (send, updates) = mpsc::sync_channel(1);
|
||||
send.send(Ok(ExternalVectorSave { bytes: b"previous save".to_vec(), preview_svg: None })).unwrap();
|
||||
let (_stop, stopped) = mpsc::channel();
|
||||
let worker = thread::spawn(move || deliver(&owned, &stamp, &send, &stopped, Err("native preview failed".into()), Duration::from_millis(2)));
|
||||
thread::sleep(Duration::from_millis(30));
|
||||
assert_eq!(updates.recv_timeout(Duration::from_secs(2)).unwrap().unwrap().bytes, b"previous save");
|
||||
assert_eq!(updates.recv_timeout(Duration::from_secs(2)).unwrap().unwrap_err(), "native preview failed");
|
||||
assert_eq!(worker.join().unwrap(), Delivery::Sent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicitly_selected_vector_build_keeps_its_adjacent_cli() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let editor = directory.path().join("custom-vectorcraft");
|
||||
let cli = directory.path().join(if cfg!(windows) { "vectorcraft-cli.exe" } else { "vectorcraft-cli" });
|
||||
std::fs::write(&cli, b"trusted test fixture").unwrap();
|
||||
assert_eq!(resolve_converter(Some(&editor)).unwrap(), editor);
|
||||
}
|
||||
|
||||
const EMPTY_SAVE: &str = "The edited SVG must contain between 1 byte and 16 MiB";
|
||||
const CHANGING_SAVE: &str = "The SVG is still being saved";
|
||||
|
||||
@@ -500,7 +662,7 @@ mod native {
|
||||
let (stop, _stopped) = mpsc::channel();
|
||||
let (send, receive) = mpsc::channel();
|
||||
for update in updates {
|
||||
send.send(update).unwrap();
|
||||
send.send(update.map(|bytes| ExternalVectorSave { bytes, preview_svg: None })).unwrap();
|
||||
}
|
||||
Session { stop, updates: receive, finished: false }
|
||||
}
|
||||
@@ -638,3 +800,7 @@ mod vector_conversion;
|
||||
#[cfg(target_os = "linux")]
|
||||
#[path = "external_svg_portal.rs"]
|
||||
mod portal;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "native_saveback_tests.rs"]
|
||||
mod native_saveback_tests;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -91,27 +91,36 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
|
||||
/// logging never panics and never silently resets to the epoch for a convertible instant.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
// Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
|
||||
// past `i64::MAX` seconds saturates rather than falling back to the epoch.
|
||||
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
|
||||
let (y, t) = utc_parts(secs, since.subsec_nanos());
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch).
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days + 719_468;
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + u64::from(m <= 2);
|
||||
(y, m, d)
|
||||
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
|
||||
/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
|
||||
/// so month, day and time of day come from chrono and only the year is carried, which keeps every
|
||||
/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
|
||||
fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -416,6 +425,20 @@ mod tests {
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
|
||||
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
|
||||
// SystemTime can't hold this instant, so it is only checked where it exists.
|
||||
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
|
||||
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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")]
|
||||
@@ -76,6 +71,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;
|
||||
@@ -98,6 +94,53 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
|
||||
CUSTOM_TITLEBAR && !prefs.interface.system_title_bar
|
||||
}
|
||||
|
||||
/// Preferences › Integrations, read before the window opens the way [`custom_titlebar`] is:
|
||||
/// whether agents may drive this launch at all, and the port to listen on when no `--control`
|
||||
/// was given (0 = only with `--control`). A missing or unreadable file keeps the defaults.
|
||||
///
|
||||
/// The section is read on its own, and when it doesn't read as a whole each value on its own,
|
||||
/// as the app's lenient load reads them (#6611): a wrong-typed value elsewhere in the file, or
|
||||
/// in the other field, must not turn Allow Agent Control back on while the dialog shows it off.
|
||||
fn integrations(prefs_file: Option<&std::path::Path>) -> photocraft_engine::prefs::Integrations {
|
||||
use serde_json::Value;
|
||||
let mut out = photocraft_engine::prefs::Preferences::default().integrations;
|
||||
let file: Option<Value> = prefs_file.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok());
|
||||
let Some(section) = file.as_ref().and_then(|v| v.get("integrations")) else {
|
||||
return out;
|
||||
};
|
||||
if let Ok(whole) = serde_json::from_value(section.clone()) {
|
||||
return whole;
|
||||
}
|
||||
if let Some(allow) = section.get("allowAgentControl").and_then(Value::as_bool) {
|
||||
out.allow_agent_control = allow;
|
||||
}
|
||||
if let Some(port) = section.get("controlPort").and_then(Value::as_u64).and_then(|p| u32::try_from(p).ok()) {
|
||||
out.control_port = port;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The control port this launch listens on, and the warning to print when a request was refused.
|
||||
/// `--control` (or `PHOTOCRAFT_CONTROL_PORT`) wins over the preference; Allow Agent Control off
|
||||
/// refuses both, and says so rather than ignoring the flag silently. A preferred port outside
|
||||
/// 1..=65535 is refused the same way.
|
||||
fn control_port_for(cli: Option<u16>, prefs: &photocraft_engine::prefs::Integrations) -> (Option<u16>, Option<String>) {
|
||||
if !prefs.allow_agent_control {
|
||||
let warning = cli.map(|p| format!("photocraft: Preferences › Integrations › Allow Agent Control is off: not listening on port {p}\n"));
|
||||
return (None, warning);
|
||||
}
|
||||
if let Some(port) = cli {
|
||||
return (Some(port), None);
|
||||
}
|
||||
match prefs.control_port {
|
||||
0 => (None, None),
|
||||
p => match u16::try_from(p).ok().filter(|p| *p != 0) {
|
||||
Some(port) => (Some(port), None),
|
||||
None => (None, Some(format!("photocraft: Preferences › Integrations › Control Port {p} is not a usable port: not listening\n"))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame
|
||||
/// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without
|
||||
/// `centered`, Windows cascades each new window from the top-left corner, so it opened at a
|
||||
@@ -117,6 +160,9 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false),
|
||||
centered: true,
|
||||
// PhotoCraft owns its fallback fonts (`cjk_fonts`, craft-fonts): eframe's system-font
|
||||
// provider would compete with it and make glyph choice machine-dependent (#8512).
|
||||
system_font_fallback: false,
|
||||
// eframe saves native window geometry and egui panel/window sizes on exit.
|
||||
// Keep that state beside preferences, including config overrides and portable mode.
|
||||
persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")),
|
||||
@@ -149,360 +195,39 @@ fn at_least_min_size(mut builder: egui::ViewportBuilder) -> egui::ViewportBuilde
|
||||
builder
|
||||
}
|
||||
|
||||
/// Parse a `--control` / `PHOTOCRAFT_CONTROL_PORT` value. An unparseable port is an error that
|
||||
/// names the value (issue #701): silently running with no control server leaves a launcher or
|
||||
/// agent unable to tell a typo from a successful grant.
|
||||
fn parse_control_port(value: &str, source: &str) -> Result<u16, String> {
|
||||
value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)"))
|
||||
}
|
||||
|
||||
/// Process exit status for the collected usage errors (a bad `--control` /
|
||||
/// `PHOTOCRAFT_CONTROL_PORT`, an unknown option): `None` when there are none, else 2 (a
|
||||
/// command-line usage error), never 0.
|
||||
fn usage_exit_code(errors: &[String]) -> Option<i32> {
|
||||
if errors.is_empty() { None } else { Some(2) }
|
||||
}
|
||||
|
||||
/// `photocraft --help`. Keep in step with the options `main` accepts and the module docs.
|
||||
const USAGE: &str = "\
|
||||
Usage: photocraft [options] [--] [files…]
|
||||
|
||||
Opens PhotoCraft with the given files.
|
||||
|
||||
Options:
|
||||
-h, --help Print this help and exit
|
||||
--version Print the version, commit and build date and exit
|
||||
--safe-gpu Start with the CPU renderer for this launch (after a graphics driver crash)
|
||||
--in-window-menus macOS: keep the menus inside the window instead of the menu bar
|
||||
--new-instance Windows and Linux: start a separate PhotoCraft instead of opening
|
||||
the files in the one already running
|
||||
--present-mode <mode> How frames reach the display: fifo, autovsync or novsync
|
||||
--frame-latency <n> Frames queued for the display: 1 to 3
|
||||
--stroke-trace <stderr|file.csv>
|
||||
Time each brush stroke's frames
|
||||
--control <port> Start the localhost JSON-lines control server on this port
|
||||
--control-token <64-hex> Control server token (default: a new random one, printed)
|
||||
--control-token-file <path> Read the control server token from this file, or create it
|
||||
with a new one
|
||||
--automation-read-root <dir> Directory the control server may read files from
|
||||
--automation-write-root <dir>
|
||||
Directory the control server may write files to
|
||||
-- Treat every later argument as a file, even one starting with `-`
|
||||
|
||||
A bad --present-mode, --frame-latency or --stroke-trace value is reported and ignored.
|
||||
Environment: PHOTOCRAFT_CONTROL_PORT, PHOTOCRAFT_CONTROL_TOKEN, PHOTOCRAFT_CONTROL_TOKEN_FILE,
|
||||
PHOTOCRAFT_AUTOMATION_READ_ROOT, PHOTOCRAFT_AUTOMATION_WRITE_ROOT, PHOTOCRAFT_PRESENT_MODE,
|
||||
PHOTOCRAFT_FRAME_LATENCY, PHOTOCRAFT_STROKE_TRACE, PHOTOCRAFT_IN_WINDOW_MENUS, PHOTOCRAFT_NEW_INSTANCE,
|
||||
PHOTOCRAFT_CONFIG_DIR.
|
||||
";
|
||||
|
||||
/// Print to standard output for `--help` / `--version`. Unlike `println!`, never panics when
|
||||
/// stdout is closed or a pipe (`photocraft --help | head -1`).
|
||||
fn print_stdout(text: &str) {
|
||||
use std::io::Write as _;
|
||||
let mut out = std::io::stdout().lock();
|
||||
let _ = out.write_all(text.as_bytes()).and_then(|()| out.flush());
|
||||
}
|
||||
|
||||
/// Everything the desktop launcher takes from the command line, on top of the environment
|
||||
/// defaults handed in to `parse_launch_args`.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct LaunchArgs {
|
||||
control_port: Option<u16>,
|
||||
control_token: Option<String>,
|
||||
control_token_file: Option<std::path::PathBuf>,
|
||||
automation_read_root: Option<std::path::PathBuf>,
|
||||
automation_write_root: Option<std::path::PathBuf>,
|
||||
files: Vec<String>,
|
||||
/// Arguments that are not valid Unicode (issue #1108): a file manager can hand over a Latin-1
|
||||
/// file name on Linux, and `std::env::args()` would panic before any window opened. Paths are
|
||||
/// reported in a notice once the app is up; a control token is a usage error like a bad port.
|
||||
unreadable_paths: Vec<String>,
|
||||
safe_gpu: bool,
|
||||
new_instance: bool,
|
||||
/// Options that apply only to the process they're given to; a launch with any keeps its own.
|
||||
launch_options: Vec<&'static str>,
|
||||
present_mode_arg: Option<String>,
|
||||
frame_latency_arg: Option<String>,
|
||||
stroke_trace_arg: Option<String>,
|
||||
in_window_menus: bool,
|
||||
/// `--help` / `-h`: print [`USAGE`] and exit 0; parsing stops there.
|
||||
help: bool,
|
||||
/// `--version`: print the version and exit 0; parsing stops there.
|
||||
version: bool,
|
||||
/// Usage errors (a bad or missing control/automation value, an unknown option); a non-empty
|
||||
/// list fails the launch with exit status 2 (issues #701, #6429, #6431).
|
||||
errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parse the desktop launch arguments over the environment defaults in `out`. A control or
|
||||
/// automation flag with no value is a usage error naming the flag (issue #6429), never a silent
|
||||
/// fall back to a generated token or to no root: like `--control` with a bad port, it exits 2.
|
||||
/// So is an unknown option (#6431); a file whose name starts with `-` goes after `--`.
|
||||
fn parse_launch_args(args: impl Iterator<Item = std::ffi::OsString>, mut out: LaunchArgs) -> LaunchArgs {
|
||||
let mut args = args;
|
||||
// After `--`, every argument is a file, even one that starts with `-`.
|
||||
let mut only_files = false;
|
||||
while let Some(a) = args.next() {
|
||||
if only_files {
|
||||
match a.into_string() {
|
||||
Ok(s) => out.files.push(s),
|
||||
Err(raw) => out.unreadable_paths.push(raw.to_string_lossy().into_owned()),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match a.to_str() {
|
||||
Some("--") => only_files = true,
|
||||
Some("--help" | "-h") => {
|
||||
out.help = true;
|
||||
break;
|
||||
}
|
||||
Some("--control") => match args.next() {
|
||||
Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
|
||||
Ok(port) => out.control_port = Some(port),
|
||||
Err(error) => out.errors.push(error),
|
||||
},
|
||||
None => out.errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
|
||||
},
|
||||
Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) {
|
||||
Some(Ok(token)) => out.control_token = Some(token),
|
||||
Some(Err(raw)) => out.errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
|
||||
None => out.errors.push("--control-token: missing token value (expected `--control-token <token>`)".to_string()),
|
||||
},
|
||||
Some("--control-token-file") => match args.next() {
|
||||
Some(value) => out.control_token_file = Some(std::path::PathBuf::from(value)),
|
||||
None => out.errors.push("--control-token-file: missing file path value (expected `--control-token-file <path>`)".to_string()),
|
||||
},
|
||||
Some("--automation-read-root") => {
|
||||
out.launch_options.push("--automation-read-root");
|
||||
match args.next() {
|
||||
Some(value) => out.automation_read_root = Some(std::path::PathBuf::from(value)),
|
||||
None => out.errors.push("--automation-read-root: missing directory value (expected `--automation-read-root <dir>`)".to_string()),
|
||||
}
|
||||
}
|
||||
Some("--automation-write-root") => {
|
||||
out.launch_options.push("--automation-write-root");
|
||||
match args.next() {
|
||||
Some(value) => out.automation_write_root = Some(std::path::PathBuf::from(value)),
|
||||
None => out.errors.push("--automation-write-root: missing directory value (expected `--automation-write-root <dir>`)".to_string()),
|
||||
}
|
||||
}
|
||||
Some("--safe-gpu") => {
|
||||
out.launch_options.push("--safe-gpu");
|
||||
out.safe_gpu = true;
|
||||
}
|
||||
Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => {
|
||||
out.launch_options.push(match flag {
|
||||
"--present-mode" => "--present-mode",
|
||||
"--frame-latency" => "--frame-latency",
|
||||
_ => "--stroke-trace",
|
||||
});
|
||||
let flag = flag.to_string();
|
||||
match args.next().map(std::ffi::OsString::into_string) {
|
||||
Some(Ok(v)) => match flag.as_str() {
|
||||
"--present-mode" => out.present_mode_arg = Some(v),
|
||||
"--frame-latency" => out.frame_latency_arg = Some(v),
|
||||
_ => out.stroke_trace_arg = Some(v),
|
||||
},
|
||||
// A bad value is reported and ignored; it never fails the launch.
|
||||
_ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"),
|
||||
}
|
||||
}
|
||||
Some("--in-window-menus") => {
|
||||
out.launch_options.push("--in-window-menus");
|
||||
out.in_window_menus = true;
|
||||
}
|
||||
Some("--new-instance") => out.new_instance = true,
|
||||
Some("--version") => {
|
||||
out.version = true;
|
||||
break;
|
||||
}
|
||||
// Old macOS passes a process serial number when launched from Finder.
|
||||
Some(s) if s.starts_with("-psn_") => {}
|
||||
// A typo such as `--safegpu`, or `--help` spelt another way, must not open the window
|
||||
// and then fail to open the flag as a file (#6431).
|
||||
Some(s) if s.starts_with('-') => out.errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")),
|
||||
Some(s) => out.files.push(s.to_owned()),
|
||||
None => out.unreadable_paths.push(a.to_string_lossy().into_owned()),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod control_port_tests {
|
||||
use super::{parse_control_port, usage_exit_code};
|
||||
|
||||
#[test]
|
||||
fn control_port_errors_exit_non_zero() {
|
||||
assert_eq!(usage_exit_code(&[]), None);
|
||||
let errors = vec![parse_control_port("nope", "--control").unwrap_err()];
|
||||
assert_eq!(usage_exit_code(&errors), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_port_accepts_valid_numbers() {
|
||||
assert_eq!(parse_control_port("50494", "--control"), Ok(50494));
|
||||
assert_eq!(parse_control_port(" 8080 ", "--control"), Ok(8080));
|
||||
assert_eq!(parse_control_port("0", "PHOTOCRAFT_CONTROL_PORT"), Ok(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_port_rejects_unparseable_values_naming_them() {
|
||||
// Out of range, a following flag eaten as the value, and an empty value all fail,
|
||||
// naming the offending value in the message.
|
||||
let err = parse_control_port("78787", "--control").unwrap_err();
|
||||
assert!(err.contains("78787"), "{err}");
|
||||
assert!(parse_control_port("--safe-gpu", "--control").is_err());
|
||||
assert!(parse_control_port("", "--control").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod launch_args_tests {
|
||||
use super::{LaunchArgs, parse_launch_args, usage_exit_code};
|
||||
use std::ffi::OsString;
|
||||
|
||||
fn parse(values: &[&str]) -> LaunchArgs {
|
||||
parse_launch_args(values.iter().map(OsString::from), LaunchArgs::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_without_a_value_are_usage_errors() {
|
||||
// Issue #6429: a missing value must fail the launch, not silently fall back to a
|
||||
// generated token or to no root.
|
||||
for flag in ["--control", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"] {
|
||||
let parsed = parse(&[flag]);
|
||||
assert_eq!(parsed.errors.len(), 1, "{flag}: {:?}", parsed.errors);
|
||||
assert!(parsed.errors[0].starts_with(flag), "{}", parsed.errors[0]);
|
||||
assert!(parsed.errors[0].contains("missing"), "{}", parsed.errors[0]);
|
||||
assert_eq!(usage_exit_code(&parsed.errors), Some(2));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_value_does_not_clear_an_environment_default() {
|
||||
// `--automation-read-root` with no value must not drop a root set through the env var.
|
||||
let defaults = LaunchArgs { automation_read_root: Some("/env/read".into()), automation_write_root: Some("/env/write".into()), ..Default::default() };
|
||||
let parsed = parse_launch_args(["--automation-read-root"].iter().map(OsString::from), defaults);
|
||||
assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/env/read")));
|
||||
assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/env/write")));
|
||||
assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_are_parsed_and_no_errors_remain() {
|
||||
let parsed = parse(&[
|
||||
"--control",
|
||||
"50494",
|
||||
"--control-token",
|
||||
"abcd",
|
||||
"--control-token-file",
|
||||
"/tmp/token",
|
||||
"--automation-read-root",
|
||||
"/r",
|
||||
"--automation-write-root",
|
||||
"/w",
|
||||
"--safe-gpu",
|
||||
"--in-window-menus",
|
||||
"--new-instance",
|
||||
"--present-mode",
|
||||
"mailbox",
|
||||
"photo.psd",
|
||||
]);
|
||||
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
|
||||
assert_eq!(parsed.control_port, Some(50494));
|
||||
assert_eq!(parsed.control_token.as_deref(), Some("abcd"));
|
||||
assert_eq!(parsed.control_token_file.as_deref(), Some(std::path::Path::new("/tmp/token")));
|
||||
assert_eq!(parsed.automation_read_root.as_deref(), Some(std::path::Path::new("/r")));
|
||||
assert_eq!(parsed.automation_write_root.as_deref(), Some(std::path::Path::new("/w")));
|
||||
assert!(parsed.safe_gpu);
|
||||
assert!(parsed.in_window_menus);
|
||||
assert!(parsed.new_instance);
|
||||
assert_eq!(parsed.present_mode_arg.as_deref(), Some("mailbox"));
|
||||
assert_eq!(parsed.launch_options, vec!["--automation-read-root", "--automation-write-root", "--safe-gpu", "--in-window-menus", "--present-mode"]);
|
||||
assert_eq!(parsed.files, vec!["photo.psd".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_stops_parsing() {
|
||||
let parsed = parse(&["--version", "--control"]);
|
||||
assert!(parsed.version);
|
||||
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_stops_parsing_and_double_dash_ends_options() {
|
||||
let parsed = parse(&["-h", "--safegpu", "--control"]);
|
||||
assert!(parsed.help);
|
||||
assert!(parsed.errors.is_empty(), "{:?}", parsed.errors);
|
||||
|
||||
let parsed = parse(&["--safegpu", "--", "--help", "-x"]);
|
||||
assert!(!parsed.help);
|
||||
assert_eq!(parsed.errors.len(), 1, "{:?}", parsed.errors);
|
||||
assert!(parsed.errors[0].contains("unknown option `--safegpu`"), "{}", parsed.errors[0]);
|
||||
assert_eq!(parsed.files, vec!["--help".to_string(), "-x".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
crash_guard::install_hook();
|
||||
// The system font scan can take seconds (minutes on some Macs): run it off the UI thread.
|
||||
photocraft_ui_egui::type_tool::warm_fonts();
|
||||
let mut launch = LaunchArgs {
|
||||
automation_read_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from),
|
||||
automation_write_root: std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from),
|
||||
in_window_menus: std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) {
|
||||
match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") {
|
||||
Ok(port) => launch.control_port = Some(port),
|
||||
Err(error) => launch.errors.push(error),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::PHOTOCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Err(e) = done {
|
||||
eprintln!("PhotoCraft update helper failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
launch = parse_launch_args(std::env::args_os().skip(1), launch);
|
||||
if launch.help {
|
||||
print_stdout(USAGE);
|
||||
return Ok(());
|
||||
}
|
||||
if launch.version {
|
||||
print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// A malformed control port must not silently drop the control server (issue #701), a control
|
||||
// or automation flag without its value must not silently fall back to a generated token or
|
||||
// to no root (issue #6429), and an unknown option must not open the window (#6431): name the
|
||||
// bad argument and fail the launch, like `photocraft-cli` does for the same typos. Exit
|
||||
// status 2 is the usual command-line usage error, so a launcher sees the failure.
|
||||
if let Some(code) = usage_exit_code(&launch.errors) {
|
||||
for error in &launch.errors {
|
||||
eprintln!("photocraft: {error}");
|
||||
}
|
||||
eprintln!("Run `photocraft --help` for usage.");
|
||||
std::process::exit(code);
|
||||
}
|
||||
let LaunchArgs {
|
||||
control_port,
|
||||
control_token,
|
||||
control_token_file,
|
||||
automation_read_root,
|
||||
automation_write_root,
|
||||
files,
|
||||
unreadable_paths,
|
||||
// The system font scan can take seconds (minutes on some Macs): run it off the UI thread.
|
||||
photocraft_ui_egui::type_tool::warm_fonts();
|
||||
// `--help` and `--version` print to standard output and exit 0. A usage error (an unknown
|
||||
// option, a missing or bad value, a bad PHOTOCRAFT_CONTROL_PORT: issues #701, #6429, #6431)
|
||||
// names the argument on standard error and exits 2, so a launcher sees the failure. Neither
|
||||
// panics when the stream is closed or a pipe (`photocraft --help | head -1`).
|
||||
let launch = launch_args::parse(launch_args::command(), std::env::args_os().skip(1)).unwrap_or_else(|e| e.exit());
|
||||
let launch_args::FileNames { openable: files, unreadable: unreadable_paths } = launch.file_names();
|
||||
let launch_args::LaunchArgs {
|
||||
files: _,
|
||||
safe_gpu,
|
||||
new_instance,
|
||||
mut launch_options,
|
||||
present_mode_arg,
|
||||
frame_latency_arg,
|
||||
stroke_trace_arg,
|
||||
in_window_menus,
|
||||
help: _,
|
||||
version: _,
|
||||
errors: _,
|
||||
new_instance,
|
||||
present_mode,
|
||||
frame_latency,
|
||||
stroke_trace,
|
||||
control: control_port,
|
||||
auth,
|
||||
automation,
|
||||
mut launch_options,
|
||||
} = launch;
|
||||
|
||||
// The log file lives under the settings directory; opened after the arguments, so `--version`
|
||||
@@ -518,7 +243,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
|
||||
@@ -526,6 +251,13 @@ fn main() -> eframe::Result {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let single_instance = {
|
||||
let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok();
|
||||
// A variable documented as the equivalent of a per-launch flag counts as that flag: a
|
||||
// launch that sets only `PHOTOCRAFT_PRESENT_MODE` (say) has per-process options too, so it
|
||||
// keeps its own process instead of handing its files over and being ignored.
|
||||
// Folded in here, inside the block that only non-macOS builds compile, so the binding
|
||||
// above keeps its `mut` off macOS, where nothing is folded in.
|
||||
let mut launch_options = launch_options;
|
||||
launch_options.extend_from_slice(&single_instance::env_launch_options());
|
||||
let context = single_instance::LaunchContext {
|
||||
per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform,
|
||||
new_instance_flag: new_instance,
|
||||
@@ -591,8 +323,12 @@ fn main() -> eframe::Result {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let (control_port, control_pref_warning) = control_port_for(control_port, &integrations(services::prefs_file().as_deref()));
|
||||
if let Some(warning) = &control_pref_warning {
|
||||
eprint!("{warning}");
|
||||
}
|
||||
let control = if let Some(port) = control_port {
|
||||
let (supplied, token_file) = photocraft_automation::security::token_inputs(control_token, control_token_file);
|
||||
let (supplied, token_file) = (auth.control_token, auth.control_token_file);
|
||||
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
|
||||
Ok(token) => token,
|
||||
Err(e) => {
|
||||
@@ -607,7 +343,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}");
|
||||
@@ -696,7 +432,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.
|
||||
@@ -740,6 +476,7 @@ fn main() -> eframe::Result {
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let session = Session::new();
|
||||
PhotocraftApp::install_type_reset_hooks(&cc.egui_ctx);
|
||||
let mut app = PhotocraftApp::new(session, services);
|
||||
app.min_frame_ms = uncapped.then_some(1000.0 / 240.0);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
@@ -774,7 +511,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() };
|
||||
@@ -786,6 +523,9 @@ fn main() -> eframe::Result {
|
||||
if let Some(rs) = cc.wgpu_render_state.clone() {
|
||||
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
|
||||
app.perf.gpu_info.set_available(&rs.available_adapters);
|
||||
// Every egui texture uploads through this adapter, whichever canvas draws:
|
||||
// Iris Xe on DX12 shows stale memory in all-zero texels (texel_guard.rs).
|
||||
photocraft_ui_egui::texel_guard::install(&cc.egui_ctx, &rs.adapter.get_info());
|
||||
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
|
||||
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
|
||||
// Keep an earlier note, such as a requested adapter that couldn't be used.
|
||||
@@ -929,9 +669,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();
|
||||
@@ -993,10 +734,101 @@ mod tests {
|
||||
egui::Panel::right("dock").default_size(290.0).show(ui, |ui| ui.set_min_width(ui.available_width()));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
let panel = egui::containers::panel::PanelState::load(&restored, egui::Id::new("dock")).unwrap();
|
||||
let panel = egui::containers::panel::PanelState::load(&restored, photocraft_ui_egui::panels::panel_state_id(&restored, "dock")).unwrap();
|
||||
assert_eq!(panel.size().x, 410.0);
|
||||
}
|
||||
|
||||
/// A 0.36 `ui.ron` saved panel widths under `Id::new("dock")`; the first 0.37 launch copies
|
||||
/// them to the parent-scoped key, leaves the old one for a rollback, and a later resize
|
||||
/// survives two more restarts.
|
||||
#[test]
|
||||
fn saved_036_panel_widths_migrate_and_survive_two_restarts() {
|
||||
#[derive(Default)]
|
||||
struct Storage(std::collections::BTreeMap<String, String>);
|
||||
impl eframe::Storage for Storage {
|
||||
fn get_string(&self, key: &str) -> Option<String> {
|
||||
self.0.get(key).cloned()
|
||||
}
|
||||
fn set_string(&mut self, key: &str, value: String) {
|
||||
self.0.insert(key.into(), value);
|
||||
}
|
||||
fn remove_string(&mut self, key: &str) {
|
||||
self.0.remove(key);
|
||||
}
|
||||
fn flush(&mut self) {}
|
||||
}
|
||||
let state = |width: f32| egui::containers::panel::PanelState { outer_rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 600.0)) };
|
||||
let input = || egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))), ..Default::default() };
|
||||
let draw = |ctx: &egui::Context| {
|
||||
let mut output = ctx.run_ui(input(), |ui| {
|
||||
egui::Panel::right("dock").default_size(290.0).show(ui, |ui| ui.set_min_width(ui.available_width()));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
};
|
||||
let save = |ctx: &egui::Context| {
|
||||
let mut storage = Storage::default();
|
||||
ctx.memory(|memory| eframe::set_value(&mut storage, "egui", memory));
|
||||
storage
|
||||
};
|
||||
let restore = |storage: &Storage| {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.memory_mut(|memory| *memory = eframe::get_value(storage, "egui").expect("the saved memory"));
|
||||
ctx
|
||||
};
|
||||
|
||||
// The real 0.14/0.15 shape: the panel state under the old key inside the egui memory.
|
||||
let ctx = egui::Context::default();
|
||||
ctx.data_mut(|d| d.insert_persisted(egui::Id::unique("dock"), state(410.0)));
|
||||
let storage = save(&ctx);
|
||||
|
||||
let first = restore(&storage);
|
||||
photocraft_ui_egui::panels::migrate_panel_state(&first);
|
||||
draw(&first);
|
||||
let key = photocraft_ui_egui::panels::panel_state_id(&first, "dock");
|
||||
assert_eq!(egui::containers::panel::PanelState::load(&first, key).map(|p| p.size().x), Some(410.0));
|
||||
assert!(egui::containers::panel::PanelState::load(&first, egui::Id::unique("dock")).is_some(), "the 0.36 key is kept");
|
||||
// Resize in 0.37: the new key wins from now on.
|
||||
first.data_mut(|d| d.insert_persisted(key, state(500.0)));
|
||||
let storage = save(&first);
|
||||
|
||||
for restart in 0..2 {
|
||||
let next = restore(&storage);
|
||||
photocraft_ui_egui::panels::migrate_panel_state(&next);
|
||||
draw(&next);
|
||||
let key = photocraft_ui_egui::panels::panel_state_id(&next, "dock");
|
||||
let width = egui::containers::panel::PanelState::load(&next, key).map(|p| p.size().x).unwrap_or_default();
|
||||
assert!((width - 500.0).abs() < 0.5, "restart {restart}: the old 410 key must not win (got {width})");
|
||||
}
|
||||
}
|
||||
|
||||
/// Schema-probe `ui.ron` shapes *reconstructed* from the documented 0.14/0.15/0.37 layouts,
|
||||
/// not recorded machine saves: 0.14 and 0.15 both store the flat window entry, which the
|
||||
/// first 0.37 launch rewrites; a 0.37 file is left alone. Keeping all three probes makes a
|
||||
/// future change to any migration rule fail here first. They do not prove eframe's real
|
||||
/// `Memory`/`PanelState` deserialization.
|
||||
#[test]
|
||||
fn the_ui_ron_schema_probe_fixtures_migrate() {
|
||||
for (name, fixture) in [("0.14", include_str!("../tests/fixtures/ui-0.14.ron")), ("0.15", include_str!("../tests/fixtures/ui-0.15.ron"))] {
|
||||
let text = super::ui_state::migrate_for_test(fixture).unwrap_or_else(|e| panic!("{name}: {e}")).unwrap_or_else(|| panic!("{name}: not rewritten"));
|
||||
assert!(text.contains("normal_geometry"), "{name}: the flat window entry is gone: {text}");
|
||||
assert!(!super::ui_state::saved_position_for_test(fixture), "{name}: a flat entry is not what 0.37 restores");
|
||||
}
|
||||
let old = include_str!("../tests/fixtures/ui-0.14.ron");
|
||||
assert!(super::ui_state::migrate_for_test(old).expect("migratable").expect("rewritten").contains("x:1100.0"), "the 0.14 size is preserved");
|
||||
let current = include_str!("../tests/fixtures/ui-0.15.ron");
|
||||
assert!(
|
||||
super::ui_state::migrate_for_test(current).expect("migratable").expect("rewritten").contains("maximized:true"),
|
||||
"0.15 maximized state survives"
|
||||
);
|
||||
// Already 0.37-shaped: no rewrite, and (with no saved position) the window centres.
|
||||
let current = include_str!("../tests/fixtures/ui-0.37.ron");
|
||||
assert_eq!(super::ui_state::migrate_for_test(current), Ok(None));
|
||||
assert!(!super::ui_state::saved_position_for_test(current));
|
||||
// And a 0.37 file with a restorable outer position is a saved position.
|
||||
let with_position = current.replace("outer_position_pixels:None", "outer_position_pixels:Some((x:2048.0,y:1228.0))");
|
||||
assert!(super::ui_state::saved_position_for_test(&with_position));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_opens_centred_at_its_default_size() {
|
||||
let o = super::native_options(super::CUSTOM_TITLEBAR);
|
||||
@@ -1065,6 +897,91 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrations_preferences_decide_the_control_port() {
|
||||
use photocraft_engine::prefs::Integrations;
|
||||
let off = Integrations { allow_agent_control: false, control_port: 0 };
|
||||
let on = Integrations { allow_agent_control: true, control_port: 0 };
|
||||
let preferred = Integrations { allow_agent_control: true, control_port: 7878 };
|
||||
|
||||
// Nothing asked for, nothing listening.
|
||||
assert_eq!(super::control_port_for(None, &on), (None, None));
|
||||
// --control wins over the preference.
|
||||
assert_eq!(super::control_port_for(Some(9000), &preferred), (Some(9000), None));
|
||||
// The preference opens the port when --control is absent.
|
||||
assert_eq!(super::control_port_for(None, &preferred), (Some(7878), None));
|
||||
|
||||
// Allow Agent Control off refuses both, and says so when --control asked for one
|
||||
// rather than ignoring the flag.
|
||||
assert_eq!(super::control_port_for(None, &off), (None, None));
|
||||
let (port, warning) = super::control_port_for(Some(9000), &off);
|
||||
assert_eq!(port, None);
|
||||
assert!(warning.is_some_and(|w| w.contains("Allow Agent Control") && w.contains("9000")), "the refusal names the port");
|
||||
let off_with_port = Integrations { allow_agent_control: false, control_port: 7878 };
|
||||
assert_eq!(super::control_port_for(None, &off_with_port), (None, None), "off beats a preferred port");
|
||||
|
||||
// A port a hand-edited file could hold, outside 1..=65535, is refused and named.
|
||||
let huge = Integrations { allow_agent_control: true, control_port: 70_000 };
|
||||
let (port, warning) = super::control_port_for(None, &huge);
|
||||
assert_eq!(port, None);
|
||||
assert!(warning.is_some_and(|w| w.contains("70000")), "the refusal names the value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrations_fall_back_to_the_defaults_without_a_readable_file() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-integrations-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("prefs.json");
|
||||
let default = photocraft_engine::prefs::Integrations::default();
|
||||
|
||||
assert_eq!(super::integrations(None), default, "no preferences file");
|
||||
std::fs::write(&file, "not json").unwrap();
|
||||
assert_eq!(super::integrations(Some(&file)), default, "an unreadable file");
|
||||
std::fs::write(&file, r#"{"integrations":{"allowAgentControl":false,"controlPort":7878}}"#).unwrap();
|
||||
let read = super::integrations(Some(&file));
|
||||
assert!(!read.allow_agent_control);
|
||||
assert_eq!(read.control_port, 7878);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The app loads preferences leniently (#6611): one wrong-typed value resets only itself. So
|
||||
/// a bad value elsewhere, or in the port, must not turn Allow Agent Control back on.
|
||||
#[test]
|
||||
fn a_bad_value_elsewhere_keeps_agent_control_off() {
|
||||
let dir = std::env::temp_dir().join(format!("pc-integrations-lenient-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("prefs.json");
|
||||
// What the Preferences dialog shows for the same file.
|
||||
let shown = |text: &str| {
|
||||
let mut session = photocraft_engine::Session::new();
|
||||
session.load_prefs_json(text).unwrap();
|
||||
session.prefs().integrations.clone()
|
||||
};
|
||||
let read = |text: &str| {
|
||||
std::fs::write(&file, text).unwrap();
|
||||
let read = super::integrations(Some(&file));
|
||||
assert_eq!(read, shown(text), "{text}");
|
||||
read
|
||||
};
|
||||
|
||||
let r = read(r#"{"interface":{"enableAnimations":"nope"},"integrations":{"allowAgentControl":false,"controlPort":7878}}"#);
|
||||
assert!(!r.allow_agent_control, "a bad value in another section");
|
||||
assert_eq!(r.control_port, 7878);
|
||||
let r = read(r#"{"integrations":{"allowAgentControl":false,"controlPort":"x"}}"#);
|
||||
assert!(!r.allow_agent_control, "a bad port");
|
||||
assert_eq!(r.control_port, 0, "the bad port alone takes its default");
|
||||
// A bad Allow Agent Control alone takes its default.
|
||||
let r = read(r#"{"integrations":{"allowAgentControl":"no","controlPort":7878}}"#);
|
||||
assert_eq!(r, photocraft_engine::prefs::Integrations { allow_agent_control: true, control_port: 7878 });
|
||||
read(r#"{"integrations":{"allowAgentControl":false,"controlPort":-1}}"#);
|
||||
read(r#"{"integrations":{"allowAgentControl":false,"controlPort":4294967296}}"#);
|
||||
read(r#"{"integrations":"off"}"#);
|
||||
// serde reads a struct from a list too; the app does.
|
||||
assert!(!read(r#"{"integrations":[false]}"#).allow_agent_control, "a list");
|
||||
assert_eq!(read(r#"{"integrations":[false,7878]}"#).control_port, 7878);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
|
||||
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Opt-in local acceptance. All artwork paths come from the test environment; originals are read-only.
|
||||
|
||||
use super::{native, vector_conversion};
|
||||
use base64::Engine as _;
|
||||
use photocraft_doc::{Document, LayerContent, LayerId, SmartSource};
|
||||
use photocraft_ui_egui::{ExternalSvgSession, ExternalVectorSource};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Arc, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Exercise the real CLI's same-format Save, the native watcher and PhotoCraft's transaction.
|
||||
#[test]
|
||||
#[ignore = "set VECTORCRAFT_TEST_EDITOR and optionally PHOTO_NATIVE_LOGO_PSD/PHOTO_NATIVE_CONTAINER"]
|
||||
fn original_format_real_editor_save_watcher_and_psd_roundtrip() {
|
||||
let editor = std::env::var_os("VECTORCRAFT_TEST_EDITOR").expect("VECTORCRAFT_TEST_EDITOR");
|
||||
let editor = Path::new(&editor);
|
||||
let mut photo = photocraft_engine::Session::new();
|
||||
let vector_path = std::env::var_os("PHOTO_NATIVE_VECTOR");
|
||||
let vector_original = vector_path.as_ref().map(|path| std::fs::read(path).unwrap());
|
||||
let original_path = std::env::var_os("PHOTO_NATIVE_LOGO_PSD");
|
||||
let original = original_path.as_ref().map(|path| std::fs::read(path).unwrap());
|
||||
let mut parent_before = None;
|
||||
let id;
|
||||
if let Some(bytes) = &original {
|
||||
photo.add_document(photocraft_io::import("Logo.psd", bytes).unwrap().document, None);
|
||||
if let Ok(name) = std::env::var("PHOTO_NATIVE_CONTAINER") {
|
||||
parent_before = Some(photo.active().unwrap().doc.clone());
|
||||
let layer = find_source(&photo.active().unwrap().doc, &name).expect("nested logo container");
|
||||
photo.execute("layer.smartObjects.editContents", json!({"layer":layer.0})).unwrap();
|
||||
}
|
||||
let name = std::env::var("PHOTO_NATIVE_SOURCE").unwrap_or("Vector Smart Object.ai".into());
|
||||
id = find_source(&photo.active().unwrap().doc, &name).expect("visible embedded vector");
|
||||
} else {
|
||||
photo.execute("file.new", json!({"width":32,"height":24})).unwrap();
|
||||
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect x="1" y="1" width="3" height="2" fill="red"/></svg>"#;
|
||||
let placed = photocraft_engine::file_cmds::place_bytes(&mut photo, "Artwork.svg", svg.to_vec(), None, &json!({"smartObject":true})).unwrap();
|
||||
id = LayerId(placed["layer"].as_u64().unwrap());
|
||||
// Generate native sources with our writer, or use a read-only local vector fixture.
|
||||
let (format, bytes) = if let Some(bytes) = &vector_original {
|
||||
let extension = Path::new(vector_path.as_ref().unwrap()).extension().unwrap().to_str().unwrap().to_ascii_lowercase();
|
||||
(extension, bytes.clone())
|
||||
} else {
|
||||
let owned = native::OwnedSvg::create(svg).unwrap();
|
||||
let format = std::env::var("PHOTO_NATIVE_FORMAT").unwrap_or("ai".into());
|
||||
let path = owned.root.join(format!("Input.{format}"));
|
||||
run_commands(
|
||||
editor,
|
||||
&owned.root,
|
||||
vec![
|
||||
("document.open", json!({"path":owned.file})),
|
||||
("document.export", json!({"path":path,"format":format,"preserveEditing":true,"useArtboards":true})),
|
||||
],
|
||||
);
|
||||
(format, std::fs::read(path).unwrap())
|
||||
};
|
||||
let state = photo.active_mut().unwrap();
|
||||
let LayerContent::Smart(sm) = &mut Arc::make_mut(&mut state.doc).layer_mut(id).unwrap().content else { panic!("smart") };
|
||||
sm.source = SmartSource::Embedded { file_name: format!("Artwork.{format}"), bytes: Arc::new(bytes) };
|
||||
}
|
||||
let handoff = photo.execute("layer.smartObjects.exportSvg", json!({"layer":id.0})).unwrap();
|
||||
let source = ExternalVectorSource {
|
||||
bytes: Arc::new(base64::engine::general_purpose::STANDARD.decode(handoff["sourceBase64"].as_str().unwrap()).unwrap()),
|
||||
extension: handoff["extension"].as_str().unwrap().into(),
|
||||
size: serde_json::from_value(handoff["sourceSize"].clone()).unwrap(),
|
||||
};
|
||||
let before = photo.active().unwrap().doc.clone();
|
||||
let (_send, stop) = mpsc::channel();
|
||||
let (owned, initial) = vector_conversion::prepare(editor, &source, &stop).unwrap();
|
||||
assert_eq!(owned.file.extension().unwrap().to_str().unwrap(), source.extension);
|
||||
assert!(photo.active().unwrap().doc == before, "preparation must not change the embedded original");
|
||||
let file = owned.file.clone();
|
||||
let root = owned.root.clone();
|
||||
let mut watcher = native::start_native_test(owned, initial.clone(), editor.to_path_buf(), source.clone()).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
assert!(watcher.poll_vector().is_none(), "opening must not accept the regenerated private AI working copy");
|
||||
// A small visible edit, ordinary Save to the already-open file, no conversion to SVG.
|
||||
run_commands(
|
||||
editor,
|
||||
&root,
|
||||
vec![
|
||||
("document.open", json!({"path":file})),
|
||||
("paint.setFill", json!({"color":"#00ff00"})),
|
||||
("shape.rectangle", json!({"x":2,"y":2,"width":3,"height":2})),
|
||||
("file.save", json!({})),
|
||||
],
|
||||
);
|
||||
let saved = std::fs::read(&file).unwrap();
|
||||
eprintln!("Native save: format={}, bytes={}", source.extension, saved.len());
|
||||
assert!(saved != initial, "Save did not update the native working file");
|
||||
assert!(photocraft_io::vector_preview::is_native_source(&saved));
|
||||
assert!(!photocraft_io::svg::is_svg(&saved));
|
||||
let deadline = Instant::now() + Duration::from_secs(90);
|
||||
let snapshot = loop {
|
||||
assert!(Instant::now() < deadline, "saved native vector never reached watcher");
|
||||
if let Some(result) = watcher.poll_vector() {
|
||||
break result.unwrap();
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
};
|
||||
assert!(snapshot.bytes == saved, "watcher snapshot differs from saved native bytes");
|
||||
let params = json!({"document":handoff["document"],"layer":id.0,"contentsId":handoff["contentsId"],"fingerprint":handoff["fingerprint"],"extension":source.extension,"sourceBase64":base64::engine::general_purpose::STANDARD.encode(&saved),"previewSvg":String::from_utf8(snapshot.preview_svg.unwrap()).unwrap()});
|
||||
assert_eq!(photo.execute("layer.smartObjects.updateVectorSource", params).unwrap()["updated"], true);
|
||||
let after = photo.active().unwrap().doc.clone();
|
||||
let LayerContent::Smart(previous) = &before.layer(id).unwrap().content else { panic!("smart") };
|
||||
let LayerContent::Smart(changed) = &after.layer(id).unwrap().content else { panic!("smart") };
|
||||
assert_eq!(changed.transform, previous.transform);
|
||||
assert_eq!(changed.perspective, previous.perspective);
|
||||
assert_eq!(changed.warp, previous.warp);
|
||||
assert_eq!(changed.smart_filters, previous.smart_filters);
|
||||
assert!(embedded(&after, changed).unwrap().1 == saved, "native source was replaced with different bytes");
|
||||
assert!(photo.undo());
|
||||
assert!(photo.active().unwrap().doc == before, "Undo did not restore original source state");
|
||||
assert!(photo.redo());
|
||||
assert!(photo.active().unwrap().doc == after, "Redo did not restore saved native source state");
|
||||
let output = photocraft_io::export(&after, "Roundtrip.psd", &Default::default()).unwrap();
|
||||
let reopened = photocraft_io::import("Roundtrip.psd", &output.bytes).unwrap().document;
|
||||
let layer = find_source(&reopened, &embedded(&after, changed).unwrap().0).unwrap();
|
||||
let LayerContent::Smart(sm) = &reopened.layer(layer).unwrap().content else { panic!("smart") };
|
||||
assert!(embedded(&reopened, sm).unwrap().1 == saved, "PSD reopen did not retain the saved native bytes");
|
||||
assert!(sm.vector_preview == changed.vector_preview, "PSD did not retain the source-bound preview");
|
||||
assert!(
|
||||
sm.transform.m.iter().zip(changed.transform.m).all(|(a, b)| (a - b).abs() < 1e-9),
|
||||
"PSD corner reconstruction changed placement beyond floating-point tolerance"
|
||||
);
|
||||
assert!(photocraft_engine::smart_cmds::render(&reopened, sm).unwrap().as_ref() == changed.cache.as_ref(), "reopened native preview changed appearance");
|
||||
if let Some(parent_before) = parent_before {
|
||||
photo.execute("layer.smartObjects.saveContents", json!({})).unwrap();
|
||||
photo.set_active(0);
|
||||
let parent_after = photo.active().unwrap().doc.clone();
|
||||
assert!(photo.undo());
|
||||
assert!(photo.active().unwrap().doc == parent_before, "parent Undo changed original content");
|
||||
assert!(photo.redo());
|
||||
assert!(photo.active().unwrap().doc == parent_after, "parent Redo changed saved content");
|
||||
let output = photocraft_io::export(&parent_after, "Roundtrip.psd", &Default::default()).unwrap();
|
||||
let reopened = photocraft_io::import("Roundtrip.psd", &output.bytes).unwrap().document;
|
||||
assert!(contains_source(&reopened, &saved, 0), "parent PSD lost its nested native vector");
|
||||
}
|
||||
if let Some(path) = vector_path {
|
||||
assert!(std::fs::read(path).unwrap() == vector_original.unwrap(), "original vector changed")
|
||||
}
|
||||
if let Some(path) = original_path {
|
||||
assert!(std::fs::read(path).unwrap() == original.unwrap(), "original PSD changed")
|
||||
}
|
||||
}
|
||||
|
||||
fn run_commands(editor: &Path, root: &Path, commands: Vec<(&str, Value)>) {
|
||||
let cli = editor.parent().unwrap().join(if cfg!(windows) { "vectorcraft-cli.exe" } else { "vectorcraft-cli" });
|
||||
let output_path = root.join("TestResponses.jsonl");
|
||||
let output = std::fs::File::create(&output_path).unwrap();
|
||||
let mut child = Command::new(cli)
|
||||
.args(["mcp", "--headless", "--automation-read-root"])
|
||||
.arg(root)
|
||||
.arg("--automation-write-root")
|
||||
.arg(root)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(output)
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
let count = commands.len();
|
||||
for (index, (command, params)) in commands.into_iter().enumerate() {
|
||||
writeln!(
|
||||
input,
|
||||
"{}",
|
||||
json!({"jsonrpc":"2.0","id":index+1,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}})
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
drop(input);
|
||||
let deadline = Instant::now() + Duration::from_secs(90);
|
||||
loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
assert!(status.success());
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
panic!("editor test timed out")
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
let responses = std::fs::read_to_string(output_path).unwrap();
|
||||
for id in 1..=count {
|
||||
let response = responses.lines().filter_map(|line| serde_json::from_str::<Value>(line).ok()).find(|r| r["id"] == id).unwrap();
|
||||
assert!(response.get("error").is_none() && response["result"]["isError"] == false, "editor command {id} failed");
|
||||
}
|
||||
}
|
||||
|
||||
fn embedded<'a>(doc: &'a Document, sm: &'a photocraft_doc::SmartObject) -> Option<(String, &'a [u8])> {
|
||||
match &sm.source {
|
||||
SmartSource::Embedded { file_name, bytes } => Some((file_name.clone(), bytes)),
|
||||
SmartSource::Linked { path } => photocraft_io::linked::embedded_payload(&doc.metadata, path),
|
||||
}
|
||||
}
|
||||
fn find_source(doc: &Document, name: &str) -> Option<LayerId> {
|
||||
doc.walk().into_iter().find_map(|(path, _, layer)| {
|
||||
if !doc.shown_at(&path) {
|
||||
return None;
|
||||
}
|
||||
let LayerContent::Smart(sm) = &layer.content else { return None };
|
||||
embedded(doc, sm).filter(|(file, _)| file == name).map(|_| layer.id)
|
||||
})
|
||||
}
|
||||
|
||||
fn contains_source(doc: &Document, saved: &[u8], depth: usize) -> bool {
|
||||
if depth > 4 {
|
||||
return false;
|
||||
}
|
||||
doc.walk().into_iter().any(|(_, _, layer)| {
|
||||
let LayerContent::Smart(sm) = &layer.content else { return false };
|
||||
let Some((name, bytes)) = embedded(doc, sm) else { return false };
|
||||
bytes == saved
|
||||
|| ((bytes.starts_with(b"8BPS") || photocraft_format::is_pcraft(bytes))
|
||||
&& photocraft_io::import(&name, bytes).is_ok_and(|child| contains_source(&child.document, saved, depth + 1)))
|
||||
})
|
||||
}
|
||||
@@ -156,6 +156,18 @@ fn file_uri_path(uri: &str) -> Option<PathBuf> {
|
||||
Some(PathBuf::from(std::ffi::OsString::from_vec(out)))
|
||||
}
|
||||
|
||||
/// Which file type's extensions belong on the name the dialog returned: the one it reported, or,
|
||||
/// when the dialog offered only one type, that one. It reports no pick then, and the name it was
|
||||
/// given is the stem (`services::dialog_file_name`), so a bare `Swatches` has to come back as
|
||||
/// `Swatches.aco`. `None` when the answer names no type this dialog offered.
|
||||
fn chosen_type(filters: &[Filter], picked: Option<usize>) -> Option<usize> {
|
||||
match picked {
|
||||
Some(i) if i < filters.len() => Some(i),
|
||||
None if filters.len() == 1 => Some(0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Save As on Linux: the portal's dialog, with the name given the extension of the file type
|
||||
/// picked. When that changes the name to one that already exists, the dialog opens again under
|
||||
/// the new name, so the portal asks before replacing it (it asked only about the typed name).
|
||||
@@ -163,10 +175,10 @@ pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Resul
|
||||
loop {
|
||||
let Some(saved) = save_file(conn, &req)? else { return Ok(None) };
|
||||
let picked = saved.filter.as_deref().and_then(|label| req.filters.iter().position(|(l, _)| l == label));
|
||||
// With one type offered there is nothing to pick, and a typed extension is the user's.
|
||||
let exts: &[String] = match picked {
|
||||
Some(i) if req.filters.len() > 1 => &req.filters[i].1,
|
||||
_ => &[],
|
||||
// A typed extension is the user's own: `with_chosen_type` keeps one it recognises.
|
||||
let exts: &[String] = match chosen_type(&req.filters, picked) {
|
||||
Some(i) => &req.filters[i].1,
|
||||
None => &[],
|
||||
};
|
||||
let path = crate::services::with_chosen_type(&saved.path, req.suggested.as_deref(), exts);
|
||||
if path == saved.path || !path.exists() {
|
||||
|
||||
@@ -235,3 +235,33 @@ fn a_single_file_type_keeps_the_typed_extension() {
|
||||
assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.ase")));
|
||||
daemon.kill().ok();
|
||||
}
|
||||
|
||||
/// The same single type, with the name left as the dialog shows it: the bare stem comes back, and
|
||||
/// that one type gives it its extension instead of a file named `Swatches` being written.
|
||||
#[test]
|
||||
fn a_single_file_type_gives_the_bare_name_its_extension() {
|
||||
let typed: &'static str = "file:///tmp/photocraft-none/Swatches";
|
||||
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "Color Swatches")))]) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let req = SaveRequest { filters: vec![("Color Swatches".into(), vec!["aco".into()])], ..request("Swatches.aco") };
|
||||
assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.aco")));
|
||||
daemon.kill().ok();
|
||||
}
|
||||
|
||||
/// Which type's extensions the returned name gets. The portal cases above need a `dbus-daemon`,
|
||||
/// so the rule itself is checked here too, which needs none.
|
||||
#[test]
|
||||
fn the_type_that_gives_the_extension_is_the_one_picked() {
|
||||
let one: Vec<Filter> = vec![("Color Swatches".into(), vec!["aco".into()])];
|
||||
// One type offered: the dialog reports no pick, and it was given the stem.
|
||||
assert_eq!(chosen_type(&one, None), Some(0));
|
||||
assert_eq!(chosen_type(&one, Some(0)), Some(0));
|
||||
let many: Vec<Filter> = request("test").filters;
|
||||
assert_eq!(chosen_type(&many, Some(1)), Some(1));
|
||||
// Several types and no pick: nothing to add, as before.
|
||||
assert_eq!(chosen_type(&many, None), None);
|
||||
// An answer naming a type this dialog never offered is not a pick either.
|
||||
assert_eq!(chosen_type(&many, Some(99)), None);
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
//! Native platform services: file dialogs (rfd), filesystem, codecs.
|
||||
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType as CS};
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Layer, Size};
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image};
|
||||
use photocraft_doc::Document;
|
||||
use photocraft_format::RecoveryStore;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_ui_egui::{FileDialogAnswer, FileDialogReply, FileDialogRequest, Recoverable, Services};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::future::Future;
|
||||
@@ -17,12 +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", "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",
|
||||
"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", "asl", "svg", "svgz", "aco", "act",
|
||||
"ase", "kys", "af", "afdesign", "afphoto", "afpub",
|
||||
];
|
||||
const SVG_EXTS: &[&str] = &["svg", "svgz"];
|
||||
|
||||
@@ -67,9 +66,12 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[
|
||||
("Radiance HDR", &["hdr"]),
|
||||
("FITS", &["fits", "fit", "fts"]),
|
||||
("DPX and Cineon", &["dpx", "cin"]),
|
||||
("Amiga IFF", &["iff", "lbm", "ilbm"]),
|
||||
("DICOM", &["dcm", "dicom", "dc3"]),
|
||||
("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"]),
|
||||
];
|
||||
@@ -102,6 +104,8 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
|
||||
("DPX", &["dpx"]),
|
||||
// Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327).
|
||||
("DDS", &["dds"]),
|
||||
("Cineon", &["cin"]),
|
||||
("Amiga IFF", &["iff"]),
|
||||
];
|
||||
|
||||
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
|
||||
@@ -198,10 +202,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
};
|
||||
if multiple {
|
||||
let picked = dialog.pick_files();
|
||||
Box::pin(async move { picked.await.map(|files| FileDialogAnswer::Paths(files.iter().map(path_of).collect())) })
|
||||
Box::pin(async move { picked.await.map(|files| answer(files.iter().map(|f| f.path()), false, FileDialogAnswer::Paths)) })
|
||||
} else {
|
||||
let picked = dialog.pick_file();
|
||||
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) })
|
||||
Box::pin(async move { picked.await.map(|file| answer([file.path()], false, FileDialogAnswer::Paths)) })
|
||||
}
|
||||
}
|
||||
FileDialogRequest::Folder { initial_dir } => {
|
||||
@@ -209,7 +213,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
let picked = dialog.pick_folder();
|
||||
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
|
||||
Box::pin(async move { picked.await.map(|folder| answer_one(folder.path(), true, FileDialogAnswer::FolderTo)) })
|
||||
}
|
||||
FileDialogRequest::Save { suggested } => {
|
||||
let filters = save_filters(&suggested);
|
||||
@@ -244,10 +248,10 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|conn| crate::portal_save::save_as(&conn, req));
|
||||
match portal {
|
||||
Ok(path) => path.map(|p| FileDialogAnswer::SaveTo(p.to_string_lossy().into_owned())),
|
||||
Ok(path) => path.map(|p| answer_one(&p, true, FileDialogAnswer::SaveTo)),
|
||||
Err(e) => {
|
||||
log::info!("portal save dialog unavailable ({e}), using rfd");
|
||||
dialog.save_file().await.map(|file| FileDialogAnswer::SaveTo(path_of(&file)))
|
||||
dialog.save_file().await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -256,7 +260,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
{
|
||||
let _ = filters;
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
|
||||
Box::pin(async move { picked.await.map(|file| answer_one(file.path(), true, FileDialogAnswer::SaveTo)) })
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -270,8 +274,22 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
}
|
||||
}
|
||||
|
||||
fn path_of(file: &rfd::FileHandle) -> String {
|
||||
file.path().to_string_lossy().into_owned()
|
||||
/// The dialog's answer for the chosen `paths`, or the error for one whose name isn't valid
|
||||
/// Unicode, which a lossy copy would turn into another file's name (#6601). `saving`: the paths
|
||||
/// are to be written to.
|
||||
fn answer<'a>(paths: impl IntoIterator<Item = &'a Path>, saving: bool, make: impl FnOnce(Vec<String>) -> FileDialogAnswer) -> FileDialogAnswer {
|
||||
match paths.into_iter().map(|p| photocraft_ui_egui::file_open::path_text(p, saving)).collect() {
|
||||
Ok(paths) => make(paths),
|
||||
Err(e) => FileDialogAnswer::Unavailable(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`answer`] for a dialog that picks one path.
|
||||
fn answer_one(path: &Path, saving: bool, make: impl FnOnce(String) -> FileDialogAnswer) -> FileDialogAnswer {
|
||||
match photocraft_ui_egui::file_open::path_text(path, saving) {
|
||||
Ok(path) => make(path),
|
||||
Err(e) => FileDialogAnswer::Unavailable(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `future` to completion on this thread, sleeping until it is woken.
|
||||
@@ -330,6 +348,44 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R
|
||||
photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` reach the same file: the same device and inode on Unix, the same final path
|
||||
/// on Windows (where it holds the name's letter case as stored). False when either can't be read.
|
||||
fn same_file(a: &Path, b: &Path) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
match (std::fs::metadata(a), std::fs::metadata(b)) {
|
||||
(Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` name one entry in one folder: `same_file` finds they reach the same file
|
||||
/// in the same folder, and the folder doesn't list both names. Another spelling of a name on a
|
||||
/// file system that ignores letter case is the same entry; a hard link is another entry, which a
|
||||
/// save replaces while the first name keeps the old file. False when the folder can't be read.
|
||||
fn same_entry(a: &Path, b: &Path, same_file: impl Fn(&Path, &Path) -> bool) -> bool {
|
||||
let folder = |p: &Path| p.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")).to_path_buf();
|
||||
if !same_file(a, b) || !same_file(&folder(a), &folder(b)) {
|
||||
return false;
|
||||
}
|
||||
let (Some(x), Some(y)) = (a.file_name(), b.file_name()) else { return false };
|
||||
if x == y {
|
||||
return true;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(folder(a)) else { return false };
|
||||
let listed: Vec<_> = entries.filter_map(|e| e.ok().map(|e| e.file_name())).collect();
|
||||
!(listed.iter().any(|n| n == x) && listed.iter().any(|n| n == y))
|
||||
}
|
||||
|
||||
fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions {
|
||||
settings.io_options()
|
||||
}
|
||||
@@ -416,6 +472,17 @@ fn recovery_services(dir: Option<PathBuf>) -> Services {
|
||||
}
|
||||
}
|
||||
|
||||
/// The OS clipboard read and write run under the crash guard, like Open and Export (#7582): a
|
||||
/// panic in arboard, or in decoding a copied file, becomes the paste's (or copy's) error and the
|
||||
/// app keeps its documents.
|
||||
fn guarded_clipboard_get(mut read: impl FnMut() -> Result<Option<(u32, u32, Vec<u8>)>, String> + 'static) -> photocraft_ui_egui::ClipboardGetFn {
|
||||
Box::new(move || crate::crash_guard::guard("Paste", &mut read))
|
||||
}
|
||||
|
||||
fn guarded_clipboard_set(mut write: impl FnMut(u32, u32, &[u8]) -> Result<(), String> + 'static) -> photocraft_ui_egui::ClipboardSetFn {
|
||||
Box::new(move |w: u32, h: u32, px: &[u8]| crate::crash_guard::guard("Copy", || write(w, h, px)))
|
||||
}
|
||||
|
||||
/// The pixels to paste when the clipboard holds copied files rather than an image (Copy in
|
||||
/// Files, Finder or Explorer puts paths on the clipboard, #338): the first file that decodes,
|
||||
/// upright, as RGBA8. Files that aren't images are skipped after reading only their header.
|
||||
@@ -495,6 +562,12 @@ fn shell_word(s: &str) -> String {
|
||||
if s.bytes().all(|b| b.is_ascii_alphanumeric() || b"/._-+:,@".contains(&b)) { s.to_string() } else { format!("'{}'", s.replace('\'', r"'\''")) }
|
||||
}
|
||||
|
||||
/// The release-check transport, or `None` inside Flatpak: the sandbox has no network (see the
|
||||
/// manifest's finish-args) and `flatpak update` owns updates there.
|
||||
fn update_transport(flatpak_id: Option<&std::ffi::OsStr>) -> Option<photocraft_engine::updates_cmds::CheckFn> {
|
||||
flatpak_id.is_none().then(crate::updates::service)
|
||||
}
|
||||
|
||||
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
|
||||
let clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
|
||||
let clip_unreadable: Rc<Cell<bool>> = Rc::default();
|
||||
@@ -528,9 +601,9 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
}) as photocraft_ui_egui::AutomationCommandFn
|
||||
});
|
||||
Services {
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8], opts: &photocraft_io::ImportOptions| {
|
||||
crate::crash_guard::guard("Open", || {
|
||||
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
|
||||
photocraft_io::import_with_options(name, bytes, opts).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
|
||||
})
|
||||
})),
|
||||
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
|
||||
@@ -545,6 +618,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
Some(photocraft_ui_egui::FileStamp { len: meta.len(), modified: meta.modified().ok() })
|
||||
})),
|
||||
same_file: Some(Box::new(|a: &str, b: &str| same_entry(Path::new(a), Path::new(b), same_file))),
|
||||
automation_read,
|
||||
automation_write,
|
||||
automation_open_pcraft,
|
||||
@@ -558,9 +632,10 @@ 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();
|
||||
Box::new(move |w: u32, h: u32, px: &[u8]| {
|
||||
guarded_clipboard_set(move |w: u32, h: u32, px: &[u8]| {
|
||||
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
|
||||
let cb = match slot.as_mut() {
|
||||
Some(c) => c,
|
||||
@@ -573,7 +648,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
clipboard_get_image: Some({
|
||||
let clip = clip.clone();
|
||||
let unreadable = clip_unreadable.clone();
|
||||
Box::new(move || {
|
||||
guarded_clipboard_get(move || {
|
||||
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
|
||||
let cb = match slot.as_mut() {
|
||||
Some(c) => c,
|
||||
@@ -637,77 +712,19 @@ fn platform_beep() {
|
||||
{
|
||||
let _ = winsafe::MessageBeep(winsafe::co::MBP::OK);
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
// The user's alert sound, at the alert volume set in System Settings ▸ Sound. The terminal
|
||||
// bell below is silent for an app started from the Finder or the Dock.
|
||||
#[cfg(target_os = "macos")]
|
||||
objc2_app_kit::NSBeep();
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
// No beep API among this app's other platforms' dependencies: write the terminal bell
|
||||
// (audible when launched from a terminal, which is the same cue macOS terminals use).
|
||||
// No beep API among the other platforms' dependencies: write the terminal bell (audible
|
||||
// when launched from a terminal).
|
||||
use std::io::Write as _;
|
||||
let _ = std::io::stdout().write_all(b"\x07").and_then(|()| std::io::stdout().flush());
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat-image import via photocraft-codecs (kept for reference/tests; the app uses photocraft-io).
|
||||
#[allow(dead_code)]
|
||||
pub fn import_flat(name: &str, bytes: &[u8]) -> Result<Document, String> {
|
||||
let img = photocraft_codecs::decode(bytes).map_err(|e| e.to_string())?;
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let depth = match img.sample_type() {
|
||||
CS::U8 => SampleType::U8,
|
||||
CS::U16 => SampleType::U16,
|
||||
_ => SampleType::F32,
|
||||
};
|
||||
let gray = matches!(img.layout(), ChannelLayout::Gray | ChannelLayout::GrayA);
|
||||
let cmyk = matches!(img.layout(), ChannelLayout::Cmyk | ChannelLayout::CmykA);
|
||||
let mode = if gray {
|
||||
ColorMode::Grayscale
|
||||
} else if cmyk {
|
||||
ColorMode::Cmyk
|
||||
} else {
|
||||
ColorMode::Rgb
|
||||
};
|
||||
let target = match mode {
|
||||
ColorMode::Grayscale => ChannelLayout::GrayA,
|
||||
ColorMode::Cmyk => ChannelLayout::CmykA,
|
||||
_ => ChannelLayout::Rgba,
|
||||
};
|
||||
let sample = match depth {
|
||||
SampleType::U8 => CS::U8,
|
||||
SampleType::U16 => CS::U16,
|
||||
SampleType::F32 => CS::F32,
|
||||
};
|
||||
let conv = img.convert(target, sample);
|
||||
let stem = std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or(name.to_string());
|
||||
let mut doc = Document::new(stem, Size::new(w, h), mode, depth);
|
||||
doc.icc_profile = img.icc.clone().map(std::sync::Arc::new);
|
||||
if let Some((x, _)) = img.meta.dpi {
|
||||
doc.resolution_dpi = x;
|
||||
}
|
||||
let mut layer = Layer::raster("Background", doc.pixel_format());
|
||||
let data = conv.to_normalized();
|
||||
layer.surface_mut().ok_or("new raster layer has no pixels")?.write_region(Rect::from_xywh(0, 0, w, h), &data);
|
||||
doc.layers.push(layer);
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn export_flat(doc: &Document, path: &str) -> Result<Vec<u8>, String> {
|
||||
let format = photocraft_codecs::from_extension(path).ok_or_else(|| format!("unknown file type for {path}"))?;
|
||||
let buf = photocraft_compose::flatten(doc);
|
||||
let (w, h) = (buf.rect.width(), buf.rect.height());
|
||||
let data: Vec<f32> = buf.px.iter().flat_map(|p| *p).collect();
|
||||
let img = match doc.depth {
|
||||
SampleType::U8 => Image::from_u8(w, h, ChannelLayout::Rgba, buf.to_rgba8().pixels),
|
||||
SampleType::U16 => Image::from_u16(w, h, ChannelLayout::Rgba, &data.iter().map(|v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16).collect::<Vec<_>>()),
|
||||
SampleType::F32 => Image::from_f32(w, h, ChannelLayout::Rgba, &data),
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
let img = match &doc.icc_profile {
|
||||
Some(icc) if doc.mode == ColorMode::Rgb => img.with_icc(Some((**icc).clone())),
|
||||
_ => img,
|
||||
};
|
||||
photocraft_codecs::encode(&img, format, &EncodeOptions::default()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -716,6 +733,31 @@ mod tests {
|
||||
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// #6601: a chosen path whose name isn't valid Unicode is reported, never handed on lossily
|
||||
/// (Open would fail with "No such file", Save would write a second, renamed file).
|
||||
#[test]
|
||||
fn a_chosen_path_that_is_not_valid_unicode_is_an_error_not_another_name() {
|
||||
#[cfg(unix)]
|
||||
let bad = {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
std::env::temp_dir().join(std::ffi::OsStr::from_bytes(b"img\xFF.png"))
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let bad = {
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
let mut wide: Vec<u16> = "img".encode_utf16().collect();
|
||||
wide.push(0xD800);
|
||||
wide.extend(".png".encode_utf16());
|
||||
std::env::temp_dir().join(std::ffi::OsString::from_wide(&wide))
|
||||
};
|
||||
let good = std::env::temp_dir().join("ok.png");
|
||||
let save = |path: &Path| answer_one(path, true, FileDialogAnswer::SaveTo);
|
||||
assert!(matches!(save(&bad), FileDialogAnswer::Unavailable(e) if e.contains("not valid Unicode") && e.contains("another name")));
|
||||
assert!(matches!(save(&good), FileDialogAnswer::SaveTo(p) if p == good.to_str().unwrap()));
|
||||
let open = answer([good.as_path(), bad.as_path()], false, FileDialogAnswer::Paths);
|
||||
assert!(matches!(open, FileDialogAnswer::Unavailable(e) if e.contains("open it again")));
|
||||
}
|
||||
|
||||
/// #6121: the file type picked in the save dialog gives the name its extension where the
|
||||
/// dialog left the one it opened with, and only there.
|
||||
#[test]
|
||||
@@ -871,6 +913,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.
|
||||
@@ -1022,6 +1070,50 @@ mod tests {
|
||||
assert_eq!(dialog_file_name("", true), None);
|
||||
}
|
||||
|
||||
/// A name in another letter case reaches the same file where the file system ignores case
|
||||
/// (Windows, and macOS by default), and only there. Two files with the same contents are two.
|
||||
#[test]
|
||||
fn same_file_follows_the_file_system() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-same-file-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (cat, dog, upper) = (dir.join("CAT.psd"), dir.join("dog.psd"), dir.join("CAT.PSD"));
|
||||
std::fs::write(&cat, b"same").unwrap();
|
||||
std::fs::write(&dog, b"same").unwrap();
|
||||
assert!(same_file(&cat, &cat));
|
||||
assert!(!same_file(&cat, &dog));
|
||||
assert_eq!(same_file(&upper, &cat), upper.exists(), "the same file only where letter case is ignored");
|
||||
assert!(!same_file(&dir.join("missing.psd"), &cat));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// A hard link is another entry in the folder, though it reaches the same file: a save to it
|
||||
/// replaces that entry and leaves the other name on the old file. Another spelling of a name,
|
||||
/// where letter case is ignored, is the same entry.
|
||||
#[test]
|
||||
fn a_hard_link_is_another_entry() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-same-entry-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("other")).unwrap();
|
||||
let (cat, bare, upper, other) = (dir.join("CAT.psd"), dir.join("CAT"), dir.join("CAT.PSD"), dir.join("other").join("CAT.psd"));
|
||||
std::fs::write(&cat, b"cat").unwrap();
|
||||
std::fs::hard_link(&cat, &bare).unwrap();
|
||||
std::fs::hard_link(&cat, &other).unwrap();
|
||||
// Where both names reach one file (one inode on Unix), the folder still lists both.
|
||||
let one_file = |_: &Path, _: &Path| true;
|
||||
assert!(!same_entry(&bare, &cat, one_file));
|
||||
assert!(!same_entry(&bare, &cat, same_file));
|
||||
assert!(!same_entry(&other, &cat, same_file), "another folder");
|
||||
assert!(same_entry(&cat, &cat, one_file));
|
||||
assert!(same_entry(&cat, &cat, same_file));
|
||||
if upper.exists() {
|
||||
assert!(same_entry(&upper, &cat, same_file), "one entry where letter case is ignored");
|
||||
} else {
|
||||
std::fs::hard_link(&cat, &upper).unwrap();
|
||||
assert!(!same_entry(&upper, &cat, same_file), "two entries where letter case tells names apart");
|
||||
}
|
||||
assert!(!same_entry(&dir.join("missing.psd"), &cat, same_file));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_on_waits_for_a_wake_from_another_thread() {
|
||||
let (tx, rx) = std::sync::mpsc::channel::<std::task::Waker>();
|
||||
@@ -1299,4 +1391,29 @@ mod tests {
|
||||
assert_eq!(image_from_files(&[tga]), Some((1, 1, vec![255, 0, 0, 255])));
|
||||
assert!(image_from_files(&[bin]).is_none());
|
||||
}
|
||||
|
||||
/// #7582: a panic while reading the OS clipboard (in arboard, or decoding a copied file) is
|
||||
/// the paste's error in the status bar, not a crash: the document stays and the next paste
|
||||
/// works. The same for a panic while writing it.
|
||||
#[test]
|
||||
fn a_panicking_clipboard_read_is_reported_not_a_crash() {
|
||||
let panics = Rc::new(Cell::new(true));
|
||||
let p = panics.clone();
|
||||
let get = guarded_clipboard_get(move || if p.get() { panic!("clipboard decoder bug") } else { Ok(Some((2, 2, [0u8, 0, 255, 255].repeat(4)))) });
|
||||
let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(get), ..Default::default() });
|
||||
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let layers = app.session.active().unwrap().doc.layers.len();
|
||||
|
||||
let error = app.run("edit.paste", json!({})).unwrap_err();
|
||||
assert!(error.contains("Paste failed"), "{error}");
|
||||
assert!(app.ui.status_error && app.ui.status.contains("Paste failed"), "{}", app.ui.status);
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "the document is unchanged");
|
||||
|
||||
panics.set(false);
|
||||
app.run("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1, "the next paste works");
|
||||
|
||||
let mut set = guarded_clipboard_set(|_: u32, _: u32, _: &[u8]| panic!("clipboard writer bug"));
|
||||
assert!(set(1, 1, &[0; 4]).unwrap_err().contains("Copy failed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use photocraft_color::Color;
|
||||
use photocraft_color::{Color, ColorMode, SampleType};
|
||||
use photocraft_doc::Size;
|
||||
use photocraft_format::{Autosaver, RecoveryEntry, SaveOptions, list_recovery};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -129,8 +131,54 @@ pub struct LaunchContext<'a> {
|
||||
pub control: bool,
|
||||
/// Paths that aren't valid Unicode: this launch reports them.
|
||||
pub unreadable_paths: bool,
|
||||
/// Options that only apply to the process they're given to (`--safe-gpu`, …).
|
||||
pub launch_options: &'a [&'static str],
|
||||
/// 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 [PerProcess],
|
||||
}
|
||||
|
||||
/// 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. 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
|
||||
/// variable is read where it takes effect, so a value ignored there keeps no process here either.
|
||||
type ValueRule = fn(&str) -> bool;
|
||||
|
||||
/// A value `presentation::parse` accepts, handed over as written: it trims, treats empty as unset and
|
||||
/// warns and ignores anything it does not recognise, so an unrecognised `PHOTOCRAFT_PRESENT_MODE`
|
||||
/// changes nothing there and so needs no process of its own here.
|
||||
fn present_mode_is_on(value: &str) -> bool {
|
||||
crate::presentation::parse(Some(value), None).0.mode.is_some()
|
||||
}
|
||||
|
||||
/// [`present_mode_is_on`] for `PHOTOCRAFT_FRAME_LATENCY`, which takes `1` to `3`.
|
||||
fn frame_latency_is_on(value: &str) -> bool {
|
||||
crate::presentation::parse(None, Some(value)).0.latency.is_some()
|
||||
}
|
||||
|
||||
/// Anything except `""`, `0`, `off` and `false`, which is how `StrokeTrace::from_value` reads it: the
|
||||
/// rest is a stderr summary or a CSV path. It trims, so `" 0 "` is off there and off here.
|
||||
fn stroke_trace_is_on(value: &str) -> bool {
|
||||
!matches!(value.trim(), "" | "0" | "off" | "false")
|
||||
}
|
||||
|
||||
/// 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<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.
|
||||
@@ -148,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
|
||||
}
|
||||
@@ -837,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
|
||||
@@ -915,4 +964,41 @@ mod tests {
|
||||
assert_eq!(sandbox_socket(Some(OsStr::new("")), Some(run)), None);
|
||||
assert_eq!(sandbox_socket(Some(id), Some(OsStr::new(""))), None);
|
||||
}
|
||||
|
||||
/// A variable documented as the equivalent of a per-launch flag counts as that flag: a launch
|
||||
/// that sets only variables has per-process options, so it keeps its own process rather than
|
||||
/// handing its files to the running instance, which never sees the override.
|
||||
#[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![PerProcess::PresentMode]);
|
||||
assert_eq!(
|
||||
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 three.
|
||||
assert!(with(&[]).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());
|
||||
// The trace and the two presentation values are trimmed where they take effect.
|
||||
assert_eq!(
|
||||
with(&[("PHOTOCRAFT_PRESENT_MODE", " novsync "), ("PHOTOCRAFT_STROKE_TRACE", " 1 ")]),
|
||||
vec![PerProcess::PresentMode, PerProcess::StrokeTrace]
|
||||
);
|
||||
// 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,
|
||||
new_instance_env: None,
|
||||
control: false,
|
||||
unreadable_paths: false,
|
||||
launch_options: &[],
|
||||
};
|
||||
let reason = skip_reason(&LaunchContext { launch_options: &with(&[("PHOTOCRAFT_PRESENT_MODE", "novsync")]), ..base });
|
||||
assert!(reason.as_deref().is_some_and(|r| r.contains("--present-mode")), "{reason:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,32 @@
|
||||
//! hand-edited file would then crash every launch. [`sanitize`] runs first and removes a file
|
||||
//! holding such values (the window and panels open at their defaults; nothing else is lost).
|
||||
//! A file that isn't RON at all is left alone: eframe already ignores it.
|
||||
//!
|
||||
//! egui 0.37 also replaced the flat `WindowSettings` (`inner_position_pixels`,
|
||||
//! `inner_size_points: Option<Vec2>`, …) with `normal_geometry: { outer_position_pixels,
|
||||
//! scale_factor, inner_size_points }`. eframe reads the file before any application code, so an
|
||||
//! old value cannot be interpreted lazily: [`sanitize`] rewrites it before eframe loads it and
|
||||
//! keeps a one-time `ui.ron.pre-0.37` backup. The old panel keys in the egui memory part are
|
||||
//! untouched (`panels::panel_state_id` migrates them additively), but this window rewrite is
|
||||
//! one-way: after it, a 0.36 build ignores the window entry and opens at its default geometry.
|
||||
//! Restoring the backup is the documented rollback.
|
||||
|
||||
use std::path::Path;
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
/// Window positions, sizes and panel rects beyond this many points or pixels are corrupt.
|
||||
const MAX_COORD: f64 = 1.0e6;
|
||||
/// egui's zoom factor is the UI scale over the display scale (75% on a 2× display is 0.375,
|
||||
/// 300% on a 1× display is 3).
|
||||
const ZOOM: std::ops::RangeInclusive<f64> = 0.1..=10.0;
|
||||
/// The size [`crate::native_options`] opens a window with when an old save has no usable size.
|
||||
const DEFAULT_SIZE: f32 = 1440.0;
|
||||
const DEFAULT_HEIGHT: f32 = 900.0;
|
||||
/// Kept beside `ui.ron` before the one-way 0.36 → 0.37 window rewrite.
|
||||
const BACKUP_SUFFIX: &str = "pre-0.37";
|
||||
|
||||
/// Remove `path` if it holds state that would crash startup. Never fails: errors are logged.
|
||||
/// Remove `path` if it holds state that would crash startup, and migrate an old window entry to
|
||||
/// the 0.37 schema before eframe reads it. Never fails: errors are logged and leave the file
|
||||
/// alone (eframe then ignores what it cannot read).
|
||||
pub fn sanitize(path: Option<&Path>) {
|
||||
let Some(path) = path else { return };
|
||||
let Ok(text) = std::fs::read_to_string(path) else { return };
|
||||
@@ -24,30 +40,72 @@ pub fn sanitize(path: Option<&Path>) {
|
||||
if let Err(e) = std::fs::remove_file(path) {
|
||||
log::warn!("could not remove {}: {e}", path.display());
|
||||
}
|
||||
return;
|
||||
}
|
||||
match migrate_window(text) {
|
||||
Ok(Some(text)) => write_migrated(path, &text),
|
||||
Ok(None) => {}
|
||||
Err(why) => log::warn!("not rewriting the saved window geometry in {}: {why}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` holds a window position from an earlier session, which eframe restores. Then
|
||||
/// `NativeOptions::centered` must be off: eframe applies it after restoring the saved geometry,
|
||||
/// Write the migrated file, keeping a one-time backup. A failed backup aborts the rewrite: the
|
||||
/// pre-0.37 window geometry is the documented rollback, so never destroy the only copy.
|
||||
fn write_migrated(path: &Path, text: &str) {
|
||||
let backup = backup_path(path);
|
||||
if !backup.exists()
|
||||
&& let Err(e) = std::fs::copy(path, &backup)
|
||||
{
|
||||
log::warn!("could not back up {} to {}: {e}; leaving it unchanged", path.display(), backup.display());
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::write(path, text) {
|
||||
log::warn!("could not write the migrated window layout to {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn backup_path(path: &Path) -> std::path::PathBuf {
|
||||
let mut name = path.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_else(|| "ui.ron".into());
|
||||
name.push(format!(".{BACKUP_SUFFIX}"));
|
||||
path.with_file_name(name)
|
||||
}
|
||||
|
||||
/// Whether `path` holds a window position that eframe restores, which means
|
||||
/// `NativeOptions::centered` must be off: eframe applies `centered` after the restored geometry,
|
||||
/// so it moved the window back to the centre of the main monitor on every launch (#4826).
|
||||
///
|
||||
/// Only the 0.37 `normal_geometry.outer_position_pixels` counts: `egui_winit::WindowGeometry`
|
||||
/// sets it and `initialize_viewport_builder` applies it. A 0.36 flat entry is not restored by
|
||||
/// 0.37 (`sanitize` rewrites it first), and its macOS `inner_position_pixels` was never an outer
|
||||
/// position.
|
||||
pub fn has_saved_position(path: Option<&Path>) -> bool {
|
||||
let Some(text) = path.and_then(|p| std::fs::read_to_string(p).ok()) else { return false };
|
||||
saved_position(&text)
|
||||
}
|
||||
|
||||
fn saved_position(text: &str) -> bool {
|
||||
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return false };
|
||||
let Ok(kv) = ron::from_str::<HashMap<String, String>>(text) else { return false };
|
||||
let Some(window) = kv.get("window").and_then(|w| ron::from_str::<ron::Value>(w).ok()) else { return false };
|
||||
// eframe restores the inner position on macOS and the outer position elsewhere
|
||||
// (`egui_winit::WindowSettings::initialize_viewport_builder`).
|
||||
let key = if cfg!(target_os = "macos") { "inner_position_pixels" } else { "outer_position_pixels" };
|
||||
matches!(field(&window, key), Some(ron::Value::Option(Some(_))))
|
||||
let Some(geometry) = field(&window, "normal_geometry") else { return false };
|
||||
matches!(field(geometry, "outer_position_pixels"), Some(ron::Value::Option(Some(_))))
|
||||
}
|
||||
|
||||
/// The 0.36 → 0.37 rewrite of one `ui.ron` text, for the recorded 0.14/0.15 fixtures.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn migrate_for_test(text: &str) -> Result<Option<String>, String> {
|
||||
migrate_window(text.to_owned())
|
||||
}
|
||||
|
||||
/// [`saved_position`] for the recorded fixtures.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn saved_position_for_test(text: &str) -> bool {
|
||||
saved_position(text)
|
||||
}
|
||||
|
||||
/// `Err` names the first value that would crash startup.
|
||||
fn check(text: &str) -> Result<(), String> {
|
||||
// eframe stores a map of key → RON string; a file that doesn't parse is ignored by eframe.
|
||||
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return Ok(()) };
|
||||
let Ok(kv) = ron::from_str::<HashMap<String, String>>(text) else { return Ok(()) };
|
||||
for (key, value) in &kv {
|
||||
// eframe also skips an entry that doesn't parse.
|
||||
let Ok(v) = ron::from_str::<ron::Value>(value) else { continue };
|
||||
@@ -60,6 +118,108 @@ fn check(text: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `Some(new_text)` when `text` is an old-schema file that should be rewritten; `None` when it
|
||||
/// is already 0.37-shaped or holds no window entry; `Err` when the old window entry is unusable
|
||||
/// and must be left untouched.
|
||||
fn migrate_window(text: String) -> Result<Option<String>, String> {
|
||||
let Ok(mut kv) = ron::from_str::<HashMap<String, String>>(&text) else { return Ok(None) };
|
||||
let Some(value) = kv.get("window").cloned() else { return Ok(None) };
|
||||
let Ok(window) = ron::from_str::<ron::Value>(&value) else { return Ok(None) };
|
||||
if field(&window, "normal_geometry").is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
if field(&window, "inner_size_points").is_none() && field(&window, "inner_position_pixels").is_none() && field(&window, "outer_position_pixels").is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let migrated = migrate_window_value(&window)?;
|
||||
kv.insert("window".into(), migrated);
|
||||
ron::to_string(&kv).map(Some).map_err(|e| format!("cannot write the new window entry: {e}"))
|
||||
}
|
||||
|
||||
/// The RON payload eframe 0.37 deserializes as `egui_winit::WindowSettings`.
|
||||
///
|
||||
/// The field order and names mirror `WindowGeometry`; the types are local because
|
||||
/// `egui_winit::WindowGeometry` keeps its fields private.
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq)]
|
||||
struct PointV37 {
|
||||
x: f32,
|
||||
y: f32,
|
||||
}
|
||||
|
||||
impl PointV37 {
|
||||
fn sane(self) -> Option<Self> {
|
||||
let ok = |v: f32| v.is_finite() && (v.abs() as f64) <= MAX_COORD;
|
||||
(ok(self.x) && ok(self.y)).then_some(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq)]
|
||||
struct GeometryV37 {
|
||||
outer_position_pixels: Option<PointV37>,
|
||||
scale_factor: f32,
|
||||
inner_size_points: PointV37,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq)]
|
||||
struct WindowV37 {
|
||||
normal_geometry: GeometryV37,
|
||||
fullscreen: bool,
|
||||
maximized: bool,
|
||||
}
|
||||
|
||||
fn migrate_window_value(window: &ron::Value) -> Result<String, String> {
|
||||
let size = old_size(window)?;
|
||||
// The old macOS entry stored an inner (not outer) position in logical points and Linux saved
|
||||
// no scale factor, so a faithful restore is only possible for the Windows physical outer
|
||||
// position. Everywhere else the window opens centred with its saved size.
|
||||
let position = if cfg!(target_os = "windows") { old_position(window, "outer_position_pixels") } else { None };
|
||||
let window = WindowV37 {
|
||||
normal_geometry: GeometryV37 {
|
||||
outer_position_pixels: position,
|
||||
// Physical pixels of the old Windows position: 0.37 divides by zoom × scale.
|
||||
scale_factor: 1.0,
|
||||
inner_size_points: size,
|
||||
},
|
||||
fullscreen: old_bool(window, "fullscreen"),
|
||||
maximized: old_bool(window, "maximized"),
|
||||
};
|
||||
ron::to_string(&window).map_err(|e| format!("cannot serialize the new window entry: {e}"))
|
||||
}
|
||||
|
||||
fn old_size(window: &ron::Value) -> Result<PointV37, String> {
|
||||
let value = field(window, "inner_size_points");
|
||||
let Some(ron::Value::Option(Some(size))) = value else {
|
||||
// A missing size is the app's own opening size, not corrupt data.
|
||||
return Ok(PointV37 { x: DEFAULT_SIZE, y: DEFAULT_HEIGHT });
|
||||
};
|
||||
let point = point_value(size).ok_or_else(|| "inner_size_points is not a finite (x, y) point".to_string())?;
|
||||
let point = point.sane().ok_or_else(|| "inner_size_points is not finite or is absurdly large".to_string())?;
|
||||
if point.x < 1.0 || point.y < 1.0 {
|
||||
return Err("inner_size_points has a zero or negative side".into());
|
||||
}
|
||||
Ok(point)
|
||||
}
|
||||
|
||||
/// One dimension of an old `Option<Pos2>` field, rejecting corrupt values rather than guessing.
|
||||
fn old_position(window: &ron::Value, key: &str) -> Option<PointV37> {
|
||||
match field(window, key) {
|
||||
Some(ron::Value::Option(Some(p))) => point_value(p).and_then(PointV37::sane),
|
||||
// `None`, missing, and malformed all mean "no position"; corrupt positions elsewhere are
|
||||
// caught by `check`, which removes a file with non-finite values before this runs.
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn old_bool(window: &ron::Value, key: &str) -> bool {
|
||||
matches!(field(window, key), Some(ron::Value::Bool(true)))
|
||||
}
|
||||
|
||||
fn point_value(v: &ron::Value) -> Option<PointV37> {
|
||||
let x = field(v, "x").and_then(as_f32)?;
|
||||
let y = field(v, "y").and_then(as_f32)?;
|
||||
Some(PointV37 { x, y })
|
||||
}
|
||||
|
||||
/// Every float finite and within [`MAX_COORD`]; depth-limited (the file is untrusted).
|
||||
fn floats_sane(v: &ron::Value, depth: usize) -> Result<(), String> {
|
||||
if depth > 64 {
|
||||
@@ -86,6 +246,13 @@ fn as_f64(v: &ron::Value) -> Option<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
fn as_f32(v: &ron::Value) -> Option<f32> {
|
||||
as_f64(v).and_then(|f| {
|
||||
let f = f as f32;
|
||||
f.is_finite().then_some(f)
|
||||
})
|
||||
}
|
||||
|
||||
fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> {
|
||||
match v {
|
||||
ron::Value::Map(m) => m.get(&ron::Value::String(name.into())),
|
||||
@@ -95,7 +262,8 @@ fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{check, saved_position};
|
||||
use super::{DEFAULT_HEIGHT, DEFAULT_SIZE, WindowV37, backup_path, check, migrate_window, saved_position};
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
fn file(window: &str, zoom: &str) -> String {
|
||||
let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))");
|
||||
@@ -103,6 +271,17 @@ mod tests {
|
||||
}
|
||||
|
||||
const WINDOW: &str = "(inner_position_pixels:Some((x:616.0,y:430.0)),fullscreen:false,inner_size_points:Some((x:1100.0,y:760.0)))";
|
||||
const WINDOW_WITH_OUTER: &str = "(inner_position_pixels:Some((x:2048.0,y:1265.0)),outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
|
||||
|
||||
/// Read the window payload back as eframe 0.37 would.
|
||||
fn window_of(text: &str) -> WindowV37 {
|
||||
let kv: HashMap<String, String> = ron::from_str(text).expect("the migrated file is a RON map");
|
||||
ron::from_str(kv.get("window").expect("a window entry")).expect("0.37 WindowSettings shape")
|
||||
}
|
||||
|
||||
fn migrated(window: &str) -> String {
|
||||
migrate_window(file(window, "1.0")).expect("migratable").expect("rewritten")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_layout_is_kept() {
|
||||
@@ -122,13 +301,83 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_size_and_maximized_state_survive() {
|
||||
let text = migrated(WINDOW_WITH_OUTER);
|
||||
let window = window_of(&text);
|
||||
assert_eq!(window.normal_geometry.inner_size_points.x, 880.0);
|
||||
assert_eq!(window.normal_geometry.inner_size_points.y, 480.0);
|
||||
assert!(!window.fullscreen);
|
||||
assert!(!window.maximized);
|
||||
let maximized = migrated(&WINDOW.replace("fullscreen:false", "fullscreen:true,maximized:true"));
|
||||
assert!(window_of(&maximized).fullscreen && window_of(&maximized).maximized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_size_uses_the_default_opening_size() {
|
||||
// A window entry with a position but no size migrates and keeps the app's opening size.
|
||||
let window = window_of(&migrated("(inner_position_pixels:Some((x:616.0,y:430.0)),fullscreen:false,maximized:false)"));
|
||||
assert_eq!(window.normal_geometry.inner_size_points.x, DEFAULT_SIZE);
|
||||
assert_eq!(window.normal_geometry.inner_size_points.y, DEFAULT_HEIGHT);
|
||||
// A window entry with no geometry fields at all is left alone: it is not a 0.36 window.
|
||||
assert_eq!(migrate_window(file("(fullscreen:false,maximized:false)", "1.0")), Ok(None));
|
||||
// So is a file with no window-like fields at all.
|
||||
assert_eq!(migrate_window(file("(something:1)", "1.0")), Ok(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_old_position_is_kept_only_on_windows() {
|
||||
let text = migrated(WINDOW_WITH_OUTER);
|
||||
let pos = window_of(&text).normal_geometry.outer_position_pixels;
|
||||
if cfg!(target_os = "windows") {
|
||||
let pos = pos.expect("the Windows physical outer position survives");
|
||||
assert_eq!((pos.x, pos.y), (2048.0, 1228.0));
|
||||
assert_eq!(window_of(&text).normal_geometry.scale_factor, 1.0);
|
||||
} else {
|
||||
assert!(pos.is_none(), "an old macOS/Linux position is not a restorable outer position");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_old_size_is_not_rewritten() {
|
||||
let bad = WINDOW.replace("(x:1100.0,y:760.0)", "(x:1100.0,y:0.0)");
|
||||
assert!(migrate_window(file(&bad, "1.0")).is_err());
|
||||
let huge = WINDOW.replace("(x:1100.0,y:760.0)", "(x:1100.0,y:9000000.0)");
|
||||
assert!(migrate_window(file(&huge, "1.0")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_already_migrated_file_is_left_alone() {
|
||||
let text = migrated(WINDOW);
|
||||
assert_eq!(migrate_window(text), Ok(None));
|
||||
// A 0.36 build accepts the entry, but its missing flat geometry defaults to None.
|
||||
// It cannot recover position or size from the nested 0.37 geometry.
|
||||
let kv: HashMap<String, String> = ron::from_str(&migrated(WINDOW)).unwrap();
|
||||
let old: OldWindow = ron::from_str(kv.get("window").unwrap()).unwrap();
|
||||
assert!(old.inner_position_pixels.is_none() && old.outer_position_pixels.is_none());
|
||||
assert!(old.inner_size_points.is_none(), "documented rollback loss of saved geometry");
|
||||
// Restoring the original backup restores both the old position and size.
|
||||
let restored: OldWindow = ron::from_str(WINDOW).unwrap();
|
||||
let position = restored.inner_position_pixels.unwrap();
|
||||
let size = restored.inner_size_points.unwrap();
|
||||
assert_eq!((position.x, position.y), (616.0, 430.0));
|
||||
assert_eq!((size.x, size.y), (1100.0, 760.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_window_position_is_detected() {
|
||||
// #4826: the position from the report (Linux Mint, window moved to the third monitor).
|
||||
let window = "(inner_position_pixels:Some((x:2048.0,y:1265.0)),outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
|
||||
assert!(saved_position(&file(window, "1.0")));
|
||||
let no_position = "(inner_position_pixels:None,outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
|
||||
// A 0.36 flat entry is not what eframe 0.37 restores (it would open at the OS default);
|
||||
// `sanitize` rewrites it before eframe runs, and the migrated nested value is detected.
|
||||
assert!(!saved_position(&file(WINDOW_WITH_OUTER, "1.0")), "an unmigrated flat entry is not a restorable position");
|
||||
// An old macOS inner position is not an outer position either.
|
||||
assert!(!saved_position(&file(WINDOW, "1.0")));
|
||||
let no_position =
|
||||
"(normal_geometry:(outer_position_pixels:None,scale_factor:1.0,inner_size_points:(x:880.0,y:480.0)),fullscreen:false,maximized:false)";
|
||||
assert!(!saved_position(&file(no_position, "1.0")));
|
||||
// The migrated nested geometry is what a 0.37 save holds (on Windows only, from the
|
||||
// migration; a later 0.37 save on macOS/Linux has one too and eframe restores it).
|
||||
let text = migrated(WINDOW_WITH_OUTER);
|
||||
assert_eq!(saved_position(&text), cfg!(target_os = "windows"));
|
||||
assert!(!saved_position(&format!("{{\"egui\":{:?}}}", "()")), "no window entry");
|
||||
for text in ["", "not ron {{{", "{\"window\":\"(((\"}"] {
|
||||
assert!(!saved_position(text), "{text:?}");
|
||||
@@ -148,7 +397,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_removes_only_a_bad_file() {
|
||||
fn sanitize_removes_only_a_bad_file_and_migrates_a_good_one() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-ui-state-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let good = dir.join("good.ron");
|
||||
@@ -159,7 +408,58 @@ mod tests {
|
||||
super::sanitize(Some(&bad));
|
||||
super::sanitize(Some(&dir.join("missing.ron")));
|
||||
super::sanitize(None);
|
||||
assert!(good.exists() && !bad.exists());
|
||||
assert!(!bad.exists());
|
||||
assert!(good.exists());
|
||||
// Migrated in place, with the one-time pre-0.37 backup beside it.
|
||||
let text = std::fs::read_to_string(&good).unwrap();
|
||||
assert!(window_of(&text).normal_geometry.inner_size_points.x == 1100.0);
|
||||
let backup = backup_path(&good);
|
||||
assert!(backup.exists(), "the pre-0.37 layout is kept for rollback");
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), file(WINDOW, "1.0"));
|
||||
// A second run leaves both untouched.
|
||||
super::sanitize(Some(&good));
|
||||
assert_eq!(std::fs::read_to_string(&good).unwrap(), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_backup_is_not_overwritten_by_a_later_migration() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-ui-state-backup-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("ui.ron");
|
||||
std::fs::write(&path, file(WINDOW, "1.0")).unwrap();
|
||||
super::sanitize(Some(&path));
|
||||
let backup = backup_path(&path);
|
||||
let original = std::fs::read_to_string(&backup).unwrap();
|
||||
// Pretend a 0.36 run wrote its old schema again; the backup must stay the first one.
|
||||
std::fs::write(&path, file(&WINDOW.replace("1100.0", "1111.0"), "1.0")).unwrap();
|
||||
super::sanitize(Some(&path));
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), original);
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("1111"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The 0.36 flat schema, kept to prove the new nested value is a one-way migration.
|
||||
#[derive(Default, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
struct OldWindow {
|
||||
inner_position_pixels: Option<Point>,
|
||||
outer_position_pixels: Option<Point>,
|
||||
fullscreen: bool,
|
||||
maximized: bool,
|
||||
inner_size_points: Option<Point>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct Point {
|
||||
x: f32,
|
||||
y: f32,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_backup_path_sits_beside_ui_ron() {
|
||||
assert_eq!(backup_path(&PathBuf::from("/tmp/x/ui.ron")), PathBuf::from("/tmp/x/ui.ron.pre-0.37"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Release checks through `craft-update`, the library every Craft App shares. Only public release
|
||||
//! metadata is requested.
|
||||
|
||||
use photocraft_engine::updates_cmds::{CheckFn, CheckResult, Release};
|
||||
|
||||
pub fn service() -> CheckFn {
|
||||
Box::new(|| {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::Builder::new()
|
||||
.name("update check".into())
|
||||
.spawn(move || {
|
||||
let result = check();
|
||||
if let Err(e) = &result {
|
||||
log::warn!("Update check: {e}");
|
||||
}
|
||||
let _ = tx.send(result);
|
||||
})
|
||||
.map_err(|e| format!("Could not start update check: {e}"))?;
|
||||
Ok(rx)
|
||||
})
|
||||
}
|
||||
|
||||
fn check() -> CheckResult {
|
||||
let http = craft_update::Http::new("PhotoCraft/Update")?;
|
||||
let found = craft_update::check_for_update(&craft_update::suite::PHOTOCRAFT, &http, photocraft_engine::build_info::VERSION)?;
|
||||
Ok(found.map(|release| Release { version: release.tag.trim_start_matches(['v', 'V']).to_string(), url: release.html_url }))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Bounded, private VectorCraft conversion before an embedded AI/PDF/EPS editing session.
|
||||
//! Bounded display previews for native vector editing. The working source retains its format.
|
||||
//! No document metadata supplies a process, command line or output path.
|
||||
|
||||
use super::native::OwnedSvg;
|
||||
@@ -9,15 +9,43 @@ use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Prepare a bounded SVG working copy, converting native vectors inside private MCP roots.
|
||||
/// Validate the source using a private preview, then expose only an original-format copy.
|
||||
pub(super) fn prepare(editor: &Path, source: &ExternalVectorSource, stop: &Receiver<()>) -> Result<(OwnedSvg, Vec<u8>), String> {
|
||||
render_preview(editor, source, stop)?;
|
||||
// Foreign AI opens read-only for ordinary Save in VectorCraft. Regenerate only this
|
||||
// private AI working copy with editable AI data; never weaken that source-file protection.
|
||||
// Comparing the watcher with this initial copy prevents an open-only/no-op replacement.
|
||||
let initial = if source.extension == "ai" { run_export(editor, source, stop, "ai")? } else { source.bytes.as_ref().clone() };
|
||||
if source.extension == "ai" {
|
||||
let prepared = ExternalVectorSource { bytes: std::sync::Arc::new(initial.clone()), extension: source.extension.clone(), size: source.size };
|
||||
render_preview(editor, &prepared, stop)?;
|
||||
}
|
||||
let owned = OwnedSvg::create_format(&initial, &source.extension)?;
|
||||
Ok((owned, initial))
|
||||
}
|
||||
|
||||
/// Render a stable source snapshot inside private MCP roots; never rewrite the editor's file.
|
||||
pub(super) fn render_preview(editor: &Path, source: &ExternalVectorSource, stop: &Receiver<()>) -> Result<Vec<u8>, String> {
|
||||
let converted = if source.extension == "svg" { source.bytes.as_ref().clone() } else { run_export(editor, source, stop, "svg")? };
|
||||
source_svg(&converted, source.size)
|
||||
}
|
||||
|
||||
/// Convert `source` to `format` (`svg` or `ai`) with the `vectorcraft-cli` next to `editor`,
|
||||
/// confined to a private temporary read/write root. Errors on a format/magic mismatch, a
|
||||
/// missing or linked CLI, a failed open/export reply, cancellation via `stop`, or the
|
||||
/// 60-second deadline; the child process is killed and reaped on any failure.
|
||||
fn run_export(editor: &Path, source: &ExternalVectorSource, stop: &Receiver<()>, format: &str) -> Result<Vec<u8>, String> {
|
||||
if !matches!(source.extension.as_str(), "svg" | "pdf" | "ai" | "eps") {
|
||||
return Err("Unsupported embedded vector source type".into());
|
||||
}
|
||||
let owned = OwnedSvg::create(&source.bytes)?;
|
||||
if source.extension == "svg" {
|
||||
return Ok((owned, source.bytes.as_ref().clone()));
|
||||
if !matches!(format, "svg" | "ai")
|
||||
|| !photocraft_io::vector_preview::is_native_source(&source.bytes)
|
||||
|| (source.extension == "pdf" && !source.bytes.starts_with(b"%PDF-"))
|
||||
|| (source.extension == "eps" && source.bytes.starts_with(b"%PDF-"))
|
||||
{
|
||||
return Err("The saved bytes do not match the original vector format".into());
|
||||
}
|
||||
let owned = OwnedSvg::create_format(&source.bytes, format)?;
|
||||
let cli_name = if cfg!(windows) { "vectorcraft-cli.exe" } else { "vectorcraft-cli" };
|
||||
let cli = editor.parent().ok_or("The editor has no installation directory")?.join(cli_name);
|
||||
// Selecting an editor explicitly also selects its adjacent VectorCraft CLI; PATH and the
|
||||
@@ -31,7 +59,7 @@ pub(super) fn prepare(editor: &Path, source: &ExternalVectorSource, stop: &Recei
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&input).map_err(|e| format!("Cannot create vector input: {e}"))?;
|
||||
file.write_all(&source.bytes).map_err(|e| format!("Cannot write vector input: {e}"))?;
|
||||
drop(file);
|
||||
let commands = conversion_commands(&input, &owned.file)?;
|
||||
let commands = export_commands(&input, &owned.file, format)?;
|
||||
// MCP's headless roots apply to every thread and file reader, including references in
|
||||
// hostile embedded artwork. Ordinary `convert` does not establish those restrictions.
|
||||
let responses_path = owned.root.join("Responses.jsonl");
|
||||
@@ -82,31 +110,40 @@ pub(super) fn prepare(editor: &Path, source: &ExternalVectorSource, stop: &Recei
|
||||
responses_file.take(1024 * 1024 + 1).read_to_string(&mut responses).map_err(|e| format!("Invalid conversion responses: {e}"))?;
|
||||
validate_responses(&responses)?;
|
||||
let converted = owned.read(&owned.stamp()?)?;
|
||||
let svg = source_svg(&converted, source.size)?;
|
||||
// Validate bounded rendering before the interactive editor sees the working copy.
|
||||
photocraft_io::svg::import_svg_bounded("Artwork.svg", &svg, 24_000_000).map_err(|e| format!("Converted SVG is unsupported: {e}"))?;
|
||||
owned.overwrite(&svg)?;
|
||||
Ok(svg)
|
||||
if format == "ai" && !converted.starts_with(b"%PDF-") {
|
||||
return Err("VectorCraft did not save a PDF-compatible AI working copy".into());
|
||||
}
|
||||
Ok(converted)
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
result.map(|svg| (owned, svg))
|
||||
result
|
||||
}
|
||||
|
||||
/// Build requests only from UTF-8 paths: serializing a non-UTF-8 `Path` inside `json!` panics.
|
||||
#[cfg(all(test, unix))]
|
||||
fn conversion_commands(input: &Path, output: &Path) -> Result<[(u8, &'static str, serde_json::Value); 2], String> {
|
||||
export_commands(input, output, "svg")
|
||||
}
|
||||
|
||||
/// The open (id 1) and export (id 2) requests that [`validate_responses`] checks; AI exports
|
||||
/// preserve editing data. Non-UTF-8 paths return errors because `json!` would panic on them.
|
||||
fn export_commands(input: &Path, output: &Path, format: &str) -> Result<[(u8, &'static str, serde_json::Value); 2], String> {
|
||||
let input = input.to_str().ok_or("The temporary vector input path is not UTF-8")?;
|
||||
let output = output.to_str().ok_or("The temporary vector output path is not UTF-8")?;
|
||||
Ok([(1, "document.open", serde_json::json!({"path": input})), (2, "document.export", serde_json::json!({"path": output, "format": "svg"}))])
|
||||
Ok([
|
||||
(1, "document.open", serde_json::json!({"path": input})),
|
||||
(2, "document.export", serde_json::json!({"path": output, "format": format, "preserveEditing": format == "ai"})),
|
||||
])
|
||||
}
|
||||
|
||||
/// Keep the original PSD source-pixel frame: PDF points and EPS bounds need not equal the
|
||||
/// placed object's stored pixel dimensions. The nested viewport scales vector geometry once.
|
||||
fn source_svg(bytes: &[u8], size: Option<(f64, f64)>) -> Result<Vec<u8>, String> {
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| "Converted SVG is not UTF-8")?;
|
||||
photocraft_io::svg::import_svg_bounded("Artwork.svg", bytes, 24_000_000).map_err(|e| format!("Converted SVG is unsupported: {e}"))?;
|
||||
photocraft_io::svg::import_svg_bounded_with_work("Artwork.svg", bytes, 96_000_000).map_err(|e| format!("Converted SVG is unsupported: {e}"))?;
|
||||
let doc = roxmltree::Document::parse(text).map_err(|e| format!("Invalid converted SVG: {e}"))?;
|
||||
let root = doc.root_element();
|
||||
if root.tag_name().name() != "svg" {
|
||||
@@ -118,7 +155,13 @@ fn source_svg(bytes: &[u8], size: Option<(f64, f64)>) -> Result<Vec<u8>, String>
|
||||
if !w.is_finite() || !h.is_finite() || w <= 0.0 || h <= 0.0 || w > 16384.0 || h > 16384.0 || w * h > 24_000_000.0 {
|
||||
return Err("The original vector source dimensions exceed the editing limit".into());
|
||||
}
|
||||
let dimension = |key| root.attribute(key).and_then(|v| v.trim_end_matches("pt").parse::<f64>().ok()).filter(|v| v.is_finite() && *v > 0.0);
|
||||
// The nested root keeps its own width/height, so the outer viewBox must use the same CSS
|
||||
// pixels: `612pt` is 816 user units at SVG's 96 dpi. Other units are rejected below.
|
||||
let dimension = |key| {
|
||||
let value = root.attribute(key)?;
|
||||
let (number, scale) = value.strip_suffix("pt").map_or((value, 1.0), |number| (number, 4.0 / 3.0));
|
||||
number.parse::<f64>().ok().map(|v| v * scale).filter(|v| v.is_finite() && *v > 0.0)
|
||||
};
|
||||
let (iw, ih) = dimension("width").zip(dimension("height")).ok_or("Converted SVG has no finite dimensions")?;
|
||||
let inner = text.get(root.range()).ok_or("Invalid SVG root range")?;
|
||||
let result =
|
||||
@@ -161,6 +204,7 @@ mod tests {
|
||||
let commands = conversion_commands(valid, valid).unwrap();
|
||||
assert_eq!(commands[0].2["path"], "/tmp/Artwork.svg");
|
||||
assert_eq!(commands[1].2["format"], "svg");
|
||||
assert_eq!(commands[1].2["preserveEditing"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -170,12 +214,39 @@ mod tests {
|
||||
let parsed = photocraft_io::svg::import_svg_bounded("Artwork.svg", &normalized, 24_000_000).unwrap();
|
||||
assert_eq!(parsed.document.size, photocraft_geom::Size::new(16, 12));
|
||||
assert_eq!(source_svg(svg, None).unwrap(), svg);
|
||||
let fractional = source_svg(svg, Some((16.3, 12.2))).unwrap();
|
||||
let parsed = photocraft_io::svg::import_svg_bounded("Artwork.svg", &fractional, 24_000_000).unwrap();
|
||||
assert_eq!(parsed.document.size, photocraft_geom::Size::new(16, 12));
|
||||
for size in [(f64::NAN, 1.0), (1.0, f64::INFINITY), (0.0, 1.0), (16384.0, 16384.0)] {
|
||||
assert!(source_svg(svg, Some(size)).is_err());
|
||||
}
|
||||
assert!(source_svg(b"%PDF-1.6", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_sized_converted_vectors_fill_the_frame_without_cropping() {
|
||||
// 12pt × 9pt is 16 × 12 CSS pixels: red on the left half, blue on the right half,
|
||||
// with and without a root viewBox. Misreading pt as px would scale by 4/3 and crop.
|
||||
for svg in [
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="12pt" height="9pt" viewBox="0 0 12 9"><rect width="6" height="9" fill="red"/><rect x="6" width="6" height="9" fill="blue"/></svg>"#,
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="12pt" height="9pt"><rect width="8" height="12" fill="red"/><rect x="8" width="8" height="12" fill="blue"/></svg>"#,
|
||||
] {
|
||||
let normalized = source_svg(svg.as_bytes(), Some((16.0, 12.0))).unwrap();
|
||||
let raster = photocraft_io::svg::rasterize_bounded(&normalized, 1.0, 24_000_000).unwrap();
|
||||
assert_eq!((raster.rect.width(), raster.rect.height()), (16, 12));
|
||||
let (x0, y0) = (raster.rect.x0, raster.rect.y0);
|
||||
let is = |x: i32, y: i32, channel: usize| {
|
||||
let px = raster.get(x0 + x, y0 + y);
|
||||
px[3] > 0.99 && px[channel] > 0.99 && px[2 - channel] < 0.01
|
||||
};
|
||||
for y in [0, 6, 11] {
|
||||
assert!(is(0, y, 0) && is(6, y, 0), "left half must be red: {svg}");
|
||||
assert!(is(9, y, 2) && is(15, y, 2), "right half must be blue and uncropped: {svg}");
|
||||
}
|
||||
}
|
||||
assert!(source_svg(br#"<svg xmlns="http://www.w3.org/2000/svg" width="12mm" height="9mm"/>"#, Some((16.0, 12.0))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversion_requires_successful_open_and_export_replies() {
|
||||
let good = r#"{"id":1,"result":{"isError":false}}
|
||||
@@ -207,7 +278,11 @@ mod live_vectorcraft_tests {
|
||||
let (_send, stop) = mpsc::channel();
|
||||
let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 8 6\n0 0 1 setrgbcolor 0 0 8 6 rectfill\nshowpage\n%%EOF\n";
|
||||
let source = ExternalVectorSource { bytes: Arc::new(eps.to_vec()), extension: "eps".into(), size: Some((16.0, 12.0)) };
|
||||
let (owned, svg) = prepare(editor, &source, &stop).unwrap();
|
||||
let (owned, initial) = prepare(editor, &source, &stop).unwrap();
|
||||
assert_eq!(initial, *source.bytes);
|
||||
assert_eq!(owned.file.extension().unwrap(), "eps");
|
||||
assert_eq!(std::fs::read(&owned.file).unwrap(), *source.bytes);
|
||||
let svg = render_preview(editor, &source, &stop).unwrap();
|
||||
assert!(owned.file.exists());
|
||||
let raster = photocraft_io::svg::rasterize_bounded(&svg, 1.0, 24_000_000).unwrap();
|
||||
assert_eq!((raster.rect.width(), raster.rect.height()), (16, 12));
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
//! `photocraft --help` and unknown options must not open the window (#6431): before the fix,
|
||||
//! any argument the loop didn't know was taken as a file, so `--help` or a typo such as
|
||||
//! `--safegpu` launched the GUI and logged "Couldn't open --help".
|
||||
//!
|
||||
//! These run the real binary, and every case must exit before the window opens: Windows and
|
||||
//! macOS runners have a desktop, so a launch that gets that far hangs until the test times out.
|
||||
//! Cases whose arguments are valid add a bad `--control` value, which fails the launch with the
|
||||
//! other usage errors, so stderr shows whether those arguments were taken as unknown options.
|
||||
|
||||
/// Run `photocraft` with `args` in an isolated config dir; return the exit
|
||||
/// status, stdout and stderr.
|
||||
fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String, String) {
|
||||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-usage-{}-{n}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"))
|
||||
.args(args)
|
||||
.env("PHOTOCRAFT_CONFIG_DIR", &dir)
|
||||
.env_remove("PHOTOCRAFT_CONTROL_PORT")
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
.output()
|
||||
.expect("failed to run photocraft binary");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
(output.status, String::from_utf8_lossy(&output.stdout).into_owned(), String::from_utf8_lossy(&output.stderr).into_owned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_prints_usage_and_exits_0() {
|
||||
for flag in ["--help", "-h"] {
|
||||
let (status, stdout, stderr) = run_photocraft(&[flag]);
|
||||
assert_eq!(status.code(), Some(0), "{flag} must exit 0, stderr: {stderr}");
|
||||
assert!(stdout.starts_with("Usage: photocraft"), "{flag} stdout: {stdout}");
|
||||
for option in ["--safe-gpu", "--new-instance", "--control <port>", "--version", "--"] {
|
||||
assert!(stdout.contains(option), "{flag} usage must list {option}: {stdout}");
|
||||
}
|
||||
assert!(!stderr.contains("Couldn't open"), "{flag} was taken as a file: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_wins_over_later_arguments() {
|
||||
let (status, stdout, _) = run_photocraft(&["--help", "picture.psd", "--safegpu"]);
|
||||
assert_eq!(status.code(), Some(0));
|
||||
assert!(stdout.starts_with("Usage: photocraft"), "stdout: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_options_exit_2_naming_them() {
|
||||
for flag in ["--safegpu", "-x", "--help=yes"] {
|
||||
let (status, stdout, stderr) = run_photocraft(&[flag]);
|
||||
assert_eq!(status.code(), Some(2), "{flag} must be a usage error, stderr: {stderr}");
|
||||
assert!(stderr.contains(&format!("unknown option `{flag}`")), "stderr: {stderr}");
|
||||
assert!(stderr.contains("photocraft --help"), "stderr should point at --help: {stderr}");
|
||||
assert!(stdout.is_empty(), "stdout: {stdout}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_option_errors_are_reported_with_control_port_errors() {
|
||||
let (status, _, stderr) = run_photocraft(&["--control", "nope", "--safegpu"]);
|
||||
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
|
||||
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
|
||||
assert!(stderr.contains("unknown option `--safegpu`"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arguments_after_double_dash_are_files() {
|
||||
// `--` ends the options: `--help` and `-x` after it are file names, so neither prints usage
|
||||
// nor is an unknown option; only the bad port is reported.
|
||||
let (status, stdout, stderr) = run_photocraft(&["--control", "nope", "--", "--help", "-x"]);
|
||||
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
|
||||
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
|
||||
assert!(!stdout.contains("Usage:"), "stdout: {stdout}");
|
||||
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_process_serial_number_is_still_ignored() {
|
||||
// Old macOS passes `-psn_<n>` when launching from Finder; it is neither a file nor an error.
|
||||
let (status, _, stderr) = run_photocraft(&["--control", "nope", "-psn_0_12345"]);
|
||||
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
|
||||
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
|
||||
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_instance_is_a_known_option() {
|
||||
let (status, _, stderr) = run_photocraft(&["--control", "nope", "--new-instance"]);
|
||||
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
|
||||
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
|
||||
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
These files are **schema probes**, not recorded machine saves.
|
||||
|
||||
`ui-0.14.ron`, `ui-0.15.ron` and `ui-0.37.ron` are minimal payloads reconstructed from the
|
||||
documented eframe/egui layouts. They exercise `apps/photocraft/src/ui_state.rs` (the flat
|
||||
window entry versus the nested `normal_geometry` entry) only. They do not prove that eframe
|
||||
and egui deserialize a real `Memory` or `PanelState`; a captured 0.14/0.15/0.37 `ui.ron`
|
||||
would be needed for that, with the machine-specific data reviewed first.
|
||||
@@ -0,0 +1 @@
|
||||
{"window":"(inner_position_pixels:Some((x:616.0,y:430.0)),outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:1100.0,y:760.0)))","egui":"()"}
|
||||
@@ -0,0 +1 @@
|
||||
{"window":"(inner_position_pixels:None,outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:true,inner_size_points:Some((x:1440.0,y:900.0)))","egui":"()"}
|
||||
@@ -0,0 +1 @@
|
||||
{"window":"(normal_geometry:(outer_position_pixels:None,scale_factor:2.0,inner_size_points:(x:1440.0,y:900.0)),fullscreen:false,maximized:false)","egui":"()"}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! `photocraft --help` and unknown options must not open the window (#6431): before the fix,
|
||||
//! any argument the loop didn't know was taken as a file, so `--help` or a typo such as
|
||||
//! `--safegpu` launched the GUI and logged "Couldn't open --help".
|
||||
//!
|
||||
//! These run the real binary, and every case must exit before the window opens: Windows and
|
||||
//! macOS runners have a desktop, so a launch that gets that far hangs until the test times out.
|
||||
//! Cases whose arguments are valid add a 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.
|
||||
|
||||
/// 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 mut cmd = std::process::Command::new(crate::binary());
|
||||
for var in VARS {
|
||||
cmd.env_remove(var);
|
||||
}
|
||||
let output = cmd
|
||||
.args(args)
|
||||
.envs(vars.iter().copied())
|
||||
.env("PHOTOCRAFT_CONFIG_DIR", &dir)
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
.output()
|
||||
.expect("failed to run photocraft binary");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
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 Run { status, stdout, stderr } = run_photocraft(&[flag]);
|
||||
assert_eq!(status.code(), Some(0), "{flag} must exit 0, stderr: {stderr}");
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_wins_over_later_arguments() {
|
||||
let Run { status, stdout, .. } = run_photocraft(&["--help", "picture.psd", "--safegpu"]);
|
||||
assert_eq!(status.code(), Some(0));
|
||||
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 (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 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 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 stderr = late_error(&["-psn_0_12345"], &[]);
|
||||
assert!(!stderr.contains("unexpected"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_instance_is_a_known_option() {
|
||||
let stderr = late_error(&["--new-instance"], &[]);
|
||||
assert!(!stderr.contains("unexpected"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
/// The variables stand in for their flags: a flag wins, an empty variable counts as unset, a bad
|
||||
/// value is a usage error naming the variable, and a flag without its value is still an error
|
||||
/// when its variable is set (#6429).
|
||||
#[test]
|
||||
fn variables_are_the_flags_fallbacks() {
|
||||
let Run { status, stderr, .. } = run_with(&[], &[("PHOTOCRAFT_CONTROL_PORT", "nope")]);
|
||||
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
|
||||
assert!(stderr.contains("invalid value 'nope' for 'PHOTOCRAFT_CONTROL_PORT'"), "stderr: {stderr}");
|
||||
// With the flag (in LATE_ERROR), the variable is never read.
|
||||
let stderr = late_error(&[], &[("PHOTOCRAFT_CONTROL_PORT", "nope")]);
|
||||
assert!(!stderr.contains("PHOTOCRAFT_CONTROL_PORT"), "stderr: {stderr}");
|
||||
// The variable turns the control server on as the flag does, so the token pair is checked.
|
||||
let Run { status, stderr, .. } = run_with(&LATE_ERROR[2..], &[("PHOTOCRAFT_CONTROL_PORT", "0")]);
|
||||
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
|
||||
assert!(stderr.contains("cannot be used with"), "stderr: {stderr}");
|
||||
let Run { status, stderr, .. } = run_with(&["--automation-read-root"], &[("PHOTOCRAFT_AUTOMATION_READ_ROOT", "/env/read")]);
|
||||
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
|
||||
assert!(stderr.contains("a value is required for '--automation-read-root <DIR>'"), "stderr: {stderr}");
|
||||
// The other half of the token pair from a variable is the same either/or error, naming it
|
||||
// but never showing the token.
|
||||
let token = "a".repeat(64);
|
||||
let Run { status, stderr, .. } = run_with(&["--control", "0", "--control-token-file", "token"], &[("PHOTOCRAFT_CONTROL_TOKEN", token.as_str())]);
|
||||
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
|
||||
assert!(stderr.contains("PHOTOCRAFT_CONTROL_TOKEN") && stderr.contains("cannot be used with") && !stderr.contains(&token), "stderr: {stderr}");
|
||||
}
|
||||
@@ -9,15 +9,26 @@
|
||||
/// workspace check instead of failing on token validation first.
|
||||
const VALID_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
/// How a run of `photocraft` ended.
|
||||
struct Run {
|
||||
status: std::process::ExitStatus,
|
||||
stderr: String,
|
||||
}
|
||||
|
||||
/// Run `photocraft` with the given arguments in an isolated config dir and
|
||||
/// return the exit status and stderr. Each call gets its own config dir (tests run in
|
||||
/// return how it ended. Each call gets its own config dir (tests run in
|
||||
/// parallel), and no display variables, so a regression fails fast instead of opening a window.
|
||||
fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String) {
|
||||
fn run_photocraft(args: &[&str]) -> Run {
|
||||
run_with(args, &[])
|
||||
}
|
||||
|
||||
/// [`run_photocraft`], with `vars` set.
|
||||
fn run_with(args: &[&str], vars: &[(&str, &str)]) -> Run {
|
||||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-control-exit-{}-{n}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"))
|
||||
let output = std::process::Command::new(crate::binary())
|
||||
.args(args)
|
||||
.env("PHOTOCRAFT_CONFIG_DIR", &dir)
|
||||
// Isolate from the host environment so the test is reproducible.
|
||||
@@ -29,37 +40,40 @@ fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String) {
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
.envs(vars.iter().copied())
|
||||
.output()
|
||||
.expect("failed to run photocraft binary");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
(output.status, String::from_utf8_lossy(&output.stderr).into_owned())
|
||||
Run { status: output.status, stderr: String::from_utf8_lossy(&output.stderr).into_owned() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_control_token_exits_with_status_2() {
|
||||
// `validate_token` rejects a token that is not exactly 64 hex characters.
|
||||
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", "invalid"]);
|
||||
let Run { status, stderr } = run_photocraft(&["--control", "0", "--control-token", "invalid"]);
|
||||
assert_eq!(status.code(), Some(2), "invalid control token must exit 2, stderr: {stderr}");
|
||||
assert!(stderr.contains("cannot configure control authentication"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_token_and_token_file_exits_with_status_2() {
|
||||
// `server_token` rejects the combination of a supplied token and a token file.
|
||||
// The command line rejects a supplied token together with a token file (#7071), before
|
||||
// `server_token` would.
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-token-file-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let token_file = dir.join("token.txt");
|
||||
std::fs::write(&token_file, VALID_TOKEN).unwrap();
|
||||
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--control-token-file", token_file.to_str().unwrap()]);
|
||||
let Run { status, stderr } = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--control-token-file", token_file.to_str().unwrap()]);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert_eq!(status.code(), Some(2), "token + token file must exit 2, stderr: {stderr}");
|
||||
assert!(stderr.contains("cannot configure control authentication"), "stderr: {stderr}");
|
||||
assert!(stderr.contains("cannot be used with"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_automation_read_root_exits_with_status_2() {
|
||||
// `AuthorizedWorkspace::new` fails when the read root does not exist.
|
||||
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-read-root", "/nonexistent/photocraft-test-path"]);
|
||||
let Run { status, stderr } =
|
||||
run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-read-root", "/nonexistent/photocraft-test-path"]);
|
||||
assert_eq!(status.code(), Some(2), "missing read root must exit 2, stderr: {stderr}");
|
||||
assert!(stderr.contains("cannot configure automation workspace"), "stderr: {stderr}");
|
||||
}
|
||||
@@ -67,7 +81,17 @@ fn missing_automation_read_root_exits_with_status_2() {
|
||||
#[test]
|
||||
fn missing_automation_write_root_exits_with_status_2() {
|
||||
// `AuthorizedWorkspace::new` fails when the write root does not exist.
|
||||
let (status, stderr) = run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-write-root", "/nonexistent/photocraft-test-path"]);
|
||||
let Run { status, stderr } =
|
||||
run_photocraft(&["--control", "0", "--control-token", VALID_TOKEN, "--automation-write-root", "/nonexistent/photocraft-test-path"]);
|
||||
assert_eq!(status.code(), Some(2), "missing write root must exit 2, stderr: {stderr}");
|
||||
assert!(stderr.contains("cannot configure automation workspace"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_roots_come_from_the_environment_too() {
|
||||
for var in ["PHOTOCRAFT_AUTOMATION_READ_ROOT", "PHOTOCRAFT_AUTOMATION_WRITE_ROOT"] {
|
||||
let Run { status, stderr } = run_with(&["--control", "0", "--control-token", VALID_TOKEN], &[(var, "/nonexistent/photocraft-test-path")]);
|
||||
assert_eq!(status.code(), Some(2), "{var}: missing root must exit 2, stderr: {stderr}");
|
||||
assert!(stderr.contains("cannot configure automation workspace"), "{var}: stderr: {stderr}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! The crate's integration tests, linked into one test binary.
|
||||
|
||||
mod command_line_usage;
|
||||
mod control_exit_code;
|
||||
mod startup_without_display;
|
||||
|
||||
/// Nextest archives relocate binaries; plain Cargo keeps the compile-time path valid.
|
||||
fn binary() -> std::ffi::OsString {
|
||||
std::env::var_os("NEXTEST_BIN_EXE_photocraft").unwrap_or_else(|| env!("CARGO_BIN_EXE_photocraft").into())
|
||||
}
|
||||
@@ -30,7 +30,7 @@ fn missing_display_preserves_existing_gpu_crash_evidence() {
|
||||
}
|
||||
|
||||
fn launch_without_display(dir: &std::path::Path) -> String {
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"))
|
||||
let output = std::process::Command::new(crate::binary())
|
||||
.env("PHOTOCRAFT_CONFIG_DIR", dir)
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M3 20h18" />
|
||||
<rect x="5" y="4" width="5" height="12" rx="1" />
|
||||
<rect x="14" y="9" width="5" height="7" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M12 3v18" />
|
||||
<rect x="5" y="5" width="14" height="5" rx="1" />
|
||||
<rect x="8" y="14" width="8" height="5" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M4 3v18" />
|
||||
<rect x="8" y="5" width="12" height="5" rx="1" />
|
||||
<rect x="8" y="14" width="7" height="5" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 415 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M20 3v18" />
|
||||
<rect x="4" y="5" width="12" height="5" rx="1" />
|
||||
<rect x="9" y="14" width="7" height="5" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M3 4h18" />
|
||||
<rect x="5" y="8" width="5" height="12" rx="1" />
|
||||
<rect x="14" y="8" width="5" height="7" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 415 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M3 12h18" />
|
||||
<rect x="5" y="5" width="5" height="14" rx="1" />
|
||||
<rect x="14" y="8" width="5" height="8" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 B |
@@ -0,0 +1,19 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M4 7h16" />
|
||||
<rect x="7" y="2" width="10" height="3" rx="1" />
|
||||
<path d="M4 14h16" />
|
||||
<rect x="7" y="9" width="10" height="3" rx="1" />
|
||||
<path d="M4 21h16" />
|
||||
<rect x="7" y="16" width="10" height="3" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 516 B |
@@ -0,0 +1,22 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<rect x="2" y="6" width="4" height="12" rx="1" />
|
||||
<path d="M4 2v2" />
|
||||
<path d="M4 20v2" />
|
||||
<rect x="10" y="6" width="4" height="12" rx="1" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<rect x="18" y="6" width="4" height="12" rx="1" />
|
||||
<path d="M20 2v2" />
|
||||
<path d="M20 20v2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 585 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M3 4v16" />
|
||||
<path d="M21 4v16" />
|
||||
<rect x="9" y="7" width="6" height="10" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 387 B |
@@ -0,0 +1,19 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M3 4v16" />
|
||||
<rect x="5" y="7" width="3" height="10" rx="1" />
|
||||
<path d="M10 4v16" />
|
||||
<rect x="12" y="7" width="3" height="10" rx="1" />
|
||||
<path d="M17 4v16" />
|
||||
<rect x="19" y="7" width="3" height="10" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 517 B |
@@ -0,0 +1,19 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M7 4v16" />
|
||||
<rect x="2" y="7" width="3" height="10" rx="1" />
|
||||
<path d="M14 4v16" />
|
||||
<rect x="9" y="7" width="3" height="10" rx="1" />
|
||||
<path d="M21 4v16" />
|
||||
<rect x="16" y="7" width="3" height="10" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 516 B |
@@ -0,0 +1,19 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M4 3h16" />
|
||||
<rect x="7" y="5" width="10" height="3" rx="1" />
|
||||
<path d="M4 10h16" />
|
||||
<rect x="7" y="12" width="10" height="3" rx="1" />
|
||||
<path d="M4 17h16" />
|
||||
<rect x="7" y="19" width="10" height="3" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 517 B |
@@ -0,0 +1,22 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<rect x="6" y="2" width="12" height="4" rx="1" />
|
||||
<path d="M2 4h2" />
|
||||
<path d="M20 4h2" />
|
||||
<rect x="6" y="10" width="12" height="4" rx="1" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<rect x="6" y="18" width="12" height="4" rx="1" />
|
||||
<path d="M2 20h2" />
|
||||
<path d="M20 20h2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 585 B |
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Move tool options-bar glyph (not from an icon set). -->
|
||||
<path d="M4 3h16" />
|
||||
<path d="M4 21h16" />
|
||||
<rect x="7" y="9" width="10" height="6" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 387 B |
@@ -0,0 +1,17 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Create Warped Text glyph (not from an icon set): a T above a bowed arc. -->
|
||||
<path d="M6 5V3h12v2" />
|
||||
<path d="M12 3v11" />
|
||||
<path d="M9.5 14h5" />
|
||||
<path d="M3 17c5 5 13 5 18 0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 419 B |
@@ -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,16 @@ Bridge to a running desktop application with the same private token file used by
|
||||
photocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file /private/path/control.token
|
||||
```
|
||||
|
||||
Use absolute root paths in MCP client configurations, since a relative root is resolved from the
|
||||
client's launch directory. In bridge mode the roots are configured on the desktop app, and root
|
||||
flags on `photocraft-cli mcp --bridge` are rejected. Explicit `--control-token` and
|
||||
`--control-token-file` flags require `--bridge` (or `PHOTOCRAFT_CONTROL_PORT`) and are mutually
|
||||
exclusive; bridge credentials can also come from `PHOTOCRAFT_CONTROL_TOKEN` or
|
||||
`PHOTOCRAFT_CONTROL_TOKEN_FILE`. Each launch option
|
||||
except `--help`/`-h` takes one non-empty value and cannot be repeated. Stray positional arguments,
|
||||
missing values and options for the wrong mode are usage errors (exit 2) reported on stderr before
|
||||
the server starts.
|
||||
|
||||
Tools cover session/document operations, command discovery and execution, batching, and—when bridged—UI inspection and control. Tool schemas improve correctness but are not authorization boundaries.
|
||||
|
||||
## From an installed release
|
||||
|
||||
@@ -6,6 +6,7 @@ PhotoCraft's release workflow builds platform packages and a draft GitHub Releas
|
||||
|
||||
- platform-specific signing/notarization steps exist for macOS and Windows;
|
||||
- signing secrets are scoped through the GitHub `release` environment;
|
||||
- the Azure Trusted Signing client that Windows signing loads is pinned to a version and SHA-256, and refused on a mismatch;
|
||||
- release artifacts include `SHA256SUMS.txt`;
|
||||
- the final release job receives `contents: write`, while build jobs use narrower access;
|
||||
- artifacts remain in a draft release until a maintainer publishes them.
|
||||
|
||||
@@ -8,8 +8,11 @@ tiles/<blake3>.zst
|
||||
blobs/<blake3>.zst
|
||||
thumb.png
|
||||
composite/preview.png
|
||||
save.lock
|
||||
```
|
||||
|
||||
`save.lock` exists only in directory bundles: a save holds an OS file lock on it while it writes objects, publishes the manifest and removes unreferenced objects, so concurrent saves of one bundle take turns. It is empty, and readers ignore it.
|
||||
|
||||
The manifest is versioned and describes the document tree. Tiles and blobs are content-addressed. Loaders verify ZIP entry bounds and CRCs; document content also uses stored hashes to detect mismatched or corrupted tile/blob content. Unsupported newer format versions fail explicitly.
|
||||
|
||||
## Implemented load limits
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## Layered TIFF
|
||||
|
||||
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: a TIFF holds one transparency sample, so extra alpha channels are not written; Lab documents are saved flat. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`.
|
||||
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: up to sixteen extra samples (spot colours and alpha channels, plus a Quick Mask) are written in the IFD and round-trip, with Photoshop image resources for the channel name, ink colour and solidity; Lab documents are saved flat, with their extra samples. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`.
|
||||
|
||||
## WebP
|
||||
|
||||
@@ -19,6 +19,7 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_
|
||||
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
|
||||
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened.
|
||||
- TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit.
|
||||
- JPEG, as Photoshop's JPEG Options: File › Save, Save As and Save a Copy ask for the Quality on Photoshop's 0–12 scale (with its Low, Medium, High and Maximum presets) and a Format Option: Baseline ("Standard"), Baseline Optimized (Huffman tables fitted to the image) or Progressive. The quality starts at the opened JPEG's own quality, else at the Export preference's JPEG quality; left there, an opened JPEG is written at exactly its own quality. Scripts pass `"quality": 0..12` and `"jpegFormat": "baseline" | "optimized" | "progressive"` (on the CLI, `--quality` on the encoder's 1–100 scale and `--jpeg-format`) and aren't asked; recorded actions replay the answer. The scale maps onto the encoder's 1–100 as `round(q / 12 × 99 + 1)`. RGB JPEGs are written 4:2:0, where Baseline Optimized keeps the standard tables for now (Gray and CMYK, written 4:4:4, get fitted ones). Matte, the progressive scan count (Scans 3–5) and the file-size preview aren't offered.
|
||||
- DDS, for game and 3D textures: Save As and Save a Copy ask (DDS Options) for the compression (None, uncompressed and lossless; BC1/DXT1, whose alpha is on or off; BC2/DXT3; BC3/DXT5) and whether to generate mipmaps (the full chain down to 1×1, each level a box filter of the one above). Scripts pass `ddsCompression` (`none`, `bc1`, `bc2`, `bc3`) and `ddsMipmaps` (`--dds-compression`, `--dds-mipmaps` on the CLI). Opening one reads its top mip level.
|
||||
|
||||
Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.brianblakely]
|
||||
sync_github_name = true
|
||||
|
||||
[people.abdul-karim-mia]
|
||||
real_name = "Abdul karim mia"
|
||||
sync_github_name = false
|
||||
|
||||
[people.jonathanwxh-cell]
|
||||
display_name = "jonathnwxh"
|
||||
sync_github_name = false
|
||||
@@ -72,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
|
||||
|
||||
@@ -116,6 +126,9 @@ sync_github_name = false
|
||||
[people.axlecoffee]
|
||||
sync_github_name = true
|
||||
|
||||
[people.zi-ger]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -166,6 +179,9 @@ sync_github_name = true
|
||||
real_name = "Sidney Markowitz"
|
||||
sync_github_name = true
|
||||
|
||||
[people.theoabw]
|
||||
real_name = "Theo Wilenius"
|
||||
|
||||
[people.TaylorBurke]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -194,3 +210,78 @@ real_name = "Don Omondi"
|
||||
|
||||
[people.ha1des]
|
||||
sync_github_name = true
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
|
||||
[people.AndreeSalazar]
|
||||
real_name = "Andreé Salazar"
|
||||
sync_github_name = true
|
||||
|
||||
[people.LorenzoFasolino]
|
||||
real_name = "Lorenzo Fasolino"
|
||||
display_name = "Lorenzo Fasolino"
|
||||
sync_github_name = false
|
||||
|
||||
[people.zolyy]
|
||||
real_name = "Gall Zoltan"
|
||||
sync_github_name = false
|
||||
|
||||
[people.theshubh77]
|
||||
real_name = "Shubham Bhamare"
|
||||
sync_github_name = true
|
||||
|
||||
[people.ksaxena149]
|
||||
sync_github_name = true
|
||||
|
||||
[people.giang17]
|
||||
real_name = "Giang Nguyen"
|
||||
sync_github_name = true
|
||||
|
||||
[people.aqibmohd271]
|
||||
sync_github_name = true
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Gotix-eg]
|
||||
real_name = "Ahmed Tammam"
|
||||
display_name = "Gotix"
|
||||
sync_github_name = true
|
||||
|
||||
[people.adi-1108]
|
||||
real_name = "Aditya Vishwakarma"
|
||||
sync_github_name = true
|
||||
|
||||
[people.benandez]
|
||||
real_name = "Martin Ball"
|
||||
|
||||
[people.ronibalthazar]
|
||||
sync_github_name = true
|
||||
|
||||
[people.batuhangozegu]
|
||||
real_name = "Batuhan Gözegü"
|
||||
sync_github_name = false
|
||||
|
||||
[people.drunkeninja42]
|
||||
real_name = "Kushagra Sharma"
|
||||
|
||||
[people.WebWork-rrs]
|
||||
display_name = "Ratul Paul"
|
||||
|
||||
[people.softwaregames1]
|
||||
sync_github_name = true
|
||||
|
||||
[people.VoiceLessQ]
|
||||
display_name = "VoiceLessQ"
|
||||
sync_github_name = false
|
||||
|
||||
[people.erenova]
|
||||
display_name = "Erenova"
|
||||
sync_github_name = false
|
||||
|
||||
[people.jasallen]
|
||||
real_name = "Jason DeBoever"
|
||||
sync_github_name = true
|
||||
|
||||
@@ -108,11 +108,11 @@ The same reader is shared, as an independent copy, with VectorCraft's `vectorcra
|
||||
|
||||
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
|
||||
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
|
||||
mutations, here and in `photocraft-io`'s `tests/affinity.rs`.
|
||||
mutations, here and in `photocraft-io`'s `tests/it/affinity.rs`.
|
||||
* `cargo xtask corpus --affinity` fetches 21 public CC0/MIT documents, including the four
|
||||
Affinity 3 `.af` files of [samuel-etver/vector-art](https://github.com/samuel-etver/vector-art)
|
||||
(CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/real_files.rs`
|
||||
parses them; `photocraft-io`'s `tests/affinity_corpus.rs` imports and flattens each one and
|
||||
(CC0), at pinned commits, each checked against `xtask/affinity-corpus.sha256`. `tests/it/real_files.rs`
|
||||
parses them; `photocraft-io`'s `tests/it/affinity_corpus.rs` imports and flattens each one and
|
||||
compares it with the thumbnail Affinity saved in it (mean difference 0–4.6 of 255, with a
|
||||
ceiling per file).
|
||||
* `fuzz/` has `cargo-fuzz` targets for the whole reader (`preview`), the object stream (`stream`)
|
||||
|
||||
@@ -15,6 +15,11 @@ const DOCUMENT_KIND: &[u8; 4] = b"nsrP";
|
||||
|
||||
/// A save appends one table; real files have one to a few hundred.
|
||||
const MAX_TABLES: usize = 4096;
|
||||
/// Distinct ids the allocation tables may name; each costs a map slot even when only deleted.
|
||||
const MAX_ENTRIES: usize = 1 << 22;
|
||||
/// Bytes of entry names the allocation tables may name. A name is retained twice (in the entry
|
||||
/// and as its map key), and tables can point many names at the same bytes.
|
||||
const MAX_NAME_BYTES: usize = 32 << 20;
|
||||
/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes.
|
||||
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
|
||||
|
||||
@@ -164,8 +169,9 @@ impl<'a> Archive<'a> {
|
||||
pub fn open(bytes: &'a [u8], limits: Limits) -> Result<Self, Error> {
|
||||
let header = header(bytes)?;
|
||||
// Newest record per id: (table date, table index, record).
|
||||
let mut latest: HashMap<u32, (u64, usize, Option<Entry>)> = HashMap::new();
|
||||
let mut latest: HashMap<u32, (u64, usize, Option<Box<Entry>>)> = HashMap::new();
|
||||
let mut names: HashMap<u32, String> = HashMap::new();
|
||||
let mut name_bytes = 0usize;
|
||||
let mut seen = HashSet::new();
|
||||
let mut next = header.fat_offset;
|
||||
let mut tables = 0usize;
|
||||
@@ -222,19 +228,31 @@ impl<'a> Archive<'a> {
|
||||
} else {
|
||||
stored
|
||||
};
|
||||
record = Some(Entry { id, name: String::new(), offset, size, compressed, crc, compression });
|
||||
record = Some(Box::new(Entry { id, name: String::new(), offset, size, compressed, crc, compression }));
|
||||
}
|
||||
2 => {}
|
||||
_ => return Err(Error::Malformed("unknown allocation record")),
|
||||
}
|
||||
if flag == 0 {
|
||||
let len = usize::from(c.u16()?);
|
||||
let name = String::from_utf8_lossy(c.take(len)?).into_owned();
|
||||
names.entry(id).or_insert(name);
|
||||
let raw = c.take(len)?;
|
||||
// Only the first name per id is kept: charge and copy just that one.
|
||||
if let std::collections::hash_map::Entry::Vacant(slot) = names.entry(id) {
|
||||
let name = String::from_utf8_lossy(raw).into_owned();
|
||||
name_bytes = name_bytes.saturating_add(name.len());
|
||||
if name_bytes > MAX_NAME_BYTES {
|
||||
return Err(Error::Limit("archive entry names too large"));
|
||||
}
|
||||
slot.insert(name);
|
||||
}
|
||||
}
|
||||
// Equal dates: the first table in the chain (the newest) wins, as in afread.
|
||||
let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d);
|
||||
if replace {
|
||||
// Deletion records are five bytes each: cap what distinct ids may retain.
|
||||
if latest.len() >= MAX_ENTRIES && !latest.contains_key(&id) {
|
||||
return Err(Error::Limit("too many archive entries"));
|
||||
}
|
||||
latest.insert(id, (date, tables, record));
|
||||
}
|
||||
}
|
||||
@@ -248,7 +266,7 @@ impl<'a> Archive<'a> {
|
||||
for (id, (_, _, record)) in latest {
|
||||
if let (Some(mut e), Some(name)) = (record, names.get(&id)) {
|
||||
e.name = name.clone();
|
||||
entries.insert(e.name.clone(), e);
|
||||
entries.insert(e.name.clone(), *e);
|
||||
}
|
||||
}
|
||||
Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits })
|
||||
@@ -278,6 +296,9 @@ impl<'a> Archive<'a> {
|
||||
if total > self.limits.max_total {
|
||||
return Err(Error::Limit("document too large"));
|
||||
}
|
||||
// Charge before decoding: a corrupt entry that fails its checksum has already cost the
|
||||
// decompression, and an importer that recovers must not get it again for free.
|
||||
self.extracted = total;
|
||||
let mut c = Cursor::new(self.bytes, offset(e.offset)?);
|
||||
if c.take(4)? != b"#Fil" {
|
||||
return Err(Error::Malformed("archive entry has no data block"));
|
||||
@@ -297,7 +318,6 @@ impl<'a> Archive<'a> {
|
||||
if crc32fast::hash(&out) != e.crc {
|
||||
return Err(Error::Malformed("archive entry checksum mismatch"));
|
||||
}
|
||||
self.extracted = total;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,21 @@ const MAX_NODES: usize = 4_000_000;
|
||||
const MAX_COMPOUND_DEPTH: usize = 64;
|
||||
const MAX_OUTLINE_VISITS: usize = 500_000;
|
||||
const MAX_OUTLINE_POINTS: usize = 4_000_000;
|
||||
/// Curve records decoded across the document. A shared curve is decoded again for each
|
||||
/// occurrence, and records that only move handles add no points.
|
||||
const MAX_OUTLINE_RECORDS: usize = 8_000_000;
|
||||
|
||||
/// Geometry is expanded per occurrence, since parametric outlines can depend on their world
|
||||
/// transform. Bound work across the entire document instead of memoizing by object id alone.
|
||||
pub(crate) struct Budget {
|
||||
visits_left: usize,
|
||||
points_left: usize,
|
||||
records_left: usize,
|
||||
}
|
||||
|
||||
impl Default for Budget {
|
||||
fn default() -> Self {
|
||||
Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS }
|
||||
Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS, records_left: MAX_OUTLINE_RECORDS }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +34,12 @@ impl Budget {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Charge curve records before they are decoded.
|
||||
fn records(&mut self, n: usize) -> Result<(), &'static str> {
|
||||
self.records_left = self.records_left.checked_sub(n).ok_or("curve geometry budget exhausted")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Charge both leaf geometry and each copy into a compound before allocating that copy.
|
||||
fn path(&mut self, path: &Path) -> Result<(), &'static str> {
|
||||
let points = path.subpaths.iter().fold(0usize, |n, s| n.saturating_add(1).saturating_add(s.segments.len().saturating_mul(3)));
|
||||
@@ -138,7 +148,21 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
|
||||
Value::UInt(n) => usize::try_from(*n).ok()?,
|
||||
_ => return None,
|
||||
};
|
||||
let corners = s.obj(crvs, b"CnrD").and_then(|c| corner_radii(s.positional(c).collect()));
|
||||
let corners = match s.obj(crvs, b"CnrD") {
|
||||
Some(c) => {
|
||||
let fields: Vec<&Value> = s.positional(c).collect();
|
||||
// The corner arrays are scanned for every occurrence of a shared curve, so charge
|
||||
// them like records before they are walked.
|
||||
if let Some(Value::Array(idx)) = fields.get(1)
|
||||
&& let Err(reason) = r.geometry_budget.records(idx.len())
|
||||
{
|
||||
r.warn(reason);
|
||||
return None;
|
||||
}
|
||||
corner_radii(fields)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let mut path = Path::default();
|
||||
let mut seen = 0usize;
|
||||
for index in 0..count {
|
||||
@@ -152,6 +176,10 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
|
||||
r.warn("curves with more than four million nodes");
|
||||
return None;
|
||||
}
|
||||
if let Err(reason) = r.geometry_budget.records(records.len()) {
|
||||
r.warn(reason);
|
||||
return None;
|
||||
}
|
||||
let anchors = anchors(records)?;
|
||||
let radii = if index == 0 { corners.as_ref() } else { None };
|
||||
if let Some(sp) = subpath(&anchors, closed, radii) {
|
||||
@@ -258,3 +286,15 @@ fn corner_radii(fields: Vec<&Value>) -> Option<Vec<(usize, f64)>> {
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn curve_records_are_charged_across_the_document() {
|
||||
let mut b = Budget::default();
|
||||
assert!(b.records(MAX_OUTLINE_RECORDS).is_ok());
|
||||
assert_eq!(b.records(1), Err("curve geometry budget exhausted"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +288,12 @@ const MAX_NODES: usize = 500_000;
|
||||
/// nodes, so a shared group holding a large null-filled child array could still be rescanned
|
||||
/// many times; this bounds that work. Generous relative to MAX_NODES for real documents.
|
||||
const MAX_CHILD_SLOTS: usize = 8_000_000;
|
||||
/// Bytes of layer names the model may retain across all occurrences.
|
||||
const MAX_NAME_BYTES: usize = 64 << 20;
|
||||
/// Page rectangles the model may retain across all spreads (real books have a few hundred).
|
||||
const MAX_PAGES: usize = 1 << 18;
|
||||
/// Bytes of font names the text runs may retain across the document.
|
||||
const MAX_FONT_NAME_BYTES: usize = 16 << 20;
|
||||
|
||||
/// Read a document: archive, `doc.dat` and the model.
|
||||
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
|
||||
@@ -304,6 +310,9 @@ pub(crate) struct Reader<'s, 'a, 'b> {
|
||||
warnings: BTreeMap<String, usize>,
|
||||
nodes: usize,
|
||||
child_slots: usize,
|
||||
name_bytes: usize,
|
||||
pages: usize,
|
||||
font_name_bytes: usize,
|
||||
active: HashSet<ObjId>,
|
||||
pub(crate) geometry_budget: crate::geometry::Budget,
|
||||
/// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so
|
||||
@@ -320,6 +329,9 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
warnings: BTreeMap::new(),
|
||||
nodes: 0,
|
||||
child_slots: 0,
|
||||
name_bytes: 0,
|
||||
pages: 0,
|
||||
font_name_bytes: 0,
|
||||
active: HashSet::new(),
|
||||
geometry_budget: crate::geometry::Budget::default(),
|
||||
pixels_left: limits.max_pixel_bytes,
|
||||
@@ -361,7 +373,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
self.warn("an unknown kind of page");
|
||||
continue;
|
||||
}
|
||||
let pages = pages(s, spread);
|
||||
let pages = self.pages(spread)?;
|
||||
let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
|
||||
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
|
||||
x0: 0.0,
|
||||
@@ -394,10 +406,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
// Charge every raw child slot (null slots included) against a document-wide budget
|
||||
// before filtering. A shared group with a large null-filled Chld array could otherwise
|
||||
// be rescanned through each reference without the node limit noticing.
|
||||
self.child_slots = self.child_slots.saturating_add(s.array_len(parent, b"Chld").unwrap_or(0));
|
||||
if self.child_slots > MAX_CHILD_SLOTS {
|
||||
return Err(Error::Limit("too many child slots in the layer tree"));
|
||||
}
|
||||
self.charge_slots(s.array_len(parent, b"Chld").unwrap_or(0))?;
|
||||
for c in s.objs(parent, b"Chld") {
|
||||
if s.is(c, b"MRst") {
|
||||
// A mask layer inside a group or layer masks that container.
|
||||
@@ -419,6 +428,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
Ok((out, mask))
|
||||
}
|
||||
|
||||
/// Counts raw array slots (null ones included) against [`MAX_CHILD_SLOTS`].
|
||||
pub(crate) fn charge_slots(&mut self, n: usize) -> Result<(), Error> {
|
||||
self.child_slots = self.child_slots.saturating_add(n);
|
||||
if self.child_slots > MAX_CHILD_SLOTS {
|
||||
return Err(Error::Limit("too many child slots in the layer tree"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result<Option<Node>, Error> {
|
||||
let s = self.s;
|
||||
if depth > MAX_TREE_DEPTH {
|
||||
@@ -437,7 +455,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
|
||||
let world = local.then(parent);
|
||||
let class = s.class(id).unwrap_or(Tag(0));
|
||||
let name = s.str(id, b"Desc").unwrap_or_default().to_string();
|
||||
// A shared layer's name is copied for every occurrence, so charge the bytes first.
|
||||
let name = s.str(id, b"Desc").unwrap_or_default();
|
||||
self.name_bytes = self.name_bytes.saturating_add(name.len());
|
||||
if self.name_bytes > MAX_NAME_BYTES {
|
||||
return Err(Error::Limit("layer names too large"));
|
||||
}
|
||||
let name = name.to_string();
|
||||
let visible = s.bool(id, b"Visi").unwrap_or(true);
|
||||
let locked = s.bool(id, b"Edtb").is_some_and(|e| !e);
|
||||
let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0));
|
||||
@@ -460,6 +484,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
let (mask, attached_mask) = self.attached_masks(id, world)?;
|
||||
let pixel_mask = attached_mask.or(child_mask);
|
||||
self.active.remove(&id);
|
||||
self.charge_slots(s.array_len(id, b"FiEf").unwrap_or(0))?;
|
||||
if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) {
|
||||
self.warn("layer effects (shadows, glows, outlines, bevels…)");
|
||||
}
|
||||
@@ -536,6 +561,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
/// an attached mask layer is a pixel mask. Adjustments attached the same way are reported.
|
||||
fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option<Path>, Option<Image>), Error> {
|
||||
let s = self.s;
|
||||
// Every occurrence of a shared layer scans its whole attachment array, null slots included.
|
||||
self.charge_slots(s.array_len(id, b"AdCh").unwrap_or(0))?;
|
||||
let attached = s.objs(id, b"AdCh");
|
||||
let mut mask: Option<Path> = None;
|
||||
let mut pixels: Option<Image> = None;
|
||||
@@ -584,21 +611,51 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
|
||||
/// equal pages (Publisher 1 facing spreads).
|
||||
fn pages(s: &Stream, spread: ObjId) -> Vec<Rect> {
|
||||
if let Some(md) = s.obj(spread, b"SpMd") {
|
||||
let v: Vec<Rect> = s.objs(md, b"PagR").into_iter().filter_map(|p| s.floats::<4>(p, b"rctp").map(rect)).filter(|r| r.x1 > r.x0 && r.y1 > r.y0).collect();
|
||||
if !v.is_empty() {
|
||||
return v;
|
||||
impl Reader<'_, '_, '_> {
|
||||
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
|
||||
/// equal pages (Publisher 1 facing spreads). A spread listed several times, or sharing one
|
||||
/// large page array, is expanded into a new vector each time, so the slots scanned and the
|
||||
/// rectangles retained are charged across the document.
|
||||
fn pages(&mut self, spread: ObjId) -> Result<Vec<Rect>, Error> {
|
||||
let s = self.s;
|
||||
if let Some(md) = s.obj(spread, b"SpMd") {
|
||||
self.charge_slots(s.array_len(md, b"PagR").unwrap_or(0))?;
|
||||
let remaining = MAX_PAGES.saturating_sub(self.pages);
|
||||
let v: Vec<Rect> = s
|
||||
.objs(md, b"PagR")
|
||||
.into_iter()
|
||||
.filter_map(|p| s.floats::<4>(p, b"rctp").map(rect))
|
||||
.filter(|r| r.x1 > r.x0 && r.y1 > r.y0)
|
||||
.take(remaining.saturating_add(1))
|
||||
.collect();
|
||||
if !v.is_empty() {
|
||||
self.charge_pages(v.len())?;
|
||||
return Ok(v);
|
||||
}
|
||||
}
|
||||
Ok(match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) {
|
||||
(Some(b), Some(n @ 2..=16)) => {
|
||||
self.charge_pages(n as usize)?;
|
||||
let w = (b.x1 - b.x0) / n as f64;
|
||||
(0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
})
|
||||
}
|
||||
match (s.floats::<4>(spread, b"SprB").map(rect), s.int(spread, b"PagC")) {
|
||||
(Some(b), Some(n @ 2..=16)) => {
|
||||
let w = (b.x1 - b.x0) / n as f64;
|
||||
(0..n).map(|i| Rect { x0: b.x0 + w * i as f64, x1: b.x0 + w * (i + 1) as f64, ..b }).collect()
|
||||
|
||||
/// Counts retained font-name bytes against [`MAX_FONT_NAME_BYTES`]; false once it is spent.
|
||||
pub(crate) fn charge_font_name(&mut self, n: usize) -> bool {
|
||||
self.font_name_bytes = self.font_name_bytes.saturating_add(n);
|
||||
self.font_name_bytes <= MAX_FONT_NAME_BYTES
|
||||
}
|
||||
|
||||
/// Counts retained page rectangles against [`MAX_PAGES`].
|
||||
fn charge_pages(&mut self, n: usize) -> Result<(), Error> {
|
||||
self.pages = self.pages.saturating_add(n);
|
||||
if self.pages > MAX_PAGES {
|
||||
return Err(Error::Limit("too many pages"));
|
||||
}
|
||||
_ => Vec::new(),
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -232,9 +232,15 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Pai
|
||||
}
|
||||
};
|
||||
let grad = s.obj(fill, b"Grad")?;
|
||||
// A shared gradient is decoded again for every occurrence: charge the arrays scanned
|
||||
// and copy only as many positions as there are stops to keep.
|
||||
if r.charge_slots(s.array_len(grad, b"Cols").unwrap_or(0).saturating_add(s.array_len(grad, b"Posn").unwrap_or(0))).is_err() {
|
||||
r.warn("gradients beyond the document work budget (left empty)");
|
||||
return Some(Paint::None);
|
||||
}
|
||||
let cols = s.objs(grad, b"Cols");
|
||||
let posn: Vec<Vec<f64>> = match s.field(grad, b"Posn") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(floats).collect(),
|
||||
Some(Value::Array(a)) => a.iter().filter_map(floats).take(MAX_STOPS).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut stops = Vec::new();
|
||||
|
||||
@@ -181,24 +181,32 @@ fn plane(
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE));
|
||||
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
|
||||
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
|
||||
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
|
||||
_ => Vec::new(),
|
||||
// Borrow the file-controlled arrays: collecting the whole state array first costs its full
|
||||
// length on every occurrence of a shared layer, however small the declared grid is.
|
||||
let states: &[Value] = match s.field(bitmap, &tag(b"Sta", c)) {
|
||||
Some(Value::Array(a)) => a,
|
||||
_ => &[],
|
||||
};
|
||||
let tiles = s.objs(bitmap, &tag(b"Idx", c));
|
||||
let mut tiles = match s.field(bitmap, &tag(b"Idx", c)) {
|
||||
Some(Value::Array(a)) => a.as_slice(),
|
||||
_ => &[],
|
||||
}
|
||||
.iter()
|
||||
.filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None });
|
||||
// The scan below runs for every occurrence of a shared bitmap, whatever it produces.
|
||||
r.charge_slots(states.len().min(tw.saturating_mul(th)))?;
|
||||
let row = (w as usize) * bps;
|
||||
let mut out = vec![0u8; row * h as usize];
|
||||
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
|
||||
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
|
||||
let mut next = 0usize;
|
||||
for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
|
||||
let state = &if let Value::UInt(u) = state { u8::try_from(*u).unwrap_or(255) } else { 255 };
|
||||
let (ty, tx) = (t / tw.max(1), t % tw.max(1));
|
||||
let fill = match state {
|
||||
0 | 1 => None,
|
||||
2 => Some(0xFF),
|
||||
4 => {
|
||||
let Some(blck) = tiles.get(next).copied() else { return Err(Error::Malformed("missing pixel tile")) };
|
||||
next += 1;
|
||||
let Some(blck) = tiles.next() else { return Err(Error::Malformed("missing pixel tile")) };
|
||||
let (ox, oy) = (tx * TILE, ty * TILE);
|
||||
if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 {
|
||||
continue;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents.
|
||||
//! Unit tests on synthetic inputs; `tests/it/real_files.rs` covers public Affinity documents.
|
||||
|
||||
mod preview {
|
||||
use crate::container::header;
|
||||
@@ -258,6 +258,20 @@ mod archive {
|
||||
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_read_still_spends_the_extraction_allowance() {
|
||||
let d = doc();
|
||||
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
|
||||
let mut broken = file.clone();
|
||||
broken[90] ^= 0x55;
|
||||
// Room for exactly one read: the failed attempt must use it up, so a caller that
|
||||
// recovers from the checksum error cannot make the same entry decode again.
|
||||
let mut a = Archive::open(&broken, Limits { max_entry: usize::MAX, max_total: d.len(), ..Limits::default() }).unwrap();
|
||||
assert!(a.read("doc.dat").is_err());
|
||||
assert_eq!(a.extracted(), d.len());
|
||||
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() {
|
||||
let big = vec![7u8; 1 << 20];
|
||||
@@ -302,6 +316,17 @@ mod archive {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_records_of_zero_width_are_rejected_before_any_value_is_made() {
|
||||
// Four million records of no bytes each pass a length check that multiplies by the width.
|
||||
let mut f = vec![0x80 | 0x2c];
|
||||
f.extend(tag(b"Big_").0.to_le_bytes());
|
||||
f.extend(4_000_000u32.to_le_bytes());
|
||||
f.extend(0u16.to_le_bytes());
|
||||
let d = synth::stream(&[(tag(b"Big_"), F::Raw(f))]);
|
||||
assert_eq!(stream::parse(&d).unwrap_err(), Error::Malformed("array longer than its data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_compressed_entry_cannot_make_the_stream_allocate_gigabytes() {
|
||||
// Each decoded value takes about 40 bytes, and a bool array packs eight in a byte: 16 MiB
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -100,6 +106,24 @@ fn int(v: &Value) -> Option<i64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest font name kept; real PostScript and family names are far shorter.
|
||||
const MAX_FONT_NAME: usize = 256;
|
||||
|
||||
/// A font name for one run. Runs repeat the same shared string, so its copies are charged
|
||||
/// against a document-wide byte budget; an overlong name or an exhausted budget leaves it empty.
|
||||
fn font_name(r: &mut Reader, font: Option<ObjId>, tag: &[u8; 4]) -> String {
|
||||
let name = font.and_then(|f| r.s.str(f, tag)).unwrap_or_default();
|
||||
if name.len() > MAX_FONT_NAME {
|
||||
r.warn("font names longer than 256 bytes (left empty)");
|
||||
return String::new();
|
||||
}
|
||||
if !r.charge_font_name(name.len()) {
|
||||
r.warn("font names beyond the document metadata budget (left empty)");
|
||||
return String::new();
|
||||
}
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextRun {
|
||||
let s = r.s;
|
||||
let doubles = s.field(attrs, b"Doub");
|
||||
@@ -119,11 +143,12 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextR
|
||||
r.warn("horizontally scaled text");
|
||||
}
|
||||
let font = s.obj(attrs, b"DFnt");
|
||||
let get = |tag: &[u8; 4]| font.and_then(|f| s.str(f, tag)).unwrap_or_default().to_string();
|
||||
let postscript = font_name(r, font, b"Post");
|
||||
let family = font_name(r, font, b"Famy");
|
||||
TextRun {
|
||||
text,
|
||||
postscript: get(b"Post"),
|
||||
family: get(b"Famy"),
|
||||
postscript,
|
||||
family,
|
||||
weight: font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400),
|
||||
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
|
||||
size,
|
||||
|
||||
@@ -48,8 +48,10 @@ fn isolated(name: &str, check: impl FnOnce()) {
|
||||
check();
|
||||
return;
|
||||
}
|
||||
// The harness names a test by its module path below the crate root.
|
||||
let test = module_path!().split_once("::").map_or(name.to_string(), |(_, m)| format!("{m}::{name}"));
|
||||
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", name, "--nocapture"])
|
||||
.args(["--exact", &test, "--nocapture"])
|
||||
.env("PHOTOCRAFT_COMPOUND_CASE", name)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
@@ -0,0 +1,4 @@
|
||||
//! The crate's integration tests, linked into one test binary.
|
||||
|
||||
mod compound_limits;
|
||||
mod real_files;
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,10 +127,23 @@ fn main() {
|
||||
photocraft_algo::puppet::ITERATIONS,
|
||||
t.elapsed().as_secs_f64() * 1e3
|
||||
);
|
||||
let dst: Vec<[f64; 2]> = pw.pins.iter().map(|p| p.dst).collect();
|
||||
let t = Instant::now();
|
||||
let _ = solver.solve(&dst, &[None; 3], PuppetMode::Normal, 4, Some(&v));
|
||||
println!("puppet interactive re-solve (4 warm rounds): {:.2} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
// Compare the old path-dependent preview with the command-equivalent drag solve, using
|
||||
// changing destinations and the same cached factorization (no remeshing per event).
|
||||
for (label, rounds, warm) in [("6 warm rounds", 6, true), ("canonical cold solve", photocraft_algo::puppet::ITERATIONS, false)] {
|
||||
let mut previous = v.clone();
|
||||
let mut times = Vec::new();
|
||||
for step in 1..=60 {
|
||||
let mut dst: Vec<[f64; 2]> = pw.pins.iter().map(|p| p.dst).collect();
|
||||
dst[1][0] -= f64::from(step) * 10.0;
|
||||
dst[1][1] += (f64::from(step) * 0.1).sin() * 400.0;
|
||||
let t = Instant::now();
|
||||
let (next, _) = solver.solve(&dst, &[None; 3], pw.mode, rounds, warm.then_some(previous.as_slice()));
|
||||
times.push(t.elapsed().as_secs_f64() * 1e3);
|
||||
previous = std::hint::black_box(next);
|
||||
}
|
||||
times.sort_by(f64::total_cmp);
|
||||
println!("puppet drag ({label}, 60 moves): median {:.2} ms, p95 {:.2} ms, max {:.2} ms", times[30], times[56], times[59]);
|
||||
}
|
||||
let t = Instant::now();
|
||||
let _ = render(&s, b, &solver.mesh, &v, &order, Interp::Bicubic);
|
||||
println!("puppet render 24 MP: {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
|
||||
@@ -1,42 +1,161 @@
|
||||
//! Time and peak memory of the Photomerge multi-band blend: `n` RGB sources of `mp` megapixels
|
||||
//! (3:2), each overlapping the next by half its width.
|
||||
//! `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]` (default 4 × 24 MP).
|
||||
//! Peak memory is the growth of the process's high-water mark during the blend (Linux only).
|
||||
//! Time and peak memory of Photomerge's multi-band blend on overlapping RGB sources.
|
||||
//! Backwards compatible: `cargo run --release -p photocraft-algo --example bench_multiband [n] [mp]`.
|
||||
//! `cargo xtask perf --bench bench_multiband --quick` also collects repeatable JSON timings.
|
||||
//! The peak is the entire process's resident-set high-water mark, not an estimate of allocations
|
||||
//! by the blend alone (`photocraft_testkit::perf`: the kernel's mark on Linux, sampled elsewhere).
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_algo::panorama::{self, RoiImage};
|
||||
use photocraft_testkit::perf::{self, RssSampler, Summary};
|
||||
use serde_json::json;
|
||||
|
||||
/// The process's peak resident set in MiB (`VmHWM`), where `/proc` has it.
|
||||
fn peak_mib() -> Option<f64> {
|
||||
let s = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
let kb: f64 = s.lines().find(|l| l.starts_with("VmHWM:"))?.split_whitespace().nth(1)?.parse().ok()?;
|
||||
Some(kb / 1024.0)
|
||||
const ROW: &str = "Photomerge multiband blend";
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Options {
|
||||
n: usize,
|
||||
mp: f64,
|
||||
reps: usize,
|
||||
json: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let n: usize = args.next().and_then(|a| a.parse().ok()).unwrap_or(4).max(1);
|
||||
let mp: f64 = args.next().and_then(|a| a.parse().ok()).unwrap_or(24.0);
|
||||
let sh = ((mp * 1e6 / 1.5).sqrt().round() as usize).max(2);
|
||||
/// Parses legacy `[n] [mp]` arguments and the perf runner's `--reps`/`--json` flags.
|
||||
/// Rejects missing, out-of-range, or non-finite values before allocating images.
|
||||
fn options(args: impl IntoIterator<Item = String>) -> Result<Options, String> {
|
||||
let mut out = Options { n: 4, mp: 24.0, reps: 1, json: None };
|
||||
let mut positional = Vec::new();
|
||||
let mut it = args.into_iter();
|
||||
while let Some(arg) = it.next() {
|
||||
match arg.as_str() {
|
||||
"--json" => {
|
||||
let path = it.next().filter(|p| !p.starts_with('-')).ok_or("--json needs a file path")?;
|
||||
out.json = Some(PathBuf::from(path));
|
||||
}
|
||||
"--reps" => {
|
||||
out.reps = it.next().ok_or("--reps needs an integer")?.parse().map_err(|_| "--reps needs an integer")?;
|
||||
}
|
||||
_ if !arg.starts_with('-') => positional.push(arg),
|
||||
_ => return Err(format!("unknown option: {arg}")),
|
||||
}
|
||||
}
|
||||
if positional.len() > 2 {
|
||||
return Err("expected at most [n] [mp]".into());
|
||||
}
|
||||
if let Some(n) = positional.first() {
|
||||
out.n = n.parse().map_err(|_| "n must be an integer")?;
|
||||
}
|
||||
if let Some(mp) = positional.get(1) {
|
||||
out.mp = mp.parse().map_err(|_| "mp must be a number")?;
|
||||
}
|
||||
if !(1..=16).contains(&out.n) || !(1..=25).contains(&out.reps) || !out.mp.is_finite() || !(0.01..=36.0).contains(&out.mp) {
|
||||
return Err("expected 1..=16 images, 0.01..=36 MP per image and 1..=25 repetitions".into());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Builds deterministic overlapping RGB inputs and times each multi-band blend.
|
||||
/// Writes individual samples and whole-process peak RSS to JSON when requested.
|
||||
fn run() -> Result<(), String> {
|
||||
let o = options(std::env::args().skip(1))?;
|
||||
let sh = ((o.mp * 1e6 / 1.5).sqrt().round() as usize).max(2);
|
||||
let sw = sh * 3 / 2;
|
||||
let step = sw / 2;
|
||||
let (cw, ch) = (step * (n - 1) + sw, sh);
|
||||
let rois: Vec<RoiImage> = (0..n)
|
||||
let (cw, ch) = (step * (o.n - 1) + sw, sh);
|
||||
let rois: Vec<RoiImage> = (0..o.n)
|
||||
.map(|k| {
|
||||
let px = (0..sw * sh * 3).map(|i| ((i / 3 % sw * 7 + i / 3 / sw * 3 + k * 31 + i % 3 * 50) % 251) as f32 / 251.0).collect();
|
||||
RoiImage { x0: (step * k) as i32, y0: 0, w: sw, h: sh, ch: 3, px, alpha: vec![1.0; sw * sh] }
|
||||
})
|
||||
.collect();
|
||||
// Each canvas column belongs to the image whose centre is nearest, as a seam would split it.
|
||||
let wts: Vec<Vec<f32>> = (0..n)
|
||||
.map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(n - 1) == k { 1.0 } else { 0.0 }).collect())
|
||||
let wts: Vec<Vec<f32>> = (0..o.n)
|
||||
.map(|k| (0..sw * sh).map(|i| if ((step * k + i % sw + step / 2) / step).saturating_sub(1).min(o.n - 1) == k { 1.0 } else { 0.0 }).collect())
|
||||
.collect();
|
||||
let levels = panorama::blend_levels(cw, ch).min(7);
|
||||
let before = peak_mib();
|
||||
let t0 = Instant::now();
|
||||
let (px, cov) = panorama::multiband(cw, ch, &rois, &wts, levels);
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
let peak = peak_mib().zip(before).map_or("n/a".to_string(), |(a, b)| format!("{:.0} MiB", a - b));
|
||||
println!("{n} × {sw}×{sh} ({mp} MP) RGB, canvas {cw}×{ch}, {levels} levels: {ms:.0} ms, peak +{peak} during the blend");
|
||||
std::hint::black_box((px, cov));
|
||||
let before = perf::process_peak_rss_bytes().or_else(perf::current_rss_bytes);
|
||||
let sampler = RssSampler::start(std::time::Duration::from_millis(2));
|
||||
let mut samples_ms = Vec::with_capacity(o.reps);
|
||||
for _ in 0..o.reps {
|
||||
let t0 = Instant::now();
|
||||
let output = panorama::multiband(cw, ch, &rois, &wts, levels);
|
||||
samples_ms.push(t0.elapsed().as_secs_f64() * 1000.0);
|
||||
std::hint::black_box(&output);
|
||||
}
|
||||
let peak = perf::process_peak_rss_bytes().or(sampler.peak());
|
||||
let delta = before.zip(peak).map(|(a, b)| b.saturating_sub(a) as f64 / (1024.0 * 1024.0));
|
||||
// The same nearest-rank p50 that `cargo xtask perf` reports for these samples.
|
||||
let p50 = Summary::of(&samples_ms).map_or(f64::NAN, |s| s.p50);
|
||||
println!(
|
||||
"{} × {}×{} ({:.2} MP) RGB, canvas {}×{}, {} levels: {:.1} ms p50 ({} runs), peak +{}",
|
||||
o.n,
|
||||
sw,
|
||||
sh,
|
||||
o.mp,
|
||||
cw,
|
||||
ch,
|
||||
levels,
|
||||
p50,
|
||||
o.reps,
|
||||
delta.map_or("n/a".to_string(), |m| format!("{m:.0} MiB"))
|
||||
);
|
||||
if let Some(path) = o.json {
|
||||
let context = json!({"sources": o.n, "mp_per_source": o.mp, "canvas_width": cw, "canvas_height": ch, "levels": levels});
|
||||
let report = perf::report("bench_multiband", context, vec![perf::row(ROW, &samples_ms, peak, None)], Some(&sampler));
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(&path, serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?).map_err(|e| format!("write {}: {e}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs the benchmark, reporting argument or output errors with a failing exit status.
|
||||
fn main() {
|
||||
if let Err(e) = run() {
|
||||
eprintln!("bench_multiband: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Converts borrowed argument slices for the owned-string command-line parser.
|
||||
fn parse(args: &[&str]) -> Result<Options, String> {
|
||||
options(args.iter().map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
/// Confirms backwards compatibility with the positional and perf-runner forms.
|
||||
#[test]
|
||||
fn parses_old_positional_args_and_perf_runner_args() {
|
||||
assert_eq!(parse(&[]).unwrap().n, 4);
|
||||
assert_eq!(
|
||||
parse(&["2", "0.25", "--reps", "3", "--json", "out.json"]).unwrap(),
|
||||
Options { n: 2, mp: 0.25, reps: 3, json: Some(PathBuf::from("out.json")) }
|
||||
);
|
||||
// The 24-36 MP range AGENTS.md benchmarks at, as before the flags were added.
|
||||
assert_eq!(parse(&["4", "36"]).unwrap().mp, 36.0);
|
||||
}
|
||||
|
||||
/// Checks invalid counts, non-finite sizes, repetitions, and missing flag values.
|
||||
#[test]
|
||||
fn rejects_bad_benchmark_args() {
|
||||
for args in [
|
||||
vec!["0"],
|
||||
vec!["17"],
|
||||
vec!["2", "0"],
|
||||
vec!["2", "NaN"],
|
||||
vec!["2", "inf"],
|
||||
vec!["2", "37"],
|
||||
vec!["--reps", "0"],
|
||||
vec!["--reps", "26"],
|
||||
vec!["--json"],
|
||||
vec!["--json", "--reps", "5"],
|
||||
vec!["--reps"],
|
||||
vec!["--unknown"],
|
||||
] {
|
||||
assert!(parse(&args).is_err(), "{args:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Timing of Content-Aware Scale's seam carving alone (no document plumbing), on a synthetic
|
||||
//! photo-like RGBA image: perf scenario P28's 24 MP width change (#211), the same image's height
|
||||
//! (the transposed pass) and two images short enough for the exact full-resolution seam search.
|
||||
//! Prints an FNV-style hash of each output's bits (over 32-bit words), so a speed-up can be checked
|
||||
//! to choose exactly the same seams.
|
||||
//! `cargo run --release -p photocraft-algo --example bench_seam_cas [reps] [case filter]`
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_algo::seam::carve;
|
||||
|
||||
/// Smooth gradients, a few hard-edged shapes and fine noise: enough structure that seams are
|
||||
/// steered by content, as in a photo.
|
||||
fn photo(w: usize, h: usize) -> Vec<f32> {
|
||||
let mut seed = 0x9e37_79b9u32;
|
||||
let mut noise = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
(seed % 1000) as f32 / 1000.0 - 0.5
|
||||
};
|
||||
let mut img = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (u, v) = (x as f32 / w as f32, y as f32 / h as f32);
|
||||
let sky = 0.35 + 0.4 * v;
|
||||
let disc = ((u - 0.3).powi(2) + (v - 0.55).powi(2)) < 0.02;
|
||||
let bar = (0.62..0.66).contains(&u) && v > 0.3;
|
||||
let hill = v > 0.7 + 0.08 * (u * 11.0).sin();
|
||||
let (r, g, b) = if disc {
|
||||
(0.85, 0.3, 0.2)
|
||||
} else if bar {
|
||||
(0.1, 0.1, 0.12)
|
||||
} else if hill {
|
||||
(0.2, 0.45 + 0.1 * (u * 40.0).sin(), 0.15)
|
||||
} else {
|
||||
(sky * 0.6, sky * 0.8, sky)
|
||||
};
|
||||
let n = noise() * 0.02;
|
||||
img.extend_from_slice(&[r + n, g + n, b + n, 1.0]);
|
||||
}
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
fn fnv(v: &[f32]) -> u64 {
|
||||
v.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, f| (h ^ u64::from(f.to_bits())).wrapping_mul(0x0100_0000_01b3))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let reps = args.get(1).and_then(|s| s.parse::<usize>().ok()).unwrap_or(1).clamp(1, 20);
|
||||
let only = args.get(2).cloned();
|
||||
// (name, w, h, new w, new h)
|
||||
let cases = [
|
||||
("24 MP to 90 % width (P28, proxy rows)", 6000, 4000, 5400, 4000),
|
||||
("24 MP to 90 % height (transposed, proxy rows)", 6000, 4000, 6000, 3600),
|
||||
("5.4 MP to 90 % width (exact)", 3000, 1800, 2700, 1800),
|
||||
("1.5 MP to 70 % width (exact)", 1500, 1000, 1050, 1000),
|
||||
];
|
||||
for (name, w, h, nw, nh) in cases {
|
||||
if only.as_ref().is_some_and(|o| !name.contains(o.as_str())) {
|
||||
continue;
|
||||
}
|
||||
let img = photo(w, h);
|
||||
let mut times = Vec::with_capacity(reps);
|
||||
let mut hash = 0;
|
||||
for _ in 0..reps {
|
||||
let t = Instant::now();
|
||||
let out = carve(w, h, 4, &img, None, nw, nh);
|
||||
times.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
hash = fnv(&out);
|
||||
}
|
||||
times.sort_by(f64::total_cmp);
|
||||
let p50 = times[times.len() / 2];
|
||||
let max = times[times.len() - 1];
|
||||
println!("{name:<48} p50 {p50:>9.1} ms max {max:>9.1} ms output {hash:016x}");
|
||||
}
|
||||
}
|
||||