mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-17 04:36:49 -04:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
478214f60b | ||
|
|
6d3443c098 | ||
|
|
297520b9c8 | ||
|
|
57437ea1a9 | ||
|
|
7053f703a5 | ||
|
|
fd1faf4e13 | ||
|
|
8f58ea4629 | ||
|
|
0cc161e2a8 | ||
|
|
3cb3580a46 | ||
|
|
b630d7cb9a | ||
|
|
2b3d05636f | ||
|
|
fdaa9b8316 | ||
|
|
8376363d8c | ||
|
|
b09aeae2e2 | ||
|
|
691a022b12 | ||
|
|
f0f0d6f58a | ||
|
|
3bd9bc72a7 | ||
|
|
b23a01aa8f | ||
|
|
4a93095939 | ||
|
|
e99f09b306 | ||
|
|
e5bf9c22c9 | ||
|
|
39904a27ce | ||
|
|
110d58bea9 | ||
|
|
60d682d65d | ||
|
|
6382148eb8 | ||
|
|
2ff756b51a | ||
|
|
fa17b6f4b1 | ||
|
|
7b1dd35b7b | ||
|
|
bf93459200 | ||
|
|
b9aa935d19 |
No files matched your search
@@ -12,7 +12,7 @@
|
||||
gpu = { max-threads = 4 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))'
|
||||
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool|background_redraw::|floating_group_titles|swap_arrow::tests::rendered_glyph/)))'
|
||||
test-group = 'gpu'
|
||||
|
||||
# CI (`--profile ci`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its
|
||||
|
||||
+132
-29
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
fuzz:
|
||||
@@ -22,8 +23,8 @@ env:
|
||||
PHOTOCRAFT_NO_PRINT: "1"
|
||||
LPDEST: photocraft-no-such-printer
|
||||
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
|
||||
# debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough
|
||||
# for the Linux job's second, craft-fonts build (it ran out of disk with full debug info).
|
||||
# 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
|
||||
@@ -43,7 +44,7 @@ jobs:
|
||||
name: select platforms
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
os: ${{ steps.pick.outputs.os }}
|
||||
legs: ${{ steps.pick.outputs.legs }}
|
||||
web: ${{ steps.pick.outputs.web }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -57,9 +58,15 @@ jobs:
|
||||
run: |
|
||||
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
|
||||
# release.yml builds and tests macOS (a failing release is fixed and rebuilt).
|
||||
all='["ubuntu-latest","windows-latest"]'
|
||||
# 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 "os=$all" >> "$GITHUB_OUTPUT"
|
||||
echo "legs=$all" >> "$GITHUB_OUTPUT"
|
||||
echo "web=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
@@ -72,20 +79,25 @@ jobs:
|
||||
echo "web=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
|
||||
echo "os=$all" >> "$GITHUB_OUTPUT"
|
||||
echo "legs=$all" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT"
|
||||
echo "legs=[$linux]" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
test:
|
||||
timeout-minutes: 100
|
||||
name: test (${{ matrix.os }})
|
||||
name: test (${{ matrix.os }}${{ matrix.part != 'all' && format(', {0}', matrix.part) || '' }})
|
||||
needs: platforms
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ${{ fromJSON(needs.platforms.outputs.os) }}
|
||||
include: ${{ fromJSON(needs.platforms.outputs.legs) }}
|
||||
env:
|
||||
# No debuginfo on Windows: writing PDBs for ~200 test binaries cost 2.2 of ~21 build minutes.
|
||||
# A panic still names its file and line; only backtrace frames lose theirs (Linux keeps
|
||||
# line-tables-only, so a backtrace with lines is one OS away).
|
||||
CARGO_PROFILE_DEV_DEBUG: ${{ startsWith(matrix.os, 'windows') && '0' || 'line-tables-only' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -108,21 +120,44 @@ jobs:
|
||||
# Only main saves caches; PRs restore main's. Every PR used to save its own set (up to
|
||||
# 3.8 GB), usable by that PR only, so the repo's 10 GB cache budget kept evicting main's.
|
||||
# A failing test still saves: the cache holds compiled dependencies, not test results.
|
||||
# The Windows legs share one key (same dependencies); the integration leg saves it,
|
||||
# because it also runs Clippy and so leaves Clippy's dependency metadata in it.
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }}
|
||||
cache-on-failure: true
|
||||
|
||||
# The cheap checks run first: a format, lint, layering or stale-scorecard failure is red
|
||||
# after a few minutes instead of after the test passes (#6119). Clippy restores main's cache.
|
||||
# sccache caches the workspace crates' compiled libraries, which rust-cache leaves out:
|
||||
# warm, each Windows leg built 4.5 min faster. Like rust-cache, only main writes (PRs read
|
||||
# main's entries); a full set is ~90 MB, so it barely touches the 10 GB budget (#2688).
|
||||
# Test executables can't be cached, so a PR still links its tests.
|
||||
- name: Install sccache (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.18.0
|
||||
|
||||
- name: Use sccache (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
env:
|
||||
MAIN: ${{ github.ref == 'refs/heads/main' }}
|
||||
run: |
|
||||
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
|
||||
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
|
||||
if [ "$MAIN" != true ]; then echo "SCCACHE_GHA_RW_MODE=READ_ONLY" >> "$GITHUB_ENV"; fi
|
||||
shell: bash
|
||||
|
||||
- name: Format
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
# Keep formatting, strict Clippy, layering and scorecard checks ahead of tests.
|
||||
- name: Clippy
|
||||
if: matrix.part == 'all' || matrix.part == 'integration'
|
||||
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
|
||||
|
||||
- name: Dependency layering
|
||||
if: matrix.part == 'all' || matrix.part == 'integration'
|
||||
run: cargo xtask layers
|
||||
|
||||
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
|
||||
@@ -131,36 +166,104 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo xtask scorecard --check
|
||||
|
||||
# cargo-nextest runs the test binaries in parallel (`cargo test` runs them one at a time) and
|
||||
# its `ci` profile (.config/nextest.toml) stops at the first failure and fails hung tests.
|
||||
# nextest runs the test binaries in parallel (cargo test runs them one at a time), with the
|
||||
# GPU tests capped by the `gpu` group in .config/nextest.toml (#2204).
|
||||
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
|
||||
with:
|
||||
tool: [email protected]
|
||||
|
||||
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
|
||||
# nextest doesn't run doctests, so `cargo test --doc` does, with the same selection.
|
||||
# Separate native commands into steps so Windows reports either command's failure.
|
||||
# `--lib --bins --tests` leaves out the examples, which cargo test would build and link but
|
||||
# never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so
|
||||
# they get their own step.
|
||||
#
|
||||
# On Windows the unit leg runs `--lib --bins` and the integration leg `--test '*'` (every
|
||||
# integration-test target); the feature selection stays the same, so both legs share the
|
||||
# dependency cache. The `ci` profile fails fast and terminates hung tests
|
||||
# (.config/nextest.toml); assertion failures are not retried.
|
||||
- name: Test
|
||||
run: cargo nextest run --workspace --locked --features heif --profile ci
|
||||
env:
|
||||
PART: ${{ matrix.part }}
|
||||
run: |
|
||||
case "$PART" in
|
||||
unit) targets=(--lib --bins) ;;
|
||||
integration) targets=(--test '*') ;;
|
||||
*) targets=(--lib --bins --tests) ;;
|
||||
esac
|
||||
cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}"
|
||||
shell: bash
|
||||
|
||||
- name: Doctests
|
||||
run: cargo test --doc --workspace --locked --features heif
|
||||
if: matrix.part == 'all' || matrix.part == 'unit'
|
||||
run: cargo test --workspace --locked --features heif --doc
|
||||
|
||||
# The Windows tests used to be one job named `test (windows-latest)`; they're now legs named
|
||||
# `test (windows-latest, unit)` and `test (windows-latest, integration)`. This job keeps the old
|
||||
# name reporting, so a required status check on it (none today) would keep working. It fails
|
||||
# unless the whole test matrix and both Linux variants passed. `needs` reports one result
|
||||
# for all matrix legs; when a PR runs no Windows legs it still checks every Linux result.
|
||||
windows-gate:
|
||||
timeout-minutes: 5
|
||||
name: test (windows-latest)
|
||||
needs: [platforms, test, test-variants]
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- env:
|
||||
PLATFORMS: ${{ needs.platforms.result }}
|
||||
TEST: ${{ needs.test.result }}
|
||||
VARIANTS: ${{ needs.test-variants.result }}
|
||||
run: |
|
||||
echo "platforms: $PLATFORMS, test: $TEST, variants: $VARIANTS"
|
||||
[ "$PLATFORMS" = success ] && [ "$TEST" = success ] && [ "$VARIANTS" = success ]
|
||||
|
||||
# The two other builds of the Linux tests, in parallel with the test job instead of after it (they
|
||||
# added ~10 min to its critical path). Each restores the test job's cache (`shared-key: test` is
|
||||
# that job's key) and saves none: the dependencies are the same, and the 10 GB cache budget is
|
||||
# tight (#2688).
|
||||
test-variants:
|
||||
timeout-minutes: 100
|
||||
name: test (${{ matrix.variant }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
variant: [without heif, with craft-fonts]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Linux system dependencies (egui/winit/rfd)
|
||||
run: |
|
||||
nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \
|
||||
/opt/hostedtoolcache/CodeQL </dev/null >/dev/null 2>&1 &
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
|
||||
libgl1-mesa-dev libgtk-3-dev
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: test
|
||||
save-if: false
|
||||
|
||||
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
|
||||
with:
|
||||
tool: [email protected]
|
||||
|
||||
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
|
||||
# error for distributors who leave it out.
|
||||
- name: Test without heif
|
||||
if: runner.os == 'Linux'
|
||||
if: matrix.variant == 'without heif'
|
||||
run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci
|
||||
|
||||
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
|
||||
# Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace
|
||||
# --features heif` of the Test step: any other package or feature selection resolves a
|
||||
# different feature set, recompiles the whole dependency graph into the same target dir and
|
||||
# runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800).
|
||||
# Only the tests of the crates that read `CRAFT_FONTS` run (the `-E` filter changes what runs,
|
||||
# not what is built); engine and the apps only pass text on to them (#6120).
|
||||
# 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: runner.os == 'Linux'
|
||||
if: matrix.variant == 'with craft-fonts'
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
@@ -168,12 +271,12 @@ jobs:
|
||||
path: craft-fonts
|
||||
|
||||
- name: Test with craft-fonts
|
||||
if: runner.os == 'Linux'
|
||||
if: matrix.variant == 'with craft-fonts'
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: >-
|
||||
cargo nextest run --workspace --locked --features heif --profile ci
|
||||
cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests
|
||||
-E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)'
|
||||
|
||||
corpus:
|
||||
|
||||
@@ -27,38 +27,10 @@ permissions:
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test + package)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build, test and package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked --features heif
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc
|
||||
# The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a GPU
|
||||
# device at once, so the GPU integration tests run one at a time here.
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1
|
||||
# The release package, as release.yml builds it. Free the debug build's disk first.
|
||||
rm -rf target/debug
|
||||
CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/photocraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/photocraft$|/bin/photocraft-cli$|/share/applications/ai\.storyteller\.photocraft\.desktop$|/share/doc/photocraft/LICENSE-MIT$'
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
cargo-build-extra-args: "--features heif"
|
||||
custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1"
|
||||
run-package-check: true
|
||||
@@ -0,0 +1,33 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
+21
-5
@@ -75,9 +75,18 @@ opt-level = 2
|
||||
opt-level = 1
|
||||
# Line tables keep file:line in panic backtraces but drop variable info, which roughly halves link
|
||||
# time and PDB size for the ~260 test binaries (#2204). CI sets the same through
|
||||
# CARGO_PROFILE_DEV_DEBUG. To inspect locals in a debugger, build with CARGO_PROFILE_DEV_DEBUG=true.
|
||||
# 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.
|
||||
debug = "line-tables-only"
|
||||
|
||||
# Test builds compile ui-egui without optimization: its library and unit tests were the biggest
|
||||
# test-build units after engine's (12.6 of ~63 compile minutes on the Windows CI runner), and its
|
||||
# tests run no heavier pixel code of their own (that lives in the crates that stay optimized).
|
||||
# Measured on CI: Windows build 18.9 -> 15.4 min, test run 10.0 -> 10.5. `cargo run` and
|
||||
# `cargo build` use the dev profile and are unaffected.
|
||||
[profile.test.package.photocraft-ui-egui]
|
||||
opt-level = 0
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
@@ -127,17 +136,24 @@ opt-level = "z"
|
||||
[profile.wasm-release.package.naga]
|
||||
opt-level = "z"
|
||||
|
||||
# Pixel processing keeps its speed-oriented optimization level.
|
||||
# Pixel processing keeps its speed-oriented optimization level, with two exceptions.
|
||||
# Colour-management transforms are built once and cached (`transform::cached`), so "z" costs
|
||||
# little runtime. The browser app composites per frame on the GPU (`crates/gpu`, WebGPU/WebGL2),
|
||||
# so the CPU compositor is not the browser's hot path there; "z" for compose keeps the wasm
|
||||
# under the 24 MiB gate (measured −122 KB vs opt-level 3). algo's filters run per pixel on
|
||||
# every interactive filter application; "z" there is the size lever of last resort (−331 KB),
|
||||
# taken because the gate is enforced in CI: if browser filters feel too slow, restore 3 here
|
||||
# and reclaim the bytes from a feature-gated decoder instead.
|
||||
[profile.wasm-release.package.photocraft-raster]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-color]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-cms]
|
||||
opt-level = 3
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-compose]
|
||||
opt-level = 3
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-algo]
|
||||
opt-level = 3
|
||||
opt-level = "z"
|
||||
[profile.wasm-release.package.photocraft-paint]
|
||||
opt-level = 3
|
||||
|
||||
@@ -443,6 +443,14 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless
|
||||
/// turned on), read before the window opens: it picks the surface's present mode.
|
||||
pub fn read_low_latency(path: Option<&Path>) -> bool {
|
||||
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
let default = photocraft_engine::prefs::Preferences::default().performance.low_latency_drawing;
|
||||
v.get("performance").and_then(|p| p.get("lowLatencyDrawing")).and_then(Value::as_bool).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Read the policy leniently at startup, including old settings without renderingMode.
|
||||
pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) {
|
||||
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
@@ -463,6 +471,23 @@ mod tests {
|
||||
Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_latency_drawing_is_read_before_the_window_opens_and_defaults_off() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-low-latency-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("preferences.json");
|
||||
let read = |json: &str| {
|
||||
std::fs::write(&file, json).unwrap();
|
||||
read_low_latency(Some(&file))
|
||||
};
|
||||
assert!(read(r#"{"performance":{"lowLatencyDrawing":true}}"#));
|
||||
assert!(!read(r#"{"performance":{"lowLatencyDrawing":false}}"#));
|
||||
assert!(!read(r#"{"performance":{"lowLatencyDrawing":"nope"}}"#), "garbage keeps the default (off)");
|
||||
assert!(!read("not json"));
|
||||
assert!(!read_low_latency(None), "no settings file: the default (off)");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_server_preference_is_read_before_the_window_opens() {
|
||||
use photocraft_engine::prefs::LinuxDisplayServer;
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
//!
|
||||
//! 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] [files…]`
|
||||
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [--present-mode <mode>]
|
||||
//! [--frame-latency <n>] [--stroke-trace <stderr|file.csv>] [files…]`
|
||||
//!
|
||||
//! `--present-mode` (`fifo`, `autovsync`, `novsync`) and `--frame-latency` (1–3) override how
|
||||
//! frames reach the display for this launch (`PHOTOCRAFT_PRESENT_MODE` / `…_FRAME_LATENCY`;
|
||||
//! `presentation`); `--stroke-trace` times each brush stroke's frames (`PHOTOCRAFT_STROKE_TRACE`;
|
||||
//! `photocraft_ui_egui::stroke_trace`).
|
||||
//!
|
||||
//! `--safe-gpu` starts with the CPU renderer (no GPU canvas; a software adapter for the window
|
||||
//! where the platform has one) for this launch, e.g. after a graphics driver crash. A start that
|
||||
@@ -44,6 +50,7 @@ mod linux_libs;
|
||||
mod logging;
|
||||
mod monitor_profile;
|
||||
mod photoshop_settings;
|
||||
mod presentation;
|
||||
mod screen_color;
|
||||
mod services;
|
||||
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
|
||||
@@ -191,6 +198,7 @@ fn main() -> eframe::Result {
|
||||
// reported in a notice once the app is up; a control token is a usage error like a bad port.
|
||||
let mut unreadable_paths: Vec<String> = Vec::new();
|
||||
let mut safe_gpu = false;
|
||||
let (mut present_mode_arg, mut frame_latency_arg, mut stroke_trace_arg): (Option<String>, Option<String>, Option<String>) = (None, None, None);
|
||||
let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
@@ -211,6 +219,18 @@ fn main() -> eframe::Result {
|
||||
Some("--automation-read-root") => automation_read_root = args.next().map(std::path::PathBuf::from),
|
||||
Some("--automation-write-root") => automation_write_root = args.next().map(std::path::PathBuf::from),
|
||||
Some("--safe-gpu") => safe_gpu = true,
|
||||
Some(flag @ ("--present-mode" | "--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" => present_mode_arg = Some(v),
|
||||
"--frame-latency" => frame_latency_arg = Some(v),
|
||||
_ => 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") => in_window_menus = true,
|
||||
Some("--version") => {
|
||||
println!("photocraft {}", photocraft_engine::build_info::long_version());
|
||||
@@ -364,6 +384,21 @@ fn main() -> eframe::Result {
|
||||
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
|
||||
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
|
||||
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
|
||||
// Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about
|
||||
// 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers.
|
||||
if gpu_startup::read_low_latency(services::prefs_file().as_deref()) {
|
||||
options.wgpu_options.surface.present_mode = eframe::wgpu::PresentMode::AutoNoVsync;
|
||||
}
|
||||
// Testers' present mode and frame latency overrides (#1343), after the preference.
|
||||
let present = presentation::Overrides::from_args_or_env(present_mode_arg.as_deref(), frame_latency_arg.as_deref());
|
||||
options.wgpu_options.surface = present.apply(options.wgpu_options.surface);
|
||||
let present_desc = presentation::describe(&options.wgpu_options.surface, &present);
|
||||
// Without vsync, frames are capped at 240 fps (#4472) so repaint loops don't spin flat out.
|
||||
let uncapped = !matches!(
|
||||
options.wgpu_options.surface.present_mode,
|
||||
eframe::wgpu::PresentMode::Fifo | eframe::wgpu::PresentMode::FifoRelaxed | eframe::wgpu::PresentMode::AutoVsync
|
||||
);
|
||||
log::info!("presentation: {present_desc}");
|
||||
let sentinel_ms = t_sentinel.elapsed().as_secs_f64() * 1000.0;
|
||||
log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan);
|
||||
let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu;
|
||||
@@ -392,6 +427,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
let mut app = PhotocraftApp::new(Session::new(), services);
|
||||
app.min_frame_ms = uncapped.then_some(1000.0 / 240.0);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.custom_titlebar = custom_titlebar;
|
||||
// Only the title bar's free gap drags the window, never the menus (mac_window.rs).
|
||||
@@ -423,6 +459,10 @@ fn main() -> eframe::Result {
|
||||
// composites on the CPU).
|
||||
let info = &mut app.perf.gpu_info;
|
||||
info.preference = pref.name().to_string();
|
||||
info.present = present_desc.clone();
|
||||
if let Some(v) = &stroke_trace_arg {
|
||||
app.stroke_trace.set(v);
|
||||
}
|
||||
info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() };
|
||||
info.fallback = match (plan.reason.clone(), gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()) {
|
||||
(Some(reason), Some(note)) => Some(format!("{reason}. {note}")),
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Launch overrides for how frames reach the display, so testers can compare input-to-pixel
|
||||
//! latency without a rebuild (#1343): `PHOTOCRAFT_PRESENT_MODE` (`fifo`, `autovsync`, or `novsync`,
|
||||
//! also spelled `immediate`, `mailbox` or `autonovsync`: no vsync, wgpu's Immediate → Mailbox → Fifo
|
||||
//! by what the driver offers) and `PHOTOCRAFT_FRAME_LATENCY` (`1` to `3`, frames the swap chain may
|
||||
//! queue). Unset, the platform default applies. A value that isn't one of these is ignored with a
|
||||
//! warning in the log, never a failed start; a mode the driver may not offer is never requested.
|
||||
//! The result is logged at start and shown in the stroke trace.
|
||||
|
||||
use eframe::egui_wgpu::SurfaceConfig;
|
||||
use eframe::wgpu::PresentMode;
|
||||
|
||||
/// The present mode and frame latency this launch asked for (`None`: the default).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Overrides {
|
||||
pub mode: Option<PresentMode>,
|
||||
pub latency: Option<u32>,
|
||||
}
|
||||
|
||||
/// Parse the two variables' values. Returns the overrides and a warning per rejected value.
|
||||
pub fn parse(mode: Option<&str>, latency: Option<&str>) -> (Overrides, Vec<String>) {
|
||||
let mut warnings = Vec::new();
|
||||
let mode = mode.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| {
|
||||
let m = match v.to_ascii_lowercase().as_str() {
|
||||
// Only modes every surface takes: wgpu crashes on an explicit Immediate, Mailbox or
|
||||
// FifoRelaxed the driver doesn't offer (X11/NVIDIA has no Mailbox, #1343). Without
|
||||
// vsync, wgpu picks Immediate, else Mailbox, else Fifo.
|
||||
"fifo" | "vsync" => PresentMode::Fifo,
|
||||
"autovsync" => PresentMode::AutoVsync,
|
||||
"novsync" | "immediate" | "mailbox" | "autonovsync" => PresentMode::AutoNoVsync,
|
||||
_ => {
|
||||
warnings.push(format!("PHOTOCRAFT_PRESENT_MODE={v:?} isn't fifo, autovsync or novsync (immediate, mailbox); ignored"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(m)
|
||||
});
|
||||
let latency = latency.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| match v.parse::<u32>() {
|
||||
Ok(n @ 1..=3) => Some(n),
|
||||
_ => {
|
||||
warnings.push(format!("PHOTOCRAFT_FRAME_LATENCY={v:?} isn't 1, 2 or 3; ignored"));
|
||||
None
|
||||
}
|
||||
});
|
||||
(Overrides { mode, latency }, warnings)
|
||||
}
|
||||
|
||||
impl Overrides {
|
||||
/// `--present-mode` / `--frame-latency` when given, else `PHOTOCRAFT_PRESENT_MODE` /
|
||||
/// `PHOTOCRAFT_FRAME_LATENCY`; rejected values are logged.
|
||||
pub fn from_args_or_env(mode: Option<&str>, latency: Option<&str>) -> Self {
|
||||
let env_mode = std::env::var("PHOTOCRAFT_PRESENT_MODE").ok();
|
||||
let env_latency = std::env::var("PHOTOCRAFT_FRAME_LATENCY").ok();
|
||||
let (o, warnings) = parse(mode.or(env_mode.as_deref()), latency.or(env_latency.as_deref()));
|
||||
for w in warnings {
|
||||
log::warn!("{w}");
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// `surface` with the overrides applied.
|
||||
pub fn apply(&self, surface: SurfaceConfig) -> SurfaceConfig {
|
||||
SurfaceConfig {
|
||||
present_mode: self.mode.unwrap_or(surface.present_mode),
|
||||
desired_maximum_frame_latency: self.latency.or(surface.desired_maximum_frame_latency),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How `surface` presents, for the stroke trace and System Info: "fifo, latency 2", with
|
||||
/// "(override)" after a value an environment variable set. wgpu's GL backend ignores the latency.
|
||||
pub fn describe(surface: &SurfaceConfig, overrides: &Overrides) -> String {
|
||||
let mode = match surface.present_mode {
|
||||
PresentMode::AutoVsync => "autovsync",
|
||||
PresentMode::AutoNoVsync => "autonovsync",
|
||||
PresentMode::Fifo => "fifo",
|
||||
PresentMode::FifoRelaxed => "fiforelaxed",
|
||||
PresentMode::Immediate => "immediate",
|
||||
PresentMode::Mailbox => "mailbox",
|
||||
};
|
||||
let latency = surface.desired_maximum_frame_latency.map_or("default".to_string(), |n| n.to_string());
|
||||
let tag = |set: bool| if set { " (override)" } else { "" };
|
||||
format!("{mode}{}, latency {latency}{}", tag(overrides.mode.is_some()), tag(overrides.latency.is_some()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn values_parse_and_bad_ones_are_ignored_with_a_warning() {
|
||||
assert_eq!(parse(None, None), (Overrides::default(), vec![]));
|
||||
assert_eq!(parse(Some(""), Some(" ")), (Overrides::default(), vec![]));
|
||||
let (o, w) = parse(Some(" Mailbox "), Some("1"));
|
||||
assert_eq!((o.mode, o.latency, w.len()), (Some(PresentMode::AutoNoVsync), Some(1), 0));
|
||||
assert_eq!(parse(Some("vsync"), None).0.mode, Some(PresentMode::Fifo));
|
||||
// Never a mode a driver may lack (wgpu crashes on it): no vsync falls back by support.
|
||||
for v in ["IMMEDIATE", "mailbox", "novsync", "autonovsync"] {
|
||||
assert_eq!(parse(Some(v), None).0.mode, Some(PresentMode::AutoNoVsync), "{v}");
|
||||
}
|
||||
for (mode, latency) in
|
||||
[(Some("fast"), None), (None, Some("0")), (None, Some("4")), (None, Some("-1")), (None, Some("one")), (None, Some("99999999999"))]
|
||||
{
|
||||
let (o, w) = parse(mode, latency);
|
||||
assert_eq!((o, w.len()), (Overrides::default(), 1), "{mode:?} {latency:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overrides_replace_only_what_they_set_and_are_described() {
|
||||
let default = SurfaceConfig::HIGH_THROUGHPUT;
|
||||
assert_eq!(Overrides::default().apply(default), default);
|
||||
let latency_only = Overrides { mode: None, latency: Some(1) };
|
||||
let s = latency_only.apply(default);
|
||||
assert_eq!((s.present_mode, s.desired_maximum_frame_latency), (PresentMode::AutoVsync, Some(1)));
|
||||
assert_eq!(describe(&s, &latency_only), "autovsync, latency 1 (override)");
|
||||
let novsync = Overrides { mode: Some(PresentMode::AutoNoVsync), latency: None };
|
||||
assert_eq!(describe(&novsync.apply(default), &novsync), "autonovsync (override), latency 2");
|
||||
let none = SurfaceConfig { present_mode: PresentMode::Fifo, desired_maximum_frame_latency: None };
|
||||
assert_eq!(describe(&none, &Overrides::default()), "fifo, latency default");
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,18 @@ fn main() {
|
||||
use photocraft_ui_egui::native_menu;
|
||||
use serde_json::json;
|
||||
|
||||
// Without a harness nothing answers cargo-nextest's `--list`, so it ran the whole test as its
|
||||
// listing and failed on the output. List the one test, as libtest's terse format would, and
|
||||
// nothing for `--ignored` (nextest asks twice). Running it passes `<name> --exact`, which falls
|
||||
// through to the test below.
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.iter().any(|a| a == "--list") {
|
||||
if !args.iter().any(|a| a == "--ignored") {
|
||||
println!("mac_menu_appkit: test");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread");
|
||||
let nsapp = NSApplication::sharedApplication(mtm);
|
||||
nsapp.finishLaunching();
|
||||
|
||||
@@ -325,10 +325,11 @@ pub struct UiSetParams {
|
||||
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
|
||||
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
|
||||
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
|
||||
/// active layer's storage tiles). layerFilter (an object patching the Layers panel filter:
|
||||
/// by: kind/name/mode; kinds: a list of pixel/adjustment/type/shape/smart; name: a string;
|
||||
/// mode: a blend mode name such as Multiply or Pass Through). Omitted filter fields keep
|
||||
/// their current values. Other fields are an error.
|
||||
/// active layer's storage tiles). layerFilter (an object patching by ("kind", "name" or
|
||||
/// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a
|
||||
/// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off":
|
||||
/// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not
|
||||
/// over this channel). Other fields are an error.
|
||||
pub fields: Value,
|
||||
}
|
||||
|
||||
|
||||
@@ -369,6 +369,10 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
|
||||
}
|
||||
// With a `path` the file is written there; without one the bytes come back as `data`.
|
||||
"actions.save" | "swatches.export" => params.get("path").is_some(),
|
||||
// Shortcut sets: a .kys read or written by path, or a Summarize page written by path.
|
||||
"edit.keyboardShortcuts" => {
|
||||
params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some())
|
||||
}
|
||||
"plugin.reload" => true,
|
||||
_ => false,
|
||||
}
|
||||
@@ -712,6 +716,12 @@ mod tests {
|
||||
assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err());
|
||||
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err());
|
||||
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "srgb"})).is_ok());
|
||||
// Shortcut sets: text in and out is fine; a path reads or writes the filesystem.
|
||||
let ks = |p: serde_json::Value| authorize_engine_command("edit.keyboardShortcuts", &p);
|
||||
assert!(ks(serde_json::json!({"exportKys": true, "summarize": true, "importKys": "<photoshop-keyboard-shortcuts/>"})).is_ok());
|
||||
for key in ["importKys", "exportKys", "summarize"] {
|
||||
assert!(ks(serde_json::json!({ key: {"path": "/outside/set.kys"} })).is_err(), "{key}");
|
||||
}
|
||||
assert!(authorize_engine_command("filter.distort.displace", &serde_json::json!({"mapPath": "outside.png"})).is_err());
|
||||
assert!(authorize_engine_command("layer.setAdjustment", &serde_json::json!({"file": "outside.cube"})).is_err());
|
||||
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "colorSettings.workingRgb", "value": "outside.icc"})).is_err());
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Stored smart-filter parameters must not grant automation ambient filesystem access.
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use photocraft_automation::{AuthorizedWorkspace, Headless, workspace::authorize_desktop_engine_step};
|
||||
use photocraft_doc::{Document, LayerContent, LayerId};
|
||||
use photocraft_engine::{Session, actions_cmds::Action, jobs::Started, smart_cmds};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let base = std::env::temp_dir().join(format!("pc-filter-auth-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&base).unwrap();
|
||||
std::fs::create_dir(base.join("workspace")).unwrap();
|
||||
std::fs::create_dir(base.join("outside")).unwrap();
|
||||
let f = Self(base);
|
||||
std::fs::write(f.root().join("stripes.png"), png(|x| if (x / 2) % 2 == 0 { 0 } else { 255 })).unwrap();
|
||||
std::fs::write(f.outside("black.png"), png(|_| 0)).unwrap();
|
||||
std::fs::write(f.outside("white.png"), png(|_| 255)).unwrap();
|
||||
std::fs::write(f.outside("black.cube"), cube(0)).unwrap();
|
||||
std::fs::write(f.outside("white.cube"), cube(1)).unwrap();
|
||||
f
|
||||
}
|
||||
|
||||
fn root(&self) -> PathBuf {
|
||||
self.0.join("workspace")
|
||||
}
|
||||
fn outside(&self, name: &str) -> PathBuf {
|
||||
self.0.join("outside").join(name)
|
||||
}
|
||||
fn headless(&self) -> Headless {
|
||||
Headless::with_workspace(AuthorizedWorkspace::new(Some(&self.root()), Some(&self.root())).unwrap())
|
||||
}
|
||||
fn setup(&self, lookup: bool) -> (Headless, LayerId) {
|
||||
let mut h = self.headless();
|
||||
h.handle("doc.new", json!({"width": 32, "height": 32, "background": "#808080"})).unwrap();
|
||||
h.handle("doc.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
let layer = LayerId(h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let (id, params) = if lookup {
|
||||
("image.adjustments.colorLookup", json!({"layer": layer.0, "lut": "none"}))
|
||||
} else {
|
||||
("filter.distort.displace", json!({"layer": layer.0, "mapDocument": 0, "horizontal": 50, "vertical": 0}))
|
||||
};
|
||||
h.session.execute(id, params).unwrap();
|
||||
(h, layer)
|
||||
}
|
||||
fn saved_filter(&self, lookup: bool) -> (Document, LayerId) {
|
||||
let (h, layer) = self.setup(lookup);
|
||||
let mut doc = (*h.session.active().unwrap().doc).clone();
|
||||
let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!("smart object") };
|
||||
sm.smart_filters[0].params = path_params(lookup, &self.outside(if lookup { "white.cube" } else { "white.png" }));
|
||||
let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap();
|
||||
std::fs::write(self.root().join("stored.pcraft"), bytes).unwrap();
|
||||
(doc, layer)
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn png(value: impl Fn(u32) -> u8) -> Vec<u8> {
|
||||
let mut pixels = Vec::new();
|
||||
for _ in 0..32 {
|
||||
for x in 0..32 {
|
||||
pixels.extend([value(x); 3]);
|
||||
}
|
||||
}
|
||||
let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, pixels).unwrap();
|
||||
photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()
|
||||
}
|
||||
fn cube(value: u8) -> String {
|
||||
format!("LUT_3D_SIZE 2\n{}", format!("{value} {value} {value}\n").repeat(8))
|
||||
}
|
||||
fn path_params(lookup: bool, path: &Path) -> Value {
|
||||
if lookup { json!({"file": path}) } else { json!({"mapPath": path, "mapDocument": null}) }
|
||||
}
|
||||
fn assert_denied<T: std::fmt::Debug>(result: Result<T, impl std::fmt::Display>) {
|
||||
match result {
|
||||
Err(error) => assert!(error.to_string().contains("ambient filesystem"), "{error}"),
|
||||
Ok(value) => panic!("ambient file access was accepted: {value:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameter_edits_reject_outside_images_and_luts_without_changing_the_document() {
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (mut h, layer) = f.setup(lookup);
|
||||
let before = h.render_png(None, 64).unwrap();
|
||||
let doc = h.session.active().unwrap().doc.clone();
|
||||
let journal = h.session.journal.len();
|
||||
for name in if lookup { ["black.cube", "white.cube"] } else { ["black.png", "white.png"] } {
|
||||
let params = path_params(lookup, &f.outside(name));
|
||||
let id = if lookup { "image.adjustments.colorLookup" } else { "filter.distort.displace" };
|
||||
assert_denied(h.session.execute(id, params.clone()));
|
||||
assert_denied(h.handle("engine.execute", json!({"command": "layer.smartFilter.setParams", "params": {"layer": layer.0, "params": params}})));
|
||||
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
|
||||
assert_eq!(h.session.journal.len(), journal);
|
||||
assert_eq!(h.render_png(None, 64).unwrap(), before);
|
||||
}
|
||||
// Safe numeric and embedded-data edits remain usable.
|
||||
let safe = if lookup { json!({"data": cube(0), "fileName": "embedded.cube"}) } else { json!({"horizontal": 10}) };
|
||||
h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_playback_cannot_hide_a_smart_filter_path_edit() {
|
||||
let f = Fixture::new();
|
||||
let (mut h, layer) = f.setup(false);
|
||||
let doc = h.session.active().unwrap().doc.clone();
|
||||
h.session.actions.list.push(Action::new(
|
||||
"edit filter",
|
||||
vec![("layer.smartFilter.setParams".into(), json!({"layer": layer.0, "params": path_params(false, &f.outside("white.png"))}))],
|
||||
));
|
||||
let result = h.session.execute("actions.play", json!({"action": "edit filter"})).unwrap();
|
||||
assert!(result["failed"]["error"].as_str().unwrap().contains("ambient filesystem"), "{result}");
|
||||
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_filters_are_checked_after_merging_even_when_the_edit_contains_no_path() {
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (_, layer) = f.saved_filter(lookup);
|
||||
let mut h = f.headless();
|
||||
h.open(Path::new("stored.pcraft")).unwrap();
|
||||
let doc = h.session.active().unwrap().doc.clone();
|
||||
assert_denied(h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": {"horizontal": 10, "dither": false}})));
|
||||
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
|
||||
// Removing an ambient input makes the filter usable again.
|
||||
let safe = if lookup { json!({"file": "", "lut": "none"}) } else { json!({"mapPath": ""}) };
|
||||
h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_filters_cannot_read_on_rerender_or_reuse_a_trusted_cached_stage() {
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (doc, layer) = f.saved_filter(lookup);
|
||||
for warm_cache in [false, true] {
|
||||
if warm_cache {
|
||||
let mut trusted = doc.clone();
|
||||
assert!(smart_cmds::refresh(&mut trusted, layer).unwrap());
|
||||
}
|
||||
let mut h = f.headless();
|
||||
h.open(Path::new("stored.pcraft")).unwrap();
|
||||
let before = h.session.active().unwrap().doc.clone();
|
||||
assert_denied(h.session.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})));
|
||||
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_rerender_keeps_the_desktop_request_authority() {
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (doc, layer) = f.saved_filter(lookup);
|
||||
let mut s = Session::new();
|
||||
s.add_document(doc, None);
|
||||
s.authorize = Some(authorize_desktop_engine_step);
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let started = s.start("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap();
|
||||
// The desktop restores UI authority as soon as the automation request returns.
|
||||
s.authorize = None;
|
||||
let Started::Job(job) = started else { panic!("expected a background render") };
|
||||
assert_denied(s.wait_job(job));
|
||||
assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc));
|
||||
// The worker's restriction must not leak into subsequent trusted UI work.
|
||||
s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transferring_stored_filters_cannot_read_outside_files() {
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (_, from) = f.saved_filter(lookup);
|
||||
let mut h = f.headless();
|
||||
h.open(Path::new("stored.pcraft")).unwrap();
|
||||
let to = h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let before = h.session.active().unwrap().doc.clone();
|
||||
for copy in [false, true] {
|
||||
assert_denied(h.session.execute("layer.smartFilter.transfer", json!({"from": from.0, "to": to, "copy": copy})));
|
||||
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_parameter_edits_still_read_maps_and_luts_after_a_denied_request() {
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (mut h, layer) = f.setup(lookup);
|
||||
let edit = |path: &Path| json!({"layer": layer.0, "params": path_params(lookup, path)});
|
||||
assert_denied(h.session.execute("layer.smartFilter.setParams", edit(&f.outside("white.png"))));
|
||||
let mut trusted = Headless::trusted_local();
|
||||
trusted.session.add_document((*h.session.active().unwrap().doc).clone(), None);
|
||||
let mut renders = Vec::new();
|
||||
for name in if lookup { ["black.cube", "white.cube", "black.cube"] } else { ["black.png", "white.png", "black.png"] } {
|
||||
trusted.session.execute("layer.smartFilter.setParams", edit(&f.outside(name))).unwrap();
|
||||
renders.push(trusted.render_png(None, 64).unwrap());
|
||||
}
|
||||
assert_ne!(renders[0], renders[1], "trusted path-based filter still works");
|
||||
assert_eq!(renders[0], renders[2], "trusted rendering remains deterministic");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_unfiltered() {
|
||||
// link-watch and transform paths re-render through Session::edit, not Session::execute or
|
||||
// jobs::run; the gate must reach them and the denial must not silently pass pixels through
|
||||
// into the process-wide stack cache.
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (doc, layer) = f.saved_filter(lookup);
|
||||
let mut h = f.headless();
|
||||
h.open(Path::new("stored.pcraft")).unwrap();
|
||||
let before = h.session.active().unwrap().doc.clone();
|
||||
// Session::edit is the outside-execute mutation path (an edit closure the UI or a
|
||||
// linked-file change runs); the stored filter must be denied there too.
|
||||
assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
}));
|
||||
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
|
||||
// A trusted session (no gate) still renders the same document.
|
||||
let mut trusted = Session::new();
|
||||
trusted.add_document(doc, None);
|
||||
let mut d = (*trusted.active().unwrap().doc).clone();
|
||||
assert!(smart_cmds::refresh(&mut d, layer).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() {
|
||||
// The denial path must fail the render before any stage is cached process-wide.
|
||||
for lookup in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let (doc, layer) = f.saved_filter(lookup);
|
||||
let mut s = Session::new();
|
||||
s.add_document(doc, None);
|
||||
s.authorize = Some(authorize_desktop_engine_step);
|
||||
let bytes = smart_cmds::stack_cache_bytes();
|
||||
assert_denied(s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})));
|
||||
assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage");
|
||||
}
|
||||
}
|
||||
@@ -174,8 +174,9 @@ pub fn params_for(id: &str, p: &Value) -> Option<FilterParams> {
|
||||
/// Runs filter command `id` with JSON `params` on `surf` (the pixel function behind every
|
||||
/// `filter.*` command; smart filters re-run it on re-render). `bounds` frames distortions;
|
||||
/// `selection` limits the result. Like layer filters, neighbourhood filters repeat the edge pixels
|
||||
/// of `canvas` ∪ the surface's content instead of fading in transparency, and the output is
|
||||
/// clipped to it. `None` for ids that aren't pixel filters.
|
||||
/// `None` for ids that aren't pixel filters. `Err` when the requesting session's authority denies
|
||||
/// the filter's params (an ambient path the session may not read): the caller must surface the
|
||||
/// error rather than render unfiltered pixels.
|
||||
pub fn apply_filter_to_surface(
|
||||
id: &str,
|
||||
params: &Value,
|
||||
@@ -183,31 +184,36 @@ pub fn apply_filter_to_surface(
|
||||
bounds: photocraft_geom::Rect,
|
||||
selection: Option<&photocraft_raster::Surface>,
|
||||
canvas: photocraft_geom::Rect,
|
||||
) -> Option<photocraft_raster::Surface> {
|
||||
) -> Result<Option<photocraft_raster::Surface>> {
|
||||
// Replays bypass command dispatch; nested paths must still obey the requesting session's
|
||||
// authority, including adjustment LUTs and plug-ins, before any resolver can read them.
|
||||
crate::jobs::authorize_render_command(id, params)?;
|
||||
// Liquify / Puppet Warp / Perspective Warp smart filters (distort_cmds).
|
||||
if let Some(out) = crate::distort_cmds::apply_to_surface(id, params, surf, canvas) {
|
||||
return Some(out);
|
||||
return Ok(Some(out));
|
||||
}
|
||||
// Lens Correction / Adaptive Wide Angle / Camera Raw smart filters (lens_cmds).
|
||||
if let Some(out) = crate::lens_cmds::apply_to_surface(id, params, surf, canvas) {
|
||||
return Some(out);
|
||||
return Ok(Some(out));
|
||||
}
|
||||
// Image › Adjustments recorded as smart filters (Levels, Curves, Shadows/Highlights… from PSD).
|
||||
if let Some(kind) = id.strip_prefix("image.adjustments.") {
|
||||
return crate::adjust_cmds::adjust_as_filter(kind, params, surf);
|
||||
return Ok(crate::adjust_cmds::adjust_as_filter(kind, params, surf));
|
||||
}
|
||||
// WebAssembly plug-in smart filters (plugin_cmds).
|
||||
if id == crate::plugin_cmds::RUN {
|
||||
return crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas);
|
||||
return Ok(crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas));
|
||||
}
|
||||
let mut fp = params_for(id, params)?;
|
||||
let Some(mut fp) = params_for(id, params) else { return Ok(None) };
|
||||
// A map that can no longer be read (moved or deleted since) leaves the pixels alone, like an
|
||||
// unknown filter; the command itself already refused an unreadable map when it was added.
|
||||
crate::filters_ext::resolve_map_path(&mut fp, params).ok()?;
|
||||
if crate::filters_ext::resolve_map_path(&mut fp, params).is_err() {
|
||||
return Ok(None);
|
||||
}
|
||||
let sel_bounds = selection.map(photocraft_raster::Surface::content_bounds);
|
||||
let content = surf.content_bounds();
|
||||
let area = algo::output_area(&fp, content, bounds, sel_bounds);
|
||||
Some(algo::apply_in(surf, &fp, area, bounds, selection, canvas.union(&content)))
|
||||
Ok(Some(algo::apply_in(surf, &fp, area, bounds, selection, canvas.union(&content))))
|
||||
}
|
||||
|
||||
pub(crate) fn has_filterable_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
+43
-11
@@ -29,6 +29,43 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
type Authorization = fn(&str, &Value) -> Result<()>;
|
||||
|
||||
thread_local! {
|
||||
// Smart-filter rendering has no Session argument. Carry the requesting session's gate
|
||||
// through synchronous rendering and explicitly reinstall it on background workers.
|
||||
static RENDER_AUTHORIZATION: std::cell::Cell<Option<Authorization>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
pub(crate) struct AuthorizationScope(Option<Authorization>);
|
||||
|
||||
impl AuthorizationScope {
|
||||
fn enter(gate: Option<Authorization>) -> Self {
|
||||
Self(RENDER_AUTHORIZATION.with(|current| current.replace(gate.or(current.get()))))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AuthorizationScope {
|
||||
fn drop(&mut self) {
|
||||
RENDER_AUTHORIZATION.with(|current| current.set(self.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// [`AuthorizationScope`] entered with `gate` (or the one already installed, so trusted work on
|
||||
/// this thread keeps ambient paths usable). [`Session::edit`] and the job paths use this to carry
|
||||
/// the requesting session's authority into every smart-object re-render.
|
||||
pub(crate) fn render_authorization_scope(gate: Option<Authorization>) -> AuthorizationScope {
|
||||
AuthorizationScope::enter(gate)
|
||||
}
|
||||
|
||||
/// Recheck a stored filter's command and parameters before evaluating it or using its cache.
|
||||
pub(crate) fn authorize_render_command(id: &str, params: &Value) -> Result<()> {
|
||||
match RENDER_AUTHORIZATION.with(std::cell::Cell::get) {
|
||||
Some(gate) => gate(id, params),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifies one background job for the session's lifetime.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, serde::Deserialize)]
|
||||
pub struct JobId(pub u64);
|
||||
@@ -284,6 +321,8 @@ pub fn run<T: Send + 'static>(
|
||||
work: impl FnOnce(&JobCtx) -> Result<T> + Send + 'static,
|
||||
apply: impl FnOnce(&mut Session, T) -> Result<Value> + Send + 'static,
|
||||
) -> Result<Value> {
|
||||
let authorization = s.authorize.or_else(|| RENDER_AUTHORIZATION.with(std::cell::Cell::get));
|
||||
let _authorization = AuthorizationScope::enter(authorization);
|
||||
if !s.jobs.spawn || cfg!(target_arch = "wasm32") {
|
||||
let t = work(&s.jobs.inline.clone().unwrap_or_default())?;
|
||||
return apply(s, t);
|
||||
@@ -299,6 +338,7 @@ pub fn run<T: Send + 'static>(
|
||||
let ctx = JobCtx::new();
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Result<Payload>>();
|
||||
let erased: Apply = Box::new(move |s, any| {
|
||||
let _authorization = AuthorizationScope::enter(authorization);
|
||||
let t = any.downcast::<T>().map_err(|_| EngineError::Other("internal error: job result type mismatch".into()))?;
|
||||
apply(s, *t)
|
||||
});
|
||||
@@ -311,6 +351,7 @@ pub fn run<T: Send + 'static>(
|
||||
// The work closure is moved into the thread. If spawning fails (thread limits), there is
|
||||
// no way to get it back, so the error is reported instead.
|
||||
let spawned = std::thread::Builder::new().name(format!("photocraft-job-{}", id.0)).spawn(move || {
|
||||
let _authorization = AuthorizationScope::enter(authorization);
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(&wctx).map(|t| Box::new(t) as Payload)))
|
||||
.unwrap_or_else(|_| Err(EngineError::Other(format!("`{label_w}` failed with an internal error (logged); the document is unchanged"))));
|
||||
// The receiver is gone when the job was cancelled: nothing to report.
|
||||
@@ -652,6 +693,7 @@ impl Session {
|
||||
if let Some(gate) = self.authorize {
|
||||
gate(id, ¶ms)?;
|
||||
}
|
||||
let _authorization = AuthorizationScope::enter(self.authorize);
|
||||
// Last Filter runs as the filter it repeats: journaled, recorded in an action, faded and
|
||||
// run as a job like that filter, so a recorded step replays the same filter anywhere
|
||||
// (#3089), as Transform Again records its transform (#1440). With no filter yet it falls
|
||||
@@ -671,17 +713,7 @@ impl Session {
|
||||
return Ok(Started::Done(v));
|
||||
}
|
||||
// Pixel commands follow the Channels panel target unless the caller names one.
|
||||
let run_params = crate::channel_cmds::inject_target(self, id, crate::commands::inject_kind(id, params.clone()));
|
||||
if let Err(why) = self.explicit_layer_enabled(spec, ¶ms).unwrap_or_else(|| self.precondition(spec, &run_params)) {
|
||||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
if let Some(why) = self.job_conflict(id, spec.journal) {
|
||||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
// Painting on or moving a hidden layer is refused, as in Photoshop (#571).
|
||||
if let Some(why) = crate::hidden_target::refusal(self, id, ¶ms) {
|
||||
return Err(EngineError::Other(why.into()));
|
||||
}
|
||||
let run_params = self.admit(id, spec, ¶ms)?;
|
||||
self.coalesce_request = params.get("coalesce").and_then(Value::as_str).map(str::to_string);
|
||||
self.color_restrict = crate::channel_cmds::color_restriction(self, id, &run_params);
|
||||
self.jobs.spawn = background;
|
||||
|
||||
+14
-13
@@ -160,12 +160,14 @@ fn key(label: &str) -> String {
|
||||
l.trim_end_matches('…').trim().to_lowercase()
|
||||
}
|
||||
|
||||
/// The engine's own candidates: every command in a menu, in command order.
|
||||
/// The engine's own candidates: every command in a menu (the engine's, then the shell's), in
|
||||
/// command order.
|
||||
pub fn command_candidates() -> Vec<Candidate> {
|
||||
crate::command_specs()
|
||||
.iter()
|
||||
.filter(|c| !c.menu.is_empty())
|
||||
.map(|c| Candidate { id: c.id.to_string(), label: c.label.to_string(), default: c.shortcut.map(str::to_string) })
|
||||
let engine = crate::command_specs().iter().map(|c| (c.id, c.label, c.menu, c.shortcut));
|
||||
engine
|
||||
.chain(crate::shell_commands::SHELL_COMMANDS.iter().copied())
|
||||
.filter(|c| !c.2.is_empty())
|
||||
.map(|(id, label, _, default)| Candidate { id: id.to_string(), label: label.to_string(), default: default.map(str::to_string) })
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -219,8 +221,8 @@ pub fn plan(candidates: impl IntoIterator<Item = Candidate>, set: &KysSet) -> Pl
|
||||
}
|
||||
|
||||
/// The tool keys of `set` matched to `tools` (our tool id and Photoshop's name for it, which is
|
||||
/// ours too: "Move Tool"): tool id → key (a single letter, upper case), and the names with no
|
||||
/// tool here. A row PhotoCraft wrote names the tool by id. A key that isn't one letter is skipped.
|
||||
/// ours too: "Move Tool"): tool id → key (normalised: a letter, or an F-key), and the names with no
|
||||
/// tool here. A row PhotoCraft wrote names the tool by id. A key with modifiers is skipped.
|
||||
pub fn plan_tools<'a>(tools: impl IntoIterator<Item = (&'a str, &'a str)>, set: &KysSet) -> (BTreeMap<String, String>, Vec<String>) {
|
||||
let tools: Vec<(&str, &str)> = tools.into_iter().collect();
|
||||
let mut keys = BTreeMap::new();
|
||||
@@ -232,11 +234,10 @@ pub fn plan_tools<'a>(tools: impl IntoIterator<Item = (&'a str, &'a str)>, set:
|
||||
unknown.push(row.name.clone());
|
||||
continue;
|
||||
};
|
||||
let mut letters = row.shortcuts.first().map(|k| k.trim()).unwrap_or_default().chars();
|
||||
if let (Some(c), None) = (letters.next(), letters.next())
|
||||
&& c.is_ascii_alphanumeric()
|
||||
{
|
||||
keys.insert((*id).to_string(), c.to_ascii_uppercase().to_string());
|
||||
// One key without modifiers (Photoshop's letters; our tools may also take an F-key).
|
||||
let key = row.shortcuts.first().and_then(|k| normalize_shortcut(k.trim()));
|
||||
if let Some(k) = key.filter(|k| !k.contains('+') || k == "+") {
|
||||
keys.insert((*id).to_string(), k);
|
||||
}
|
||||
}
|
||||
(keys, unknown)
|
||||
@@ -448,7 +449,7 @@ mod tests {
|
||||
assert_eq!(keys.get("tools.select.move").map(String::as_str), Some("V"));
|
||||
assert_eq!(keys.get("tools.select.pencil").map(String::as_str), Some("N"), "case-insensitive, upper-cased");
|
||||
assert_eq!(keys.get("tools.select.brush").map(String::as_str), Some("B"), "by pc-id");
|
||||
assert!(!keys.contains_key("tools.select.lasso"), "a tool key is one letter");
|
||||
assert!(!keys.contains_key("tools.select.lasso"), "a tool key has no modifiers");
|
||||
assert_eq!(unknown, vec!["Imaginary Tool".to_string()]);
|
||||
// Our own export reads back by id.
|
||||
let out = write("T", &[Entry { id: "tools.select.pencil".into(), label: "Pencil Tool".into(), tool: true, keys: vec!["F".into()] }]);
|
||||
|
||||
@@ -1487,7 +1487,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
link_layers(s)
|
||||
}),
|
||||
spec!("layer.mergeLayers", "Merge Layers", &["Layer"], Some("Cmd+E"), "{} (one layer selected: Merge Down)", has_layer, |s, _| merge_layers(s)),
|
||||
spec!("layer.new.groupFromLayers", "Group from Layers…", &["Layer", "New"], None, r##"{"name":str?}"##, has_layer, group_layers),
|
||||
spec!(
|
||||
"layer.new.groupFromLayers",
|
||||
"Group from Layers…",
|
||||
&["Layer", "New"],
|
||||
None,
|
||||
r##"{"layer":id?,"name":str?} (no layer: every selected layer)"##,
|
||||
has_layer,
|
||||
group_layers
|
||||
),
|
||||
spec!("layer.arrange.reverse", "Reverse", &["Layer", "Arrange"], None, "{}", two_plus, |s, _| reverse(s)),
|
||||
spec!(
|
||||
"layer.lockLayers",
|
||||
|
||||
@@ -103,6 +103,7 @@ pub mod sample_cmds;
|
||||
pub mod sampler_cmds;
|
||||
pub mod select_extra_cmds;
|
||||
pub mod selection_cmds;
|
||||
pub mod shell_commands;
|
||||
pub mod shortcut_sets;
|
||||
pub mod slice_cmds;
|
||||
pub mod smart_cmds;
|
||||
@@ -421,6 +422,14 @@ pub struct CopiedStyle {
|
||||
pub mode: photocraft_color::ColorMode,
|
||||
}
|
||||
|
||||
/// The commands whose [`Session::precheck`] is complete: it accepts what `execute` does (Free
|
||||
/// Transform applies its box before these, and only when they will run), but for the group
|
||||
/// nesting limit ([`photocraft_doc::MAX_GROUP_DEPTH`]): grouping layers already that deep is
|
||||
/// refused only as the edit is made. Layer via Copy and
|
||||
/// Cut aren't: whether they have anything to copy (a selection over transparent pixels, a group
|
||||
/// or fill layer) is known only when they run.
|
||||
pub const PRECHECKED: &[&str] = &["layer.new.layer", "layer.new.group", "layer.new.groupFromLayers", "layer.new.layerFromBackground"];
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Session {
|
||||
docs: Vec<DocState>,
|
||||
@@ -659,6 +668,53 @@ impl Session {
|
||||
self.live_commit = None;
|
||||
}
|
||||
|
||||
/// Check a call without running it: the command exists, its params have the shape
|
||||
/// [`Self::execute`] requires (New Layer's fully), and it can run now. For a caller that does
|
||||
/// something first only when the command will go through: Free Transform applies its box
|
||||
/// before a new layer is made.
|
||||
///
|
||||
/// Complete for the commands in [`PRECHECKED`]: there it accepts what `execute` accepts, but
|
||||
/// for the group nesting limit (a test holds the two together). For others it checks only what they share.
|
||||
pub fn precheck(&mut self, id: &str, params: &Value) -> Result<()> {
|
||||
let spec = crate::commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
if !(params.is_object() || params.is_null()) {
|
||||
return Err(EngineError::BadParams { cmd: id.to_string(), msg: "params must be a JSON object".into() });
|
||||
}
|
||||
crate::param_check::check(spec, params)?;
|
||||
crate::filters::validate_params(id, params)?;
|
||||
if id == "layer.new.layer" {
|
||||
crate::new_layer::parse(params)?;
|
||||
}
|
||||
// A command that takes `"layer"` (an id or a name, as it resolves it) fails on one the
|
||||
// document doesn't have.
|
||||
if spec.params.contains("\"layer\":id")
|
||||
&& let Some(l) = commands::explicit_layer(self, params)?
|
||||
&& self.active().is_none_or(|st| st.doc.layer(l).is_none())
|
||||
{
|
||||
return Err(EngineError::BadParams { cmd: id.to_string(), msg: format!("no layer {}", l.0) });
|
||||
}
|
||||
self.admit(id, spec, params).map(|_| ())
|
||||
}
|
||||
|
||||
/// What `dispatch` checks once the params are valid and a floating selection has dropped:
|
||||
/// the precondition (on a named layer for commands that take one), a job running on the
|
||||
/// document, a hidden target. The run params (the Channels panel's target filled in) when it
|
||||
/// may run.
|
||||
pub(crate) fn admit(&mut self, id: &str, spec: &commands::CommandSpec, params: &Value) -> Result<Value> {
|
||||
let run_params = channel_cmds::inject_target(self, id, commands::inject_kind(id, params.clone()));
|
||||
if let Err(why) = self.explicit_layer_enabled(spec, params).unwrap_or_else(|| self.precondition(spec, &run_params)) {
|
||||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
if let Some(why) = self.job_conflict(id, spec.journal) {
|
||||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
// Painting on or moving a hidden layer is refused, as in Photoshop (#571).
|
||||
if let Some(why) = hidden_target::refusal(self, id, params) {
|
||||
return Err(EngineError::Other(why.into()));
|
||||
}
|
||||
Ok(run_params)
|
||||
}
|
||||
|
||||
/// Is the command currently runnable? (drives menu enablement)
|
||||
pub fn is_enabled(&self, id: &str) -> bool {
|
||||
self.is_enabled_with(id, &Value::Null)
|
||||
@@ -723,6 +779,9 @@ impl Session {
|
||||
|
||||
/// Apply an undoable edit to the active document.
|
||||
pub fn edit<R>(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Option<LayerId>) -> Result<R>) -> Result<R> {
|
||||
// Every mutation path (linked-file refresh, transform, place) re-renders smart objects
|
||||
// through this entry; the requesting session's authority must reach those renders.
|
||||
let _authorization = crate::jobs::render_authorization_scope(self.authorize);
|
||||
let restrict = self.color_restrict;
|
||||
let sampler_map = self.sampler_map.take();
|
||||
let st = self.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
|
||||
@@ -24,9 +24,49 @@ pub fn neutral_color(mode: BlendMode) -> Option<([f32; 3], &'static str)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A New Layer call's params, checked.
|
||||
pub(crate) struct NewLayer {
|
||||
clip: bool,
|
||||
mode: BlendMode,
|
||||
opacity: f32,
|
||||
label: LabelColor,
|
||||
neutral: Option<[f32; 3]>,
|
||||
below: bool,
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
/// `layer.new.layer`: a pixel layer above the active one (below it with `position: below`, the
|
||||
/// Layers panel's ⌘/Ctrl-click), joining the clip group it lands in.
|
||||
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let NewLayer { clip, mode, opacity, label, neutral, below, name } = parse(p)?;
|
||||
let id = s.edit("New Layer", |doc, active| {
|
||||
let name = name.clone().unwrap_or_else(|| doc.next_layer_name("Layer"));
|
||||
let mut layer = Layer::raster(name, doc.pixel_format());
|
||||
layer.blend = mode;
|
||||
layer.opacity = opacity;
|
||||
layer.label = label;
|
||||
if let Some(rgb) = neutral {
|
||||
let bounds = doc.bounds();
|
||||
let surface = layer.surface_mut().ok_or_else(|| bad("a new pixel layer has no pixels"))?;
|
||||
let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]);
|
||||
surface.fill_rect(bounds, &px);
|
||||
}
|
||||
// Nothing may sit under the Background: below it the layer lands above it instead.
|
||||
let id = match *active {
|
||||
Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer),
|
||||
_ => doc.insert_above(*active, layer),
|
||||
};
|
||||
crate::clip_group::settle(doc, &[id], &Default::default(), clip);
|
||||
*active = Some(id);
|
||||
Ok(id)
|
||||
})?;
|
||||
crate::layer_multi_cmds::note_inert_insert(s, id);
|
||||
Ok(json!({ "layer": id.0 }))
|
||||
}
|
||||
|
||||
/// Check a New Layer call's params without running it (Free Transform applies its box before a
|
||||
/// new layer only when the new layer can be made).
|
||||
pub(crate) fn parse(p: &Value) -> Result<NewLayer> {
|
||||
let flag = |k: &str| match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(false),
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false"))),
|
||||
@@ -62,29 +102,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_str().ok_or_else(|| bad("`name` must be a string"))?.to_string()),
|
||||
};
|
||||
let id = s.edit("New Layer", |doc, active| {
|
||||
let name = name.clone().unwrap_or_else(|| doc.next_layer_name("Layer"));
|
||||
let mut layer = Layer::raster(name, doc.pixel_format());
|
||||
layer.blend = mode;
|
||||
layer.opacity = opacity;
|
||||
layer.label = label;
|
||||
if let Some(rgb) = neutral {
|
||||
let bounds = doc.bounds();
|
||||
let surface = layer.surface_mut().ok_or_else(|| bad("a new pixel layer has no pixels"))?;
|
||||
let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]);
|
||||
surface.fill_rect(bounds, &px);
|
||||
}
|
||||
// Nothing may sit under the Background: below it the layer lands above it instead.
|
||||
let id = match *active {
|
||||
Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer),
|
||||
_ => doc.insert_above(*active, layer),
|
||||
};
|
||||
crate::clip_group::settle(doc, &[id], &Default::default(), clip);
|
||||
*active = Some(id);
|
||||
Ok(id)
|
||||
})?;
|
||||
crate::layer_multi_cmds::note_inert_insert(s, id);
|
||||
Ok(json!({ "layer": id.0 }))
|
||||
Ok(NewLayer { clip, mode, opacity, label, neutral, below, name })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+367
-31
@@ -529,8 +529,19 @@ pub struct Performance {
|
||||
/// sliders while they drag) render on a reduced copy: fast, but blocky when zoomed in. Off:
|
||||
/// they render at full resolution.
|
||||
pub low_resolution_previews: bool,
|
||||
/// Low-latency drawing (applies at next launch): frames reach the screen without waiting for
|
||||
/// vsync, so a stroke follows the pen about 50 ms sooner (the swap chain's wait, #1343). wgpu
|
||||
/// picks Immediate where the driver has it (most Vulkan and DX12 drivers on Windows and X11),
|
||||
/// so the canvas usually tears while it changes; Mailbox (no tearing) only where Immediate is
|
||||
/// missing. Frames are capped at 240 fps. Off (the default): vsync, smooth but late. Desktop
|
||||
/// only.
|
||||
pub low_latency_drawing: bool,
|
||||
/// Linux display server (applies at next launch; see [`LinuxDisplayServer`]).
|
||||
pub linux_display_server: LinuxDisplayServer,
|
||||
/// GPU memory the canvas compositor may hold for layer pages and effect maps, in MB; 0 is
|
||||
/// automatic (a graphics card's own memory isn't charged for the document's pixels, memory
|
||||
/// shared with the system is). Layers that don't fit redraw the whole document on the CPU.
|
||||
pub gpu_memory_mb: u32,
|
||||
/// Memory budget of the layer-effect cache, in MB.
|
||||
pub effect_cache_mb: u32,
|
||||
pub legacy_compositing: bool,
|
||||
@@ -560,7 +571,9 @@ impl Default for Performance {
|
||||
rendering_mode: None,
|
||||
gpu_backend: GpuBackend::Auto,
|
||||
low_resolution_previews: true,
|
||||
low_latency_drawing: false,
|
||||
linux_display_server: LinuxDisplayServer::Auto,
|
||||
gpu_memory_mb: 0,
|
||||
effect_cache_mb: 768,
|
||||
legacy_compositing: false,
|
||||
}
|
||||
@@ -1099,6 +1112,7 @@ pub fn range(path: &str) -> Option<(f64, f64)> {
|
||||
"interface.notificationDurationSeconds" => (1.0, 120.0),
|
||||
"export.jpegQuality" | "export.webpQuality" => (1.0, 100.0),
|
||||
"performance.memoryUsageMb" => (256.0, 1_048_576.0),
|
||||
"performance.gpuMemoryMb" => (0.0, 1_048_576.0),
|
||||
"performance.historyStates" => (1.0, 1000.0),
|
||||
"performance.cacheLevels" => (1.0, 8.0),
|
||||
"performance.cacheTileSize" => (256.0, 16384.0),
|
||||
@@ -1495,9 +1509,65 @@ pub fn default_alternates(id: &str) -> impl Iterator<Item = &'static str> + '_ {
|
||||
ALTERNATE_SHORTCUTS.iter().filter(move |(c, _)| *c == id).map(|(_, sc)| *sc)
|
||||
}
|
||||
|
||||
/// Every bindable id with its default: commands, then the temporary tools.
|
||||
/// Keys the system takes before the app sees them (switching apps, closing the window, force
|
||||
/// quit): `edit.keyboardShortcuts` won't bind them, as Photoshop won't. `sc` is in our notation,
|
||||
/// where `Cmd` is Ctrl off the Mac.
|
||||
pub fn reserved_shortcut(sc: &str) -> bool {
|
||||
let Some(n) = normalize_shortcut(sc) else { return false };
|
||||
let reserved: &[&str] = if cfg!(target_os = "macos") {
|
||||
&["Cmd+Tab", "Cmd+Shift+Tab", "Cmd+Alt+Escape", "Cmd+`"]
|
||||
} else if cfg!(target_os = "windows") {
|
||||
&["Alt+Tab", "Alt+Shift+Tab", "Alt+F4", "Cmd+Alt+Delete", "Cmd+Escape", "Cmd+Shift+Escape"]
|
||||
} else {
|
||||
// Linux and the BSDs: window switching and closing, and the virtual consoles.
|
||||
&[
|
||||
"Alt+Tab",
|
||||
"Alt+Shift+Tab",
|
||||
"Alt+F4",
|
||||
"Cmd+Alt+Delete",
|
||||
"Cmd+Alt+F1",
|
||||
"Cmd+Alt+F2",
|
||||
"Cmd+Alt+F3",
|
||||
"Cmd+Alt+F4",
|
||||
"Cmd+Alt+F5",
|
||||
"Cmd+Alt+F6",
|
||||
"Cmd+Alt+F7",
|
||||
"Cmd+Alt+F8",
|
||||
"Cmd+Alt+F9",
|
||||
"Cmd+Alt+F10",
|
||||
"Cmd+Alt+F11",
|
||||
"Cmd+Alt+F12",
|
||||
]
|
||||
};
|
||||
reserved.iter().filter_map(|r| normalize_shortcut(r)).any(|r| r == n)
|
||||
}
|
||||
|
||||
/// What a bindable id is: a command (the engine's or the shell's), a tool, or a held tool.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BindKind {
|
||||
Command,
|
||||
Tool,
|
||||
Hold,
|
||||
}
|
||||
|
||||
/// Every bindable id: (id, label, menu path, default key, kind). The engine's commands, the
|
||||
/// shell's ([`crate::shell_commands::SHELL_COMMANDS`]), the tools and the held temporary tools,
|
||||
/// so Edit › Keyboard Shortcuts works the same with or without the app's UI.
|
||||
pub fn bindable_items() -> impl Iterator<Item = (&'static str, &'static str, &'static [&'static str], Option<&'static str>, BindKind)> {
|
||||
let engine = crate::command_specs().iter().map(|c| (c.id, c.label, c.menu, c.shortcut, BindKind::Command));
|
||||
let shell = crate::shell_commands::SHELL_COMMANDS.iter().map(|c| (c.0, c.1, c.2, c.3, BindKind::Command));
|
||||
let tools = crate::shell_commands::TOOLS.iter().map(|t| (t.0, t.1, &["Tools"][..], t.2, BindKind::Tool));
|
||||
let hold = TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, &["Tools", "Temporary"][..], Some(t.2), BindKind::Hold));
|
||||
engine.chain(shell).chain(tools).chain(hold)
|
||||
}
|
||||
|
||||
/// Every bindable id with its default.
|
||||
fn bindable() -> impl Iterator<Item = (&'static str, Option<&'static str>)> {
|
||||
crate::command_specs().iter().map(|c| (c.id, c.shortcut)).chain(TEMPORARY_TOOLS.iter().map(|t| (t.0, Some(t.2))))
|
||||
bindable_items().map(|i| (i.0, i.3))
|
||||
}
|
||||
|
||||
fn is_tool(id: &str) -> bool {
|
||||
crate::shell_commands::tool_default(id).is_some()
|
||||
}
|
||||
|
||||
/// Shortcuts bound to more than one command: (shortcut, ids). `bindings` are (id, shortcut).
|
||||
@@ -1866,18 +1936,45 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let imported;
|
||||
let mut import = None;
|
||||
let p = match p.get("importKys") {
|
||||
Some(xml) => {
|
||||
let xml = xml.as_str().ok_or_else(|| bad(cmd, "`importKys` is the text of a .kys file"))?;
|
||||
Some(v) => {
|
||||
// The file's text, or {"path": …} to read it.
|
||||
let read;
|
||||
let xml = match v {
|
||||
Value::String(s) => s.as_str(),
|
||||
Value::Object(o) => {
|
||||
let path =
|
||||
o.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(cmd, "`importKys.path` is a .kys file"))?;
|
||||
read = read_text_file(path, crate::kys::MAX_BYTES).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
read.as_str()
|
||||
}
|
||||
_ => return Err(bad(cmd, "`importKys` is the text of a .kys file, or {\"path\": …}")),
|
||||
};
|
||||
let set = crate::kys::parse(xml).map_err(|e| bad(cmd, e))?;
|
||||
let plan = crate::kys::plan(crate::kys::command_candidates(), &set);
|
||||
let mut merged = serde_json::Map::new();
|
||||
for (id, sc) in &plan.set {
|
||||
// A key the system reserves is left out (reported as `reserved`), not a failed import.
|
||||
let mut skipped: Vec<String> = Vec::new();
|
||||
for (id, sc) in plan.set.iter().filter(|(id, sc)| {
|
||||
let bad = reserved_shortcut(sc);
|
||||
if bad {
|
||||
skipped.push(format!("{id}: {sc}"));
|
||||
}
|
||||
!bad
|
||||
}) {
|
||||
let default = crate::command_specs().iter().find(|c| c.id == id).and_then(|c| c.shortcut).and_then(normalize_shortcut);
|
||||
merged.insert(id.clone(), if default.as_deref() == Some(sc.as_str()) { Value::Null } else { json!(sc) });
|
||||
}
|
||||
// Tool keys: a tool's own letter restores it (and its Shift cycling).
|
||||
let tools = crate::shell_commands::TOOLS.iter().map(|t| (t.0, t.1));
|
||||
let (tool_keys, unknown_tools) = crate::kys::plan_tools(tools, &set);
|
||||
for (id, key) in &tool_keys {
|
||||
let own = crate::shell_commands::tool_default(id).flatten().is_some_and(|d| d.eq_ignore_ascii_case(key));
|
||||
merged.insert(id.clone(), if own { Value::Null } else { json!(key) });
|
||||
}
|
||||
merged.extend(p.get("set").and_then(Value::as_object).cloned().unwrap_or_default());
|
||||
let mut alternates = serde_json::Map::new();
|
||||
for (id, keys) in &plan.alternate_keys {
|
||||
let keys: &Vec<String> = &keys.iter().filter(|k| !reserved_shortcut(k)).cloned().collect();
|
||||
let main = plan.set.get(id).map(String::as_str);
|
||||
let default = Preferences::default().alternates(id, main);
|
||||
alternates.insert(id.clone(), if *keys == default { Value::Null } else { json!(keys) });
|
||||
@@ -1892,6 +1989,9 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"alternates": plan.alternates,
|
||||
"alternateKeys": plan.alternate_keys,
|
||||
"toolKeys": set.tool_keys,
|
||||
"tools": tool_keys,
|
||||
"unknownTools": unknown_tools,
|
||||
"reserved": skipped,
|
||||
"windows": set.windows,
|
||||
}));
|
||||
let mut with_import = p.clone();
|
||||
@@ -1914,6 +2014,17 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !set.is_empty() || !alts.is_empty() {
|
||||
let allow_unknown = p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false);
|
||||
changed = true;
|
||||
// Reserved system keys can't be bound, by hand or from a file.
|
||||
let reserved = |v: &Value| -> Option<String> {
|
||||
let keys = match v {
|
||||
Value::String(s) => vec![s.clone()],
|
||||
other => alternate_keys(other).unwrap_or_default(),
|
||||
};
|
||||
keys.into_iter().find(|k| reserved_shortcut(k))
|
||||
};
|
||||
if let Some((id, k)) = set.iter().chain(alts.iter()).find_map(|(id, v)| reserved(v).map(|k| (id, k))) {
|
||||
return Err(bad(cmd, format!("`{k}` (for `{id}`) is reserved by the system and can't be assigned")));
|
||||
}
|
||||
for (id, v) in set {
|
||||
if !bindable().any(|(b, _)| b == id) && !allow_unknown {
|
||||
return Err(bad(cmd, format!("unknown command `{id}`")));
|
||||
@@ -1935,9 +2046,11 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.chain(alts.iter().flat_map(|(id, v)| alternate_keys(v).unwrap_or_default().into_iter().map(move |k| (id.as_str(), k))))
|
||||
.collect();
|
||||
for (id, sc) in assigned {
|
||||
// Shell commands (View › Zoom In) have alternates but no engine default.
|
||||
let shell = ALTERNATE_SHORTCUTS.iter().filter(|(c, _)| crate::commands::find(c).is_none()).map(|(c, _)| (*c, None));
|
||||
for (c, def) in bindable().chain(shell) {
|
||||
for (c, def) in bindable() {
|
||||
// Tools may share a key: they cycle with Shift, as Photoshop's groups do.
|
||||
if is_tool(id) && is_tool(c) {
|
||||
continue;
|
||||
}
|
||||
// Never strip a command that this same call is assigning:
|
||||
// two entries for one key are a clash inside the call, which
|
||||
// the returned conflicts list reports (#719). Stripping
|
||||
@@ -1959,16 +2072,11 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Then the sets are saved, deleted or switched, from the bindings as they now stand.
|
||||
changed |= crate::shortcut_sets::store(&mut next, p).map_err(|e| bad(cmd, e))?;
|
||||
if changed {
|
||||
s.edit_prefs(|p| *p = next);
|
||||
}
|
||||
let prefs = s.prefs();
|
||||
// The reply and the exports read the bindings as they will be; they are committed only once
|
||||
// the exports are written, so a bad export target changes nothing.
|
||||
let prefs = &next;
|
||||
let filter = p.get("filter").and_then(Value::as_str).map(str::to_ascii_lowercase);
|
||||
let temporary = TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, &["Tools", "Temporary"][..], Some(t.2), true));
|
||||
let list: Vec<Value> = crate::command_specs()
|
||||
.iter()
|
||||
.map(|c| (c.id, c.label, c.menu, c.shortcut, false))
|
||||
.chain(temporary)
|
||||
let list: Vec<Value> = bindable_items()
|
||||
.filter(|c| filter.as_ref().is_none_or(|f| c.0.to_ascii_lowercase().contains(f) || c.1.to_ascii_lowercase().contains(f)))
|
||||
.filter(|c| {
|
||||
p.get("list").and_then(Value::as_bool).unwrap_or(false)
|
||||
@@ -1976,15 +2084,17 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|| prefs.shortcuts.contains_key(c.0)
|
||||
|| prefs.alternate_shortcuts.contains_key(c.0)
|
||||
})
|
||||
.map(|(id, label, menu, def, hold)| {
|
||||
.map(|(id, label, menu, def, kind)| {
|
||||
let main = prefs.shortcut(id, def);
|
||||
let defaults: Vec<&str> = default_alternates(id).collect();
|
||||
let mut v = json!({"id": id, "label": label, "menu": menu, "default": def, "shortcut": main, "alternates": prefs.alternates(id, main)});
|
||||
if !defaults.is_empty() {
|
||||
v["defaultAlternates"] = json!(defaults);
|
||||
}
|
||||
if hold {
|
||||
v["hold"] = json!(true);
|
||||
match kind {
|
||||
BindKind::Hold => v["hold"] = json!(true),
|
||||
BindKind::Tool => v["tool"] = json!(true),
|
||||
BindKind::Command => {}
|
||||
}
|
||||
v
|
||||
})
|
||||
@@ -1995,30 +2105,59 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
bindings.extend(main.map(|m| (id, m.to_string())));
|
||||
bindings.extend(prefs.alternates(id, main).into_iter().map(|k| (id, k)));
|
||||
}
|
||||
let conflicts: Vec<Value> =
|
||||
conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str()))).into_iter().map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})).collect();
|
||||
// Tools sharing a key are a cycling group, not a conflict.
|
||||
let conflicts: Vec<Value> = conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str())))
|
||||
.into_iter()
|
||||
.filter(|(_, ids)| !ids.iter().all(|id| is_tool(id)))
|
||||
.map(|(sc, ids)| json!({"shortcut": sc, "commands": ids}))
|
||||
.collect();
|
||||
let mut out = json!({"overrides": prefs.shortcuts, "alternateOverrides": prefs.alternate_shortcuts, "commands": list, "conflicts": conflicts});
|
||||
if let (Some(o), Value::Object(sets)) = (out.as_object_mut(), crate::shortcut_sets::describe(prefs)) {
|
||||
o.extend(sets);
|
||||
}
|
||||
// The bindings as a `.kys` file, or Photoshop's Summarize page (the engine's commands; the
|
||||
// app's dialog writes both with the shell's commands and tools added).
|
||||
// The bindings as a `.kys` file, or Photoshop's Summarize page: every bindable item (the
|
||||
// engine's commands, the shell's commands and the tools).
|
||||
let set_name = if prefs.shortcut_set.is_empty() { crate::shortcut_sets::DEFAULTS_NAME } else { prefs.shortcut_set.as_str() };
|
||||
if p.get("exportKys").and_then(Value::as_bool) == Some(true) {
|
||||
// Both targets are resolved and their texts made before anything is written, and a path that
|
||||
// can't be written is refused before either file is: a bad export changes nothing.
|
||||
let kys_target = file_target(cmd, p.get("exportKys"))?;
|
||||
let html_target = file_target(cmd, p.get("summarize"))?;
|
||||
let mut files: Vec<(&str, String, String)> = Vec::new();
|
||||
if let Some(target) = kys_target {
|
||||
let entries: Vec<crate::kys::Entry> =
|
||||
crate::shortcut_sets::bound(prefs).into_iter().map(|(id, label, _, keys)| crate::kys::Entry { id, label, tool: false, keys }).collect();
|
||||
out["kys"] = json!(crate::kys::write(set_name, &entries));
|
||||
crate::shortcut_sets::bound(prefs).into_iter().map(|b| crate::kys::Entry { id: b.id, label: b.label, tool: b.tool, keys: b.keys }).collect();
|
||||
let text = crate::kys::write(set_name, &entries);
|
||||
match target {
|
||||
Some(path) => files.push(("kysPath", path, text)),
|
||||
None => out["kys"] = json!(text),
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("summarize").filter(|v| !v.is_null() && *v != &json!(false)) {
|
||||
if let Some(target) = html_target {
|
||||
let v = p.get("summarize").cloned().unwrap_or(Value::Null);
|
||||
let mac = v.get("mac").and_then(Value::as_bool).unwrap_or(cfg!(target_os = "macos"));
|
||||
let mut sections: Vec<crate::shortcut_sets::Section> = Vec::new();
|
||||
for (_, label, menu, keys) in crate::shortcut_sets::bound(prefs) {
|
||||
for crate::shortcut_sets::Bound { label, menu, keys, .. } in crate::shortcut_sets::bound(prefs) {
|
||||
match sections.iter_mut().find(|(m, _)| *m == menu) {
|
||||
Some((_, rows)) => rows.push((label, keys)),
|
||||
None => sections.push((menu, vec![(label, keys)])),
|
||||
}
|
||||
}
|
||||
out["html"] = json!(crate::shortcut_sets::summarize(set_name, §ions, mac));
|
||||
let html = crate::shortcut_sets::summarize(set_name, §ions, mac);
|
||||
match target {
|
||||
Some(path) => files.push(("htmlPath", path, html)),
|
||||
None => out["html"] = json!(html),
|
||||
}
|
||||
}
|
||||
for (_, path, _) in &files {
|
||||
writable(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
}
|
||||
let list: Vec<(&str, &str)> = files.iter().map(|(_, path, text)| (path.as_str(), text.as_str())).collect();
|
||||
write_files_atomically(&list, &rename_path).map_err(|e| bad(cmd, e))?;
|
||||
for (key, path, _) in &files {
|
||||
out[*key] = json!(path);
|
||||
}
|
||||
if changed {
|
||||
s.edit_prefs(|p| *p = next);
|
||||
}
|
||||
if let Some(import) = import {
|
||||
out["import"] = import;
|
||||
@@ -2026,6 +2165,203 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// An output parameter (`exportKys`, `summarize`): absent or false → `None`; true (or an object
|
||||
/// without `path`) → `Some(None)`, the text comes back in the reply; `{"path": …}` →
|
||||
/// `Some(Some(path))`, written there.
|
||||
fn file_target(cmd: &str, v: Option<&Value>) -> Result<Option<Option<String>>> {
|
||||
match v {
|
||||
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
|
||||
Some(Value::Bool(true)) => Ok(Some(None)),
|
||||
Some(Value::Object(o)) => match o.get("path") {
|
||||
None | Some(Value::Null) => Ok(Some(None)),
|
||||
Some(Value::String(s)) if !s.trim().is_empty() => Ok(Some(Some(s.clone()))),
|
||||
Some(_) => Err(bad(cmd, "`path` must be a non-empty string")),
|
||||
},
|
||||
Some(_) => Err(bad(cmd, "expected true or {\"path\": …}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_text_file(path: &str, max: usize) -> std::result::Result<String, String> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| e.to_string())?;
|
||||
if !meta.is_file() || meta.len() > max as u64 {
|
||||
return Err(format!("not a file of at most {} KiB", max / 1024));
|
||||
}
|
||||
std::fs::read_to_string(path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn read_text_file(_: &str, _: usize) -> std::result::Result<String, String> {
|
||||
Err("paths can't be read on the web; pass the file's text instead".into())
|
||||
}
|
||||
|
||||
/// Whether `path` can be written: not a folder, in a folder that exists.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn writable(path: &str) -> std::result::Result<(), String> {
|
||||
let p = std::path::Path::new(path);
|
||||
if p.is_dir() {
|
||||
return Err("is a folder".into());
|
||||
}
|
||||
if std::fs::metadata(p).is_ok_and(|m| m.permissions().readonly()) {
|
||||
return Err("is read-only".into());
|
||||
}
|
||||
match p.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
Some(d) if !d.is_dir() => Err("its folder doesn't exist".into()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn writable(_: &str) -> std::result::Result<(), String> {
|
||||
Err("paths can't be written on the web; omit `path` to get the text back".into())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn rename_path(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> {
|
||||
std::fs::rename(from, to)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn rename_path(_: &std::path::Path, _: &std::path::Path) -> std::io::Result<()> {
|
||||
Err(std::io::Error::other("paths can't be written on the web"))
|
||||
}
|
||||
|
||||
/// Where `path` points, for telling whether two targets are one file: its folder resolved, its
|
||||
/// name compared without case where the file system ignores it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn path_key(path: &str) -> String {
|
||||
let p = std::path::Path::new(path);
|
||||
let dir = p.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
|
||||
let dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
|
||||
let key = dir.join(p.file_name().unwrap_or_default()).to_string_lossy().into_owned();
|
||||
if cfg!(any(target_os = "windows", target_os = "macos")) { key.to_lowercase() } else { key }
|
||||
}
|
||||
|
||||
/// A new, empty file beside `path` with a name nothing else uses (`create_new`), for staging
|
||||
/// (`kind` "tmp") or a backup ("bak").
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn sibling(path: &str, kind: &str) -> std::io::Result<(std::path::PathBuf, std::fs::File)> {
|
||||
for n in 0..1000u32 {
|
||||
let p = std::path::PathBuf::from(format!("{path}.{}-{n}.photocraft-{kind}", std::process::id()));
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&p) {
|
||||
Ok(f) => return Ok((p, f)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::other("no free name beside it"))
|
||||
}
|
||||
|
||||
/// Write `files` ((path, text)) all or nothing. Each text is staged in a new file beside its
|
||||
/// target; once every one is written, each existing target is moved aside to a backup and the
|
||||
/// staged file moved in. Any failure moves the backups back and removes what was staged, so the
|
||||
/// user's files end as they began; on success the backups go. Two targets naming one file are
|
||||
/// refused. `rename` is `std::fs::rename` (a test fails it on purpose).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn write_files_atomically(
|
||||
files: &[(&str, &str)],
|
||||
rename: &dyn Fn(&std::path::Path, &std::path::Path) -> std::io::Result<()>,
|
||||
) -> std::result::Result<(), String> {
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
for (i, (a, _)) in files.iter().enumerate() {
|
||||
if files.iter().skip(i + 1).any(|(b, _)| path_key(a) == path_key(b)) {
|
||||
return Err(format!("{a}: two exports to the same file"));
|
||||
}
|
||||
}
|
||||
let mut staged: Vec<PathBuf> = Vec::new();
|
||||
let drop_all = |paths: &[PathBuf]| {
|
||||
for p in paths {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
};
|
||||
for (path, text) in files {
|
||||
let (tmp, mut f) = match sibling(path, "tmp") {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
drop_all(&staged);
|
||||
return Err(format!("{path}: {e}"));
|
||||
}
|
||||
};
|
||||
staged.push(tmp);
|
||||
if let Err(e) = f.write_all(text.as_bytes()).and_then(|()| f.sync_all()) {
|
||||
drop_all(&staged);
|
||||
return Err(format!("{path}: {e}"));
|
||||
}
|
||||
}
|
||||
// (target, its backup) of each file moved in so far.
|
||||
let mut moved: Vec<(&Path, Option<PathBuf>)> = Vec::new();
|
||||
// Put each backup back over its target (a target that had none is removed). What couldn't be
|
||||
// put back is named, so the user can find their previous file.
|
||||
let undo = |moved: &[(&Path, Option<PathBuf>)]| -> String {
|
||||
let mut stranded = Vec::new();
|
||||
for (target, backup) in moved.iter().rev() {
|
||||
match backup {
|
||||
Some(b) => {
|
||||
if rename(b, target).is_err() {
|
||||
stranded.push(format!("the previous {} is at {}", target.display(), b.display()));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let _ = std::fs::remove_file(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
if stranded.is_empty() { String::new() } else { format!(" ({})", stranded.join("; ")) }
|
||||
};
|
||||
for ((path, _), tmp) in files.iter().zip(&staged) {
|
||||
let target = Path::new(*path);
|
||||
let backup = if target.exists() {
|
||||
let moved_aside = sibling(path, "bak").and_then(|(b, f)| {
|
||||
drop(f);
|
||||
match rename(target, &b) {
|
||||
Ok(()) => Ok(b),
|
||||
Err(e) => {
|
||||
// The placeholder the backup name reserved goes too.
|
||||
let _ = std::fs::remove_file(&b);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
});
|
||||
match moved_aside {
|
||||
Ok(b) => Some(b),
|
||||
Err(e) => {
|
||||
let stranded = undo(&moved);
|
||||
drop_all(&staged);
|
||||
return Err(format!("{path}: {e}{stranded}"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Err(e) = rename(tmp, target) {
|
||||
let mut stranded = undo(&moved);
|
||||
if let Some(b) = &backup
|
||||
&& rename(b, target).is_err()
|
||||
{
|
||||
stranded = format!("{stranded} (the previous {path} is at {})", b.display());
|
||||
}
|
||||
drop_all(&staged);
|
||||
return Err(format!("{path}: {e}{stranded}"));
|
||||
}
|
||||
moved.push((target, backup));
|
||||
}
|
||||
for (_, backup) in moved {
|
||||
if let Some(b) = backup {
|
||||
let _ = std::fs::remove_file(b);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn write_files_atomically(
|
||||
files: &[(&str, &str)],
|
||||
_: &dyn Fn(&std::path::Path, &std::path::Path) -> std::io::Result<()>,
|
||||
) -> std::result::Result<(), String> {
|
||||
if files.is_empty() { Ok(()) } else { Err("paths can't be written on the web; omit `path` to get the text back".into()) }
|
||||
}
|
||||
|
||||
/// Edit › Menus: hide/show items and give them colours.
|
||||
fn menus(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = |k: &str| -> Vec<String> {
|
||||
@@ -2151,7 +2487,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Keyboard Shortcuts…",
|
||||
["Edit"],
|
||||
Some("Cmd+Alt+Shift+K"),
|
||||
r##"{"set":{"<command id>|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"<command id>":["F3",…]|[](none)|null(default)}?,"reset":true|["<id>",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?,"useSet":"<name>"|""(defaults)?,"saveSet":"<name>"?,"deleteSet":"<name>"?,"sets":{"<name>":{"shortcuts":{…},"alternateShortcuts":{…}}}?,"activeSet":"<name>"|""?,"exportKys":bool?,"summarize":bool|{"mac":bool}?}"##,
|
||||
r##"{"set":{"<command id>|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"<command id>":["F3",…]|[](none)|null(default)}?,"reset":true|["<id>",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"|{"path":".kys"}?,"useSet":"<name>"|""(defaults)?,"saveSet":"<name>"?,"deleteSet":"<name>"?,"sets":{"<name>":{"shortcuts":{…},"alternateShortcuts":{…}}}?,"activeSet":"<name>"|""?,"exportKys":true|{"path":".kys"}?,"summarize":true|{"mac":bool,"path":".html"}?} (with a path the file is written and the reply has kysPath/htmlPath instead of kys/html)"##,
|
||||
keyboard_shortcuts,
|
||||
true
|
||||
),
|
||||
|
||||
@@ -827,3 +827,91 @@ fn shift_key_tool_switch_is_on_by_default_and_a_stored_false_is_kept() {
|
||||
let stored: Tools = serde_json::from_value(json!({"useShiftKeyForToolSwitch": false})).unwrap();
|
||||
assert!(!stored.use_shift_key_for_tool_switch);
|
||||
}
|
||||
|
||||
/// Shortcut exports are all or nothing (#5801): a rename that fails after the first file was
|
||||
/// moved in puts the user's files back as they were and leaves nothing staged.
|
||||
#[test]
|
||||
fn exports_are_written_all_or_nothing() {
|
||||
use super::write_files_atomically;
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-atomic-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (a, b) = (dir.join("a.kys"), dir.join("b.html"));
|
||||
std::fs::write(&a, "old A").unwrap();
|
||||
std::fs::write(&b, "old B").unwrap();
|
||||
let (pa, pb) = (a.to_str().unwrap(), b.to_str().unwrap());
|
||||
let staged_moves = std::cell::Cell::new(0);
|
||||
let failing = |from: &std::path::Path, to: &std::path::Path| {
|
||||
if from.to_string_lossy().ends_with("photocraft-tmp") {
|
||||
staged_moves.set(staged_moves.get() + 1);
|
||||
if staged_moves.get() == 2 {
|
||||
return Err(std::io::Error::other("disk gone"));
|
||||
}
|
||||
}
|
||||
std::fs::rename(from, to)
|
||||
};
|
||||
assert!(write_files_atomically(&[(pa, "new A"), (pb, "new B")], &failing).is_err());
|
||||
assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&b).unwrap()), ("old A".into(), "old B".into()));
|
||||
let leftovers = || std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).filter(|e| e.file_name().to_string_lossy().contains(".photocraft-")).count();
|
||||
assert_eq!(leftovers(), 0);
|
||||
// A backup move that fails leaves no empty placeholder behind.
|
||||
let backups = std::cell::Cell::new(0);
|
||||
let failing_backup = |from: &std::path::Path, to: &std::path::Path| {
|
||||
if to.to_string_lossy().ends_with("photocraft-bak") {
|
||||
backups.set(backups.get() + 1);
|
||||
if backups.get() == 2 {
|
||||
return Err(std::io::Error::other("locked"));
|
||||
}
|
||||
}
|
||||
std::fs::rename(from, to)
|
||||
};
|
||||
assert!(write_files_atomically(&[(pa, "new A"), (pb, "new B")], &failing_backup).is_err());
|
||||
assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&b).unwrap()), ("old A".into(), "old B".into()));
|
||||
assert_eq!(leftovers(), 0);
|
||||
// The same target twice is refused before anything is written.
|
||||
assert!(write_files_atomically(&[(pa, "x"), (pa, "y")], &|f, t| std::fs::rename(f, t)).is_err());
|
||||
assert_eq!(std::fs::read_to_string(&a).unwrap(), "old A");
|
||||
// A target named like a staging file is just a file.
|
||||
let odd = dir.join("c.kys.photocraft-tmp");
|
||||
std::fs::write(&odd, "old C").unwrap();
|
||||
write_files_atomically(&[(pa, "new A"), (odd.to_str().unwrap(), "new C")], &|f, t| std::fs::rename(f, t)).unwrap();
|
||||
assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&odd).unwrap()), ("new A".into(), "new C".into()));
|
||||
assert_eq!(leftovers(), 1, "only c.kys.photocraft-tmp itself");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A backup that can't be put back after a failed export is named in the error, and stays where
|
||||
/// it is with the user's previous text.
|
||||
#[test]
|
||||
fn a_stranded_backup_is_named_in_the_error() {
|
||||
use super::write_files_atomically;
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-stranded-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (a, b) = (dir.join("a.kys"), dir.join("b.html"));
|
||||
std::fs::write(&a, "old A").unwrap();
|
||||
std::fs::write(&b, "old B").unwrap();
|
||||
let (pa, pb) = (a.to_str().unwrap(), b.to_str().unwrap());
|
||||
let staged = std::cell::Cell::new(0);
|
||||
// The second staged move fails, and so does putting a.kys's backup back.
|
||||
let rename = |from: &std::path::Path, to: &std::path::Path| {
|
||||
let f = from.to_string_lossy();
|
||||
if f.ends_with("photocraft-tmp") {
|
||||
staged.set(staged.get() + 1);
|
||||
if staged.get() == 2 {
|
||||
return Err(std::io::Error::other("disk gone"));
|
||||
}
|
||||
}
|
||||
if f.ends_with("photocraft-bak") && to == a.as_path() {
|
||||
return Err(std::io::Error::other("locked"));
|
||||
}
|
||||
std::fs::rename(from, to)
|
||||
};
|
||||
let e = write_files_atomically(&[(pa, "new A"), (pb, "new B")], &rename).unwrap_err();
|
||||
assert!(e.contains(&format!("the previous {} is at", a.display())), "{e}");
|
||||
let backup: Vec<_> =
|
||||
std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.path()).filter(|p| p.to_string_lossy().ends_with("photocraft-bak")).collect();
|
||||
assert_eq!(backup.len(), 1, "{backup:?}");
|
||||
assert!(e.contains(&backup[0].display().to_string()), "{e}");
|
||||
assert_eq!(std::fs::read_to_string(&backup[0]).unwrap(), "old A");
|
||||
assert_eq!(std::fs::read_to_string(&b).unwrap(), "old B");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! The commands the app's shell carries out itself (view, panels, windows: they change no
|
||||
//! document), and the tools, as data: what they're called and which keys they have by default.
|
||||
//! The shell implements them (`photocraft_ui_egui::menus::UI_COMMANDS`, its `Tool`); the engine
|
||||
//! knows them so a headless session sees every bindable command: Edit › Keyboard Shortcuts'
|
||||
//! list, conflicts, `.kys` import and export all work without the UI (the UI is a layer on
|
||||
//! top). The app's tests keep these tables equal to its own.
|
||||
|
||||
/// Shell commands: id, label, menu path, default shortcut.
|
||||
pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
|
||||
("ui.symmetryTransform", "Transform Symmetry", &[], None),
|
||||
// Right-click the options bar's tool icon (#2825); `tool.resetAll` asks first.
|
||||
("tool.reset", "Reset Tool", &[], None),
|
||||
("tool.resetAll", "Reset All Tools", &[], None),
|
||||
("window.panel.group", "Change Panel Group", &[], None),
|
||||
("window.panel.layout", "Change Panel Layout", &[], None),
|
||||
("window.panel.move", "Move Panel", &[], None),
|
||||
("window.panel.float", "Float Panel", &[], None),
|
||||
("window.panel.dock", "Dock Panel", &[], None),
|
||||
("window.panel.activate", "Activate Panel", &[], None),
|
||||
("window.panel.close", "Close Panel", &[], None),
|
||||
("file.open", "Open…", &["File"], Some("Cmd+O")),
|
||||
("file.save", "Save", &["File"], Some("Cmd+S")),
|
||||
("file.saveAs", "Save As…", &["File"], Some("Cmd+Shift+S")),
|
||||
("file.exit", "Exit", &["File"], Some("Cmd+Q")),
|
||||
("edit.freeTransform", "Free Transform", &["Edit"], Some("Cmd+T")),
|
||||
("file.export.exportAs", "Export As…", &["File", "Export"], Some("Cmd+Alt+Shift+W")),
|
||||
("file.export.quickExportAsPng", "Quick Export as PNG", &["File", "Export"], None),
|
||||
("edit.transform.scale", "Scale", &["Edit", "Transform"], None),
|
||||
("edit.transform.rotate", "Rotate", &["Edit", "Transform"], None),
|
||||
("edit.transform.skew", "Skew", &["Edit", "Transform"], None),
|
||||
("edit.transform.distort", "Distort", &["Edit", "Transform"], None),
|
||||
("edit.transform.perspective", "Perspective", &["Edit", "Transform"], None),
|
||||
("select.selectAndMask", "Select and Mask…", &["Select"], Some("Cmd+Alt+R")),
|
||||
("view.rulers", "Rulers", &["View"], Some("Cmd+R")),
|
||||
("view.show.grid", "Grid", &["View", "Show"], Some("Cmd+'")),
|
||||
("view.show.guides", "Guides", &["View", "Show"], Some("Cmd+;")),
|
||||
("view.snap", "Snap", &["View"], Some("Cmd+Shift+;")),
|
||||
("view.lockGuides", "Lock Guides", &["View"], Some("Cmd+Alt+;")),
|
||||
("view.extras", "Extras", &["View"], Some("Cmd+H")),
|
||||
("view.show.targetPath", "Target Path", &["View", "Show"], Some("Cmd+Shift+H")),
|
||||
("view.screenMode.cycle", "Cycle Screen Mode", &[], Some("F")),
|
||||
// Photoshop's interface brightness shortcuts: step through the four Pro themes.
|
||||
("view.darkenInterface", "Darken Interface", &[], Some("Cmd+Shift+F1")),
|
||||
("view.lightenInterface", "Lighten Interface", &[], Some("Cmd+Shift+F2")),
|
||||
("edit.freeTransformCopy", "Free Transform a Copy", &[], Some("Cmd+Alt+T")),
|
||||
("type.editText", "Edit Type", &[], None),
|
||||
("view.zoomIn", "Zoom In", &["View"], Some("Cmd+=")),
|
||||
("view.zoomOut", "Zoom Out", &["View"], Some("Cmd+-")),
|
||||
("view.fitOnScreen", "Fit on Screen", &["View"], Some("Cmd+0")),
|
||||
("view.actualPixels", "100%", &["View"], Some("Cmd+1")),
|
||||
("view.rotateView", "Rotate View", &[], None),
|
||||
("view.resetView", "Reset View", &["View"], None),
|
||||
("window.newWindowForDocument", "New Window for Document", &["Window", "Arrange"], None),
|
||||
("window.toggle.layers", "Layers", &["Window"], Some("F7")),
|
||||
("window.toggle.history", "History", &["Window"], None),
|
||||
("window.toggle.properties", "Properties", &["Window"], None),
|
||||
("window.toggle.color", "Color", &["Window"], Some("F6")),
|
||||
("window.toggle.brushSettings", "Brush Settings", &["Window"], Some("F5")),
|
||||
("window.toggle.brushes", "Brushes", &["Window"], None),
|
||||
("window.toggle.navigator", "Navigator", &["Window"], None),
|
||||
("window.toggle.toolbar", "Tools", &["Window"], None),
|
||||
// Photoshop's Tab and ⇧Tab: all panels (Tools, options bar and the dock), or only the dock.
|
||||
("window.togglePanels", "Show/Hide All Panels", &[], Some("Tab")),
|
||||
("window.toggle.dock", "Show/Hide Panels", &[], Some("Shift+Tab")),
|
||||
("window.toggle.options", "Options", &["Window"], None),
|
||||
// Photoshop's Ctrl+Tab and Ctrl+Shift+Tab, on macOS too (⌘Tab belongs to the system, #2340).
|
||||
("window.nextDocument", "Next Document", &[], Some("Ctrl+Tab")),
|
||||
("window.previousDocument", "Previous Document", &[], Some("Ctrl+Shift+Tab")),
|
||||
("window.theme.toggle", "Next Appearance Mode", &["Window"], None),
|
||||
("window.theme.pro", "Pro Theme", &["Window", "Theme"], None),
|
||||
("window.theme.proMedium", "Pro Medium Gray Theme", &["Window", "Theme"], None),
|
||||
("window.theme.proLightGray", "Pro Light Gray Theme", &["Window", "Theme"], None),
|
||||
("window.theme.proLight", "Pro Light Theme", &["Window", "Theme"], None),
|
||||
("window.theme.studio", "Studio Theme", &["Window", "Theme"], None),
|
||||
("window.theme.studioLight", "Studio Light Theme", &["Window", "Theme"], None),
|
||||
("window.theme.classic", "Classic Theme", &["Window", "Theme"], None),
|
||||
("window.theme.solarizedDark", "Solarized Dark Theme", &["Window", "Theme"], None),
|
||||
("window.theme.adwaita", "Adwaita Light Theme", &["Window", "Theme"], None),
|
||||
("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None),
|
||||
("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None),
|
||||
("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None),
|
||||
("window.theme.softLight", "Soft Light Theme", &["Window", "Theme"], None),
|
||||
("window.theme.softDark", "Soft Dark Theme", &["Window", "Theme"], None),
|
||||
("window.theme.system", "Sync with system", &["Window", "Theme"], None),
|
||||
("edit.search", "Search…", &["Edit"], Some("Cmd+F")),
|
||||
("help.discord", "Join the ArtCraft Discord…", &["Help"], None),
|
||||
("help.website", "PhotoCraft Website", &["Help"], None),
|
||||
("help.artcraftWebsite", "ArtCraft Website", &["Help"], None),
|
||||
("help.github", "PhotoCraft on GitHub", &["Help"], None),
|
||||
("help.reportIssue", "Report an Issue…", &["Help"], None),
|
||||
("help.systemInfo", "System Info…", &["Help"], None),
|
||||
("help.about", "About PhotoCraft", &["Help"], None),
|
||||
];
|
||||
|
||||
/// The tools, in toolbar order: shortcut id (`tools.select.<tool>`), Photoshop's name for it
|
||||
/// (ours too), and its default key. Tools that share a key cycle with Shift (Photoshop's
|
||||
/// tool groups), so a shared default letter isn't a conflict.
|
||||
pub const TOOLS: &[(&str, &str, Option<&str>)] = &[
|
||||
("tools.select.move", "Move Tool", Some("V")),
|
||||
("tools.select.artboard", "Artboard Tool", Some("V")),
|
||||
("tools.select.rectMarquee", "Rectangular Marquee Tool", Some("M")),
|
||||
("tools.select.ellipseMarquee", "Elliptical Marquee Tool", Some("M")),
|
||||
("tools.select.singleRowMarquee", "Single Row Marquee Tool", None),
|
||||
("tools.select.singleColumnMarquee", "Single Column Marquee Tool", None),
|
||||
("tools.select.lasso", "Lasso Tool", Some("L")),
|
||||
("tools.select.polygonLasso", "Polygonal Lasso Tool", Some("L")),
|
||||
("tools.select.magneticLasso", "Magnetic Lasso Tool", Some("L")),
|
||||
("tools.select.magicWand", "Magic Wand Tool", Some("W")),
|
||||
("tools.select.crop", "Crop Tool", Some("C")),
|
||||
("tools.select.perspectiveCrop", "Perspective Crop Tool", Some("C")),
|
||||
("tools.select.eyedropper", "Eyedropper Tool", Some("I")),
|
||||
("tools.select.colorSampler", "Color Sampler Tool", Some("I")),
|
||||
("tools.select.ruler", "Ruler Tool", Some("I")),
|
||||
("tools.select.note", "Note Tool", Some("I")),
|
||||
("tools.select.count", "Count Tool", Some("I")),
|
||||
("tools.select.brush", "Brush Tool", Some("B")),
|
||||
("tools.select.pencil", "Pencil Tool", Some("B")),
|
||||
("tools.select.mixerBrush", "Mixer Brush Tool", Some("B")),
|
||||
("tools.select.colorReplacement", "Color Replacement Tool", Some("B")),
|
||||
("tools.select.eraser", "Eraser Tool", Some("E")),
|
||||
("tools.select.backgroundEraser", "Background Eraser Tool", Some("E")),
|
||||
("tools.select.magicEraser", "Magic Eraser Tool", Some("E")),
|
||||
("tools.select.gradient", "Gradient Tool", Some("G")),
|
||||
("tools.select.paintBucket", "Paint Bucket Tool", Some("G")),
|
||||
("tools.select.type", "Horizontal Type Tool", Some("T")),
|
||||
("tools.select.verticalType", "Vertical Type Tool", Some("T")),
|
||||
("tools.select.horizontalTypeMask", "Horizontal Type Mask Tool", Some("T")),
|
||||
("tools.select.verticalTypeMask", "Vertical Type Mask Tool", Some("T")),
|
||||
("tools.select.hand", "Hand Tool", Some("H")),
|
||||
("tools.select.rotateView", "Rotate View Tool", Some("R")),
|
||||
("tools.select.zoom", "Zoom Tool", Some("Z")),
|
||||
("tools.select.remove", "Remove Tool", Some("J")),
|
||||
("tools.select.spotHealing", "Spot Healing Brush Tool", Some("J")),
|
||||
("tools.select.healing", "Healing Brush Tool", Some("J")),
|
||||
("tools.select.patch", "Patch Tool", Some("J")),
|
||||
("tools.select.contentAwareMove", "Content-Aware Move Tool", Some("J")),
|
||||
("tools.select.redEye", "Red Eye Tool", Some("J")),
|
||||
("tools.select.cloneStamp", "Clone Stamp Tool", Some("S")),
|
||||
("tools.select.patternStamp", "Pattern Stamp Tool", Some("S")),
|
||||
("tools.select.historyBrush", "History Brush Tool", Some("Y")),
|
||||
("tools.select.blur", "Blur Tool", None),
|
||||
("tools.select.sharpen", "Sharpen Tool", None),
|
||||
("tools.select.smudge", "Smudge Tool", None),
|
||||
("tools.select.dodge", "Dodge Tool", Some("O")),
|
||||
("tools.select.burn", "Burn Tool", Some("O")),
|
||||
("tools.select.sponge", "Sponge Tool", Some("O")),
|
||||
("tools.select.quickSelection", "Quick Selection Tool", Some("W")),
|
||||
("tools.select.objectSelection", "Object Selection Tool", Some("W")),
|
||||
("tools.select.pen", "Pen Tool", Some("P")),
|
||||
("tools.select.freeformPen", "Freeform Pen Tool", Some("P")),
|
||||
("tools.select.curvaturePen", "Curvature Pen Tool", Some("P")),
|
||||
("tools.select.addAnchorPoint", "Add Anchor Point Tool", Some("P")),
|
||||
("tools.select.deleteAnchorPoint", "Delete Anchor Point Tool", Some("P")),
|
||||
("tools.select.convertPoint", "Convert Point Tool", Some("P")),
|
||||
("tools.select.pathSelection", "Path Selection Tool", Some("A")),
|
||||
("tools.select.directSelection", "Direct Selection Tool", Some("A")),
|
||||
("tools.select.rectangle", "Rectangle Tool", Some("U")),
|
||||
("tools.select.ellipseShape", "Ellipse Tool", Some("U")),
|
||||
("tools.select.triangle", "Triangle Tool", Some("U")),
|
||||
("tools.select.polygon", "Polygon Tool", Some("U")),
|
||||
("tools.select.line", "Line Tool", Some("U")),
|
||||
("tools.select.customShape", "Custom Shape Tool", Some("U")),
|
||||
("tools.select.slice", "Slice Tool", Some("C")),
|
||||
("tools.select.sliceSelect", "Slice Select Tool", Some("C")),
|
||||
("tools.select.frame", "Frame Tool", Some("K")),
|
||||
];
|
||||
|
||||
/// The default key of tool shortcut `id`, if it's a tool.
|
||||
pub fn tool_default(id: &str) -> Option<Option<&'static str>> {
|
||||
TOOLS.iter().find(|t| t.0 == id).map(|t| t.2)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ids_are_unique_and_keys_are_valid() {
|
||||
let mut ids: Vec<&str> = SHELL_COMMANDS.iter().map(|c| c.0).chain(TOOLS.iter().map(|t| t.0)).collect();
|
||||
let n = ids.len();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), n, "duplicate ids");
|
||||
for (id, _, _, sc) in SHELL_COMMANDS {
|
||||
assert!(sc.is_none_or(|k| crate::prefs::normalize_shortcut(k).is_some()), "{id}: {sc:?}");
|
||||
}
|
||||
for (id, name, key) in TOOLS {
|
||||
assert!(id.starts_with("tools.select.") && name.ends_with("Tool"), "{id} {name}");
|
||||
assert!(key.is_none_or(|k| k.len() == 1), "{id}: {key:?}");
|
||||
}
|
||||
assert_eq!(tool_default("tools.select.move"), Some(Some("V")));
|
||||
assert_eq!(tool_default("edit.copy"), None);
|
||||
}
|
||||
}
|
||||
@@ -234,20 +234,28 @@ pub fn summarize(set: &str, sections: &[Section], mac: bool) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Every engine command (and temporary tool) with a key under the bindings of `p`: (id,
|
||||
/// label, top-level menu, keys with the main one first). The app's dialog adds the commands
|
||||
/// only the shell knows (View › Zoom In, the tools).
|
||||
pub fn bound(p: &Preferences) -> Vec<(String, String, String, Vec<String>)> {
|
||||
let temporary = crate::prefs::TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, "Tools", Some(t.2)));
|
||||
crate::command_specs()
|
||||
.iter()
|
||||
.map(|c| (c.id, c.label, c.menu.first().copied().unwrap_or("Other"), c.shortcut))
|
||||
.chain(temporary)
|
||||
.filter_map(|(id, label, menu, def)| {
|
||||
/// A command (or tool) with a key under some bindings.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Bound {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
/// Its top-level menu ("Tools" for tools).
|
||||
pub menu: String,
|
||||
/// Main key first, then the further keys.
|
||||
pub keys: Vec<String>,
|
||||
pub tool: bool,
|
||||
}
|
||||
|
||||
/// Every bindable command with a key under the bindings of `p`: the engine's and the shell's
|
||||
/// commands, the tools and the temporary tools ([`crate::prefs::bindable_items`]).
|
||||
pub fn bound(p: &Preferences) -> Vec<Bound> {
|
||||
crate::prefs::bindable_items()
|
||||
.filter_map(|(id, label, menu, def, kind)| {
|
||||
let main = p.shortcut(id, def);
|
||||
let mut keys: Vec<String> = main.and_then(normalize_shortcut).into_iter().collect();
|
||||
keys.extend(p.alternates(id, main));
|
||||
(!keys.is_empty()).then(|| (id.to_string(), label.to_string(), menu.to_string(), keys))
|
||||
let menu = menu.first().copied().unwrap_or("Other").to_string();
|
||||
(!keys.is_empty()).then(|| Bound { id: id.to_string(), label: label.to_string(), menu, keys, tool: kind == crate::prefs::BindKind::Tool })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -340,8 +348,12 @@ mod tests {
|
||||
fn bound_lists_main_keys_then_alternates() {
|
||||
let p = prefs();
|
||||
let b = bound(&p);
|
||||
let copy = b.iter().find(|(id, ..)| id == "edit.copy").expect("Copy has keys");
|
||||
assert_eq!(copy.3, vec!["Cmd+C".to_string(), "F3".to_string()]);
|
||||
assert!(b.iter().any(|(id, ..)| id == "tools.temporary.hand"));
|
||||
let copy = b.iter().find(|x| x.id == "edit.copy").expect("Copy has keys");
|
||||
assert_eq!(copy.keys, vec!["Cmd+C".to_string(), "F3".to_string()]);
|
||||
assert!(b.iter().any(|x| x.id == "tools.temporary.hand"));
|
||||
// The shell's commands and the tools too: a set saved headless is the whole set.
|
||||
assert!(b.iter().any(|x| x.id == "view.zoomIn" && x.keys.len() >= 2), "Zoom In with its Cmd++");
|
||||
let mv = b.iter().find(|x| x.id == "tools.select.move").expect("Move Tool");
|
||||
assert!(mv.tool && mv.keys == ["V"] && mv.menu == "Tools");
|
||||
}
|
||||
}
|
||||
@@ -298,28 +298,35 @@ fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32)
|
||||
out
|
||||
}
|
||||
|
||||
/// Runs the smart filter stack on the placed (unfiltered) content.
|
||||
pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> Surface {
|
||||
/// Runs the smart filter stack on the placed (unfiltered) content. A filter the session's
|
||||
/// authority denies surfaces its error (the trusted compositor path has no gate installed, so
|
||||
/// this reads ambiently only when the caller established one and it refuses).
|
||||
pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> Result<Surface> {
|
||||
if !sm.filters_enabled || !sm.smart_filters.iter().any(|f| f.visible) {
|
||||
return placed.clone();
|
||||
return Ok(placed.clone());
|
||||
}
|
||||
let mut cur = placed.clone();
|
||||
// Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters.
|
||||
let extent = canvas.union(&placed.content_bounds());
|
||||
for f in sm.smart_filters.iter().filter(|f| f.visible) {
|
||||
cur = apply_smart_filter(f, &cur, canvas, extent);
|
||||
cur = apply_smart_filter(f, &cur, canvas, extent)?;
|
||||
}
|
||||
match sm.filter_mask.as_ref().filter(|m| m.enabled) {
|
||||
Ok(match sm.filter_mask.as_ref().filter(|m| m.enabled) {
|
||||
Some(m) => photocraft_compose::filter_mask::mix(placed, &cur, m),
|
||||
None => cur,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// One visible smart filter over `cur`, blended with its blend mode and opacity.
|
||||
fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect) -> Surface {
|
||||
// Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone.
|
||||
let Some(out) = crate::filters::apply_filter_to_surface(&f.command, &f.params, cur, canvas, None, extent) else { return cur.clone() };
|
||||
if f.blend == BlendMode::Normal && f.opacity >= 1.0 { out } else { blend_surfaces(cur, &out, f.blend, f.opacity.clamp(0.0, 1.0)) }
|
||||
/// One visible smart filter over `cur`, blended with its blend mode and opacity. `Err` when the
|
||||
/// requesting session's authority denies the filter's params: callers surface the error instead of
|
||||
/// rendering (and caching) unfiltered pixels.
|
||||
fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect) -> Result<Surface> {
|
||||
let out = match crate::filters::apply_filter_to_surface(&f.command, &f.params, cur, canvas, None, extent)? {
|
||||
// Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone.
|
||||
Some(out) => out,
|
||||
None => cur.clone(),
|
||||
};
|
||||
if f.blend == BlendMode::Normal && f.opacity >= 1.0 { Ok(out) } else { Ok(blend_surfaces(cur, &out, f.blend, f.opacity.clamp(0.0, 1.0))) }
|
||||
}
|
||||
|
||||
/// The source placed in document pixels, before any smart filter.
|
||||
@@ -377,6 +384,11 @@ pub fn render(doc: &Document, sm: &SmartObject) -> Result<Option<Surface>> {
|
||||
/// is cancelled. Gives the same pixels as [`apply_smart_filters`] on the placed source, but
|
||||
/// starts from the deepest stage of the stack already rendered (see [`stack_cache`]).
|
||||
fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result<Option<Surface>> {
|
||||
// Stored filters can come from a file or another layer. Check before looking up cached
|
||||
// stages too: a trusted UI render may have populated this process-wide cache earlier.
|
||||
for filter in sm.smart_filters.iter().filter(|f| sm.filters_enabled && f.visible) {
|
||||
crate::jobs::authorize_render_command(&filter.command, &filter.params)?;
|
||||
}
|
||||
let Some((name, bytes)) = source_bytes(&doc.metadata, &sm.source) else { return Ok(None) };
|
||||
let (fmt, canvas) = (doc.pixel_format(), doc.bounds());
|
||||
let placed_key = stack_cache::placed_key(&bytes, fmt, sm, canvas);
|
||||
@@ -410,7 +422,7 @@ fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result<Option<Su
|
||||
for (i, (f, key)) in filters.iter().zip(&keys).enumerate().skip(done) {
|
||||
ctx.check()?;
|
||||
ctx.progress(i as f32 / n, "");
|
||||
cur = Stage { surface: apply_smart_filter(f, &cur.surface, canvas, cur.extent), extent: cur.extent };
|
||||
cur = Stage { surface: apply_smart_filter(f, &cur.surface, canvas, cur.extent)?, extent: cur.extent };
|
||||
stack_cache::put(*key, &cur);
|
||||
}
|
||||
ctx.progress(1.0, "");
|
||||
@@ -522,7 +534,7 @@ pub(crate) fn add_smart_filter(doc: &mut Document, id: LayerId, sf: photocraft_d
|
||||
}
|
||||
let bounds = selection.map(Surface::content_bounds).filter(|b| !b.is_empty()).unwrap_or(canvas);
|
||||
let cache = sm.cache.as_ref().ok_or_else(|| other("smart object has no pixels"))?;
|
||||
let out = crate::filters::apply_filter_to_surface(&sf.command, &sf.params, cache, bounds, selection, canvas)
|
||||
let out = crate::filters::apply_filter_to_surface(&sf.command, &sf.params, cache, bounds, selection, canvas)?
|
||||
.ok_or_else(|| other(format!("unknown filter {}", sf.command)))?;
|
||||
sm.cache = Some(out);
|
||||
sm.smart_filters.push(sf);
|
||||
@@ -1000,6 +1012,7 @@ fn set_filter_visible(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn set_filter_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "layer.smartFilter.setParams";
|
||||
let new = p.get("params").cloned().ok_or_else(|| bad(CMD, "pass `params` (merged into the filter's parameters)"))?;
|
||||
let authorize = s.authorize;
|
||||
edit_filters(s, p, "Edit Smart Filter", |sm| {
|
||||
let i = filter_index(CMD, p, sm)?;
|
||||
let f = &mut sm.smart_filters[i];
|
||||
@@ -1010,6 +1023,11 @@ fn set_filter_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(Value::Object(old), Value::Object(n)) => old.extend(n),
|
||||
(slot, n) => *slot = n,
|
||||
}
|
||||
// Authorize the underlying command with the complete merged params, including paths
|
||||
// already stored in the document, before committing the edit or starting a worker.
|
||||
if let Some(gate) = authorize {
|
||||
gate(&f.command, &f.params)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ fn uncached(s: &Session) -> Surface {
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let sm = active_smart(s);
|
||||
let (name, bytes) = source_bytes(&doc.metadata, &sm.source).unwrap();
|
||||
apply_smart_filters(&place(&name, &bytes, doc.pixel_format(), &sm).unwrap(), &sm, doc.bounds())
|
||||
apply_smart_filters(&place(&name, &bytes, doc.pixel_format(), &sm).unwrap(), &sm, doc.bounds()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//! `Session::precheck` accepts what `execute` accepts for the commands it covers (all but the
|
||||
//! group nesting limit, which only the edit checks)
|
||||
//! (`PRECHECKED`): Free Transform applies its box before them only when they will run (#5859).
|
||||
|
||||
use photocraft_engine::{PRECHECKED, Session};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Documents in different states: a Background only, a pixel layer active, a group active, a
|
||||
/// selection, no document.
|
||||
fn states() -> Vec<(&'static str, Session)> {
|
||||
let mut out = Vec::new();
|
||||
out.push(("no document", Session::new()));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
|
||||
out.push(("background only", s));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
out.push(("pixel layer", s));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap();
|
||||
s.execute("layer.new.group", json!({})).unwrap();
|
||||
out.push(("group, no background", s));
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap();
|
||||
out.push(("selection", s));
|
||||
out
|
||||
}
|
||||
|
||||
fn params() -> Vec<Value> {
|
||||
vec![
|
||||
Value::Null,
|
||||
json!({}),
|
||||
json!([1]),
|
||||
json!("x"),
|
||||
json!({"mode": "Bogus"}),
|
||||
json!({"mode": "Multiply"}),
|
||||
json!({"fillNeutral": true}),
|
||||
json!({"mode": "Overlay", "fillNeutral": true}),
|
||||
json!({"position": "x"}),
|
||||
json!({"position": "below"}),
|
||||
json!({"opacity": "half"}),
|
||||
json!({"name": 5}),
|
||||
json!({"name": "ok"}),
|
||||
json!({"color": "chartreuse"}),
|
||||
json!({"layer": 999_999}),
|
||||
json!({"layer": 1}),
|
||||
json!({"layer": 2}),
|
||||
json!({"layer": null}),
|
||||
json!({"layer": "Layer 1"}),
|
||||
json!({"layer": "no such layer"}),
|
||||
json!({"layer": true}),
|
||||
json!({"ifPresent": true}),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precheck_agrees_with_execute() {
|
||||
let mut disagreements = Vec::new();
|
||||
for id in PRECHECKED {
|
||||
for p in params() {
|
||||
for (state, _) in states() {
|
||||
// A fresh copy of the state for each call: execute changes it.
|
||||
let mut s = states().into_iter().find(|(n, _)| *n == state).map(|(_, s)| s).unwrap();
|
||||
let pre = s.precheck(id, &p).is_ok();
|
||||
let ran = s.execute(id, p.clone()).is_ok();
|
||||
if pre != ran {
|
||||
disagreements.push(format!("{id} {p} on {state}: precheck {pre}, execute {ran}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(disagreements.is_empty(), "{disagreements:#?}");
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Edit › Keyboard Shortcuts without the UI: the shell's commands and the tools are bindable,
|
||||
//! listed, imported and exported; tools share keys without conflicts; system keys are refused;
|
||||
//! sets read and write files by path.
|
||||
|
||||
use photocraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn ks(s: &mut Session, p: Value) -> Value {
|
||||
s.execute("edit.keyboardShortcuts", p).expect("edit.keyboardShortcuts")
|
||||
}
|
||||
|
||||
fn listed<'a>(r: &'a Value, id: &str) -> &'a Value {
|
||||
r["commands"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap_or_else(|| panic!("{id} listed"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_commands_and_tools_are_bindable_without_the_ui() {
|
||||
let mut s = Session::new();
|
||||
let r = ks(&mut s, json!({"list": true}));
|
||||
assert_eq!(listed(&r, "view.zoomIn")["alternates"], json!(["Cmd++"]), "the shell's View › Zoom In");
|
||||
let mv = listed(&r, "tools.select.move");
|
||||
assert_eq!((mv["tool"].clone(), mv["shortcut"].clone(), mv["menu"].clone()), (json!(true), json!("V"), json!(["Tools"])));
|
||||
assert!(
|
||||
r["conflicts"].as_array().unwrap().iter().all(|c| c["commands"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|id| !id.as_str().unwrap().starts_with("tools.select."))),
|
||||
"tools sharing a letter cycle, they don't conflict: {}",
|
||||
r["conflicts"]
|
||||
);
|
||||
// No allowUnknown needed any more.
|
||||
let r = ks(&mut s, json!({"set": {"tools.select.pencil": "F6", "view.zoomIn": "Cmd+Shift+Z"}}));
|
||||
assert_eq!((r["overrides"]["tools.select.pencil"].clone(), r["overrides"]["view.zoomIn"].clone()), (json!("F6"), json!("Cmd+Shift+Z")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_may_share_a_key_but_a_command_takes_it() {
|
||||
let mut s = Session::new();
|
||||
// Pencil joins the V group: Move and Artboard keep V.
|
||||
let r = ks(&mut s, json!({"set": {"tools.select.pencil": "V"}}));
|
||||
assert!(r["overrides"].get("tools.select.move").is_none(), "{}", r["overrides"]);
|
||||
// A command taking V removes it from every tool that held it.
|
||||
let r = ks(&mut s, json!({"set": {"edit.copy": "V"}}));
|
||||
for t in ["tools.select.move", "tools.select.artboard", "tools.select.pencil"] {
|
||||
assert_eq!(r["overrides"][t], json!(""), "{t}: {}", r["overrides"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_keys_are_refused_and_dropped_from_imports() {
|
||||
let mut s = Session::new();
|
||||
let key = if cfg!(target_os = "macos") { "Cmd+Tab" } else { "Alt+F4" };
|
||||
let before = s.prefs().clone();
|
||||
assert!(s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": key}})).is_err());
|
||||
assert!(s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": ["F9", key]}})).is_err());
|
||||
assert_eq!(s.prefs(), &before);
|
||||
let set = format!(
|
||||
"<photoshop-keyboard-shortcuts><command name=\"Copy\"><shortcut>F9</shortcut><shortcut>{key}</shortcut></command><command name=\"Cut\"><shortcut>{key}</shortcut></command><tool name=\"Pencil Tool\">N</tool><tool name=\"Move Tool\">V</tool></photoshop-keyboard-shortcuts>"
|
||||
);
|
||||
let r = ks(&mut s, json!({"importKys": set}));
|
||||
assert_eq!(r["overrides"]["edit.copy"], json!("F9"));
|
||||
assert!(r["overrides"].get("edit.cut").is_none(), "the reserved key is left out: {}", r["overrides"]);
|
||||
assert_eq!(r["import"]["reserved"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(r["overrides"]["tools.select.pencil"], json!("N"), "tool keys import headless");
|
||||
assert!(r["overrides"].get("tools.select.move").is_none(), "Move's own V keeps its cycling");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sets_read_and_write_files_and_export_everything() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-shortcuts-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let kys = dir.join("mine.kys");
|
||||
let html = dir.join("mine.html");
|
||||
let mut s = Session::new();
|
||||
ks(&mut s, json!({"set": {"tools.select.pencil": "F6", "edit.copy": "F9"}, "saveSet": "Mine"}));
|
||||
let r = ks(&mut s, json!({"exportKys": {"path": kys.to_str().unwrap()}, "summarize": {"path": html.to_str().unwrap(), "mac": false}}));
|
||||
assert!(r.get("kys").is_none() && r["kysPath"].is_string() && r["htmlPath"].is_string(), "{r}");
|
||||
let text = std::fs::read_to_string(&kys).unwrap();
|
||||
assert!(text.contains("pc-id=\"tools.select.pencil\">F6</tool>") && text.contains("pc-id=\"view.zoomIn\""), "the whole set: {text}");
|
||||
assert!(std::fs::read_to_string(&html).unwrap().contains("<h2>Tools</h2>"));
|
||||
// Back to the defaults, then the file restores the set, tools included.
|
||||
ks(&mut s, json!({"useSet": ""}));
|
||||
let r = ks(&mut s, json!({"reset": true, "importKys": {"path": kys.to_str().unwrap()}}));
|
||||
assert_eq!((r["overrides"]["tools.select.pencil"].clone(), r["overrides"]["edit.copy"].clone()), (json!("F6"), json!("F9")));
|
||||
// Bad paths and shapes are errors, not panics.
|
||||
for p in [
|
||||
json!({"importKys": {"path": dir.join("missing.kys").to_str().unwrap()}}),
|
||||
json!({"importKys": {"path": ""}}),
|
||||
json!({"importKys": 5}),
|
||||
json!({"exportKys": "yes"}),
|
||||
json!({"exportKys": {"path": 3}}),
|
||||
json!({"summarize": {"path": ""}}),
|
||||
] {
|
||||
assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
// A change paired with an export that fails (a folder that doesn't exist, a bad summarize
|
||||
// value) changes nothing.
|
||||
let before = ks(&mut s, json!({}))["overrides"].clone();
|
||||
for p in [
|
||||
json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": dir.join("no").join("such").join("x.kys").to_str().unwrap()}}),
|
||||
json!({"set": {"edit.copy": "F10"}, "summarize": 7}),
|
||||
json!({"set": {"edit.copy": "F10"}, "saveSet": "Other", "summarize": {"path": dir.join("no").join("x.html").to_str().unwrap()}}),
|
||||
// A good .kys path with a bad summarize: neither file is written.
|
||||
json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": {"path": dir.join("no").join("x.html").to_str().unwrap()}}),
|
||||
json!({"exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": 7}),
|
||||
json!({"exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": {"path": dir.to_str().unwrap()}}),
|
||||
] {
|
||||
assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}");
|
||||
let r = ks(&mut s, json!({}));
|
||||
assert_eq!(r["overrides"], before, "{p}");
|
||||
assert!(r["sets"].get("Other").is_none(), "{p}: {r}");
|
||||
assert!(!dir.join("both.kys").exists(), "{p}: no file written");
|
||||
}
|
||||
// A second file that passes the checks but can't be written (a NUL in its name): the user's
|
||||
// existing first file is left exactly as it was, and no staged file stays behind.
|
||||
let mine = dir.join("existing.kys");
|
||||
std::fs::write(&mine, "the user's own file").unwrap();
|
||||
let html = format!("{}/a\u{0}.html", dir.to_str().unwrap());
|
||||
let p = json!({"exportKys": {"path": mine.to_str().unwrap()}, "summarize": {"path": html}});
|
||||
assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}");
|
||||
assert_eq!(std::fs::read_to_string(&mine).unwrap(), "the user's own file");
|
||||
let left: Vec<_> =
|
||||
std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name()).filter(|n| n.to_string_lossy().contains("photocraft-tmp")).collect();
|
||||
assert!(left.is_empty(), "{left:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_only_target_is_refused_before_anything_is_written() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-readonly-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let ro = dir.join("locked.kys");
|
||||
std::fs::write(&ro, "locked").unwrap();
|
||||
let mut perms = std::fs::metadata(&ro).unwrap().permissions();
|
||||
perms.set_readonly(true);
|
||||
std::fs::set_permissions(&ro, perms.clone()).unwrap();
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": ro.to_str().unwrap()}}));
|
||||
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("read-only")), "{r:?}");
|
||||
assert_eq!(std::fs::read_to_string(&ro).unwrap(), "locked");
|
||||
#[allow(clippy::permissions_set_readonly_false)]
|
||||
perms.set_readonly(false);
|
||||
let _ = std::fs::set_permissions(&ro, perms);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -794,6 +794,11 @@ impl Compositor {
|
||||
total
|
||||
}
|
||||
|
||||
/// The GPU memory resident layer pages may hold (bytes; see [`Compositor::set_memory_budget`]).
|
||||
pub fn resident_budget(&self) -> u64 {
|
||||
self.resident_budget
|
||||
}
|
||||
|
||||
/// Whether a render of `region` keeps every layer page it needs within the budget; when it
|
||||
/// doesn't, each render re-uploads what was evicted.
|
||||
pub fn fits_budget(&self, doc: &Document, region: Rect) -> bool {
|
||||
|
||||
@@ -275,7 +275,11 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m
|
||||
}
|
||||
if is_psd(bytes) {
|
||||
// A merged image the import reads is decoded with the parse: a ZIP one is inflated once.
|
||||
let (file, merged) = PsdFile::from_bytes_and_merged(bytes, |f| psd_import::check_group_depth(f).is_ok() && psd_import::merged_needed(f))?;
|
||||
let (file, merged) = PsdFile::from_bytes_and_merged_recovering(
|
||||
bytes,
|
||||
|f| psd_import::check_group_depth(f).is_ok() && psd_import::merged_needed(f),
|
||||
psd_import::can_recover_layers,
|
||||
)?;
|
||||
// Nesting past the document model's cap could never be saved; reject the file with the
|
||||
// actionable limit.
|
||||
psd_import::check_group_depth(&file)?;
|
||||
|
||||
@@ -764,6 +764,12 @@ pub(crate) fn merged_needed(file: &PsdFile) -> bool {
|
||||
!layered || file.layers().is_empty() || usize::from(h.channels) > mode.color_channels() + usize::from(file.merged_has_alpha())
|
||||
}
|
||||
|
||||
/// A damaged merged preview need not prevent opening independently stored layers (#6152).
|
||||
/// Other modes are imported flattened, so their merged image cannot be discarded.
|
||||
pub(crate) fn can_recover_layers(file: &PsdFile) -> bool {
|
||||
matches!(file.header.color_mode, PsdMode::Grayscale | PsdMode::Rgb | PsdMode::Cmyk | PsdMode::Lab) && file.header.depth != 1 && !file.layers().is_empty()
|
||||
}
|
||||
|
||||
/// [`psd_to_document_with`] with the merged image already decoded (by
|
||||
/// [`PsdFile::from_bytes_and_merged`] with [`merged_needed`]), so it is not decoded again.
|
||||
pub(crate) fn psd_to_document_decoded(
|
||||
@@ -901,9 +907,15 @@ pub(crate) fn psd_to_document_decoded(
|
||||
ctl.progress(0.1);
|
||||
if let Err(e) = &m {
|
||||
cx.warn(format!("merged image could not be decoded: {e}"));
|
||||
if can_recover_layers(file) {
|
||||
cx.warn("extra alpha/spot channels in the merged image could not be recovered");
|
||||
}
|
||||
}
|
||||
Some(m)
|
||||
} else {
|
||||
if let Some(Err(e)) = decoded {
|
||||
cx.warn(format!("merged image could not be decoded; opened the available layers instead: {e}"));
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ thread_local! {
|
||||
}
|
||||
|
||||
/// Files known to be invalid upstream: their parse error is expected.
|
||||
const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools fixture stripped to 1906 bytes: merged image data missing")];
|
||||
const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "malformed merged image data; recovery is tested in psd_damaged_merged.rs (#6152)")];
|
||||
|
||||
/// One corpus directory with its own floors.
|
||||
struct Source {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! #6152: intact layers can be recovered when only the merged preview is damaged.
|
||||
|
||||
use photocraft_color::{ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Layer};
|
||||
use photocraft_geom::Size;
|
||||
use photocraft_io::{ExportOptions, export, import};
|
||||
use photocraft_psd::{Compression, GroupSpec, PsdBuilder, PsdFile};
|
||||
|
||||
fn layered(depth: SampleType, extension: &str) -> PsdFile {
|
||||
layered_in_mode(ColorMode::Rgb, depth, extension)
|
||||
}
|
||||
|
||||
fn layered_in_mode(mode: ColorMode, depth: SampleType, extension: &str) -> PsdFile {
|
||||
let mut doc = Document::new("recovery", Size::new(3, 2), mode, depth);
|
||||
let format = doc.pixel_format();
|
||||
let mut layer = Layer::raster("red pixels", doc.pixel_format());
|
||||
let mut pixel = vec![0.0; format.channels()];
|
||||
photocraft_raster::from_rgba_into(&format, [1.0, 0.0, 0.0, 1.0], &mut pixel);
|
||||
layer.surface_mut().unwrap().fill_rect(doc.bounds(), &pixel);
|
||||
doc.layers.push(layer);
|
||||
PsdFile::from_bytes(&export(&doc, extension, &ExportOptions::default()).unwrap().bytes).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_merged_image_keeps_layer_pixels_at_every_depth_and_compression() {
|
||||
use ColorMode::{Cmyk, Grayscale, Lab, Rgb};
|
||||
use SampleType::{F32, U8, U16};
|
||||
for extension in ["x.psd", "x.psb"] {
|
||||
for (mode, depth) in
|
||||
[(Rgb, U8), (Rgb, U16), (Rgb, F32), (Grayscale, U8), (Grayscale, U16), (Grayscale, F32), (Cmyk, U8), (Cmyk, U16), (Lab, U8), (Lab, U16)]
|
||||
{
|
||||
for compression in Compression::ALL {
|
||||
let mut file = layered_in_mode(mode, depth, extension);
|
||||
let intact = import(extension, &file.to_bytes().unwrap()).unwrap();
|
||||
let expected = photocraft_compose::flatten(&intact.document).px;
|
||||
let pixels = file.decode_merged().unwrap();
|
||||
file.image_data = photocraft_psd::ImageData::encode(compression, &pixels, &file.header).unwrap();
|
||||
file.image_data.data.truncate(file.image_data.data.len() / 2);
|
||||
let bytes = file.to_bytes().unwrap();
|
||||
assert!(PsdFile::from_bytes(&bytes).is_err(), "strict parsing stays strict");
|
||||
let result = import(extension, &bytes).expect("recover the intact layers");
|
||||
assert_eq!(result.document.layer_count(), 1);
|
||||
assert_eq!(result.document.depth, depth);
|
||||
assert!(result.warnings.iter().any(|w| w.contains("merged image")), "{:?}", result.warnings);
|
||||
let rendered = photocraft_compose::flatten(&result.document);
|
||||
assert_eq!(rendered.px, expected, "{extension} {mode:?} {depth:?} {compression:?}");
|
||||
// Saving the recovered document replaces the bad preview with a valid one.
|
||||
let saved = export(&result.document, extension, &ExportOptions::default()).unwrap();
|
||||
assert!(PsdFile::from_bytes(&saved.bytes).unwrap().decode_merged().is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_zip_merged_image_keeps_the_layers() {
|
||||
let mut file = layered(SampleType::U8, "x.psd");
|
||||
file.image_data = photocraft_psd::ImageData::encode(Compression::Zip, &file.decode_merged().unwrap(), &file.header).unwrap();
|
||||
*file.image_data.data.last_mut().unwrap() ^= 0xff;
|
||||
let result = import("x.psd", &file.to_bytes().unwrap()).unwrap();
|
||||
assert_eq!(result.document.layer_count(), 1);
|
||||
assert!(result.warnings.iter().any(|w| w.contains("merged image")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_group_survives_a_truncated_preview() {
|
||||
// The reported psd-tools fixture has only the two records of an empty group.
|
||||
let mut builder = PsdBuilder::new(100, 100);
|
||||
builder.begin_group(GroupSpec::new("empty group"));
|
||||
builder.end_group().unwrap();
|
||||
let mut file = builder.build().unwrap();
|
||||
file.image_data.data.truncate(file.image_data.data.len() / 2);
|
||||
let result = import("empty.psd", &file.to_bytes().unwrap()).unwrap();
|
||||
assert_eq!(result.document.layer_count(), 1);
|
||||
assert_eq!(result.document.layers[0].name, "empty group");
|
||||
assert!(result.warnings.iter().any(|w| w.contains("merged image")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flattened_file_with_no_recoverable_layers_still_fails() {
|
||||
let mut file = layered(SampleType::U8, "x.psd");
|
||||
file.layer_info = None;
|
||||
file.image_data.data.truncate(file.image_data.data.len() / 2);
|
||||
assert!(import("flat.psd", &file.to_bytes().unwrap()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_imported_flattened_do_not_recover_from_unusable_layer_records() {
|
||||
for mode in [photocraft_psd::ColorMode::Indexed, photocraft_psd::ColorMode::Multichannel, photocraft_psd::ColorMode::Duotone] {
|
||||
let mut file = layered(SampleType::U8, "x.psd");
|
||||
file.header.color_mode = mode;
|
||||
file.image_data = photocraft_psd::ImageData { compression: Compression::Raw, data: Vec::new() };
|
||||
assert!(import("flat-mode.psd", &file.to_bytes().unwrap()).is_err(), "{mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_extra_channels_are_reported_while_layers_remain_usable() {
|
||||
let mut file = layered(SampleType::U8, "x.psd");
|
||||
file.header.channels = 5;
|
||||
file.image_data = photocraft_psd::ImageData { compression: Compression::Raw, data: Vec::new() };
|
||||
let result = import("extra-channel.psd", &file.to_bytes().unwrap()).unwrap();
|
||||
assert_eq!(result.document.layer_count(), 1);
|
||||
assert!(result.document.channels.is_empty());
|
||||
assert!(result.warnings.iter().any(|w| w.contains("alpha/spot channels")), "{:?}", result.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_layer_sections_are_not_recovered() {
|
||||
let file = layered(SampleType::U8, "x.psd");
|
||||
let mut bytes = file.to_bytes().unwrap();
|
||||
bytes.truncate(bytes.len() - file.image_data.data.len() - 3);
|
||||
assert!(import("broken-layers.psd", &bytes).is_err());
|
||||
}
|
||||
|
||||
#[cfg(feature = "corpus")]
|
||||
#[test]
|
||||
fn reported_group_divider_fixture_opens_with_its_group() {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd-tools/blend-modes/group-divider-blend-mode.psd");
|
||||
let bytes = std::fs::read(path).expect("run cargo xtask corpus --psd-tools");
|
||||
let result = import("group-divider-blend-mode.psd", &bytes).expect("#6152 opens");
|
||||
assert_eq!(result.document.layer_count(), 1);
|
||||
assert!(matches!(result.document.layers[0].content, photocraft_doc::LayerContent::Group(_)));
|
||||
assert!(result.warnings.iter().any(|w| w.contains("merged image")));
|
||||
}
|
||||
+40
-4
@@ -102,6 +102,22 @@ impl PsdFile {
|
||||
/// Errors are those of [`Self::from_bytes`]; a merged image that passes the parse's check but
|
||||
/// cannot be decoded is the inner `Err`, as [`Self::decode_merged`] would return it.
|
||||
pub fn from_bytes_and_merged(data: &[u8], want: impl FnOnce(&PsdFile) -> bool) -> Result<(Self, Option<Result<Vec<u8>>>)> {
|
||||
Self::from_bytes_and_merged_recovering(data, want, |_| false)
|
||||
}
|
||||
|
||||
/// Parses like [`Self::from_bytes_and_merged`], but lets `recover` accept damage confined to
|
||||
/// the merged image after all layer records and channel data have been read successfully.
|
||||
/// Callers should opt in only when they can reconstruct the document from its layers.
|
||||
///
|
||||
/// The original encoded image bytes are preserved and its parse error is returned as
|
||||
/// `Some(Err(..))`, even when `want` did not request a decode. The caller must report that
|
||||
/// error rather than silently discarding the merged image (which may hold extra channels).
|
||||
/// Header/layer errors, missing compression fields and parse safety-limit errors stay fatal.
|
||||
pub fn from_bytes_and_merged_recovering(
|
||||
data: &[u8],
|
||||
want: impl FnOnce(&PsdFile) -> bool,
|
||||
recover: impl FnOnce(&PsdFile) -> bool,
|
||||
) -> Result<(Self, Option<Result<Vec<u8>>>)> {
|
||||
let mut r = Reader::new(data);
|
||||
let header = Header::read(&mut r)?;
|
||||
let psb = header.version.is_psb();
|
||||
@@ -161,10 +177,30 @@ impl PsdFile {
|
||||
let image_data = ImageData { compression: Compression::Raw, data: Vec::new() };
|
||||
let mut file =
|
||||
PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement, global_layer_mask, global_blocks, layer_mask_trailing, image_data };
|
||||
let merged = if want(&file) { Some(file.read_merged_decoded(&mut r)?) } else { None };
|
||||
if merged.is_none() {
|
||||
file.image_data = ImageData::read(&mut r, &file.header)?;
|
||||
}
|
||||
let mut encoded = r.clone();
|
||||
let merged = if want(&file) {
|
||||
file.read_merged_decoded(&mut r).map(Some)
|
||||
} else {
|
||||
ImageData::read(&mut r, &file.header).map(|image| {
|
||||
file.image_data = image;
|
||||
None
|
||||
})
|
||||
};
|
||||
let merged = match merged {
|
||||
Ok(merged) => merged,
|
||||
Err(error) => {
|
||||
if matches!(error, PsdError::LimitExceeded(_)) || !recover(&file) {
|
||||
return Err(error);
|
||||
}
|
||||
let compression = Compression::from_u16(encoded.u16()?);
|
||||
let source = encoded.peek_rest();
|
||||
let mut data = Vec::new();
|
||||
data.try_reserve_exact(source.len()).map_err(|_| PsdError::LimitExceeded("not enough memory to retain the damaged merged image"))?;
|
||||
data.extend_from_slice(source);
|
||||
file.image_data = ImageData { compression, data };
|
||||
Some(Err(error))
|
||||
}
|
||||
};
|
||||
Ok((file, merged))
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
|
||||
/// Files known to be invalid upstream, with the reason. They are reported but
|
||||
/// do not fail the test.
|
||||
const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools fixture stripped to 1906 bytes: merged image data missing")];
|
||||
const KNOWN_BAD: &[(&str, &str)] =
|
||||
&[("group-divider-blend-mode.psd", "malformed merged image data; strict parsing rejects it, but the importer recovers its group (#6152)")];
|
||||
|
||||
#[test]
|
||||
fn corpus_parse_and_byte_stable() {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Opt-in merged-image recovery must not weaken strict parsing or lose encoded bytes.
|
||||
|
||||
use photocraft_psd::{Compression, PsdError, PsdFile, Version, compression, testgen};
|
||||
|
||||
#[test]
|
||||
fn recovery_is_explicit_and_preserves_the_original_file() {
|
||||
for version in [Version::Psd, Version::Psb] {
|
||||
for compression in Compression::ALL {
|
||||
let mut original = testgen::small(version, compression);
|
||||
original.image_data.data.pop();
|
||||
let bytes = original.to_bytes().unwrap();
|
||||
let strict_error = PsdFile::from_bytes(&bytes).unwrap_err();
|
||||
for want in [false, true] {
|
||||
let rejected = PsdFile::from_bytes_and_merged_recovering(&bytes, |_| want, |_| false).unwrap_err();
|
||||
assert_eq!(rejected, strict_error);
|
||||
let (file, merged) = PsdFile::from_bytes_and_merged_recovering(&bytes, |_| want, |_| true).unwrap();
|
||||
assert_eq!(merged.unwrap().unwrap_err(), strict_error);
|
||||
assert_eq!(file, original);
|
||||
assert_eq!(file.to_bytes().unwrap(), bytes, "{version:?} {compression:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_does_not_accept_missing_compression_or_incomplete_layers() {
|
||||
let original = testgen::small(Version::Psd, Compression::Raw);
|
||||
let bytes = original.to_bytes().unwrap();
|
||||
let merged_offset = bytes.len() - original.image_data.data.len() - 2;
|
||||
for cut in [merged_offset - 1, merged_offset, merged_offset + 1] {
|
||||
assert!(PsdFile::from_bytes_and_merged_recovering(&bytes[..cut], |_| true, |_| true).is_err(), "cut {cut}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_never_suppresses_a_merged_stream_limit_error() {
|
||||
let mut original = testgen::small(Version::Psd, Compression::Zip);
|
||||
original.image_data.data = compression::zip_compress(&vec![0; (1 << 20) + 4096]);
|
||||
let bytes = original.to_bytes().unwrap();
|
||||
for want in [false, true] {
|
||||
assert!(matches!(PsdFile::from_bytes_and_merged_recovering(&bytes, |_| want, |_| true), Err(PsdError::LimitExceeded(_))));
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,16 @@ fn main() {
|
||||
use photocraft_tablet::Sample;
|
||||
use photocraft_tablet::macos::Monitor;
|
||||
|
||||
// Answer cargo-nextest's `--list` (and nothing for `--ignored`), as tests/mac_menu_appkit.rs
|
||||
// in apps/photocraft does; `monitor_macos --exact` falls through to the test.
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.iter().any(|a| a == "--list") {
|
||||
if !args.iter().any(|a| a == "--ignored") {
|
||||
println!("monitor_macos: test");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread");
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
let event = |kind: CGEventType, pressure: f64, tilt: (f64, f64), subtype: CGEventMouseSubtype| {
|
||||
|
||||
@@ -47,7 +47,7 @@ sys-locale = "=0.3.2"
|
||||
# Installed RAM for the GPU memory budget (`gpu_canvas::physical_memory`): the safe binding the
|
||||
# desktop app already uses, pinned to the same version.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] }
|
||||
winsafe = { version = "=0.0.29", default-features = false, features = ["kernel", "dxgi"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# egui's own default fonts in tests: the oracle for egui_fonts.rs, and contexts made with
|
||||
|
||||
@@ -200,9 +200,17 @@ fn main() {
|
||||
.map(|s| s.tiles().map(|(_, t)| t.bytes().len() as u64).sum::<u64>())
|
||||
.sum();
|
||||
let ram = photocraft_ui_egui::gpu_canvas::physical_memory();
|
||||
let budget = photocraft_ui_egui::gpu_canvas::memory_budget(allowance, pixels, ram);
|
||||
let vram = g.video_memory();
|
||||
let budget = photocraft_ui_egui::gpu_canvas::memory_budget(allowance, pixels, ram, vram, 0);
|
||||
g.set_memory_budget(budget);
|
||||
println!("RAM {} MB, document pixels {} MB, Memory Usage {} MB -> GPU budget {} MB", ram.unwrap_or(0) >> 20, pixels >> 20, allowance >> 20, budget >> 20);
|
||||
println!(
|
||||
"RAM {} MB, VRAM {} MB, document pixels {} MB, Memory Usage {} MB -> GPU budget {} MB",
|
||||
ram.unwrap_or(0) >> 20,
|
||||
vram.unwrap_or(0) >> 20,
|
||||
pixels >> 20,
|
||||
allowance >> 20,
|
||||
budget >> 20
|
||||
);
|
||||
let first_view = Rect::from_xywh((w as f32 * 0.1) as i32, (h as f32 * 0.1) as i32, 2560, 1440);
|
||||
g.set_focus(Some(if op == "view" { first_view } else { doc.bounds() }));
|
||||
|
||||
|
||||
@@ -242,6 +242,8 @@ struct Sizes {
|
||||
big_layer: (u32, u32),
|
||||
/// Layers in the "150-layer document".
|
||||
many_layers: usize,
|
||||
/// The full-canvas layers document (L3): size and layer count.
|
||||
full_layers: (u32, u32, usize),
|
||||
/// The 16-bit document (15000 × 10000).
|
||||
huge16: (u32, u32),
|
||||
/// The 32-bit document with 20 layers.
|
||||
@@ -258,6 +260,7 @@ const FULL: Sizes = Sizes {
|
||||
layered: (5472, 3648),
|
||||
big_layer: (4243, 2828),
|
||||
many_layers: 150,
|
||||
full_layers: (3000, 2000, 150),
|
||||
huge16: (15000, 10000),
|
||||
float32: (4000, 3000),
|
||||
ten_mp: (3872, 2592),
|
||||
@@ -269,6 +272,7 @@ const QUICK: Sizes = Sizes {
|
||||
layered: (1600, 1066),
|
||||
big_layer: (1200, 800),
|
||||
many_layers: 40,
|
||||
full_layers: (800, 600, 20),
|
||||
huge16: (2400, 1600),
|
||||
float32: (1200, 900),
|
||||
ten_mp: (1200, 800),
|
||||
@@ -357,9 +361,10 @@ fn main() {
|
||||
// A panic in one group (a crash the scenario found) is reported against the row that was
|
||||
// running, and the other groups still run.
|
||||
type Group = fn(&mut Bench, &Sizes);
|
||||
let groups: [(&str, Group); 5] = [
|
||||
let groups: [(&str, Group); 6] = [
|
||||
("layered", layered_scenarios),
|
||||
("many layers", many_layer_scenarios),
|
||||
("full-canvas layers", full_canvas_layer_scenarios),
|
||||
("bit depths", depth_scenarios),
|
||||
("long operations", job_scenarios),
|
||||
("algorithms", algorithm_scenarios),
|
||||
@@ -538,6 +543,67 @@ fn layered_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Layers overhaul L3: edits that redraw the whole document (a layer's opacity, hiding it) with
|
||||
/// many full-canvas layers, under the app's own GPU memory budget (Memory Usage at its default,
|
||||
/// the adapter's kind; `gpu_canvas::memory_budget`). Past what the budget holds, each one went to
|
||||
/// the CPU compositor (2.6 s at 150 layers of 6 MP on a discrete card).
|
||||
fn full_canvas_layer_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
let opacity = "opacity of a layer among many full-canvas layers + refresh";
|
||||
let hide = "hide a layer among many full-canvas layers + refresh";
|
||||
if !b.wanted(opacity) && !b.wanted(hide) {
|
||||
return;
|
||||
}
|
||||
b.current = Some(opacity.to_string());
|
||||
let (w, h, n) = sz.full_layers;
|
||||
let mut s = Session::new();
|
||||
let built = (|| -> Res<u64> {
|
||||
exec(&mut s, "file.new", json!({"width": w, "height": h, "background": "white"}))?;
|
||||
let mut mid = 0;
|
||||
for i in 0..n as u32 {
|
||||
exec(&mut s, "layer.new.layer", json!({"name": format!("full {i}")}))?;
|
||||
exec(&mut s, "edit.fill", json!({"color": format!("#{:02x}{:02x}80", (i * 37) % 256, (i * 91) % 256), "opacity": 3}))?;
|
||||
if i % 7 == 3 {
|
||||
exec(&mut s, "layer.layerStyle.dropShadow", json!({"distance": 6, "size": 8}))?;
|
||||
}
|
||||
if i as usize == n / 2 {
|
||||
mid = active_layer(&s)?;
|
||||
}
|
||||
}
|
||||
Ok(mid)
|
||||
})();
|
||||
let mid = match built {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
b.errors.push((opacity.to_string(), e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let (Some((g, _)), Some(st)) = (&b.gpu, s.active()) {
|
||||
// As the app counts it (`canvas::gpu_budget`).
|
||||
let pixels = st.memory_bytes() as u64;
|
||||
let allowance = u64::from(photocraft_engine::prefs::Performance::default().memory_usage_mb) << 20;
|
||||
g.set_memory_budget(photocraft_ui_egui::gpu_canvas::memory_budget(
|
||||
allowance,
|
||||
pixels,
|
||||
photocraft_ui_egui::gpu_canvas::physical_memory(),
|
||||
g.video_memory(),
|
||||
0,
|
||||
));
|
||||
}
|
||||
let _ = b.refresh(&s, true);
|
||||
let reps = b.reps;
|
||||
b.time(opacity, &mut s, reps, true, |b, s, i| {
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.setProps", json!({"layer": mid, "opacity": if i % 2 == 0 { 0.5 } else { 1.0 }}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
b.time(hide, &mut s, reps, true, |b, s, i| {
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.setProps", json!({"layer": mid, "visible": i % 2 == 1}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
}
|
||||
|
||||
/// #209: arrow-key nudge in the 150-layer document; #1771: a new layer there.
|
||||
fn many_layer_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
// Row names stay stable across modes: the layer count is in the mode, not the key.
|
||||
|
||||
@@ -41,16 +41,21 @@ fn gpu_app() -> Option<Gpu> {
|
||||
let observer = Arc::new(std::sync::Mutex::new(None));
|
||||
let observer_out = observer.clone();
|
||||
let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
|
||||
egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
if let Some(rs) = cc.wgpu_render_state.as_ref() {
|
||||
*observer_out.lock().expect("observer") = Some(GpuCanvas::new(rs));
|
||||
app.set_wgpu(rs.clone());
|
||||
}
|
||||
app.background_jobs = true;
|
||||
app
|
||||
})
|
||||
egui_kittest::Harness::builder()
|
||||
.with_size(egui::vec2(1000.0, 700.0))
|
||||
.with_pixels_per_point(1.0)
|
||||
.with_max_steps(64)
|
||||
.wgpu_setup(crate::gpu_canvas::wgpu_setup())
|
||||
.build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
if let Some(rs) = cc.wgpu_render_state.as_ref() {
|
||||
*observer_out.lock().expect("observer") = Some(GpuCanvas::new(rs));
|
||||
app.set_wgpu(rs.clone());
|
||||
}
|
||||
app.background_jobs = true;
|
||||
app
|
||||
})
|
||||
}));
|
||||
let Ok(harness) = built else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
|
||||
@@ -15,6 +15,13 @@ use crate::state::{Tool, View};
|
||||
/// Largest texture side we upload; bigger documents display downsampled until the GPU path lands.
|
||||
pub const MAX_TEXTURE: u32 = 4096;
|
||||
|
||||
/// The largest texture side to upload on this device: [`MAX_TEXTURE`], or less where the GPU
|
||||
/// allows less (2048 on some older or low-end GPUs and WebGL contexts). Past the device's limit
|
||||
/// egui asserts and the backend fails to create the texture.
|
||||
fn texture_side(ctx: &egui::Context) -> u32 {
|
||||
u32::try_from(ctx.input(|i| i.max_texture_side)).unwrap_or(MAX_TEXTURE).clamp(1, MAX_TEXTURE)
|
||||
}
|
||||
|
||||
pub(crate) struct ClonePreviewCache {
|
||||
key: String,
|
||||
texture: egui::TextureHandle,
|
||||
@@ -1421,7 +1428,7 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize,
|
||||
if cache.tex_revision != revision || cache.texture.is_none() || cache.tex_preview_key != preview_key {
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
let longest = doc.size.width.max(doc.size.height);
|
||||
let factor = longest.div_ceil(MAX_TEXTURE).max(1);
|
||||
let factor = longest.div_ceil(texture_side(ctx)).max(1);
|
||||
let (w, h) = ((doc.size.width / factor).max(1), (doc.size.height / factor).max(1));
|
||||
let current = cache.texture.as_mut().filter(|t| t.size() == [w as usize, h as usize]);
|
||||
if let (Some(d), Some(t)) = (damage, current) {
|
||||
@@ -1608,7 +1615,21 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible:
|
||||
grid: None,
|
||||
});
|
||||
let present = cache.on_gpu && gpu.has(id.0, size);
|
||||
if present && cache.revision == revision && cache.preview_key == preview_key {
|
||||
let current = cache.revision == revision && cache.preview_key == preview_key;
|
||||
// GPU Memory or Memory Usage changed with nothing to redraw: the new budget, and whether the
|
||||
// layers fit it, show at once (the status bar's GPU Memory Full).
|
||||
if present && app.perf.gpu_budget_allowance != budget_allowance(app) {
|
||||
gpu_budget(app, &gpu, idx, false, visible);
|
||||
}
|
||||
// Switched to a document whose canvas is already current: size the budget for it and measure
|
||||
// whether its layers fit, so GPU Memory Full shows (or clears) at once rather than at its
|
||||
// next full refresh. Once per switch: the fit then names this document.
|
||||
if present && app.perf.gpu_layer_fit.is_none_or(|f| f.doc != id.0) {
|
||||
gpu_budget(app, &gpu, idx, false, visible);
|
||||
let fit = gpu.measure_fit(&doc).unwrap_or(crate::gpu_canvas::LayerFit { doc: id.0, needed: 0, budget: 0 });
|
||||
app.perf.gpu_layer_fit = Some(fit);
|
||||
}
|
||||
if present && current {
|
||||
// An adjustment preview composited where the view was: catch up where it moved to.
|
||||
if let Some(r) = crate::adjust_preview::uncovered(app, id, raw_key, visible) {
|
||||
let r = gpu.refresh(id.0, &doc, Some(r), display.as_deref());
|
||||
@@ -1620,12 +1641,17 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible:
|
||||
// draws it (checked before the memory budget, which walks the history, every frame).
|
||||
if !gpu.fits_canvas(&doc) {
|
||||
crate::background_redraw::cancel(app, id);
|
||||
// Drawn on the CPU canvas: no layer budget applies to it.
|
||||
if app.perf.gpu_layer_fit.is_some_and(|f| f.doc == id.0) {
|
||||
app.perf.gpu_layer_fit = None;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let partial = present && damage.is_some();
|
||||
gpu_budget(app, &gpu, idx, partial, visible);
|
||||
let defer = crate::background_redraw::enabled(app);
|
||||
let r = gpu.refresh_with(id.0, &doc, if partial { damage } else { None }, display.as_deref(), defer);
|
||||
app.perf.gpu_layer_fit = gpu.layer_fit();
|
||||
if r.kind == "lost" || r.kind == crate::gpu_canvas::REFRESH_OVER_BUDGET {
|
||||
// The device is gone (`gpu_status` then drops the GPU canvas) or the canvas is over the
|
||||
// texture budget: draw this frame on the CPU path.
|
||||
@@ -1666,21 +1692,32 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible:
|
||||
true
|
||||
}
|
||||
|
||||
/// Point the wgpu compositor at what the view shows, and size its memory budget from Memory
|
||||
/// Usage, the document's pixels and History, and physical memory: on full refreshes (pixels
|
||||
/// counted once per structural change, not per brush dab) and whenever the preference changes.
|
||||
/// Point the wgpu compositor at what the view shows, and size its memory budget from GPU Memory,
|
||||
/// or Memory Usage, the document's pixels and History (charged where the GPU shares the system's
|
||||
/// memory), physical memory and the GPU's own: on full refreshes (pixels counted once per
|
||||
/// structural change, not per brush dab) and whenever either preference changes.
|
||||
fn gpu_budget(app: &mut PhotocraftApp, gpu: &crate::gpu_canvas::GpuCanvas, idx: usize, partial: bool, visible: DRect) {
|
||||
gpu.set_focus(Some(visible));
|
||||
let allowance = u64::from(app.session.prefs().performance.memory_usage_mb).saturating_mul(1 << 20);
|
||||
if partial && app.perf.gpu_budget_allowance == allowance && gpu.memory_budget().is_some() {
|
||||
let allowance = budget_allowance(app);
|
||||
let changed = app.perf.gpu_budget_allowance != allowance;
|
||||
if partial && !changed && gpu.memory_budget().is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(st) = app.session.documents().get(idx) else { return };
|
||||
let pixels = st.memory_bytes() as u64;
|
||||
let budget = crate::gpu_canvas::memory_budget(allowance, pixels, crate::gpu_canvas::physical_memory());
|
||||
let budget = crate::gpu_canvas::memory_budget(allowance.0, pixels, crate::gpu_canvas::physical_memory(), gpu.video_memory(), allowance.1);
|
||||
gpu.set_memory_budget(budget);
|
||||
app.perf.gpu_budget_mb = budget >> 20;
|
||||
app.perf.gpu_budget_allowance = allowance;
|
||||
if changed && let Some(fit) = gpu.measure_fit(&st.doc) {
|
||||
app.perf.gpu_layer_fit = Some(fit);
|
||||
}
|
||||
}
|
||||
|
||||
/// Memory Usage and GPU Memory (Preferences › Performance), in bytes.
|
||||
fn budget_allowance(app: &PhotocraftApp) -> (u64, u64) {
|
||||
let performance = &app.session.prefs().performance;
|
||||
(u64::from(performance.memory_usage_mb).saturating_mul(1 << 20), u64::from(performance.gpu_memory_mb).saturating_mul(1 << 20))
|
||||
}
|
||||
|
||||
/// Live preview for an open filter dialog: run the filter on the proxy and upload it. With
|
||||
@@ -4520,8 +4557,8 @@ fn draw_beyond_canvas(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie
|
||||
None => {
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
let extent = photocraft_engine::extra_cmds::reveal_all_bounds(&doc);
|
||||
// One texel per `k` document pixels keeps every texture within MAX_TEXTURE.
|
||||
let k = extent.width().max(extent.height()).div_ceil(MAX_TEXTURE).max(1);
|
||||
// One texel per `k` document pixels keeps every texture within the device's side.
|
||||
let k = extent.width().max(extent.height()).div_ceil(texture_side(&ctx)).max(1);
|
||||
let proxy = (k > 1).then(|| photocraft_compose::proxy::proxy_document(&doc, k));
|
||||
let src = proxy.as_ref().unwrap_or(&*doc);
|
||||
let k = k as i32;
|
||||
@@ -6525,6 +6562,25 @@ mod tests {
|
||||
assert_eq!(uploads(&ctx), vec![(true, crisp)], "a partial update keeps the crisp sampling");
|
||||
}
|
||||
|
||||
/// A device that allows smaller textures than MAX_TEXTURE (2048 on some older GPUs and WebGL
|
||||
/// contexts, and on the fallback adapter the GPU tests sometimes get under load): the canvas
|
||||
/// texture is reduced to fit it. A 2600 px document was uploaded at full size, which egui
|
||||
/// asserts on and the backend can't create.
|
||||
#[test]
|
||||
fn cpu_canvas_texture_fits_the_device_limit() {
|
||||
for (side, (w, h), want) in [(2048, (2600, 1700), [1300, 850]), (2048, (5000, 2000), [1666, 666]), (8192, (5000, 2000), [2500, 1000])] {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": w, "height": h})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
ctx.input_mut(|i| i.max_texture_side = side);
|
||||
let (tex, _) = ensure_texture(&mut app, &ctx, 0, None).unwrap();
|
||||
let mut delta = ctx.tex_manager().write().take_delta();
|
||||
let sizes: Vec<_> = delta.set.get(&tex).into_iter().flatten().map(|d| d.image.size()).collect();
|
||||
delta.clear();
|
||||
assert_eq!(sizes, vec![want], "{w}x{h} on a {side} px device");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_canvas_of_a_reduced_document_stays_bilinear() {
|
||||
// Over MAX_TEXTURE the texture holds one texel per `factor` pixels: a texel is not a
|
||||
@@ -6542,6 +6598,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn gpu_display_luts_are_per_display() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
let Ok(rs) = std::panic::catch_unwind(|| egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default())) else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
@@ -7460,6 +7517,7 @@ mod tests {
|
||||
// must update only the reduced pixels it touched.
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
ctx.input_mut(|i| i.max_texture_side = 8192);
|
||||
app.run("file.new", json!({"width": 4097, "height": 90, "background": "transparent"})).unwrap();
|
||||
let tilt = json!({"size": 30, "hardness": 1.0, "spacing": 0.05, "shapeDynamics": {"enabled": true, "size": {"control": "penTilt"}}});
|
||||
app.run("tools.setBrush", json!({ "brush": tilt })).unwrap();
|
||||
|
||||
@@ -183,6 +183,24 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
status_message(app, ui, &t);
|
||||
}
|
||||
|
||||
/// The active document's layers don't fit the GPU memory budget (`perf.gpuLayerFit`): edits that
|
||||
/// redraw the whole document do it on the CPU, so say so in the status bar until they fit again.
|
||||
/// Clicking it opens Preferences › Performance, where GPU Memory raises the budget.
|
||||
pub fn gpu_memory_status(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let Some(doc) = app.session.active().map(|st| st.doc.id.0) else { return };
|
||||
let Some(fit) = app.perf.gpu_layer_fit.filter(|f| f.doc == doc && !f.fits()) else { return };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let resp = ui
|
||||
.add(egui::Label::new(RichText::new(tl!("GPU Memory Full")).color(t.warning).size(12.0)).sense(Sense::click()))
|
||||
.on_hover_text(crate::i18n::fmt(
|
||||
tl!("The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory."),
|
||||
&[("needed", &fmt_bytes(fit.needed)), ("budget", &fmt_bytes(fit.budget))],
|
||||
));
|
||||
if resp.clicked() {
|
||||
crate::shortcut_dispatch::dispatch(app, ui.ctx(), "edit.preferences.performance");
|
||||
}
|
||||
}
|
||||
|
||||
/// The latest status message after a separator, in the warning colour when it is an error.
|
||||
fn status_message(app: &PhotocraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
if app.ui.status.is_empty() {
|
||||
@@ -293,6 +311,39 @@ mod tests {
|
||||
assert_eq!(status_info_text_with_units(&d, "dimensions", "", "", &units), "722.7 pt x 451.7 pt (240 ppi)");
|
||||
}
|
||||
|
||||
/// L3: layers over the GPU memory budget show "GPU Memory Full" in the status bar, for that
|
||||
/// document only, and clicking it opens Preferences › Performance (GPU Memory).
|
||||
#[test]
|
||||
fn status_bar_says_when_layers_dont_fit_gpu_memory() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
for theme in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Studio] {
|
||||
let mut h = Harness::builder().with_size(vec2(1200.0, 700.0)).with_max_steps(64).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, theme);
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.ui.theme = theme;
|
||||
app
|
||||
});
|
||||
h.state_mut().run("file.new", json!({"width": 800, "height": 600})).unwrap();
|
||||
h.state_mut().sync_views();
|
||||
h.run_steps(4);
|
||||
let doc = h.state().session.active().unwrap().doc.id.0;
|
||||
assert!(h.query_by_label("GPU Memory Full").is_none(), "{theme:?}: nothing measured yet");
|
||||
let over = crate::gpu_canvas::LayerFit { doc, needed: 3 << 30, budget: 2 << 30 };
|
||||
h.state_mut().perf.gpu_layer_fit = Some(crate::gpu_canvas::LayerFit { doc: doc + 1, ..over });
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("GPU Memory Full").is_none(), "{theme:?}: another document's layers");
|
||||
h.state_mut().perf.gpu_layer_fit = Some(crate::gpu_canvas::LayerFit { budget: 4 << 30, ..over });
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("GPU Memory Full").is_none(), "{theme:?}: they fit");
|
||||
h.state_mut().perf.gpu_layer_fit = Some(over);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("GPU Memory Full").click();
|
||||
h.run_steps(4);
|
||||
let open = h.state().ui.dialogs.iter().any(|d| d.fields.get("section") == Some(&json!("performance")));
|
||||
assert!(open, "{theme:?}: Preferences › Performance opens");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_bar_updates_dimensions_after_a_ruler_preference_change() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
@@ -83,6 +83,7 @@ fn alt_click_clipping_still_creates_releases_and_undoes_without_moving_rows() {
|
||||
#[test]
|
||||
#[ignore = "writes synthetic English Layers-panel screenshots for before/after inspection"]
|
||||
fn write_clipping_indicator_preview() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
let output = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plan/evidence/clipping");
|
||||
std::fs::create_dir_all(&output).unwrap();
|
||||
for theme in crate::theme::ThemeKind::ALL {
|
||||
|
||||
@@ -131,8 +131,9 @@ pub enum Outcome {
|
||||
/// field's value is validated before the first one is applied, so a typo, an unknown field, a
|
||||
/// bad value or a bad nested key can't reply with success while nothing — or only half of it —
|
||||
/// changed (#412).
|
||||
pub const UI_SET_FIELDS: [&str; 37] = [
|
||||
pub const UI_SET_FIELDS: [&str; 38] = [
|
||||
"tool",
|
||||
"strokeTrace",
|
||||
"panels",
|
||||
"dock",
|
||||
"dockTabs",
|
||||
@@ -492,6 +493,19 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
None => None,
|
||||
};
|
||||
let gradient_classic = bool_field(p, "gradientClassic")?;
|
||||
// The stroke latency trace (`stroke_trace`), at runtime: on (stderr) or off. A CSV
|
||||
// file is only for the user's own launch (`--stroke-trace <path>`): a path over the
|
||||
// control channel would write outside the automation write root.
|
||||
let stroke_trace = match p.get("strokeTrace") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Bool(b)) => Some(if *b { "stderr" } else { "off" }),
|
||||
Some(Value::String(v)) => match v.trim() {
|
||||
"stderr" | "on" | "true" => Some("stderr"),
|
||||
"off" | "false" => Some("off"),
|
||||
_ => return Err("strokeTrace must be true, false, \"stderr\" or \"off\"; a CSV file is set at launch (--stroke-trace <path>)".into()),
|
||||
},
|
||||
Some(_) => return Err("strokeTrace must be true, false, \"stderr\" or \"off\"".into()),
|
||||
};
|
||||
// The Eyedropper's options bar (#1649): Sample Size, Sample, Show Sampling Ring.
|
||||
let eyedropper_size = match p.get("eyedropperSampleSize") {
|
||||
Some(v) => Some(
|
||||
@@ -663,6 +677,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
if let Some(mode) = gradient_blend {
|
||||
app.ui.tool_options.gradient_blend_mode = mode;
|
||||
}
|
||||
if let Some(v) = &stroke_trace {
|
||||
app.stroke_trace.set(v);
|
||||
}
|
||||
if let Some(classic) = gradient_classic {
|
||||
app.ui.tool_options.gradient_classic = classic;
|
||||
}
|
||||
@@ -1385,6 +1402,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_set_turns_the_stroke_trace_on_and_off_at_runtime() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
assert!(!app.stroke_trace.enabled());
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"strokeTrace": true}))["ok"], true);
|
||||
assert!(app.stroke_trace.enabled());
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"strokeTrace": "off"}))["ok"], true);
|
||||
assert!(!app.stroke_trace.enabled());
|
||||
let bad = call(&mut app, &ctx, "ui.set", json!({"strokeTrace": 3, "tool": "Pencil"}));
|
||||
assert_eq!(bad["ok"], false);
|
||||
// A file path isn't accepted over the channel (it would write outside the automation
|
||||
// write root); nothing is created.
|
||||
let path = std::env::temp_dir().join(format!("photocraft-trace-{}.csv", std::process::id()));
|
||||
let bad = call(&mut app, &ctx, "ui.set", json!({"strokeTrace": path.to_str().unwrap(), "tool": "Pencil"}));
|
||||
assert_eq!(bad["ok"], false);
|
||||
assert!(!path.exists());
|
||||
assert!(!app.stroke_trace.enabled() && app.ui.tool != crate::state::Tool::Pencil, "a rejected call applies nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_raw_control_updates_and_commits_or_cancels_an_open_raw() {
|
||||
use photocraft_raw::testgen::{DngSpec, mosaic, scene};
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::{cell::RefCell, rc::Rc};
|
||||
type Writes = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
fn tiff_prompt(command: &str) -> (Prompted, Writes, DocId, DocId) {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
let mut app = app_with_docs(2);
|
||||
make_dirty(&mut app, 0);
|
||||
let (saved, other) = (doc_id(&app, 0), doc_id(&app, 1));
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::num::NonZeroU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use eframe::egui_wgpu::{self, CallbackResources, CallbackTrait, RenderState, ScreenDescriptor};
|
||||
use eframe::wgpu;
|
||||
@@ -110,6 +111,11 @@ pub struct GpuCanvas {
|
||||
tile: u32,
|
||||
high: HighPolicy,
|
||||
health: photocraft_gpu::DeviceHealth,
|
||||
/// [`video_memory`] of the adapter, detected once.
|
||||
vram: Option<u64>,
|
||||
/// [`canvas_budget`], shared with the paint callback's resources: [`GpuCanvas::format_for`]
|
||||
/// reads it while the renderer is locked.
|
||||
canvas_budget: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
|
||||
/// Whether `regenerate_mips` builds each level in a scratch texture and copies it back: on
|
||||
@@ -158,9 +164,16 @@ impl GpuCanvas {
|
||||
res.health = health.clone();
|
||||
let info = rs.adapter.get_info();
|
||||
res.separate_mip_targets = separate_mip_targets(info.backend, info.vendor, std::env::var("PHOTOCRAFT_SEPARATE_MIPS").ok().as_deref());
|
||||
let vram = video_memory(&info);
|
||||
res.canvas_budget.store(canvas_budget(physical_memory(), vram), Ordering::Relaxed);
|
||||
let budget = res.canvas_budget.clone();
|
||||
rs.renderer.write().callback_resources.insert(res);
|
||||
log::info!("gpu canvas: target {:?}, max texture {max}, tile {tile}, 16F canvas {high:?}", rs.target_format);
|
||||
Self { rs: rs.clone(), tile, high, health }
|
||||
log::info!(
|
||||
"gpu canvas: target {:?}, max texture {max}, tile {tile}, 16F canvas {high:?}, video memory {}",
|
||||
rs.target_format,
|
||||
vram.map_or_else(|| "unknown".to_owned(), |b| format!("{} MB", b >> 20))
|
||||
);
|
||||
Self { rs: rs.clone(), tile, high, health, vram, canvas_budget: budget }
|
||||
}
|
||||
|
||||
/// The device's health flag (shared with the wgpu compositor and the paint callback).
|
||||
@@ -192,6 +205,12 @@ impl GpuCanvas {
|
||||
self.rs.adapter.get_info()
|
||||
}
|
||||
|
||||
/// The adapter's own memory where it has one and the platform reports it (see
|
||||
/// [`video_memory`]); it caps [`memory_budget`] and [`canvas_budget`].
|
||||
pub fn video_memory(&self) -> Option<u64> {
|
||||
self.vram
|
||||
}
|
||||
|
||||
/// The canvas texture format for a document of `depth` and `size`: `Rgba16Float` for 16/32-bit
|
||||
/// documents when the adapter supports it and the document fits [`F16_BUDGET_PX`], else
|
||||
/// `Rgba8Unorm`.
|
||||
@@ -204,7 +223,11 @@ impl GpuCanvas {
|
||||
let px = size[0] as u64 * size[1] as u64;
|
||||
let high = match self.high {
|
||||
HighPolicy::Off => false,
|
||||
HighPolicy::Budget => px <= F16_BUDGET_PX,
|
||||
// Within the canvas budget too: a 16-bit canvas a small card can't hold as floats
|
||||
// stays on the GPU as `Rgba8Unorm` rather than falling back to the CPU canvas.
|
||||
HighPolicy::Budget => {
|
||||
px <= F16_BUDGET_PX && canvas_texture_bytes(size, self.tile, texel_bytes(FORMAT_HIGH)) <= self.canvas_budget.load(Ordering::Relaxed)
|
||||
}
|
||||
HighPolicy::Always => true,
|
||||
};
|
||||
if high && depth != photocraft_doc::SampleType::U8 { FORMAT_HIGH } else { FORMAT }
|
||||
@@ -240,10 +263,7 @@ impl GpuCanvas {
|
||||
|
||||
/// Set the GPU bytes one document's display texture set may take (see [`canvas_budget`]).
|
||||
pub fn set_canvas_budget(&self, bytes: u64) {
|
||||
let mut r = self.rs.renderer.write();
|
||||
if let Some(res) = r.callback_resources.get_mut::<Resources>() {
|
||||
res.canvas_budget = bytes;
|
||||
}
|
||||
self.canvas_budget.store(bytes, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether `doc`'s display textures fit [`canvas_budget`]; when they don't, nothing is
|
||||
@@ -252,7 +272,7 @@ impl GpuCanvas {
|
||||
let size = [doc.size.width, doc.size.height];
|
||||
let bytes = canvas_texture_bytes(size, self.tile, texel_bytes(self.format_for(doc.depth, size)));
|
||||
let r = self.rs.renderer.read();
|
||||
r.callback_resources.get::<Resources>().is_some_and(|res| bytes <= res.canvas_budget)
|
||||
r.callback_resources.get::<Resources>().is_some_and(|res| bytes <= res.canvas_budget.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Cap lower-stack composite reuse; zero disables it for profiling the uncached path.
|
||||
@@ -283,6 +303,28 @@ impl GpuCanvas {
|
||||
Some((c.resident_bytes(), c.fx_cache_bytes().saturating_add(usize::try_from(c.composite_cache_bytes()).unwrap_or(usize::MAX))))
|
||||
}
|
||||
|
||||
/// Whether `doc`'s layers fit the budget as it now stands, measured without a refresh (a GPU
|
||||
/// Memory change shows in the status bar at once). `None` before the compositor exists.
|
||||
pub fn measure_fit(&self, doc: &photocraft_doc::Document) -> Option<LayerFit> {
|
||||
let mut r = self.rs.renderer.write();
|
||||
let res = r.callback_resources.get_mut::<Resources>()?;
|
||||
let budget = res.compositor_budget;
|
||||
let comp = res.compositor.as_mut()?;
|
||||
if let Some(b) = budget {
|
||||
comp.set_memory_budget(b);
|
||||
}
|
||||
let fit = LayerFit { doc: doc.id.0, needed: comp.working_set(doc, doc.bounds()), budget: comp.resident_budget() };
|
||||
res.layer_fit = Some(fit);
|
||||
Some(fit)
|
||||
}
|
||||
|
||||
/// Whether the layers of the last full (or large) refresh fit the GPU memory budget, and by
|
||||
/// how much (`None` before the first one).
|
||||
pub fn layer_fit(&self) -> Option<LayerFit> {
|
||||
let r = self.rs.renderer.read();
|
||||
r.callback_resources.get::<Resources>()?.layer_fit
|
||||
}
|
||||
|
||||
/// Set the checkerboard and gamut warning colours.
|
||||
pub fn set_style(&self, style: CanvasStyle) {
|
||||
let mut r = self.rs.renderer.write();
|
||||
@@ -451,9 +493,13 @@ impl GpuCanvas {
|
||||
// compositor does it (in the background on desktop). So does a large damage rect, such
|
||||
// as hiding a big layer (#4024); small edits in the view stay on the GPU.
|
||||
let large = region.width() as u64 * region.height() as u64 >= DEFER_PX;
|
||||
if (region == doc.bounds() || large) && !comp.fits_budget(doc, region) {
|
||||
res.compositor = Some(comp);
|
||||
return Err(photocraft_gpu::Unsupported(OVER_BUDGET.into()));
|
||||
if region == doc.bounds() || large {
|
||||
let fit = LayerFit { doc: doc.id.0, needed: comp.working_set(doc, region), budget: comp.resident_budget() };
|
||||
res.layer_fit = Some(fit);
|
||||
if !fit.fits() {
|
||||
res.compositor = Some(comp);
|
||||
return Err(photocraft_gpu::Unsupported(OVER_BUDGET.into()));
|
||||
}
|
||||
}
|
||||
if fresh {
|
||||
let Some(tex) = DocTextures::new(device, res, size, self.tile, format) else {
|
||||
@@ -906,9 +952,13 @@ pub struct Perf {
|
||||
/// GPU memory the compositor may hold for layer pages and effect maps (MB; see
|
||||
/// [`memory_budget`]).
|
||||
pub gpu_budget_mb: u64,
|
||||
/// The Memory Usage allowance (bytes) `gpu_budget_mb` was computed from.
|
||||
/// The Memory Usage allowance and the GPU Memory preference (bytes) `gpu_budget_mb` was
|
||||
/// computed from.
|
||||
#[serde(skip)]
|
||||
pub gpu_budget_allowance: u64,
|
||||
pub gpu_budget_allowance: (u64, u64),
|
||||
/// The layer pages the last full refresh needed against the part of the budget they may
|
||||
/// hold; when they don't fit, edits that redraw the whole document do it on the CPU.
|
||||
pub gpu_layer_fit: Option<LayerFit>,
|
||||
/// Last command run through the app and its duration (ms).
|
||||
pub last_command: String,
|
||||
pub command_ms: f64,
|
||||
@@ -934,6 +984,9 @@ pub struct GpuInfo {
|
||||
pub device_type: String,
|
||||
/// Driver name and version as the driver reports them.
|
||||
pub driver: String,
|
||||
/// The adapter's own memory in MB ([`video_memory`]): discrete GPUs where the platform
|
||||
/// reports it. It caps the GPU memory budgets.
|
||||
pub video_memory_mb: Option<u64>,
|
||||
/// `performance.gpuBackend` at launch.
|
||||
pub preference: String,
|
||||
/// The backend startup chose ("auto", "dx12", "cpu", …, or "env" for `WGPU_BACKEND`).
|
||||
@@ -945,6 +998,9 @@ pub struct GpuInfo {
|
||||
pub canvas: String,
|
||||
/// Why the GPU canvas was dropped during this session (device lost or a GPU error).
|
||||
pub lost: Option<String>,
|
||||
/// Present mode and frame latency ("fifo, latency 2"; launch overrides marked, see the
|
||||
/// app's `presentation`). Empty in hosts that don't set it.
|
||||
pub present: String,
|
||||
}
|
||||
|
||||
impl GpuInfo {
|
||||
@@ -961,6 +1017,7 @@ impl GpuInfo {
|
||||
}
|
||||
.to_string();
|
||||
self.driver = [info.driver.as_str(), info.driver_info.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" ");
|
||||
self.video_memory_mb = video_memory(info).map(|b| b >> 20);
|
||||
}
|
||||
|
||||
/// Human-readable lines for Help › System Info.
|
||||
@@ -1185,21 +1242,53 @@ fn texel_to_f32(format: wgpu::TextureFormat, b: &[u8]) -> [f32; 4] {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// GPU resources
|
||||
|
||||
/// The GPU memory a refresh's layer pages need against what they may hold (bytes).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LayerFit {
|
||||
/// The document refreshed.
|
||||
pub doc: u64,
|
||||
pub needed: u64,
|
||||
pub budget: u64,
|
||||
}
|
||||
|
||||
impl LayerFit {
|
||||
pub fn fits(&self) -> bool {
|
||||
self.needed <= self.budget
|
||||
}
|
||||
}
|
||||
|
||||
/// Floor of the compositor's memory budget: enough for a viewport's pages of a dozen layers.
|
||||
pub const MIN_GPU_BUDGET: u64 = 512 << 20;
|
||||
|
||||
/// Why a full refresh whose layer pages don't fit [`memory_budget`] goes to the CPU compositor.
|
||||
pub const OVER_BUDGET: &str = "layers exceed the GPU memory budget; full refresh on the CPU";
|
||||
|
||||
/// GPU memory the wgpu compositor may hold for layer pages and effect maps: what Memory Usage
|
||||
/// (`allowance`, Preferences › Performance) leaves after the document's pixels and History
|
||||
/// (`pixels`), at most a quarter of physical memory (`ram`; 16 GB assumed when unknown), and at
|
||||
/// least [`MIN_GPU_BUDGET`]. On unified-memory machines these textures share RAM with the
|
||||
/// document, so a huge document keeps its layers on the GPU only as far as memory allows; the
|
||||
/// rest is uploaded per refresh as the view needs it.
|
||||
pub fn memory_budget(allowance: u64, pixels: u64, ram: Option<u64>) -> u64 {
|
||||
/// GPU memory the wgpu compositor may hold for layer pages and effect maps.
|
||||
///
|
||||
/// `fixed` (Preferences › Performance › GPU Memory, bytes) sets it when it isn't 0, within
|
||||
/// [`vram_share`] of a discrete GPU's own memory (`vram`, from [`video_memory`]). Otherwise it is
|
||||
/// Memory Usage (`allowance`) within a quarter of physical memory (`ram`; 16 GB assumed when
|
||||
/// unknown) and the VRAM share. The document's pixels and History (`pixels`) come off Memory Usage
|
||||
/// unless the card's own memory is known: a discrete card doesn't hold them, while integrated and
|
||||
/// software adapters (and a card whose memory can't be read) share the system's. Never below
|
||||
/// [`MIN_GPU_BUDGET`]. Layers beyond it redraw full refreshes on the CPU ([`OVER_BUDGET`]).
|
||||
pub fn memory_budget(allowance: u64, pixels: u64, ram: Option<u64>, vram: Option<u64>, fixed: u64) -> u64 {
|
||||
let card = vram.map_or(u64::MAX, vram_share);
|
||||
if fixed > 0 {
|
||||
return fixed.min(card).max(MIN_GPU_BUDGET);
|
||||
}
|
||||
let ram = ram.unwrap_or(16 << 30);
|
||||
allowance.saturating_sub(pixels).min(ram / 4).max(MIN_GPU_BUDGET)
|
||||
let charged = if vram.is_some() { 0 } else { pixels };
|
||||
allowance.saturating_sub(charged).min(ram / 4).min(card).max(MIN_GPU_BUDGET)
|
||||
}
|
||||
|
||||
/// The share of a discrete GPU's memory each of [`memory_budget`] and [`canvas_budget`] may
|
||||
/// plan for: three eighths, so the two together leave a quarter of the card to the window's
|
||||
/// swapchain, egui and other applications instead of over-committing it and letting the driver
|
||||
/// page (#4536).
|
||||
pub fn vram_share(vram: u64) -> u64 {
|
||||
vram / 8 * 3
|
||||
}
|
||||
|
||||
/// Why a document whose display textures don't fit [`canvas_budget`] is drawn on the CPU canvas.
|
||||
@@ -1217,11 +1306,12 @@ pub const REFRESH_DEFERRED: &str = "deferred";
|
||||
pub const DEFER_PX: u64 = 1 << 22;
|
||||
|
||||
/// GPU bytes one document's display texture set (every tile and mip level) may take: a quarter
|
||||
/// of physical memory (`ram`; 16 GB assumed when unknown), at least [`MIN_GPU_BUDGET`]. The set
|
||||
/// covers the whole canvas at full resolution, so its size follows the declared canvas, not the
|
||||
/// layer pixels; a larger canvas is drawn on the (reduced) CPU canvas instead (#2934).
|
||||
pub fn canvas_budget(ram: Option<u64>) -> u64 {
|
||||
(ram.unwrap_or(16 << 30) / 4).max(MIN_GPU_BUDGET)
|
||||
/// of physical memory (`ram`; 16 GB assumed when unknown) and at most [`vram_share`] of a
|
||||
/// discrete GPU's own memory (`vram`), at least [`MIN_GPU_BUDGET`]. The set covers the whole
|
||||
/// canvas at full resolution, so its size follows the declared canvas, not the layer pixels; a
|
||||
/// larger canvas is drawn on the (reduced) CPU canvas instead (#2934).
|
||||
pub fn canvas_budget(ram: Option<u64>, vram: Option<u64>) -> u64 {
|
||||
(ram.unwrap_or(16 << 30) / 4).min(vram.map_or(u64::MAX, vram_share)).max(MIN_GPU_BUDGET)
|
||||
}
|
||||
|
||||
/// GPU bytes of a display texture set for a `size` canvas in `tile`-sided tiles of `bpp` bytes
|
||||
@@ -1285,6 +1375,70 @@ fn detect_physical_memory() -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The memory of `adapter` itself, in bytes, when it is a discrete GPU and the platform reports
|
||||
/// it: DXGI's dedicated video memory on Windows, amdgpu's VRAM size on Linux. `None` for
|
||||
/// integrated and software adapters, whose textures live in system memory (the RAM-based
|
||||
/// budgets already cover them), and where nothing reports it. `PHOTOCRAFT_VRAM_MB` overrides it
|
||||
/// for any adapter, simulating a discrete card of that size: on an integrated GPU the budget then
|
||||
/// stops charging the document's pixels too, as for a real card (a development switch).
|
||||
pub fn video_memory(adapter: &wgpu::AdapterInfo) -> Option<u64> {
|
||||
if let Some(mb) = std::env::var("PHOTOCRAFT_VRAM_MB").ok().and_then(|v| v.trim().parse::<u64>().ok()) {
|
||||
return mb.checked_mul(1 << 20).filter(|b| *b > 0);
|
||||
}
|
||||
if adapter.device_type != wgpu::DeviceType::DiscreteGpu {
|
||||
return None;
|
||||
}
|
||||
// Detected once per adapter, as `physical_memory` is: on Windows each detection builds a
|
||||
// DXGI factory, and startup asks three times. Keyed by the adapter, since a GPU retry can
|
||||
// pick another one.
|
||||
/// (vendor, device) ids and the memory found for them.
|
||||
type Seen = Vec<((u32, u32), Option<u64>)>;
|
||||
static SEEN: std::sync::Mutex<Seen> = std::sync::Mutex::new(Vec::new());
|
||||
let key = (adapter.vendor, adapter.device);
|
||||
let mut seen = SEEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some((_, bytes)) = seen.iter().find(|(k, _)| *k == key) {
|
||||
return *bytes;
|
||||
}
|
||||
let bytes = detect_video_memory(key.0, key.1).filter(|b| *b > 0);
|
||||
seen.push((key, bytes));
|
||||
bytes
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn detect_video_memory(vendor: u32, device: u32) -> Option<u64> {
|
||||
use winsafe::prelude::*;
|
||||
let factory = winsafe::CreateDXGIFactory1().ok()?;
|
||||
factory
|
||||
.EnumAdapters1()
|
||||
.filter_map(|a| a.ok()?.GetDesc1().ok())
|
||||
.find(|d| d.VendorId == vendor && d.DeviceId == device)
|
||||
.and_then(|d| u64::try_from(d.DedicatedVideoMemory).ok())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn detect_video_memory(vendor: u32, device: u32) -> Option<u64> {
|
||||
drm_video_memory(std::path::Path::new("/sys/class/drm"), vendor, device)
|
||||
}
|
||||
|
||||
/// amdgpu's VRAM size (`<card>/device/mem_info_vram_total`, bytes) of the card under `drm` (the
|
||||
/// sysfs `/sys/class/drm` folder) whose PCI `vendor` and `device` ids (hex files beside it) match.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn drm_video_memory(drm: &std::path::Path, vendor: u32, device: u32) -> Option<u64> {
|
||||
let hex = |p: std::path::PathBuf| u32::from_str_radix(std::fs::read_to_string(p).ok()?.trim().trim_start_matches("0x"), 16).ok();
|
||||
std::fs::read_dir(drm).ok()?.flatten().find_map(|card| {
|
||||
let dev = card.path().join("device");
|
||||
if hex(dev.join("vendor"))? != vendor || hex(dev.join("device"))? != device {
|
||||
return None;
|
||||
}
|
||||
std::fs::read_to_string(dev.join("mem_info_vram_total")).ok()?.trim().parse().ok()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
fn detect_video_memory(_vendor: u32, _device: u32) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
struct Resources {
|
||||
view_bgl: wgpu::BindGroupLayout,
|
||||
tile_bgl: wgpu::BindGroupLayout,
|
||||
@@ -1312,10 +1466,13 @@ struct Resources {
|
||||
/// GPU memory the compositor may hold (`None`: its default), and the document area the
|
||||
/// view shows; applied before every composite.
|
||||
compositor_budget: Option<u64>,
|
||||
/// The layer pages the last full (or large) refresh needed and the budget they had to fit
|
||||
/// (bytes): when they don't fit, full refreshes go to the CPU ([`OVER_BUDGET`]).
|
||||
layer_fit: Option<LayerFit>,
|
||||
compositor_cache_budget: Option<u64>,
|
||||
compositor_focus: Option<photocraft_geom::Rect>,
|
||||
/// GPU bytes one document's display texture set may take (see [`canvas_budget`]).
|
||||
canvas_budget: u64,
|
||||
canvas_budget: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
||||
encode_bgl: wgpu::BindGroupLayout,
|
||||
encode_pipeline: wgpu::RenderPipeline,
|
||||
lut_bgl: wgpu::BindGroupLayout,
|
||||
@@ -1579,9 +1736,10 @@ impl Resources {
|
||||
compositor_failed: None,
|
||||
health: photocraft_gpu::DeviceHealth::new(),
|
||||
compositor_budget: None,
|
||||
layer_fit: None,
|
||||
compositor_cache_budget: None,
|
||||
compositor_focus: None,
|
||||
canvas_budget: canvas_budget(physical_memory()),
|
||||
canvas_budget: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(canvas_budget(physical_memory(), None))),
|
||||
encode_bgl,
|
||||
encode_pipeline,
|
||||
}
|
||||
@@ -1620,7 +1778,7 @@ impl DocTextures {
|
||||
/// The texture set for a `size` canvas, or `None` (nothing created) when it doesn't fit
|
||||
/// the canvas budget.
|
||||
fn new(device: &wgpu::Device, res: &Resources, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Option<Self> {
|
||||
if canvas_texture_bytes(size, tile, texel_bytes(format)) > res.canvas_budget {
|
||||
if canvas_texture_bytes(size, tile, texel_bytes(format)) > res.canvas_budget.load(Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
let mut tiles = Vec::new();
|
||||
@@ -2237,16 +2395,84 @@ mod tests {
|
||||
#[test]
|
||||
fn gpu_budget_follows_memory_usage_and_ram() {
|
||||
const G: u64 = 1 << 30;
|
||||
// What Memory Usage leaves after the document, within a quarter of RAM.
|
||||
assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G)), 5 * G);
|
||||
assert_eq!(memory_budget(8 * G, 3 * G, Some(16 * G)), 4 * G);
|
||||
assert_eq!(memory_budget(64 * G, 3 * G, Some(16 * G)), 4 * G);
|
||||
// Card memory unknown (integrated, software, or unreadable): what Memory Usage leaves
|
||||
// after the document, within a quarter of RAM.
|
||||
assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G), None, 0), 5 * G);
|
||||
assert_eq!(memory_budget(8 * G, 3 * G, Some(16 * G), None, 0), 4 * G);
|
||||
assert_eq!(memory_budget(64 * G, 3 * G, Some(16 * G), None, 0), 4 * G);
|
||||
// Never below the floor, even with nothing left; 16 GB assumed when RAM is unknown.
|
||||
assert_eq!(memory_budget(8 * G, 10 * G, Some(48 * G)), MIN_GPU_BUDGET);
|
||||
assert_eq!(memory_budget(64 * G, 0, None), 4 * G);
|
||||
assert_eq!(memory_budget(8 * G, 10 * G, Some(48 * G), None, 0), MIN_GPU_BUDGET);
|
||||
assert_eq!(memory_budget(64 * G, 0, None, None, 0), 4 * G);
|
||||
// A card's own memory, known, doesn't hold the document's pixels: 10 GB of layers no
|
||||
// longer squeeze the budget, which stays within 3/8 of the card (#4536).
|
||||
assert_eq!(memory_budget(8 * G, 10 * G, Some(32 * G), Some(32 * G), 0), 8 * G);
|
||||
assert_eq!(memory_budget(8 * G, 10 * G, Some(32 * G), Some(16 * G), 0), 6 * G);
|
||||
assert_eq!(memory_budget(8 * G, 10 * G, Some(16 * G), Some(32 * G), 0), 4 * G);
|
||||
// GPU Memory set: that much, within the card's share, above the floor.
|
||||
assert_eq!(memory_budget(8 * G, 10 * G, Some(16 * G), None, 12 * G), 12 * G);
|
||||
assert_eq!(memory_budget(8 * G, 10 * G, Some(16 * G), Some(16 * G), 12 * G), 6 * G);
|
||||
assert_eq!(memory_budget(8 * G, 0, None, Some(32 * G), 1 << 20), MIN_GPU_BUDGET);
|
||||
assert!(physical_memory().is_none_or(|b| b >= 256 << 20));
|
||||
}
|
||||
|
||||
/// An 8 GB card in a 64 GB machine: both budgets used to plan for 16 GB, twice the card (#4536).
|
||||
#[test]
|
||||
fn gpu_budgets_fit_a_discrete_gpus_own_memory() {
|
||||
const G: u64 = 1 << 30;
|
||||
let (ram, vram) = (Some(64 * G), Some(8 * G));
|
||||
assert_eq!(memory_budget(64 * G, 3 * G, ram, vram, 0), 3 * G);
|
||||
assert_eq!(canvas_budget(ram, vram), 3 * G);
|
||||
assert!(memory_budget(64 * G, 0, ram, vram, 0) + canvas_budget(ram, vram) <= 6 * G, "a quarter of the card stays free");
|
||||
// A card larger than a quarter of RAM caps nothing (its own memory isn't charged for the
|
||||
// document's 3 GB); a tiny one keeps the floor.
|
||||
assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G), Some(24 * G), 0), 8 * G);
|
||||
assert_eq!(canvas_budget(Some(16 * G), Some(24 * G)), 4 * G);
|
||||
assert_eq!(memory_budget(64 * G, 0, ram, Some(G), 0), MIN_GPU_BUDGET);
|
||||
assert_eq!(canvas_budget(ram, Some(G)), MIN_GPU_BUDGET);
|
||||
}
|
||||
|
||||
/// DXGI reports each adapter's dedicated memory, found by the vendor and device ids wgpu gives.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_reports_the_adapters_own_memory() {
|
||||
use winsafe::prelude::*;
|
||||
let descs: Vec<_> = winsafe::CreateDXGIFactory1().unwrap().EnumAdapters1().map(|a| a.unwrap().GetDesc1().unwrap()).collect();
|
||||
assert!(!descs.is_empty(), "DXGI lists at least its software adapter");
|
||||
for d in descs.iter().filter(|d| d.DedicatedVideoMemory > 0) {
|
||||
// The first adapter with these ids is the one matched.
|
||||
let first = descs.iter().find(|e| e.VendorId == d.VendorId && e.DeviceId == d.DeviceId).unwrap();
|
||||
assert_eq!(detect_video_memory(d.VendorId, d.DeviceId), Some(first.DedicatedVideoMemory as u64));
|
||||
}
|
||||
assert_eq!(detect_video_memory(0xdead, 0xbeef), None, "no such adapter");
|
||||
}
|
||||
|
||||
/// The Linux reader finds the card by its PCI ids in a `/sys/class/drm`-shaped folder and reads
|
||||
/// amdgpu's VRAM size; a card without one, unreadable ids or a malformed size give nothing.
|
||||
#[test]
|
||||
fn drm_video_memory_reads_the_matching_cards_vram() {
|
||||
let drm = std::env::temp_dir().join(format!("pc-drm-{}", std::process::id()));
|
||||
let card = |name: &str, vendor: &str, device: &str, vram: Option<&str>| {
|
||||
let dev = drm.join(name).join("device");
|
||||
std::fs::create_dir_all(&dev).unwrap();
|
||||
std::fs::write(dev.join("vendor"), vendor).unwrap();
|
||||
std::fs::write(dev.join("device"), device).unwrap();
|
||||
if let Some(v) = vram {
|
||||
std::fs::write(dev.join("mem_info_vram_total"), v).unwrap();
|
||||
}
|
||||
};
|
||||
// An Intel iGPU (no VRAM file), an AMD card, and an entry with garbage ids.
|
||||
card("card0", "0x8086\n", "0x9a49\n", None);
|
||||
card("card1", "0x1002\n", "0x73bf\n", Some("17163091968\n"));
|
||||
card("card2", "not hex\n", "0x73bf\n", Some("1\n"));
|
||||
card("card3", "0x10de\n", "0x2684\n", Some("lots\n"));
|
||||
assert_eq!(drm_video_memory(&drm, 0x1002, 0x73bf), Some(17_163_091_968));
|
||||
assert_eq!(drm_video_memory(&drm, 0x8086, 0x9a49), None, "no amdgpu VRAM file");
|
||||
assert_eq!(drm_video_memory(&drm, 0x10de, 0x2684), None, "a malformed size");
|
||||
assert_eq!(drm_video_memory(&drm, 0xdead, 0xbeef), None, "no such card");
|
||||
assert_eq!(drm_video_memory(&drm.join("missing"), 0x1002, 0x73bf), None, "no drm folder");
|
||||
std::fs::remove_dir_all(&drm).unwrap();
|
||||
}
|
||||
|
||||
/// Windows used to report no RAM at all, so the budget assumed 16 GB on every machine.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
@@ -2277,12 +2503,12 @@ mod tests {
|
||||
assert_eq!(canvas_texture_bytes([0, 500], 64, 4), 0);
|
||||
// A canvas declared in a few bytes of a file is priced without building (or looping over) it.
|
||||
let huge = canvas_texture_bytes([300_000, 300_000], 8192, 4);
|
||||
assert!(huge > 300_000u64 * 300_000 * 4 && huge > canvas_budget(Some(1 << 40)));
|
||||
assert!(huge > 300_000u64 * 300_000 * 4 && huge > canvas_budget(Some(1 << 40), None));
|
||||
assert_eq!(canvas_texture_bytes([u32::MAX, u32::MAX], 64, 8), u64::MAX, "saturates");
|
||||
// A quarter of RAM (16 GB assumed), never below the compositor's floor.
|
||||
assert_eq!(canvas_budget(None), 4 << 30);
|
||||
assert_eq!(canvas_budget(Some(1 << 30)), MIN_GPU_BUDGET);
|
||||
assert!(canvas_texture_bytes([12_000, 8_000], DEFAULT_TILE, 8) < canvas_budget(None), "a 96 MP 16-bit canvas stays on the GPU");
|
||||
assert_eq!(canvas_budget(None, None), 4 << 30);
|
||||
assert_eq!(canvas_budget(Some(1 << 30), None), MIN_GPU_BUDGET);
|
||||
assert!(canvas_texture_bytes([12_000, 8_000], DEFAULT_TILE, 8) < canvas_budget(None, None), "a 96 MP 16-bit canvas stays on the GPU");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -51,6 +51,7 @@ fn read_mips(gpu: &GpuCanvas, key: u64) -> Vec<Vec<[f32; 4]>> {
|
||||
|
||||
#[test]
|
||||
fn canvas_mips_preserve_opaque_pixels_and_partial_updates() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
let rs = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| egui_kittest::wgpu::create_render_state(wgpu_setup(), Default::default())));
|
||||
let Ok(rs) = rs else {
|
||||
eprintln!("skipping mip readback: no GPU adapter");
|
||||
|
||||
@@ -50,6 +50,8 @@ pub fn fall_back(app: &mut PhotocraftApp, fault: &photocraft_gpu::Fault) {
|
||||
app.prefs_rt.gpu_style = None;
|
||||
app.perf.gpu = false;
|
||||
app.perf.gpu_fallback = Some(fault.to_string());
|
||||
// The CPU canvas has no GPU memory budget: GPU Memory Full no longer applies.
|
||||
app.perf.gpu_layer_fit = None;
|
||||
app.perf.gpu_info.canvas = "cpu".into();
|
||||
app.perf.gpu_info.lost = Some(fault.to_string());
|
||||
let title = if fault.is_lost() { LOST_MESSAGE } else { ERROR_MESSAGE };
|
||||
|
||||
@@ -2442,7 +2442,6 @@ cameraRaw Color Barvy
|
||||
Redrawing Překreslování
|
||||
This redraw took {seconds} s on the CPU. Toto překreslení na CPU trvalo {seconds} s.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Úpravy, které mění celý dokument, například režim prolnutí, krytí nebo viditelnost vrstvy, jej překreslí celý.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Vrstvy se nevejdou do rozpočtu paměti GPU. Zvýšením hodnoty Využití paměti v Předvolbách › Výkon se rozpočet zvětší, nejvýše na čtvrtinu paměti tohoto počítače.
|
||||
The GPU compositor wasn't used: {reason} Kompozitor GPU nebyl použit: {reason}
|
||||
fontWeight Thin Jemná
|
||||
fontWeight ExtraLight Extrajemná
|
||||
@@ -3804,3 +3803,16 @@ navigator Zoom Zvětšení
|
||||
Take the shortcut from the other command and select it Odebrat zkratku druhému příkazu a vybrat ho
|
||||
Undo Changes Vrátit změny
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} je vyhrazena systémem a nelze ji přiřadit.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Kreslení s nízkou latencí (bez vertikální synchronizace, platí po dalším spuštění)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. Volná transformace se stále použije; zkuste to znovu, až skončí.
|
||||
The transform couldn't be applied. Transformaci nelze použít.
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. Vrstvy potřebují {needed} paměti GPU; rozpočet jim ponechává {budget}.
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. V Předvolbách › Výkon nastavte Paměť GPU na tolik, kolik vaše grafická karta pojme, nejvýše 3/8 její paměti. Automaticky se řídí Využitím paměti, v rámci čtvrtiny paměti tohoto počítače a 3/8 paměti karty, a u karty se známou pamětí do ní dokument nezapočítává. Dokud se nevejdou, stavový řádek ukazuje Paměť GPU je plná.
|
||||
GPU Memory Full Paměť GPU je plná
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Vrstvy potřebují {needed} paměti GPU a mohou zabrat {budget}, takže úpravy, které překreslují celý dokument, to dělají na CPU. Kliknutím nastavíte Paměť GPU.
|
||||
GPU memory budget: {budget} Rozpočet paměti GPU: {budget}
|
||||
This document's layers: {needed} of {budget} for layers Vrstvy tohoto dokumentu: {needed} z {budget} pro vrstvy
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Vrstvy tohoto dokumentu potřebují {needed}, ale mohou zabrat {budget}: překreslení celého dokumentu probíhá na CPU
|
||||
GPU Memory (MB, 0 = Automatic) Paměť GPU (MB, 0 = Automaticky)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2481,7 +2481,6 @@ layerLabel No Color Keine Farbe
|
||||
Redrawing Neuzeichnen
|
||||
This redraw took {seconds} s on the CPU. Dieses Neuzeichnen auf der CPU dauerte {seconds} s.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Änderungen, die das ganze Dokument betreffen, etwa Füllmethode, Deckkraft oder Sichtbarkeit einer Ebene, zeichnen es vollständig neu.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Die Ebenen passen nicht in das GPU-Speicherbudget. Eine höhere Speichernutzung unter Voreinstellungen › Leistung erhöht das Budget, bis zu einem Viertel des Arbeitsspeichers dieses Computers.
|
||||
The GPU compositor wasn't used: {reason} Der GPU-Compositor wurde nicht verwendet: {reason}
|
||||
cameraRaw Light Licht
|
||||
fontWeight Thin Haarfein
|
||||
@@ -3198,3 +3197,16 @@ curves Smooth Glätten
|
||||
Take the shortcut from the other command and select it Den Kurzbefehl dem anderen Befehl entziehen und diesen auswählen
|
||||
Undo Changes Änderungen rückgängig machen
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} ist vom System reserviert und kann nicht zugewiesen werden.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Zeichnen mit geringer Latenz (ohne VSync, gilt ab dem nächsten Start)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. Frei transformieren wird noch angewendet; versuchen Sie es erneut, wenn es fertig ist.
|
||||
The transform couldn't be applied. Die Transformation konnte nicht angewendet werden.
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. Die Ebenen brauchen {needed} GPU-Speicher; das Budget lässt ihnen {budget}.
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Stellen Sie unter Voreinstellungen › Leistung den GPU-Speicher auf das ein, was Ihre Grafikkarte fassen kann, höchstens 3/8 ihres Speichers. Automatisch folgt der Speichernutzung, innerhalb eines Viertels des Arbeitsspeichers dieses Computers und 3/8 des Kartenspeichers, und rechnet das Dokument bei einer Karte mit bekanntem Speicher nicht an. Solange sie nicht hineinpassen, zeigt die Statusleiste GPU-Speicher voll.
|
||||
GPU Memory Full GPU-Speicher voll
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Die Ebenen brauchen {needed} GPU-Speicher und dürfen {budget} belegen, daher zeichnen Änderungen, die das ganze Dokument neu zeichnen, auf der CPU. Klicken, um den GPU-Speicher einzustellen.
|
||||
GPU memory budget: {budget} GPU-Speicherbudget: {budget}
|
||||
This document's layers: {needed} of {budget} for layers Ebenen dieses Dokuments: {needed} von {budget} für Ebenen
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Die Ebenen dieses Dokuments brauchen {needed}, dürfen aber {budget} belegen: Das ganze Dokument wird auf der CPU neu gezeichnet
|
||||
GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3439,7 +3439,6 @@ cameraRaw Yellow Κίτρινο
|
||||
HSB/HSL HSB/HSL
|
||||
Import Swatches… Εισαγωγή δειγμάτων χρωμάτων…
|
||||
Impressionist Ιμπρεσιονιστικό
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Τα επίπεδά του δεν χωρούν στο όριο μνήμης της GPU. Αυξάνοντας τη Χρήση μνήμης στις Προτιμήσεις › Επιδόσεις αυξάνεται το όριο, έως το ένα τέταρτο της μνήμης αυτού του υπολογιστή.
|
||||
Knockout Διάτρηση
|
||||
Large document: redrawing on the CPU Μεγάλο έγγραφο: επανασχεδίαση στη CPU
|
||||
Redrawing Επανασχεδίαση
|
||||
@@ -3896,3 +3895,16 @@ curves Smooth Εξομάλυνση
|
||||
Take the shortcut from the other command and select it Αφαίρεση της συντόμευσης από την άλλη εντολή και επιλογή της
|
||||
Undo Changes Αναίρεση αλλαγών
|
||||
{shortcut} is reserved by the system and can't be assigned. Η συντόμευση {shortcut} δεσμεύεται από το σύστημα και δεν μπορεί να εκχωρηθεί.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Σχεδίαση χαμηλής καθυστέρησης (χωρίς vsync, ισχύει στην επόμενη εκκίνηση)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. Ο Ελεύθερος μετασχηματισμός εφαρμόζεται ακόμη· δοκιμάστε ξανά όταν ολοκληρωθεί.
|
||||
The transform couldn't be applied. Δεν ήταν δυνατή η εφαρμογή του μετασχηματισμού.
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. Τα επίπεδά του χρειάζονται {needed} μνήμης GPU· το όριο τους αφήνει {budget}.
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Ορίστε τη Μνήμη GPU στις Προτιμήσεις › Επιδόσεις σε όσο χωράει η κάρτα γραφικών σας, το πολύ στα 3/8 της μνήμης της. Το Αυτόματο ακολουθεί τη Χρήση μνήμης, εντός του ενός τετάρτου της μνήμης αυτού του υπολογιστή και των 3/8 της κάρτας, και δεν χρεώνει το έγγραφο σε κάρτα με γνωστή μνήμη. Όσο δεν χωρούν, η γραμμή κατάστασης δείχνει Η μνήμη GPU είναι πλήρης.
|
||||
GPU Memory Full Η μνήμη GPU είναι πλήρης
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Τα επίπεδα χρειάζονται {needed} μνήμης GPU και μπορούν να καταλάβουν {budget}, οπότε οι αλλαγές που ξανασχεδιάζουν όλο το έγγραφο το κάνουν στη CPU. Κάντε κλικ για να ορίσετε τη Μνήμη GPU.
|
||||
GPU memory budget: {budget} Όριο μνήμης GPU: {budget}
|
||||
This document's layers: {needed} of {budget} for layers Επίπεδα αυτού του εγγράφου: {needed} από {budget} για επίπεδα
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Τα επίπεδα αυτού του εγγράφου χρειάζονται {needed} αλλά μπορούν να καταλάβουν {budget}: η επανασχεδίαση όλου του εγγράφου γίνεται στη CPU
|
||||
GPU Memory (MB, 0 = Automatic) Μνήμη GPU (MB, 0 = Αυτόματο)
|
||||
|
Can't render this file because it contains an unexpected character in line 1030 and column 18.
|
@@ -2512,7 +2512,6 @@ cameraRaw Amount Cantidad
|
||||
Redrawing Redibujando
|
||||
This redraw took {seconds} s on the CPU. Este redibujado en la CPU tardó {seconds} s.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Las ediciones que cambian todo el documento, como el modo de fusión, la opacidad o la visibilidad de una capa, lo redibujan por completo.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Las capas no caben en el presupuesto de memoria de la GPU. Aumentar Uso de memoria en Preferencias › Rendimiento amplía el presupuesto, hasta una cuarta parte de la memoria de este equipo.
|
||||
The GPU compositor wasn't used: {reason} No se usó el compositor de GPU: {reason}
|
||||
fontWeight Thin Ultrafina
|
||||
fontWeight ExtraLight Extrafina
|
||||
@@ -4158,3 +4157,16 @@ navigator Zoom Zoom
|
||||
Take the shortcut from the other command and select it Quitar el método abreviado al otro comando y seleccionarlo
|
||||
Undo Changes Deshacer cambios
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} está reservado por el sistema y no se puede asignar.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Dibujo de baja latencia (sin sincronización vertical, se aplica al volver a iniciar)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. Transformación libre aún se está aplicando; inténtelo de nuevo cuando termine.
|
||||
The transform couldn't be applied. No se pudo aplicar la transformación.
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. Sus capas necesitan {needed} de memoria de GPU; el presupuesto les deja {budget}.
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Ajuste Memoria de GPU en Preferencias › Rendimiento a lo que pueda alojar su tarjeta gráfica, como máximo 3/8 de su memoria. Automático sigue Uso de memoria, dentro de una cuarta parte de la memoria de este equipo y 3/8 de la de la tarjeta, y no descuenta el documento en una tarjeta cuya memoria se conoce. Mientras no quepan, la barra de estado muestra Memoria de GPU llena.
|
||||
GPU Memory Full Memoria de GPU llena
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Las capas necesitan {needed} de memoria de GPU y pueden ocupar {budget}, así que las ediciones que redibujan todo el documento lo hacen en la CPU. Haga clic para ajustar Memoria de GPU.
|
||||
GPU memory budget: {budget} Presupuesto de memoria de GPU: {budget}
|
||||
This document's layers: {needed} of {budget} for layers Capas de este documento: {needed} de {budget} para capas
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Las capas de este documento necesitan {needed} pero pueden ocupar {budget}: los redibujados de todo el documento usan la CPU
|
||||
GPU Memory (MB, 0 = Automatic) Memoria de GPU (MB, 0 = Automático)
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2862,7 +2862,6 @@ cameraRaw Blending Fusion
|
||||
Redrawing Redessin
|
||||
This redraw took {seconds} s on the CPU. Ce redessin sur le CPU a pris {seconds} s.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Les modifications qui touchent tout le document, comme le mode de fusion, l'opacité ou la visibilité d'un calque, le redessinent entièrement.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Les calques dépassent le budget de mémoire GPU. Augmenter Utilisation de la mémoire dans Préférences › Performances relève ce budget, jusqu'au quart de la mémoire de cet ordinateur.
|
||||
The GPU compositor wasn't used: {reason} Le compositeur GPU n'a pas été utilisé : {reason}
|
||||
fontWeight Thin Ultrafin
|
||||
fontWeight ExtraLight Extrafin
|
||||
@@ -3201,3 +3200,16 @@ curves Smooth Lisser
|
||||
Take the shortcut from the other command and select it Retirer le raccourci de l’autre commande et la sélectionner
|
||||
Undo Changes Annuler les modifications
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} est réservé par le système et ne peut pas être attribué.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Dessin à faible latence (sans synchronisation verticale, au prochain lancement)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. La transformation manuelle est encore en cours d'application ; réessayez quand elle sera terminée.
|
||||
The transform couldn't be applied. Impossible d'appliquer la transformation.
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. Ses calques ont besoin de {needed} de mémoire GPU ; le budget leur laisse {budget}.
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Réglez Mémoire GPU dans Préférences › Performances sur ce que votre carte graphique peut contenir, au plus 3/8 de sa mémoire. Automatique suit Utilisation de la mémoire, dans la limite du quart de la mémoire de cet ordinateur et des 3/8 de celle de la carte, et ne compte pas le document sur une carte dont la mémoire est connue. Tant qu'ils ne tiennent pas, la barre d'état affiche Mémoire GPU pleine.
|
||||
GPU Memory Full Mémoire GPU pleine
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Les calques ont besoin de {needed} de mémoire GPU et peuvent en occuper {budget} : les modifications qui redessinent tout le document le font sur le CPU. Cliquez pour régler Mémoire GPU.
|
||||
GPU memory budget: {budget} Budget de mémoire GPU : {budget}
|
||||
This document's layers: {needed} of {budget} for layers Calques de ce document : {needed} sur {budget} pour les calques
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Les calques de ce document ont besoin de {needed} mais peuvent occuper {budget} : le document entier se redessine sur le CPU
|
||||
GPU Memory (MB, 0 = Automatic) Mémoire GPU (Mo, 0 = Automatique)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3312,7 +3312,6 @@ cameraRaw Yellow Sárga
|
||||
Interpret Footage Videoanyag értelmezése
|
||||
Interval Intervallum
|
||||
Invalid {mode} profile Érvénytelen profil ({mode})
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. A dokumentum rétegei nem férnek el a GPU memóriakeretében. Ha a Beállítások › Teljesítmény alatt növeled a memóriahasználatot, a keret is nő, legfeljebb a számítógép memóriájának negyedéig.
|
||||
Join us on Discord Csatlakozz hozzánk a Discordon
|
||||
Keep Using CPU Marad a CPU
|
||||
Kernel Szűrőmag
|
||||
@@ -4115,3 +4114,16 @@ cameraRaw Yellow Sárga
|
||||
Take the shortcut from the other command and select it A billentyűparancs elvétele a másik parancstól, és annak kijelölése
|
||||
Undo Changes Változások visszavonása
|
||||
{shortcut} is reserved by the system and can't be assigned. A(z) {shortcut} a rendszer számára fenntartott, nem rendelhető hozzá.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Kis késleltetésű rajzolás (vsync nélkül, a következő indításkor lép életbe)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. A szabad alakítás alkalmazása még folyamatban van; próbáld újra, ha befejeződött.
|
||||
The transform couldn't be applied. Az átalakítás nem alkalmazható.
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. A rétegeknek {needed} GPU-memória kell; a keret {budget} hagy nekik.
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. A Beállítások › Teljesítmény alatt állítsd a GPU-memóriát arra, amennyit a videokártyád elbír, legfeljebb a memóriája 3/8-ára. Az Automatikus a memóriahasználatot követi, a számítógép memóriájának negyedén és a kártya 3/8-án belül, és ismert memóriájú kártyánál nem számítja bele a dokumentumot. Amíg nem férnek el, az állapotsor a GPU-memória megtelt üzenetet mutatja.
|
||||
GPU Memory Full GPU-memória megtelt
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. A rétegeknek {needed} GPU-memória kell, és {budget} foglalhatnak, ezért a teljes dokumentumot újrarajzoló szerkesztések a CPU-n futnak. Kattints a GPU-memória beállításához.
|
||||
GPU memory budget: {budget} GPU-memóriakeret: {budget}
|
||||
This document's layers: {needed} of {budget} for layers A dokumentum rétegei: {needed} / {budget} a rétegeknek
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU A dokumentum rétegeinek {needed} kell, de {budget} foglalhatnak: a teljes dokumentum újrarajzolása a CPU-n fut
|
||||
GPU Memory (MB, 0 = Automatic) GPU-memória (MB, 0 = Automatikus)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2849,7 +2849,6 @@ cameraRaw Blending Pembauran
|
||||
Redrawing Menggambar ulang
|
||||
This redraw took {seconds} s on the CPU. Penggambaran ulang di CPU ini memakan waktu {seconds} dtk.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Suntingan yang mengubah seluruh dokumen, seperti mode campuran, opasitas, atau visibilitas layer, menggambar ulang semuanya.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Layer tidak muat dalam anggaran memori GPU. Menaikkan Pemakaian memori di Preferensi › Performa menaikkan anggaran, hingga seperempat memori komputer ini.
|
||||
The GPU compositor wasn't used: {reason} Kompositor GPU tidak digunakan: {reason}
|
||||
fontWeight Thin Sangat Tipis
|
||||
fontWeight ExtraLight Ekstra Tipis
|
||||
@@ -3188,3 +3187,16 @@ curves Smooth Haluskan
|
||||
Take the shortcut from the other command and select it Ambil pintasan dari perintah lain dan pilih perintah itu
|
||||
Undo Changes Batalkan Perubahan
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} dicadangkan oleh sistem dan tidak dapat ditetapkan.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Menggambar latensi rendah (tanpa vsync, berlaku saat peluncuran berikutnya)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. Transformasi Bebas masih diterapkan; coba lagi setelah selesai.
|
||||
The transform couldn't be applied. Transformasi tidak dapat diterapkan.
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. Layer-nya membutuhkan {needed} memori GPU; anggaran menyisakan {budget}.
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Atur Memori GPU di Preferensi › Performa sesuai kapasitas kartu grafis Anda, paling banyak 3/8 memorinya. Otomatis mengikuti Pemakaian memori, dalam seperempat memori komputer ini dan 3/8 memori kartu, dan tidak membebankan dokumen pada kartu yang memorinya diketahui. Selama belum muat, bilah status menampilkan Memori GPU Penuh.
|
||||
GPU Memory Full Memori GPU Penuh
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Layer membutuhkan {needed} memori GPU dan boleh memakai {budget}, sehingga suntingan yang menggambar ulang seluruh dokumen melakukannya di CPU. Klik untuk mengatur Memori GPU.
|
||||
GPU memory budget: {budget} Anggaran memori GPU: {budget}
|
||||
This document's layers: {needed} of {budget} for layers Layer dokumen ini: {needed} dari {budget} untuk layer
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Layer dokumen ini membutuhkan {needed} tetapi boleh memakai {budget}: penggambaran ulang seluruh dokumen memakai CPU
|
||||
GPU Memory (MB, 0 = Automatic) Memori GPU (MB, 0 = Otomatis)
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2512,7 +2512,6 @@ cameraRaw Amount Quantità
|
||||
Redrawing Ridisegno
|
||||
This redraw took {seconds} s on the CPU. Questo ridisegno sulla CPU ha richiesto {seconds} s.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Le modifiche che cambiano tutto il documento, come metodo di fusione, opacità o visibilità di un livello, lo ridisegnano per intero.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. I livelli non rientrano nel budget di memoria della GPU. Aumentare Uso memoria in Preferenze › Prestazioni alza il budget, fino a un quarto della memoria di questo computer.
|
||||
The GPU compositor wasn't used: {reason} Il compositore GPU non è stato usato: {reason}
|
||||
fontWeight Thin Sottile
|
||||
fontWeight ExtraLight Extraleggero
|
||||
@@ -4317,3 +4316,16 @@ shape Appearance Aspetto
|
||||
Take the shortcut from the other command and select it Togli la scorciatoia all'altro comando e selezionalo
|
||||
Undo Changes Annulla modifiche
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} è riservata dal sistema e non può essere assegnata.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Disegno a bassa latenza (senza vsync, si applica al prossimo avvio)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. Trasformazione libera è ancora in corso di applicazione; riprova quando è terminata.
|
||||
The transform couldn't be applied. Impossibile applicare la trasformazione.
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. I livelli richiedono {needed} di memoria GPU; il budget ne lascia {budget}.
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Imposta Memoria GPU in Preferenze › Prestazioni su quanto può contenere la tua scheda grafica, al massimo 3/8 della sua memoria. Automatico segue Uso memoria, entro un quarto della memoria di questo computer e 3/8 di quella della scheda, e non conta il documento su una scheda la cui memoria è nota. Finché non ci stanno, la barra di stato mostra Memoria GPU piena.
|
||||
GPU Memory Full Memoria GPU piena
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. I livelli richiedono {needed} di memoria GPU e possono occuparne {budget}, quindi le modifiche che ridisegnano tutto il documento lo fanno sulla CPU. Fai clic per impostare Memoria GPU.
|
||||
GPU memory budget: {budget} Budget di memoria GPU: {budget}
|
||||
This document's layers: {needed} of {budget} for layers Livelli di questo documento: {needed} su {budget} per i livelli
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU I livelli di questo documento richiedono {needed} ma possono occupare {budget}: i ridisegni dell'intero documento usano la CPU
|
||||
GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico)
|
||||
|
Can't render this file because it contains an unexpected character in line 2653 and column 53.
|
@@ -2413,7 +2413,6 @@ cameraRaw Color カラー
|
||||
Redrawing 再描画中
|
||||
This redraw took {seconds} s on the CPU. この再描画は CPU で {seconds} 秒かかりました。
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. レイヤーの描画モード、不透明度、表示/非表示など、ドキュメント全体に関わる編集では全体が再描画されます。
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. レイヤーが GPU メモリの予算に収まりません。環境設定 › パフォーマンスのメモリ使用量を増やすと、このコンピューターのメモリの4分の1まで予算が増えます。
|
||||
The GPU compositor wasn't used: {reason} GPU コンポジターは使用されませんでした: {reason}
|
||||
fontWeight Thin 極細
|
||||
fontWeight ExtraLight 特細
|
||||
@@ -3872,3 +3871,16 @@ curves Smooth スムーズ
|
||||
Take the shortcut from the other command and select it もう一方のコマンドからショートカットを外して選択
|
||||
Undo Changes 変更を取り消し
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} はシステムで予約されているため割り当てられません。
|
||||
Low-Latency Drawing (vsync off, applies at next launch) 低遅延描画(垂直同期オフ、次回起動時に適用)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. 自由変形を適用中です。完了してからもう一度お試しください。
|
||||
The transform couldn't be applied. 変形を適用できませんでした。
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. レイヤーには {needed} の GPU メモリが必要ですが、予算で使えるのは {budget} です。
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. 環境設定 › パフォーマンスの GPU メモリを、グラフィックカードが保持できる量(最大でメモリの 3/8)に設定してください。自動はメモリ使用量に従い、このコンピューターのメモリの4分の1とカードの 3/8 の範囲内で、メモリ量がわかるカードではドキュメント分を差し引きません。収まらない間、ステータスバーに「GPU メモリ不足」と表示されます。
|
||||
GPU Memory Full GPU メモリ不足
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. レイヤーには {needed} の GPU メモリが必要ですが、使えるのは {budget} のため、ドキュメント全体を再描画する編集は CPU で行われます。クリックして GPU メモリを設定します。
|
||||
GPU memory budget: {budget} GPU メモリ予算: {budget}
|
||||
This document's layers: {needed} of {budget} for layers このドキュメントのレイヤー: レイヤー用 {budget} のうち {needed}
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU このドキュメントのレイヤーには {needed} が必要ですが、使えるのは {budget} です: ドキュメント全体の再描画は CPU で行われます
|
||||
GPU Memory (MB, 0 = Automatic) GPU メモリ(MB、0 = 自動)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3022,7 +3022,6 @@ cameraRaw Color 색상
|
||||
Redrawing 다시 그리는 중
|
||||
This redraw took {seconds} s on the CPU. 이번 다시 그리기는 CPU에서 {seconds}초 걸렸습니다.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. 레이어의 혼합 모드, 불투명도, 표시 여부처럼 문서 전체를 바꾸는 편집은 문서 전체를 다시 그립니다.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. 레이어가 GPU 메모리 예산에 맞지 않습니다. 환경 설정 › 성능에서 메모리 사용량을 늘리면 이 컴퓨터 메모리의 4분의 1까지 예산이 늘어납니다.
|
||||
The GPU compositor wasn't used: {reason} GPU 합성기를 사용하지 않았습니다: {reason}
|
||||
fontWeight Thin 아주 가늘게
|
||||
fontWeight ExtraLight 더 가늘게
|
||||
@@ -3374,3 +3373,16 @@ curves Smooth 매끄럽게
|
||||
Take the shortcut from the other command and select it 다른 명령에서 단축키를 빼고 그 명령 선택
|
||||
Undo Changes 변경 사항 취소
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut}은(는) 시스템에서 예약되어 할당할 수 없습니다.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) 저지연 그리기(수직 동기화 끔, 다음 실행 시 적용)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. 자유 변형을 아직 적용하는 중입니다. 완료된 후 다시 시도하세요.
|
||||
The transform couldn't be applied. 변형을 적용할 수 없습니다.
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. 레이어에 GPU 메모리 {needed}가 필요하지만 예산은 {budget}만 남겨 둡니다.
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. 환경 설정 › 성능에서 GPU 메모리를 그래픽 카드가 감당할 수 있는 양으로 설정하세요(최대 카드 메모리의 3/8). 자동은 메모리 사용량을 따르며, 이 컴퓨터 메모리의 4분의 1과 카드 메모리의 3/8 이내이고, 메모리를 알 수 있는 카드에서는 문서를 차감하지 않습니다. 맞지 않는 동안 상태 표시줄에 GPU 메모리 부족이 표시됩니다.
|
||||
GPU Memory Full GPU 메모리 부족
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. 레이어에 GPU 메모리 {needed}가 필요하지만 {budget}만 사용할 수 있어 문서 전체를 다시 그리는 편집은 CPU에서 처리됩니다. 클릭하여 GPU 메모리를 설정합니다.
|
||||
GPU memory budget: {budget} GPU 메모리 예산: {budget}
|
||||
This document's layers: {needed} of {budget} for layers 이 문서의 레이어: 레이어용 {budget} 중 {needed}
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 이 문서의 레이어에 {needed}가 필요하지만 {budget}만 사용할 수 있습니다: 문서 전체 다시 그리기는 CPU를 사용합니다
|
||||
GPU Memory (MB, 0 = Automatic) GPU 메모리(MB, 0 = 자동)
|
||||
|
Can't render this file because it contains an unexpected character in line 3165 and column 53.
|
@@ -2441,7 +2441,6 @@ layerLabel No Color Geen kleur
|
||||
Redrawing Opnieuw tekenen
|
||||
This redraw took {seconds} s on the CPU. Opnieuw tekenen duurde {seconds} s op de CPU.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Bewerkingen die het hele document veranderen, zoals de overvloeimodus, dekking of zichtbaarheid van een laag, tekenen alles opnieuw.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. De lagen passen niet binnen het GPU-geheugenbudget. Door Geheugengebruik te verhogen in Voorkeuren › Prestaties wordt het budget groter, tot een kwart van het geheugen van deze computer.
|
||||
The GPU compositor wasn't used: {reason} De GPU-compositor is niet gebruikt: {reason}
|
||||
cameraRaw Light Licht
|
||||
fontWeight Thin Dun
|
||||
@@ -3956,3 +3955,16 @@ curves Smooth Vloeiend maken
|
||||
Take the shortcut from the other command and select it De sneltoets van de andere opdracht afnemen en die selecteren
|
||||
Undo Changes Wijzigingen ongedaan maken
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} is door het systeem gereserveerd en kan niet worden toegewezen.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Tekenen met lage latentie (zonder vsync, na de volgende start)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. Vrije transformatie wordt nog toegepast; probeer het opnieuw als die klaar is.
|
||||
The transform couldn't be applied. De transformatie kon niet worden toegepast.
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. De lagen hebben {needed} GPU-geheugen nodig; het budget laat ze {budget}.
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Stel GPU-geheugen in bij Voorkeuren › Prestaties op wat uw videokaart aankan, hooguit 3/8 van haar geheugen. Automatisch volgt Geheugengebruik, binnen een kwart van het geheugen van deze computer en 3/8 van dat van de kaart, en rekent het document niet af van een kaart waarvan het geheugen bekend is. Zolang ze niet passen, toont de statusbalk GPU-geheugen vol.
|
||||
GPU Memory Full GPU-geheugen vol
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. De lagen hebben {needed} GPU-geheugen nodig en mogen {budget} gebruiken, dus bewerkingen die het hele document opnieuw tekenen doen dat op de CPU. Klik om GPU-geheugen in te stellen.
|
||||
GPU memory budget: {budget} GPU-geheugenbudget: {budget}
|
||||
This document's layers: {needed} of {budget} for layers Lagen van dit document: {needed} van {budget} voor lagen
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU De lagen van dit document hebben {needed} nodig maar mogen {budget} gebruiken: het hele document wordt op de CPU opnieuw getekend
|
||||
GPU Memory (MB, 0 = Automatic) GPU-geheugen (MB, 0 = Automatisch)
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -3471,7 +3471,6 @@ cameraRaw Roughness Chropowatość
|
||||
Redrawing Odświeżanie
|
||||
This redraw took {seconds} s on the CPU. To odświeżenie na CPU trwało {seconds} s.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Zmiany obejmujące cały dokument, takie jak tryb mieszania, krycie lub widoczność warstwy, odświeżają go w całości.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Warstwy nie mieszczą się w budżecie pamięci GPU. Zwiększenie wartości Użycie pamięci w Preferencje › Wydajność zwiększa budżet, maksymalnie do jednej czwartej pamięci tego komputera.
|
||||
The GPU compositor wasn't used: {reason} Kompozytor GPU nie został użyty: {reason}
|
||||
# Pattern Stamp tool
|
||||
Pattern Stamp Tool Narzędzie Pieczątka wzoru
|
||||
@@ -3794,3 +3793,16 @@ curves Smooth Wygładź
|
||||
Take the shortcut from the other command and select it Odbierz skrót drugiemu poleceniu i zaznacz je
|
||||
Undo Changes Cofnij zmiany
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} jest zarezerwowany przez system i nie można go przypisać.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Rysowanie z małym opóźnieniem (bez synchronizacji pionowej, od następnego uruchomienia)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. Przekształcanie swobodne jest nadal stosowane; spróbuj ponownie, gdy się zakończy.
|
||||
The transform couldn't be applied. Nie można zastosować przekształcenia.
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. Warstwy potrzebują {needed} pamięci GPU; budżet zostawia im {budget}.
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Ustaw Pamięć GPU w Preferencje › Wydajność na tyle, ile zmieści Twoja karta graficzna, najwyżej 3/8 jej pamięci. Automatycznie podąża za Użyciem pamięci, w granicach jednej czwartej pamięci tego komputera i 3/8 pamięci karty, i nie wlicza dokumentu przy karcie o znanej pamięci. Dopóki się nie mieszczą, pasek stanu pokazuje Pamięć GPU pełna.
|
||||
GPU Memory Full Pamięć GPU pełna
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Warstwy potrzebują {needed} pamięci GPU i mogą zająć {budget}, więc edycje, które odświeżają cały dokument, robią to na CPU. Kliknij, aby ustawić Pamięć GPU.
|
||||
GPU memory budget: {budget} Budżet pamięci GPU: {budget}
|
||||
This document's layers: {needed} of {budget} for layers Warstwy tego dokumentu: {needed} z {budget} na warstwy
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Warstwy tego dokumentu potrzebują {needed}, ale mogą zająć {budget}: odświeżanie całego dokumentu używa CPU
|
||||
GPU Memory (MB, 0 = Automatic) Pamięć GPU (MB, 0 = Automatycznie)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2865,7 +2865,6 @@ cameraRaw Whites Brancos
|
||||
Redrawing Redesenhando
|
||||
This redraw took {seconds} s on the CPU. Este redesenho na CPU levou {seconds} s.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Edições que mudam o documento inteiro, como modo de mesclagem, opacidade ou visibilidade de uma camada, redesenham tudo.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. As camadas não cabem no orçamento de memória da GPU. Aumentar Uso de memória em Preferências › Desempenho eleva o orçamento, até um quarto da memória deste computador.
|
||||
The GPU compositor wasn't used: {reason} O compositor de GPU não foi usado: {reason}
|
||||
fontWeight Thin Fina
|
||||
fontWeight ExtraLight Extrafina
|
||||
@@ -3986,3 +3985,16 @@ layerLabel No Color Sem cor
|
||||
Take the shortcut from the other command and select it Tirar o atalho do outro comando e selecioná-lo
|
||||
Undo Changes Desfazer alterações
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} é reservado pelo sistema e não pode ser atribuído.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Desenho de baixa latência (sem sincronização vertical, aplicado na próxima inicialização)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. A Transformação livre ainda está sendo aplicada; tente novamente quando terminar.
|
||||
The transform couldn't be applied. Não foi possível aplicar a transformação.
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. As camadas precisam de {needed} de memória da GPU; o orçamento lhes deixa {budget}.
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Ajuste Memória da GPU em Preferências › Desempenho para o que sua placa de vídeo comporta, no máximo 3/8 da memória dela. Automático segue Uso de memória, dentro de um quarto da memória deste computador e 3/8 da memória da placa, e não desconta o documento de uma placa cuja memória é conhecida. Enquanto não couberem, a barra de status mostra Memória da GPU cheia.
|
||||
GPU Memory Full Memória da GPU cheia
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. As camadas precisam de {needed} de memória da GPU e podem ocupar {budget}, então as edições que redesenham o documento inteiro fazem isso na CPU. Clique para ajustar Memória da GPU.
|
||||
GPU memory budget: {budget} Orçamento de memória da GPU: {budget}
|
||||
This document's layers: {needed} of {budget} for layers Camadas deste documento: {needed} de {budget} para camadas
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU As camadas deste documento precisam de {needed} mas podem ocupar {budget}: o documento inteiro é redesenhado na CPU
|
||||
GPU Memory (MB, 0 = Automatic) Memória da GPU (MB, 0 = Automático)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2712,7 +2712,6 @@ cameraRaw Roughness Неровность
|
||||
Redrawing Перерисовка
|
||||
This redraw took {seconds} s on the CPU. Эта перерисовка на CPU заняла {seconds} с.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Правки, затрагивающие весь документ, например режим наложения, непрозрачность или видимость слоя, перерисовывают его целиком.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Слои не помещаются в бюджет памяти GPU. Увеличение параметра «Память» в разделе Настройки › Производительность повышает бюджет, но не больше четверти памяти этого компьютера.
|
||||
The GPU compositor wasn't used: {reason} Компоновщик GPU не использовался: {reason}
|
||||
fontWeight Thin Тонкий
|
||||
fontWeight ExtraLight Сверхтонкий
|
||||
@@ -3166,3 +3165,16 @@ curves Smooth Сгладить
|
||||
Take the shortcut from the other command and select it Снять комбинацию с другой команды и выбрать её
|
||||
Undo Changes Отменить изменения
|
||||
{shortcut} is reserved by the system and can't be assigned. Комбинация {shortcut} зарезервирована системой и не может быть назначена.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Рисование с малой задержкой (без вертикальной синхронизации, после перезапуска)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. Свободное трансформирование ещё применяется; повторите попытку, когда оно завершится.
|
||||
The transform couldn't be applied. Не удалось применить трансформирование.
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. Слоям нужно {needed} памяти GPU; бюджет оставляет им {budget}.
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Задайте «Память GPU» в разделе Настройки › Производительность по объёму, который вмещает ваша видеокарта, не больше 3/8 её памяти. «Автоматически» следует параметру «Память», в пределах четверти памяти этого компьютера и 3/8 памяти видеокарты, и не учитывает документ на видеокарте с известным объёмом памяти. Пока слои не помещаются, в строке состояния показано «Память GPU заполнена».
|
||||
GPU Memory Full Память GPU заполнена
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Слоям нужно {needed} памяти GPU, а занимать они могут {budget}, поэтому правки, перерисовывающие весь документ, выполняются на CPU. Нажмите, чтобы задать «Память GPU».
|
||||
GPU memory budget: {budget} Бюджет памяти GPU: {budget}
|
||||
This document's layers: {needed} of {budget} for layers Слои этого документа: {needed} из {budget} для слоёв
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Слоям этого документа нужно {needed}, но занимать они могут {budget}: весь документ перерисовывается на CPU
|
||||
GPU Memory (MB, 0 = Automatic) Память GPU (МБ, 0 = автоматически)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2836,7 +2836,6 @@ cameraRaw Blending Stapanje
|
||||
Large document: redrawing on the CPU Veliki dokument: ponovno iscrtavanje procesorom
|
||||
This redraw took {seconds} s on the CPU. Ovo iscrtavanje je trajalo {seconds} s na procesoru.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Izmene koje menjaju ceo dokument, kao što su režim stapanja, neprozirnost ili vidljivost sloja, ponovo iscrtavaju ceo dokument.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Njegovi slojevi ne staju u budžet GPU memorije. Povećanje vrednosti Korišćenje memorije u Podešavanja › Performanse povećava budžet, do četvrtine memorije ovog računara.
|
||||
The GPU compositor wasn't used: {reason} GPU kompozitor nije korišćen: {reason}
|
||||
fontWeight Thin Tanko
|
||||
fontWeight ExtraLight Ekstra lagano
|
||||
@@ -4269,3 +4268,16 @@ shape Appearance Izgled
|
||||
Take the shortcut from the other command and select it Uzmi prečicu drugoj komandi i izaberi je
|
||||
Undo Changes Poništi izmene
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} je rezervisana od strane sistema i ne može se dodeliti.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Crtanje sa malim kašnjenjem (bez vsync-a, primenjuje se pri sledećem pokretanju)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. Slobodna transformacija se još primenjuje; pokušajte ponovo kad se završi.
|
||||
The transform couldn't be applied. Transformacija nije mogla da se primeni.
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. Njegovim slojevima treba {needed} GPU memorije; budžet im ostavlja {budget}.
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Podesite GPU memoriju u Podešavanja › Performanse na onoliko koliko vaša grafička kartica može da primi, najviše 3/8 njene memorije. Automatski prati Korišćenje memorije, u okviru četvrtine memorije ovog računara i 3/8 memorije kartice, i ne računa dokument kod kartice čija je memorija poznata. Dok ne staju, statusna traka prikazuje GPU memorija je puna.
|
||||
GPU Memory Full GPU memorija je puna
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Slojevima treba {needed} GPU memorije, a smeju da zauzmu {budget}, pa izmene koje ponovo iscrtavaju ceo dokument to rade na CPU-u. Kliknite da podesite GPU memoriju.
|
||||
GPU memory budget: {budget} Budžet GPU memorije: {budget}
|
||||
This document's layers: {needed} of {budget} for layers Slojevi ovog dokumenta: {needed} od {budget} za slojeve
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Slojevima ovog dokumenta treba {needed}, a smeju da zauzmu {budget}: ceo dokument se ponovo iscrtava na CPU-u
|
||||
GPU Memory (MB, 0 = Automatic) GPU memorija (MB, 0 = Automatski)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2410,7 +2410,6 @@ layerLabel No Color Renk Yok
|
||||
Large document: redrawing on the CPU Büyük belge: CPU'da yeniden çizim
|
||||
This redraw took {seconds} s on the CPU. Bu yeniden çizim CPU'da {seconds} saniye sürdü.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Bir katmanın karışım modu, opaklığı veya görünürlüğü gibi belgenin tamamını değiştiren düzenlemeler, belgenin tamamını yeniden çizer.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Katmanları GPU bellek bütçesine uymuyor. Tercihler'de Bellek kullanımını artırma › Performans, bütçeyi bu bilgisayarın belleğinin dörtte birine kadar artırır.
|
||||
The GPU compositor wasn't used: {reason} GPU dizgicisi kullanılmadı: {reason}
|
||||
cameraRaw Light Açık Tonlar
|
||||
fontWeight Thin İnce
|
||||
@@ -3926,3 +3925,16 @@ cameraData Lens Lens
|
||||
Take the shortcut from the other command and select it Kısayolu diğer komuttan al ve o komutu seç
|
||||
Undo Changes Değişiklikleri Geri Al
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} sistem tarafından ayrılmıştır ve atanamaz.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Düşük gecikmeli çizim (dikey eşitleme kapalı, sonraki açılışta geçerli)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. Serbest Dönüştürme hâlâ uygulanıyor; bittiğinde yeniden deneyin.
|
||||
The transform couldn't be applied. Dönüştürme uygulanamadı.
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. Katmanlarının {needed} GPU belleğine ihtiyacı var; bütçe onlara {budget} bırakıyor.
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Tercihler › Performans içindeki GPU Belleği'ni ekran kartınızın kaldırabileceği miktara, en fazla belleğinin 3/8'ine ayarlayın. Otomatik, Bellek kullanımını izler; bu bilgisayarın belleğinin dörtte biri ve kartın belleğinin 3/8'i içinde kalır, belleği bilinen bir kartta belgeyi hesaba katmaz. Sığmadıkları sürece durum çubuğu GPU Belleği Dolu gösterir.
|
||||
GPU Memory Full GPU Belleği Dolu
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Katmanların {needed} GPU belleğine ihtiyacı var ve en fazla {budget} kullanabilirler; bu yüzden tüm belgeyi yeniden çizen düzenlemeler bunu CPU'da yapar. GPU Belleği'ni ayarlamak için tıklayın.
|
||||
GPU memory budget: {budget} GPU bellek bütçesi: {budget}
|
||||
This document's layers: {needed} of {budget} for layers Bu belgenin katmanları: katmanlar için {budget} içinden {needed}
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Bu belgenin katmanlarının {needed} belleğe ihtiyacı var ama en fazla {budget} kullanabilirler: tüm belgenin yeniden çizimi CPU'yu kullanır
|
||||
GPU Memory (MB, 0 = Automatic) GPU Belleği (MB, 0 = Otomatik)
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -2679,7 +2679,6 @@ cameraRaw Roughness Шорсткість
|
||||
Redrawing Перемальовування
|
||||
This redraw took {seconds} s on the CPU. Це перемальовування на CPU тривало {seconds} с.
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Зміни, що впливають на весь документ, як-от режим накладання, непрозорість чи видимість шару, перемальовують його повністю.
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Його шари не вміщуються в бюджет пам'яті GPU. Збільшення використання пам'яті в Налаштування › Продуктивність підвищує бюджет до чверті пам'яті цього комп'ютера.
|
||||
The GPU compositor wasn't used: {reason} Компонувальник GPU не використовувався: {reason}
|
||||
fontWeight Thin Тонкий
|
||||
fontWeight ExtraLight Екстралегкий
|
||||
@@ -3895,3 +3894,16 @@ curves Smooth Згладити
|
||||
Take the shortcut from the other command and select it Забрати скорочення в іншої команди й вибрати її
|
||||
Undo Changes Скасувати зміни
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} зарезервовано системою, його не можна призначити.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Малювання з малою затримкою (без вертикальної синхронізації, після перезапуску)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. Вільне трансформування ще застосовується; спробуйте знову, коли воно завершиться.
|
||||
The transform couldn't be applied. Не вдалося застосувати трансформування.
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. Його шарам потрібно {needed} пам'яті GPU; бюджет залишає їм {budget}.
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Задайте «Пам'ять GPU» в Налаштування › Продуктивність відповідно до того, скільки вміщує ваша відеокарта, не більше 3/8 її пам'яті. «Автоматично» стежить за використанням пам'яті, у межах чверті пам'яті цього комп'ютера й 3/8 пам'яті відеокарти, і не враховує документ на відеокарті з відомим обсягом пам'яті. Поки шари не вміщуються, рядок стану показує «Пам'ять GPU заповнена».
|
||||
GPU Memory Full Пам'ять GPU заповнена
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Шарам потрібно {needed} пам'яті GPU, а займати вони можуть {budget}, тому зміни, що перемальовують увесь документ, виконуються на CPU. Натисніть, щоб задати «Пам'ять GPU».
|
||||
GPU memory budget: {budget} Бюджет пам'яті GPU: {budget}
|
||||
This document's layers: {needed} of {budget} for layers Шари цього документа: {needed} з {budget} для шарів
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Шарам цього документа потрібно {needed}, але займати вони можуть {budget}: увесь документ перемальовується на CPU
|
||||
GPU Memory (MB, 0 = Automatic) Пам'ять GPU (МБ, 0 = автоматично)
|
||||
|
Can't render this file because it contains an unexpected character in line 1388 and column 18.
|
@@ -2156,7 +2156,6 @@ layers Fill: Độ tô:
|
||||
Imported {n} shortcuts from {file}. Click OK to keep them. Đã nhập {n} phím tắt từ {file}. Nhấp OK để giữ.
|
||||
Indexed Color… Màu lập chỉ mục…
|
||||
Install Plug-in… Cài plug-in…
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Các lớp không vừa ngân sách bộ nhớ GPU. Tăng mức sử dụng bộ nhớ trong Tùy chọn › Hiệu năng sẽ nâng ngân sách, tối đa một phần tư bộ nhớ máy này.
|
||||
Labels Nhãn
|
||||
Large document: redrawing on the CPU Tài liệu lớn: đang vẽ lại trên CPU
|
||||
Redrawing Đang vẽ lại
|
||||
@@ -3682,3 +3681,16 @@ curves Smooth Làm mượt
|
||||
Take the shortcut from the other command and select it Lấy phím tắt khỏi lệnh kia và chọn lệnh đó
|
||||
Undo Changes Hoàn tác thay đổi
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} được hệ thống dành riêng và không thể gán.
|
||||
Low-Latency Drawing (vsync off, applies at next launch) Vẽ độ trễ thấp (tắt vsync, áp dụng ở lần khởi động sau)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. Biến đổi tự do vẫn đang được áp dụng; hãy thử lại khi hoàn tất.
|
||||
The transform couldn't be applied. Không thể áp dụng phép biến đổi.
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. Các lớp cần {needed} bộ nhớ GPU; ngân sách chỉ dành cho chúng {budget}.
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Đặt Bộ nhớ GPU trong Tùy chọn › Hiệu năng bằng mức card đồ họa của bạn chứa được, tối đa 3/8 bộ nhớ của card. Tự động theo mức sử dụng bộ nhớ, trong giới hạn một phần tư bộ nhớ máy này và 3/8 bộ nhớ card, và không tính tài liệu vào card đã biết dung lượng bộ nhớ. Khi chúng chưa vừa, thanh trạng thái hiện Bộ nhớ GPU đầy.
|
||||
GPU Memory Full Bộ nhớ GPU đầy
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Các lớp cần {needed} bộ nhớ GPU và chỉ được dùng {budget}, nên những chỉnh sửa vẽ lại toàn bộ tài liệu sẽ làm trên CPU. Nhấp để đặt Bộ nhớ GPU.
|
||||
GPU memory budget: {budget} Ngân sách bộ nhớ GPU: {budget}
|
||||
This document's layers: {needed} of {budget} for layers Các lớp của tài liệu này: {needed} trên {budget} dành cho lớp
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Các lớp của tài liệu này cần {needed} nhưng chỉ được dùng {budget}: vẽ lại toàn bộ tài liệu dùng CPU
|
||||
GPU Memory (MB, 0 = Automatic) Bộ nhớ GPU (MB, 0 = Tự động)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2454,7 +2454,6 @@ cameraRaw Blending 混合
|
||||
Redrawing 正在重绘
|
||||
This redraw took {seconds} s on the CPU. 此次 CPU 重绘耗时 {seconds} 秒。
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. 更改整个文档的编辑(如图层的混合模式、不透明度或可见性)会重绘整个文档。
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. 图层超出 GPU 内存预算。在 首选项 › 性能 中提高内存使用量可增加预算,最多为本机内存的四分之一。
|
||||
The GPU compositor wasn't used: {reason} 未使用 GPU 合成器:{reason}
|
||||
fontWeight Thin 极细
|
||||
fontWeight ExtraLight 特细
|
||||
@@ -2812,3 +2811,16 @@ curves Smooth 平滑
|
||||
Take the shortcut from the other command and select it 从另一个命令移除该快捷键并选中它
|
||||
Undo Changes 撤消更改
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} 由系统保留,无法指定。
|
||||
Low-Latency Drawing (vsync off, applies at next launch) 低延迟绘画(关闭垂直同步,下次启动时生效)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. 自由变换仍在应用中;请在完成后重试。
|
||||
The transform couldn't be applied. 无法应用变换。
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. 其图层需要 {needed} GPU 内存;预算只留给它们 {budget}。
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. 在 首选项 › 性能 中将 GPU 内存设置为显卡能容纳的大小,最多为其显存的 3/8。自动跟随内存使用量,不超过本机内存的四分之一和显卡显存的 3/8,且在显存已知的显卡上不把文档计入。在图层放不下期间,状态栏显示“GPU 内存已满”。
|
||||
GPU Memory Full GPU 内存已满
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. 图层需要 {needed} GPU 内存,但只能占用 {budget},因此重绘整个文档的编辑会在 CPU 上进行。点击以设置 GPU 内存。
|
||||
GPU memory budget: {budget} GPU 内存预算:{budget}
|
||||
This document's layers: {needed} of {budget} for layers 此文档的图层:图层可用 {budget} 中的 {needed}
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文档的图层需要 {needed},但只能占用 {budget}:整个文档的重绘使用 CPU
|
||||
GPU Memory (MB, 0 = Automatic) GPU 内存(MB,0 = 自动)
|
||||
|
Can't render this file because it contains an unexpected character in line 2599 and column 53.
|
@@ -2424,7 +2424,6 @@ cameraRaw Blending 混合
|
||||
Redrawing 正在重繪
|
||||
This redraw took {seconds} s on the CPU. 此次 CPU 重繪耗時 {seconds} 秒。
|
||||
Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. 變更整份文件的編輯(例如圖層的混合模式、不透明度或可見度)會重繪整份文件。
|
||||
Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. 圖層超出 GPU 記憶體預算。在 偏好設定 › 效能 中提高記憶體使用量可增加預算,最多為本機記憶體的四分之一。
|
||||
The GPU compositor wasn't used: {reason} 未使用 GPU 合成器:{reason}
|
||||
fontWeight Thin 極細
|
||||
fontWeight ExtraLight 特細
|
||||
@@ -2797,3 +2796,16 @@ curves Smooth 平滑
|
||||
Take the shortcut from the other command and select it 從另一個指令移除該快速鍵並選取它
|
||||
Undo Changes 還原變更
|
||||
{shortcut} is reserved by the system and can't be assigned. {shortcut} 由系統保留,無法指定。
|
||||
Low-Latency Drawing (vsync off, applies at next launch) 低延遲繪圖(關閉垂直同步,下次啟動時生效)
|
||||
# Free Transform: a new layer while the box is open (#5859)
|
||||
Free Transform is still being applied; try again when it has finished. 任意變形仍在套用中;請在完成後再試一次。
|
||||
The transform couldn't be applied. 無法套用變形。
|
||||
# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice
|
||||
Its layers need {needed} of GPU memory; the budget leaves them {budget}. 其圖層需要 {needed} GPU 記憶體;預算只留給它們 {budget}。
|
||||
Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. 在 偏好設定 › 效能 中將 GPU 記憶體設為顯示卡能容納的大小,最多為其記憶體的 3/8。自動會跟隨記憶體使用量,不超過本機記憶體的四分之一和顯示卡記憶體的 3/8,且在記憶體已知的顯示卡上不把文件計入。在圖層放不下期間,狀態列會顯示「GPU 記憶體已滿」。
|
||||
GPU Memory Full GPU 記憶體已滿
|
||||
The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. 圖層需要 {needed} GPU 記憶體,但只能佔用 {budget},因此重繪整份文件的編輯會在 CPU 上進行。按一下以設定 GPU 記憶體。
|
||||
GPU memory budget: {budget} GPU 記憶體預算:{budget}
|
||||
This document's layers: {needed} of {budget} for layers 此文件的圖層:圖層可用 {budget} 中的 {needed}
|
||||
This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文件的圖層需要 {needed},但只能佔用 {budget}:整份文件的重繪使用 CPU
|
||||
GPU Memory (MB, 0 = Automatic) GPU 記憶體(MB,0 = 自動)
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -35,8 +35,7 @@ pub fn plan(app: &PhotocraftApp, set: &KysSet) -> Plan {
|
||||
/// The set's tool keys matched to our tools by name (Photoshop's names are ours): tool
|
||||
/// shortcut id (`tools.select.move`) → letter, and the tools PhotoCraft doesn't have.
|
||||
pub fn plan_tools(set: &KysSet) -> (BTreeMap<String, String>, Vec<String>) {
|
||||
let tools: Vec<(String, &str)> = crate::state::Tool::ALL.into_iter().map(|t| (crate::tool_shortcuts::id(t), t.label())).collect();
|
||||
photocraft_engine::kys::plan_tools(tools.iter().map(|(id, name)| (id.as_str(), *name)), set)
|
||||
photocraft_engine::kys::plan_tools(photocraft_engine::shell_commands::TOOLS.iter().map(|t| (t.0, t.1)), set)
|
||||
}
|
||||
|
||||
/// Put the plan into the open Keyboard Shortcuts dialog (opening one when none is), as
|
||||
|
||||
@@ -800,6 +800,12 @@ pub struct PhotocraftApp {
|
||||
/// `jobs_ui`). The desktop app turns it on; off (the default), everything runs inline as
|
||||
/// before, which tests and scripts rely on.
|
||||
pub background_jobs: bool,
|
||||
/// Shortest time between frames (ms), or `None` for no cap. A present mode that doesn't wait
|
||||
/// for the display (Low-Latency Drawing) would otherwise redraw as fast as it can while
|
||||
/// anything keeps requesting repaints (a drag hovering, autoscroll, Liquify held still).
|
||||
/// After #4472 (astigmata).
|
||||
pub min_frame_ms: Option<f64>,
|
||||
last_frame_start_ms: f64,
|
||||
/// Background job bookkeeping: opening tabs, control replies waiting on a job.
|
||||
pub jobs: jobs_ui::JobsUi,
|
||||
#[cfg(all(debug_assertions, not(target_arch = "wasm32")))]
|
||||
@@ -935,6 +941,8 @@ impl PhotocraftApp {
|
||||
allow_close: false,
|
||||
stylus: Default::default(),
|
||||
background_jobs: false,
|
||||
min_frame_ms: None,
|
||||
last_frame_start_ms: 0.0,
|
||||
jobs: Default::default(),
|
||||
#[cfg(all(debug_assertions, not(target_arch = "wasm32")))]
|
||||
live_tokens: theme::live::LiveTokens::from_env(),
|
||||
@@ -1044,6 +1052,21 @@ impl PhotocraftApp {
|
||||
{
|
||||
authorize(id, ¶ms)?;
|
||||
}
|
||||
// A new layer while Free Transform is open applies the transform first, as Photoshop does
|
||||
// (#5667): History reads the transform (or Place Embedded), then New Layer. Only for a
|
||||
// command that can run; a transform refused or still being applied keeps the box open and
|
||||
// the command doesn't run.
|
||||
if photocraft_engine::PRECHECKED.contains(&id) && self.ui.transform.is_some() {
|
||||
// Bad params or a command that can't run leave the box as it is.
|
||||
self.session.precheck(id, ¶ms).map_err(|e| e.to_string())?;
|
||||
transform_tool::commit(self);
|
||||
if transform_tool::committing(self).is_some() {
|
||||
return Err(tl!("Free Transform is still being applied; try again when it has finished.").into());
|
||||
}
|
||||
if self.ui.transform.is_some() {
|
||||
return Err(if self.ui.status.is_empty() { tl!("The transform couldn't be applied.").into() } else { self.ui.status.clone() });
|
||||
}
|
||||
}
|
||||
if let Some(result) = panel_docking::command(self, id, ¶ms) {
|
||||
return result;
|
||||
}
|
||||
@@ -1759,6 +1782,20 @@ impl PhotocraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
impl PhotocraftApp {
|
||||
/// Wait out the rest of [`Self::min_frame_ms`] since the last frame started.
|
||||
fn cap_frame_rate(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(min) = self.min_frame_ms.filter(|m| m.is_finite() && *m > 0.0) {
|
||||
let wait = min.min(100.0) - (gpu_canvas::now_ms() - self.last_frame_start_ms);
|
||||
if wait > 0.0 {
|
||||
std::thread::sleep(std::time::Duration::from_secs_f64(wait / 1000.0));
|
||||
}
|
||||
}
|
||||
self.last_frame_start_ms = gpu_canvas::now_ms();
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for PhotocraftApp {
|
||||
fn on_exit(&mut self) {
|
||||
if let Err(err) = tool_memory::save_on_exit(self) {
|
||||
@@ -1767,6 +1804,7 @@ impl eframe::App for PhotocraftApp {
|
||||
}
|
||||
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
self.cap_frame_rate();
|
||||
i18n::set_current(i18n::Lang::from_pref(&self.session.prefs().interface.language));
|
||||
// Caps Lock state read from the OS once per frame (see `Services::caps_lock`): the canvas
|
||||
// cursor block needs it before the frame renders. `None` (Wayland/web) keeps `false`.
|
||||
@@ -2004,7 +2042,10 @@ impl eframe::App for PhotocraftApp {
|
||||
if self.stroke_trace.enabled() {
|
||||
self.stroke_trace.canvas(self.perf.frame_refresh_ms);
|
||||
let info = &self.perf.gpu_info;
|
||||
self.stroke_trace.end_frame(ui_ms, self.live_stroke.is_some(), || format!("{} {}", info.backend, info.adapter).trim().to_string());
|
||||
self.stroke_trace.end_frame(ui_ms, self.live_stroke.is_some(), || {
|
||||
let gpu = format!("{} {}", info.backend, info.adapter).trim().to_string();
|
||||
if info.present.is_empty() { gpu } else { format!("{gpu}, {}", info.present) }
|
||||
});
|
||||
}
|
||||
// Synthetic input is injected one press/release step per frame: keep frames coming until
|
||||
// the queue is empty, then release control replies waiting on it.
|
||||
@@ -2641,6 +2682,8 @@ mod transform_fx_tests;
|
||||
#[cfg(test)]
|
||||
mod layer_props_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod transform_switch_tests;
|
||||
#[cfg(test)]
|
||||
mod transform_undo_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -20,91 +20,9 @@ pub(crate) fn modal_allows(app: &PhotocraftApp, id: &str) -> bool {
|
||||
pub const TOP_MENUS: [&str; 10] = ["File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"];
|
||||
|
||||
/// UI-level commands (handled by the shell rather than the engine): id, label, menu, shortcut.
|
||||
pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
|
||||
("ui.symmetryTransform", "Transform Symmetry", &[], None),
|
||||
// Right-click the options bar's tool icon (#2825); `tool.resetAll` asks first.
|
||||
("tool.reset", "Reset Tool", &[], None),
|
||||
("tool.resetAll", "Reset All Tools", &[], None),
|
||||
("window.panel.group", "Change Panel Group", &[], None),
|
||||
("window.panel.layout", "Change Panel Layout", &[], None),
|
||||
("window.panel.move", "Move Panel", &[], None),
|
||||
("window.panel.float", "Float Panel", &[], None),
|
||||
("window.panel.dock", "Dock Panel", &[], None),
|
||||
("window.panel.activate", "Activate Panel", &[], None),
|
||||
("window.panel.close", "Close Panel", &[], None),
|
||||
("file.open", "Open…", &["File"], Some("Cmd+O")),
|
||||
("file.save", "Save", &["File"], Some("Cmd+S")),
|
||||
("file.saveAs", "Save As…", &["File"], Some("Cmd+Shift+S")),
|
||||
("file.exit", "Exit", &["File"], Some("Cmd+Q")),
|
||||
("edit.freeTransform", "Free Transform", &["Edit"], Some("Cmd+T")),
|
||||
("file.export.exportAs", "Export As…", &["File", "Export"], Some("Cmd+Alt+Shift+W")),
|
||||
("file.export.quickExportAsPng", "Quick Export as PNG", &["File", "Export"], None),
|
||||
("edit.transform.scale", "Scale", &["Edit", "Transform"], None),
|
||||
("edit.transform.rotate", "Rotate", &["Edit", "Transform"], None),
|
||||
("edit.transform.skew", "Skew", &["Edit", "Transform"], None),
|
||||
("edit.transform.distort", "Distort", &["Edit", "Transform"], None),
|
||||
("edit.transform.perspective", "Perspective", &["Edit", "Transform"], None),
|
||||
("select.selectAndMask", "Select and Mask…", &["Select"], Some("Cmd+Alt+R")),
|
||||
("view.rulers", "Rulers", &["View"], Some("Cmd+R")),
|
||||
("view.show.grid", "Grid", &["View", "Show"], Some("Cmd+'")),
|
||||
("view.show.guides", "Guides", &["View", "Show"], Some("Cmd+;")),
|
||||
("view.snap", "Snap", &["View"], Some("Cmd+Shift+;")),
|
||||
("view.lockGuides", "Lock Guides", &["View"], Some("Cmd+Alt+;")),
|
||||
("view.extras", "Extras", &["View"], Some("Cmd+H")),
|
||||
("view.show.targetPath", "Target Path", &["View", "Show"], Some("Cmd+Shift+H")),
|
||||
("view.screenMode.cycle", "Cycle Screen Mode", &[], Some("F")),
|
||||
// Photoshop's interface brightness shortcuts: step through the four Pro themes.
|
||||
("view.darkenInterface", "Darken Interface", &[], Some("Cmd+Shift+F1")),
|
||||
("view.lightenInterface", "Lighten Interface", &[], Some("Cmd+Shift+F2")),
|
||||
("edit.freeTransformCopy", "Free Transform a Copy", &[], Some("Cmd+Alt+T")),
|
||||
("type.editText", "Edit Type", &[], None),
|
||||
("view.zoomIn", "Zoom In", &["View"], Some("Cmd+=")),
|
||||
("view.zoomOut", "Zoom Out", &["View"], Some("Cmd+-")),
|
||||
("view.fitOnScreen", "Fit on Screen", &["View"], Some("Cmd+0")),
|
||||
("view.actualPixels", "100%", &["View"], Some("Cmd+1")),
|
||||
("view.rotateView", "Rotate View", &[], None),
|
||||
("view.resetView", "Reset View", &["View"], None),
|
||||
("window.newWindowForDocument", "New Window for Document", &["Window", "Arrange"], None),
|
||||
("window.toggle.layers", "Layers", &["Window"], Some("F7")),
|
||||
("window.toggle.history", "History", &["Window"], None),
|
||||
("window.toggle.properties", "Properties", &["Window"], None),
|
||||
("window.toggle.color", "Color", &["Window"], Some("F6")),
|
||||
("window.toggle.brushSettings", "Brush Settings", &["Window"], Some("F5")),
|
||||
("window.toggle.brushes", "Brushes", &["Window"], None),
|
||||
("window.toggle.navigator", "Navigator", &["Window"], None),
|
||||
("window.toggle.toolbar", "Tools", &["Window"], None),
|
||||
// Photoshop's Tab and ⇧Tab: all panels (Tools, options bar and the dock), or only the dock.
|
||||
("window.togglePanels", "Show/Hide All Panels", &[], Some("Tab")),
|
||||
("window.toggle.dock", "Show/Hide Panels", &[], Some("Shift+Tab")),
|
||||
("window.toggle.options", "Options", &["Window"], None),
|
||||
// Photoshop's Ctrl+Tab and Ctrl+Shift+Tab, on macOS too (⌘Tab belongs to the system, #2340).
|
||||
("window.nextDocument", "Next Document", &[], Some("Ctrl+Tab")),
|
||||
("window.previousDocument", "Previous Document", &[], Some("Ctrl+Shift+Tab")),
|
||||
("window.theme.toggle", "Next Appearance Mode", &["Window"], None),
|
||||
("window.theme.pro", "Pro Theme", &["Window", "Theme"], None),
|
||||
("window.theme.proMedium", "Pro Medium Gray Theme", &["Window", "Theme"], None),
|
||||
("window.theme.proLightGray", "Pro Light Gray Theme", &["Window", "Theme"], None),
|
||||
("window.theme.proLight", "Pro Light Theme", &["Window", "Theme"], None),
|
||||
("window.theme.studio", "Studio Theme", &["Window", "Theme"], None),
|
||||
("window.theme.studioLight", "Studio Light Theme", &["Window", "Theme"], None),
|
||||
("window.theme.classic", "Classic Theme", &["Window", "Theme"], None),
|
||||
("window.theme.solarizedDark", "Solarized Dark Theme", &["Window", "Theme"], None),
|
||||
("window.theme.adwaita", "Adwaita Light Theme", &["Window", "Theme"], None),
|
||||
("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None),
|
||||
("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None),
|
||||
("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None),
|
||||
("window.theme.softLight", "Soft Light Theme", &["Window", "Theme"], None),
|
||||
("window.theme.softDark", "Soft Dark Theme", &["Window", "Theme"], None),
|
||||
("window.theme.system", "Sync with system", &["Window", "Theme"], None),
|
||||
("edit.search", "Search…", &["Edit"], Some("Cmd+F")),
|
||||
("help.discord", "Join the ArtCraft Discord…", &["Help"], None),
|
||||
("help.website", "PhotoCraft Website", &["Help"], None),
|
||||
("help.artcraftWebsite", "ArtCraft Website", &["Help"], None),
|
||||
("help.github", "PhotoCraft on GitHub", &["Help"], None),
|
||||
("help.reportIssue", "Report an Issue…", &["Help"], None),
|
||||
("help.systemInfo", "System Info…", &["Help"], None),
|
||||
("help.about", "About PhotoCraft", &["Help"], None),
|
||||
];
|
||||
/// The table is the engine's ([`photocraft_engine::shell_commands::SHELL_COMMANDS`]), so the
|
||||
/// commands and their keys exist without this UI (headless shortcut sets, lists, conflicts).
|
||||
pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = photocraft_engine::shell_commands::SHELL_COMMANDS;
|
||||
|
||||
/// Window › <document>: the open documents, listed at the end of the Window menu as in Photoshop.
|
||||
/// The id ends in the document's index, which is its tab's position.
|
||||
|
||||
@@ -9,20 +9,25 @@ type Harness = egui_kittest::Harness<'static, PhotocraftApp>;
|
||||
|
||||
fn harness() -> Option<Harness> {
|
||||
let built = std::panic::catch_unwind(|| {
|
||||
egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
if let Some(rs) = cc.wgpu_render_state.as_ref() {
|
||||
app.set_wgpu(rs.clone());
|
||||
}
|
||||
app.ui.panels.navigator = true;
|
||||
app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap();
|
||||
app.run("select.rect", json!({"x": 0, "y": 0, "width": 200, "height": 300})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
app.run("select.deselect", json!({})).unwrap();
|
||||
app.sync_views();
|
||||
app
|
||||
})
|
||||
egui_kittest::Harness::builder()
|
||||
.with_size(egui::vec2(1400.0, 900.0))
|
||||
.with_pixels_per_point(1.0)
|
||||
.with_max_steps(64)
|
||||
.wgpu_setup(crate::gpu_canvas::wgpu_setup())
|
||||
.build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
if let Some(rs) = cc.wgpu_render_state.as_ref() {
|
||||
app.set_wgpu(rs.clone());
|
||||
}
|
||||
app.ui.panels.navigator = true;
|
||||
app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap();
|
||||
app.run("select.rect", json!({"x": 0, "y": 0, "width": 200, "height": 300})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
app.run("select.deselect", json!({})).unwrap();
|
||||
app.sync_views();
|
||||
app
|
||||
})
|
||||
});
|
||||
match built {
|
||||
Ok(h) => Some(h),
|
||||
@@ -47,6 +52,7 @@ fn near(a: [u8; 4], b: [u8; 3]) -> bool {
|
||||
|
||||
#[test]
|
||||
fn navigator_draws_the_gpu_canvas_texture_and_follows_edits() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
let Some(mut h) = harness() else { return };
|
||||
h.run_steps(8);
|
||||
if !h.state().perf.gpu {
|
||||
|
||||
@@ -105,8 +105,14 @@ pub fn slow_cpu_refresh(app: &mut PhotocraftApp, doc: u64, ms: f64, reason: Opti
|
||||
tl!("Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it.").into(),
|
||||
];
|
||||
if reason == Some(crate::gpu_canvas::OVER_BUDGET) {
|
||||
if let Some(fit) = app.perf.gpu_layer_fit.filter(|f| f.doc == doc && !f.fits()) {
|
||||
lines.push(crate::i18n::fmt(
|
||||
tl!("Its layers need {needed} of GPU memory; the budget leaves them {budget}."),
|
||||
&[("needed", &crate::chrome_ui::fmt_bytes(fit.needed)), ("budget", &crate::chrome_ui::fmt_bytes(fit.budget))],
|
||||
));
|
||||
}
|
||||
lines.push(
|
||||
tl!("Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory.")
|
||||
tl!("Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit.")
|
||||
.into(),
|
||||
);
|
||||
} else if let Some(r) = reason {
|
||||
@@ -354,12 +360,15 @@ mod tests {
|
||||
slow_cpu_refresh(&mut app, 1, f64::NAN, Some(crate::gpu_canvas::OVER_BUDGET));
|
||||
assert!(app.ui.notices.is_empty(), "a quick refresh needs no explanation");
|
||||
|
||||
// The layers of document 1 need 3.6 GB and may hold 3.4 GB: the notice says so.
|
||||
app.perf.gpu_layer_fit = Some(crate::gpu_canvas::LayerFit { doc: 1, needed: 3600 << 20, budget: 3400 << 20 });
|
||||
slow_cpu_refresh(&mut app, 1, 55_000.0, Some(crate::gpu_canvas::OVER_BUDGET));
|
||||
assert_eq!(app.ui.notices.len(), 1);
|
||||
let text = app.ui.notices[0].lines.join(" ");
|
||||
assert!(text.contains("55.0 s"), "{text}");
|
||||
assert!(text.contains("blend mode, opacity or visibility"), "{text}");
|
||||
assert!(text.contains("Memory usage in Preferences › Performance"), "{text}");
|
||||
assert!(text.contains("need 3.5G of GPU memory; the budget leaves them 3.3G"), "{text}");
|
||||
assert!(text.contains("GPU Memory in Preferences › Performance"), "{text}");
|
||||
assert!(!app.ui.notices[0].error);
|
||||
|
||||
slow_cpu_refresh(&mut app, 1, 55_000.0, Some(crate::gpu_canvas::OVER_BUDGET));
|
||||
|
||||
@@ -175,6 +175,7 @@ fn shared_layout_keeps_panel_identity_across_themes_and_respects_workspace_lock(
|
||||
/// Explicit offscreen evidence; this creates no native windows.
|
||||
#[test]
|
||||
fn capture_custom_panel_docking_visual_fixtures() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return };
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
for theme in crate::theme::ThemeKind::ALL {
|
||||
@@ -211,6 +212,7 @@ fn capture_custom_panel_docking_visual_fixtures() {
|
||||
|
||||
#[test]
|
||||
fn floating_group_titles_are_opaque_in_studio_themes() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
// Studio's tab strip is transparent over the dock's card; a floating group must not show the canvas (#4509).
|
||||
let mut app = app();
|
||||
app.run("window.panel.float", json!({"panel":"swatches","x":40,"y":60,"width":300,"height":300})).unwrap();
|
||||
@@ -346,6 +348,7 @@ fn splitting_a_short_column_keeps_the_layers_footer_visible_and_usable() {
|
||||
|
||||
#[test]
|
||||
fn populated_canvas_keeps_a_real_opacity_edit_after_floating_and_redocking() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
let mut app = app();
|
||||
app.ui.theme = crate::theme::ThemeKind::Studio;
|
||||
app.run("file.new", json!({"width":480,"height":360})).unwrap();
|
||||
|
||||
@@ -1613,6 +1613,7 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
ui.with_layout(crate::direction::trailing(), |ui| {
|
||||
crate::jobs_ui::status_progress(app, ui);
|
||||
crate::background_redraw::status(app, ui);
|
||||
crate::chrome_ui::gpu_memory_status(app, ui);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1653,6 +1654,7 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
crate::jobs_ui::status_progress(app, ui);
|
||||
}
|
||||
crate::background_redraw::status(app, ui);
|
||||
crate::chrome_ui::gpu_memory_status(app, ui);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6003,6 +6005,7 @@ mod compact_visual_regressions {
|
||||
|
||||
#[test]
|
||||
fn capture_compact_visual_fixtures() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return };
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
for kind in crate::theme::ThemeKind::ALL {
|
||||
|
||||
@@ -816,33 +816,7 @@ fn conflict_state(overrides: &BTreeMap<String, String>, alternates: &BTreeMap<St
|
||||
/// quit): Edit › Keyboard Shortcuts won't assign them, as Photoshop won't. `sc` is in our
|
||||
/// notation, where `Cmd` is Ctrl off the Mac.
|
||||
pub fn reserved_shortcut(sc: &str) -> bool {
|
||||
let Some(n) = prefs::normalize_shortcut(sc) else { return false };
|
||||
let reserved: &[&str] = if cfg!(target_os = "macos") {
|
||||
&["Cmd+Tab", "Cmd+Shift+Tab", "Cmd+Alt+Escape", "Cmd+`"]
|
||||
} else if cfg!(target_os = "windows") {
|
||||
&["Alt+Tab", "Alt+Shift+Tab", "Alt+F4", "Cmd+Alt+Delete", "Cmd+Escape", "Cmd+Shift+Escape"]
|
||||
} else {
|
||||
// Linux and the BSDs: window switching and closing, and the virtual consoles.
|
||||
&[
|
||||
"Alt+Tab",
|
||||
"Alt+Shift+Tab",
|
||||
"Alt+F4",
|
||||
"Cmd+Alt+Delete",
|
||||
"Cmd+Alt+F1",
|
||||
"Cmd+Alt+F2",
|
||||
"Cmd+Alt+F3",
|
||||
"Cmd+Alt+F4",
|
||||
"Cmd+Alt+F5",
|
||||
"Cmd+Alt+F6",
|
||||
"Cmd+Alt+F7",
|
||||
"Cmd+Alt+F8",
|
||||
"Cmd+Alt+F9",
|
||||
"Cmd+Alt+F10",
|
||||
"Cmd+Alt+F11",
|
||||
"Cmd+Alt+F12",
|
||||
]
|
||||
};
|
||||
reserved.iter().filter_map(|r| prefs::normalize_shortcut(r)).any(|r| r == n)
|
||||
prefs::reserved_shortcut(sc)
|
||||
}
|
||||
|
||||
/// Text of a key press for the shortcut editor (`Cmd+Shift+K`), `None` for bare modifiers.
|
||||
@@ -1079,7 +1053,9 @@ pub fn open_mismatch(app: &mut PhotocraftApp, message: &str, action: &str, missi
|
||||
pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
||||
match f.get("__prefsui").and_then(Value::as_str).unwrap_or("") {
|
||||
"prefs" => {
|
||||
f.insert("__gpuInfo".into(), json!(app.perf.gpu_info.lines()));
|
||||
let mut info = app.perf.gpu_info.lines();
|
||||
info.extend(gpu_memory_lines(app));
|
||||
f.insert("__gpuInfo".into(), json!(info));
|
||||
let system = system_theme(app, ui.ctx());
|
||||
prefs_body(ui, f, system);
|
||||
}
|
||||
@@ -1091,6 +1067,27 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
|
||||
}
|
||||
}
|
||||
|
||||
/// Performance › Graphics: the compositor's GPU memory budget and whether the active document's
|
||||
/// layers fit it (the GPU Memory preference sets it).
|
||||
fn gpu_memory_lines(app: &PhotocraftApp) -> Vec<String> {
|
||||
let mut v = Vec::new();
|
||||
if app.perf.gpu_budget_mb > 0 {
|
||||
let budget = crate::chrome_ui::fmt_bytes(app.perf.gpu_budget_mb.saturating_mul(1 << 20));
|
||||
v.push(crate::i18n::fmt(tl!("GPU memory budget: {budget}"), &[("budget", &budget)]));
|
||||
}
|
||||
let doc = app.session.active().map(|st| st.doc.id.0);
|
||||
if let Some(fit) = app.perf.gpu_layer_fit.filter(|f| Some(f.doc) == doc) {
|
||||
let args = [("needed", crate::chrome_ui::fmt_bytes(fit.needed)), ("budget", crate::chrome_ui::fmt_bytes(fit.budget))];
|
||||
let args: Vec<(&str, &str)> = args.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
v.push(if fit.fits() {
|
||||
crate::i18n::fmt(tl!("This document's layers: {needed} of {budget} for layers"), &args)
|
||||
} else {
|
||||
crate::i18n::fmt(tl!("This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU"), &args)
|
||||
});
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn humanize(key: &str) -> String {
|
||||
match key {
|
||||
"appearanceMode" => return "Appearance Mode".into(),
|
||||
@@ -1120,6 +1117,15 @@ fn humanize(key: &str) -> String {
|
||||
if key == "nudgeBundlePauseMs" {
|
||||
return "Nudge Bundle Pause (ms)".into();
|
||||
}
|
||||
// Performance: presentation without vsync (#1343); like the other graphics settings, it
|
||||
// takes effect when the window next opens.
|
||||
if key == "lowLatencyDrawing" {
|
||||
return "Low-Latency Drawing (vsync off, applies at next launch)".into();
|
||||
}
|
||||
// Performance: the canvas compositor's GPU memory, 0 for the automatic budget (L3).
|
||||
if key == "gpuMemoryMb" {
|
||||
return "GPU Memory (MB, 0 = Automatic)".into();
|
||||
}
|
||||
let mut s = String::new();
|
||||
for (i, ch) in key.chars().enumerate() {
|
||||
if i == 0 {
|
||||
@@ -1556,6 +1562,8 @@ fn field_visible(section: &str, obj: &Map<String, Value>, k: &str, query: &str)
|
||||
// View › Rulers (⌘R) sets it, as in Photoshop.
|
||||
|| path == "unitsAndRulers.showRulers"
|
||||
|| (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "renderingMode"))
|
||||
// The browser presents frames itself: Low-Latency Drawing does nothing there.
|
||||
|| (cfg!(target_arch = "wasm32") && path == "performance.lowLatencyDrawing")
|
||||
|| (section == "export" && !export_field_visible(obj, k))
|
||||
|| (section == "tools" && !tools_field_visible(obj, k));
|
||||
(query.is_empty() || preference_matches(section, k, query))
|
||||
|
||||
@@ -113,13 +113,32 @@ impl StrokeTrace {
|
||||
/// Reads `PHOTOCRAFT_STROKE_TRACE` (off on the web, where there is no clock).
|
||||
pub fn from_env() -> Self {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(v) = std::env::var_os("PHOTOCRAFT_STROKE_TRACE").filter(|v| !v.is_empty()) {
|
||||
let output = if v == "1" || v == "stderr" { Output::Stderr } else { Output::Csv(v.into()) };
|
||||
return Self { output, ..Default::default() };
|
||||
if let Some(v) = std::env::var_os("PHOTOCRAFT_STROKE_TRACE") {
|
||||
return Self::from_value(&v.to_string_lossy());
|
||||
}
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// A trace set by value: `""`, `"0"` or `"off"` is off, `"1"` or `"stderr"` prints
|
||||
/// summaries, anything else is a CSV path (`--stroke-trace`, `PHOTOCRAFT_STROKE_TRACE`; the
|
||||
/// control channel's `ui.set {"strokeTrace"}` passes only on or off, never a path).
|
||||
pub fn from_value(v: &str) -> Self {
|
||||
let v = v.trim();
|
||||
let output = match v {
|
||||
_ if cfg!(target_arch = "wasm32") => Output::Off,
|
||||
"" | "0" | "off" | "false" => Output::Off,
|
||||
"1" | "stderr" | "true" => Output::Stderr,
|
||||
path => Output::Csv(path.into()),
|
||||
};
|
||||
Self { output, ..Default::default() }
|
||||
}
|
||||
|
||||
/// Switch the output, keeping the last summary.
|
||||
pub fn set(&mut self, v: &str) {
|
||||
let last = self.last.take();
|
||||
*self = Self { last, ..Self::from_value(v) };
|
||||
}
|
||||
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.output != Output::Off
|
||||
}
|
||||
@@ -265,6 +284,19 @@ mod tests {
|
||||
assert!(off.last.is_none() && off.rows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trace_is_set_by_value() {
|
||||
assert_eq!(StrokeTrace::from_value("off").output, Output::Off);
|
||||
assert_eq!(StrokeTrace::from_value(" 0 ").output, Output::Off);
|
||||
assert_eq!(StrokeTrace::from_value("stderr").output, Output::Stderr);
|
||||
assert_eq!(StrokeTrace::from_value("true").output, Output::Stderr);
|
||||
assert_eq!(StrokeTrace::from_value("t.csv").output, Output::Csv("t.csv".into()));
|
||||
let mut t = on();
|
||||
t.last = Some(Summary::default());
|
||||
t.set("off");
|
||||
assert!(!t.enabled() && t.last.is_some(), "turning it off keeps the last summary");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_rows_and_odd_numbers() {
|
||||
let rows = [Row { t: 1.0, interval: 16.5, moves: 2, stroke: 0.25, canvas: 1.0, ui: 3.0, cpu_prev: None }];
|
||||
|
||||
@@ -66,6 +66,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rendered_glyph_has_a_left_head_and_a_lower_right_head() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(32.0, 32.0)).wgpu().build_ui(|ui| {
|
||||
paint(ui.painter(), pos2(8.0, 8.0), Color32::WHITE);
|
||||
});
|
||||
|
||||
@@ -94,3 +94,17 @@ mod tests {
|
||||
assert_eq!(h.state().ui.status, "q");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod engine_table_tests {
|
||||
use super::*;
|
||||
|
||||
/// The engine knows the tools as data (headless shortcut sets); it must match the toolbar.
|
||||
#[test]
|
||||
fn the_engines_tool_table_matches_the_tools() {
|
||||
let ours: Vec<(String, &str, Option<String>)> = Tool::ALL.into_iter().map(|t| (id(t), t.label(), default(t))).collect();
|
||||
let engine: Vec<(String, &str, Option<String>)> =
|
||||
photocraft_engine::shell_commands::TOOLS.iter().map(|(i, n, k)| (i.to_string(), *n, k.map(str::to_string))).collect();
|
||||
assert_eq!(ours, engine, "update photocraft_engine::shell_commands::TOOLS");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Free Transform with the whole app: picking another tool or layer, or New Layer, while the box
|
||||
//! is open applies the transform as it stands, as Photoshop does (#5441, #5665, #5667).
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// The app with a 400 × 300 document: a red 100 × 60 rectangle at (100, 100) on its own layer
|
||||
/// above the Background.
|
||||
fn app() -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
app.run("select.deselect", json!({})).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(6);
|
||||
h
|
||||
}
|
||||
|
||||
/// Open Free Transform and move the box 50 px right, 40 px down.
|
||||
fn transform_and_move(h: &mut Harness<'static, PhotocraftApp>) {
|
||||
let ctx = h.ctx.clone();
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "edit.freeTransform", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
let t = h.state_mut().ui.transform.as_mut().expect("Free Transform is open");
|
||||
for p in &mut t.quad {
|
||||
p[0] += 50.0;
|
||||
p[1] += 40.0;
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn red_bounds(h: &Harness<'static, PhotocraftApp>) -> photocraft_geom::Rect {
|
||||
let st = h.state().session.active().unwrap();
|
||||
let top = st.doc.layers.last().unwrap();
|
||||
top.surface().unwrap().content_bounds()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picking_another_tool_in_the_toolbar_applies_the_transform() {
|
||||
let mut h = app();
|
||||
transform_and_move(&mut h);
|
||||
let hand = h.query_by_label_contains("Hand Tool").expect("the toolbar's Hand Tool");
|
||||
hand.click();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().ui.transform.is_none(), "the box closed");
|
||||
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200), "the move was applied, not cancelled");
|
||||
}
|
||||
|
||||
/// The app with the red rectangle on a layer the last step made (Layer via Copy, which deselects in
|
||||
/// the same step), as Place Embedded leaves it for the box `begin_placed` opens: cancelling the
|
||||
/// place takes that step back.
|
||||
fn placed_app() -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap();
|
||||
app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
app.run("layer.new.layerViaCopy", json!({})).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(6);
|
||||
assert!(h.state().session.active().unwrap().doc.selection.is_none(), "Layer via Copy deselects in its own step");
|
||||
h
|
||||
}
|
||||
|
||||
fn history(h: &Harness<'static, PhotocraftApp>) -> Vec<String> {
|
||||
h.state().session.active().unwrap().history.entries().to_vec()
|
||||
}
|
||||
|
||||
/// The box on a just-placed layer (a file dropped on the canvas), moved.
|
||||
fn placed_and_moved(h: &mut Harness<'static, PhotocraftApp>) {
|
||||
let ctx = h.ctx.clone();
|
||||
crate::transform_tool::begin_placed(h.state_mut(), &ctx).unwrap();
|
||||
h.run_steps(2);
|
||||
let t = h.state_mut().ui.transform.as_mut().expect("Free Transform is open");
|
||||
for p in &mut t.quad {
|
||||
p[0] += 50.0;
|
||||
p[1] += 40.0;
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_image_is_applied_when_another_tool_is_picked() {
|
||||
let mut h = placed_app();
|
||||
placed_and_moved(&mut h);
|
||||
h.query_by_label_contains("Hand Tool").expect("the toolbar's Hand Tool").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().ui.transform.is_none());
|
||||
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays");
|
||||
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picking_another_layer_in_the_panel_applies_the_transform() {
|
||||
for placed in [false, true] {
|
||||
let mut h = if placed { placed_app() } else { app() };
|
||||
if placed {
|
||||
placed_and_moved(&mut h);
|
||||
} else {
|
||||
transform_and_move(&mut h);
|
||||
}
|
||||
h.get_by_label("Background").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().ui.transform.is_none(), "placed {placed}");
|
||||
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "placed {placed}");
|
||||
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200), "placed {placed}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_layer_applies_the_transform_first() {
|
||||
for placed in [false, true] {
|
||||
let mut h = if placed { placed_app() } else { app() };
|
||||
let before = history(&h);
|
||||
if placed {
|
||||
placed_and_moved(&mut h);
|
||||
} else {
|
||||
transform_and_move(&mut h);
|
||||
}
|
||||
// The Layers panel's New Layer button (the menu item opens the New Layer dialog).
|
||||
h.get_by_label("Create a new layer").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().ui.transform.is_none(), "placed {placed}");
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), 3, "placed {placed}");
|
||||
let red = st.doc.layers[1].surface().unwrap().content_bounds();
|
||||
assert_eq!(red, photocraft_geom::Rect::new(150, 140, 250, 200), "placed {placed}: the moved image, not the original");
|
||||
// History: the transform (folded into Place Embedded for a placed layer), then New Layer.
|
||||
let after = history(&h);
|
||||
let added: Vec<&str> = after.iter().skip(before.len().saturating_sub(usize::from(placed))).map(String::as_str).collect();
|
||||
let first = if placed { photocraft_engine::file_cmds::PLACE_EMBEDDED } else { "Free Transform" };
|
||||
assert_eq!(added, [first, "New Layer"], "placed {placed}: {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untouched_placed_image_stays_when_another_tool_or_layer_is_picked() {
|
||||
for via_layer in [false, true] {
|
||||
let mut h = placed_app();
|
||||
let ctx = h.ctx.clone();
|
||||
crate::transform_tool::begin_placed(h.state_mut(), &ctx).unwrap();
|
||||
h.run_steps(2);
|
||||
if via_layer {
|
||||
let bg = h.state().session.active().unwrap().doc.layers[0].id;
|
||||
h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap();
|
||||
} else {
|
||||
h.query_by_label_contains("Hand Tool").expect("Hand Tool").click();
|
||||
}
|
||||
h.run_steps(4);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), 2, "via layer {via_layer}: the placed image stays");
|
||||
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(100, 100, 200, 160));
|
||||
}
|
||||
}
|
||||
|
||||
/// The placed fixture is a real place: cancelling the box takes the layer back, so the tests
|
||||
/// above would see a switch that cancelled it.
|
||||
#[test]
|
||||
fn cancelling_the_placed_fixture_removes_its_layer() {
|
||||
let mut h = placed_app();
|
||||
placed_and_moved(&mut h);
|
||||
crate::transform_tool::cancel(h.state_mut());
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 1);
|
||||
}
|
||||
|
||||
/// A new-layer command that can't run (Artboard from Group with a pixel layer active) leaves the box and the
|
||||
/// placed layer as they were.
|
||||
#[test]
|
||||
fn a_new_layer_command_that_cant_run_leaves_the_box_open() {
|
||||
let mut h = placed_app();
|
||||
placed_and_moved(&mut h);
|
||||
let before = history(&h);
|
||||
assert!(h.state_mut().run("layer.new.artboardFromGroup", json!({})).is_err());
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.transform.is_some(), "the box stays open");
|
||||
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays");
|
||||
assert_eq!(history(&h), before);
|
||||
}
|
||||
|
||||
/// A transform the engine refuses (a result far over the pixel budget, #3773) keeps the box open
|
||||
/// with the placed layer, and the new layer isn't made: the refusal comes back.
|
||||
#[test]
|
||||
fn a_refused_transform_keeps_the_placed_layer_and_refuses_the_new_layer() {
|
||||
let mut h = placed_app();
|
||||
placed_and_moved(&mut h);
|
||||
let t = h.state_mut().ui.transform.as_mut().unwrap();
|
||||
t.quad = [[0.0, 0.0], [4.0e6, 0.0], [4.0e6, 4.0e6], [0.0, 4.0e6]];
|
||||
h.run_steps(2);
|
||||
let before = history(&h);
|
||||
let r = h.state_mut().run("layer.new.layer", json!({}));
|
||||
h.run_steps(2);
|
||||
assert!(r.is_err(), "{r:?}");
|
||||
assert!(h.state().ui.transform.is_some(), "the box stays open");
|
||||
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays, no new layer");
|
||||
assert_eq!(history(&h), before);
|
||||
}
|
||||
|
||||
/// New Layer with bad params, while a moved box is open, is refused before the transform is
|
||||
/// applied: the box, the layer and History stay as they were.
|
||||
#[test]
|
||||
fn new_layer_with_bad_params_leaves_the_box_open() {
|
||||
for bad in [json!({"mode": "Bogus"}), json!({"fillNeutral": true}), json!({"position": "x"}), json!([1])] {
|
||||
let mut h = app();
|
||||
transform_and_move(&mut h);
|
||||
let before = history(&h);
|
||||
assert!(h.state_mut().run("layer.new.layer", bad.clone()).is_err(), "{bad}");
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.transform.is_some(), "{bad}: the box stays open");
|
||||
assert_eq!(history(&h), before, "{bad}: nothing applied");
|
||||
assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(100, 100, 200, 160), "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -1130,6 +1130,7 @@ fn temporary_type_transform_captures_press_and_rejects_collapsed_geometry() {
|
||||
#[test]
|
||||
#[ignore = "writes visual evidence to target/issue-1091; run explicitly for visual QA"]
|
||||
fn temporary_type_transform_visual_evidence() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
let out = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/issue-1091");
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
for (index, theme) in crate::theme::ThemeKind::ALL.into_iter().enumerate() {
|
||||
|
||||
@@ -1934,6 +1934,7 @@ mod tests {
|
||||
/// `%`'s, so the number lines up with the box and its unit (#5150).
|
||||
#[test]
|
||||
fn value_field_digits_ink_is_centred_and_level_with_the_unit() {
|
||||
let _gpu = photocraft_testkit::gpu_lock();
|
||||
let scale = 4.0;
|
||||
for kind in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Studio] {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(100.0, 40.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
|
||||
|
||||
@@ -259,3 +259,24 @@ fn thirty_two_bit_preview_exposes_values_above_one() {
|
||||
eprintln!("shown {got}, want {want:.1} (clipped would be {clipped:.1})");
|
||||
assert!((got - want).abs() <= 3.0, "shown {got}, want {want:.1} (clipped {clipped:.1})");
|
||||
}
|
||||
|
||||
/// A 16-bit canvas whose float textures don't fit the canvas budget (a small graphics card,
|
||||
/// #4536) stays on the GPU as `Rgba8Unorm` instead of falling back to the CPU canvas.
|
||||
#[test]
|
||||
fn sixteen_bit_canvas_too_big_for_floats_stays_on_the_gpu_in_8_bit() {
|
||||
let _gpu = gpu_lock();
|
||||
let Some((g, _rs)) = canvas() else { return };
|
||||
let doc = gradient(SampleType::U16, 512, 256, 0.0, 1.0);
|
||||
let size = [512, 256];
|
||||
let floats = gpu_canvas::canvas_texture_bytes(size, gpu_canvas::DEFAULT_TILE, 8);
|
||||
assert_eq!(g.format_for(SampleType::U16, size), TextureFormat::Rgba16Float, "fits by default");
|
||||
// Room for the 8-bit set, not the float one.
|
||||
g.set_canvas_budget(floats - 1);
|
||||
assert_eq!(g.format_for(SampleType::U16, size), TextureFormat::Rgba8Unorm);
|
||||
assert!(g.fits_canvas(&doc));
|
||||
assert_ne!(g.refresh(doc.id.0, &doc, None, None).kind, gpu_canvas::REFRESH_OVER_BUDGET);
|
||||
assert_eq!(g.texture_info(doc.id.0).map(|(f, _)| f), Some(TextureFormat::Rgba8Unorm));
|
||||
g.set_canvas_budget(floats);
|
||||
assert_eq!(g.format_for(SampleType::U16, size), TextureFormat::Rgba16Float);
|
||||
g.release();
|
||||
}
|
||||
@@ -53,6 +53,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar.
|
||||
- `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`.
|
||||
- `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`.
|
||||
- `ui.set` also accepts `strokeTrace` (`true`, `false`, `"stderr"` or `"off"`): turn the stroke latency trace on or off at runtime; each stroke's summary is in `ui.inspect` `perf.strokeTrace`. A CSV file of every frame is set only at launch (`PHOTOCRAFT_STROKE_TRACE=<file.csv>` / `--stroke-trace <file.csv>`): the control channel doesn't write files outside the automation write root
|
||||
- `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, mode?}`, any subset. `by` is `kind`, `name` or `mode` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `mode` is a blend mode name (`Multiply`, `Pass Through`). Kind and Name filter only once a kind is on or text is typed; Mode always filters, by Normal at first, as in Photoshop. Fields left out keep their value; a bad one is an error and changes nothing.
|
||||
- Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back.
|
||||
- `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks.
|
||||
@@ -227,7 +228,7 @@ the same commands work in the app, the CLI and headless MCP. Paths are dotted ca
|
||||
| `prefs.set` | `{"path":"cursors.painting","value":"precise"}` or `{"values":{"unitsAndRulers.rulers":"cm","guidesGridAndSlices.gridColor":"#ff8800"}}`. Values are validated (choices, ranges, `#rrggbb` colours, shortcut syntax); a batch applies all or nothing. A section path takes an object and merges it key by key |
|
||||
| `prefs.reset` | `{"path":"performance"}` (a section or key); no path resets everything. An individual `shortcuts.<id>` or `menus.colors.<id>` reset removes the override and returns `null`, including when it is already absent |
|
||||
| `edit.preferences.<section>` | the section's values; in the app (no params) it opens the Preferences dialog on that section |
|
||||
| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Alt+Space; Alt added to a held Zoom In, Cmd+Alt+Space, zooms out too), `tools.temporary.erase` (`` ` ``; with the Brush or Pencil, a stroke started while it is held is in Clear mode, `paint.stroke`/`paint.pencil` `"mode": "clear"`, erasing with that tool's brush); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by our id (`pc-id`, in sets PhotoCraft exported) or else by menu label, a key equal to the default restores it, keys also given in `set` win, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`, `windows`). A set saved by Photoshop on Windows (no ⌘ or Opt anywhere, Ctrl used) reads Ctrl as the primary modifier, so `Ctrl+L` is the `Cmd+L` default (`windows: true`). Tool keys are matched to tools by name in the app's dialog import (a tool's own letter keeps its Shift cycling); this command doesn't know the tools and leaves them. In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once. **Sets** (Edit › Keyboard Shortcuts › Set): `useSet` loads a saved set's bindings (`""` or `"PhotoCraft Defaults"`: the defaults) before the call's other changes; then `saveSet` saves the bindings as they end up under a name and uses it, `deleteSet` removes one (the bindings stay), `sets` replaces every saved set (`{"<name>": {"shortcuts": {…}, "alternateShortcuts": {…}}}`, the dialog's OK) and `activeSet` names the set in use without loading it. Names are trimmed, 1–64 characters, at most 64 sets. Every reply has `sets`, `activeSet` (`""` for the defaults), `defaultsName` and `modified` (the bindings differ from the set in use). `exportKys: true` adds `kys`, the bindings of the engine's commands as a `.kys` file (Photoshop's macOS key spelling; each row carries our id as `pc-id`, so the file imports back exactly); `summarize: true` (or `{"mac": false}`) adds `html`, Photoshop's Summarize page. The app's dialog exports and summarizes the shell's commands and tools as well. A bad parameter anywhere in the call changes nothing |
|
||||
| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Alt+Space; Alt added to a held Zoom In, Cmd+Alt+Space, zooms out too), `tools.temporary.erase` (`` ` ``; with the Brush or Pencil, a stroke started while it is held is in Clear mode, `paint.stroke`/`paint.pencil` `"mode": "clear"`, erasing with that tool's brush); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by our id (`pc-id`, in sets PhotoCraft exported) or else by menu label, a key equal to the default restores it, keys also given in `set` win, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`, `windows`). A set saved by Photoshop on Windows (no ⌘ or Opt anywhere, Ctrl used) reads Ctrl as the primary modifier, so `Ctrl+L` is the `Cmd+L` default (`windows: true`). Tool keys are matched to tools by `pc-id` or name (a tool's own letter keeps its Shift cycling); `import.tools` lists the keys given. In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once. **Sets** (Edit › Keyboard Shortcuts › Set): `useSet` loads a saved set's bindings (`""` or `"PhotoCraft Defaults"`: the defaults) before the call's other changes; then `saveSet` saves the bindings as they end up under a name and uses it, `deleteSet` removes one (the bindings stay), `sets` replaces every saved set (`{"<name>": {"shortcuts": {…}, "alternateShortcuts": {…}}}`, the dialog's OK) and `activeSet` names the set in use without loading it. Names are trimmed, 1–64 characters, at most 64 sets. Every reply has `sets`, `activeSet` (`""` for the defaults), `defaultsName` and `modified` (the bindings differ from the set in use). `exportKys: true` adds `kys`, the bindings of every bindable command and tool as a `.kys` file (Photoshop's macOS key spelling; each row carries our id as `pc-id`, so the file imports back exactly); `summarize: true` (or `{"mac": false}`) adds `html`, Photoshop's Summarize page. A bad parameter anywhere in the call, an export path that can't be written included, changes nothing. **Headless parity:** the shell's commands (View › Zoom In, the panels; `photocraft_engine::shell_commands::SHELL_COMMANDS`) and the tools (`tools.select.<tool>`, listed with `"tool": true`) are bindable, listed, imported, exported and checked for conflicts without the UI; tools may share a key (they cycle with Shift, so a shared letter is no conflict), while a command given a tool's key takes it from the tools. Keys the system takes first (Alt+Tab, Alt+F4, Ctrl+Alt+Delete everywhere but the Mac, plus Ctrl+Esc and Ctrl+Shift+Esc on Windows and Ctrl+Alt+F1–F12 on Linux; ⌘Tab, ⌘`, ⌘⌥Esc on the Mac) are refused, and left out of an imported set (`import.reserved`). Files: `importKys: {"path": …}` reads a `.kys`, `exportKys: {"path": …}` and `summarize: {"path": …}` write the file (the reply has `kysPath` / `htmlPath`); automation (MCP) refuses these paths, as it does `actions.save`'s |
|
||||
| `edit.menus` / `edit.toolbar` | `{"hide":["edit.fade"],"show":[…],"color":{"edit.fill":"red"},"reset":false}` / `{"hidden":["Sponge"],"order":[…]}` |
|
||||
| `edit.colorSettings` | `{"workingRgb":"display-p3","workingCmyk":"coated-cmyk","workingGray":"sgray","policyRgb":"preserve\|convert\|off",…,"askOnMismatch":true,"intent":"perceptual","bpc":true}`; honoured when opening files (`file.openAs`, the app's File › Open) and by Image › Mode and "working" profile specs. `color.profileMismatch {"action":"preserve\|convert\|discard\|assignWorking"}` answers the mismatch prompt |
|
||||
|
||||
|
||||
+6
-4
@@ -44,7 +44,7 @@ cargo xtask stats # tests and lines per
|
||||
cargo xtask parity # Photoshop menu coverage -> docs/parity-checklist.md
|
||||
```
|
||||
|
||||
Image code is slow at `opt-level 0`, so the workspace profile builds dependencies at `opt-level 2`. Use `--release` for anything interactive.
|
||||
Image code is slow at `opt-level 0`, so the workspace profile builds dependencies at `opt-level 2`. Use `--release` for anything interactive. Test builds compile ui-egui itself at `opt-level 0` (`[profile.test.package.photocraft-ui-egui]` in `Cargo.toml`): it was one of the biggest test-build units, and its tests run no heavy pixel code of their own.
|
||||
|
||||
The dev profile builds with `debug = "line-tables-only"`: panic backtraces keep file and line, and test binaries link about twice as fast. To inspect local variables in a debugger, build with `CARGO_PROFILE_DEV_DEBUG=true`.
|
||||
|
||||
@@ -78,7 +78,7 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo test --workspace # runs the Japa
|
||||
- **UI:** the Japanese fonts (BIZ UDPGothic Regular first) are the first Japanese fallback in the lazy CJK loader (`crates/ui-egui/src/cjk_fonts.rs`), ahead of the system Japanese fonts and in the same locale script order, appended last to every egui family with the usual baseline alignment.
|
||||
- **Type tool:** the text engine registers them in `FontDb::new` (so also with no system fonts) and puts them first in the Japanese slot of the locale-ordered fallback list: BIZ UDPGothic for sans runs, Shippori Mincho / BIZ UDMincho for serif runs.
|
||||
- **Web:** the wasm32 build never embeds craft-fonts, even with `CRAFT_FONTS_DIR` set: measured on 2026-10-06, the UI face alone took the release wasm from 24.19 MB to 28.87 MB, over the 24 MiB gate in `packaging/web/package.sh`. Instead it loads fonts served next to it, on demand: `fonts/manifest.txt` beside `index.html`, in craft-fonts' manifest format (`crates/text/src/served.rs`; hosting: `packaging/web/README.md` › Fonts). `PHOTOCRAFT_WEB_FONTS_DIR="$PWD/../craft-fonts" packaging/web/package.sh` puts craft-fonts there. Served families are in the font menus and are fetched when picked or when a document's text needs them; they aren't in the script fallback list yet, so Japanese text set in a Latin font still needs a Japanese font picked (#1615).
|
||||
- Tests that need the fonts skip with a message when `CRAFT_FONTS` is empty; CI's Linux job runs the tests a second time with `CRAFT_FONTS_DIR` set. Desktop releases check out craft-fonts at the commit pinned in `.github/workflows/release.yml` (`CRAFT_FONTS_REF`; ci.yml pins the same commit) and ship each font's `OFL.txt` as `OFL-<family>.txt`.
|
||||
- Tests that need the fonts skip with a message when `CRAFT_FONTS` is empty; CI runs them again with `CRAFT_FONTS_DIR` set, in its own Linux job (`test (with craft-fonts)`). Desktop releases check out craft-fonts at the commit pinned in `.github/workflows/release.yml` (`CRAFT_FONTS_REF`; ci.yml pins the same commit) and ship each font's `OFL.txt` as `OFL-<family>.txt`.
|
||||
|
||||
## Graphics startup and device loss
|
||||
|
||||
@@ -119,7 +119,9 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`.
|
||||
| `PHOTOCRAFT_CPU_COMPOSE=1` | Keep the wgpu canvas but composite on the CPU (compare GPU vs CPU renders, e.g. with the snapshot example) |
|
||||
| `PHOTOCRAFT_LOCALE` | Override Auto's native UI language for this launch; unsupported tags use English. See [system language detection](localization.md#first-launch-and-system-language). |
|
||||
| `PHOTOCRAFT_FX_TRACE=1` | Print the CPU time spent on GPU effect shapes and distance fields per rebuild |
|
||||
| `PHOTOCRAFT_STROKE_TRACE=1` (or `=<file.csv>`) | Time every frame of each brush stroke; print a summary per stroke, and with a path also append each frame to that CSV (see [Stroke latency trace](#stroke-latency-trace)) |
|
||||
| `PHOTOCRAFT_STROKE_TRACE=1` (or `=<file.csv>`; also `--stroke-trace <stderr|file.csv>`, or `ui.set {"strokeTrace": true|false}` at runtime, without a file) | Time every frame of each brush stroke; print a summary per stroke, and with a path also append each frame to that CSV (see [Stroke latency trace](#stroke-latency-trace)) |
|
||||
| `PHOTOCRAFT_PRESENT_MODE=novsync` (or `--present-mode`) | Present mode for this launch: `fifo`, `autovsync`, or `novsync` (also `immediate`, `mailbox`: no vsync, the first of Immediate, Mailbox, Fifo the driver offers). Default: vsync, or no vsync when Preferences › Performance › Low-Latency Drawing is on (it ships off). For latency comparisons; shown in the stroke trace |
|
||||
| `PHOTOCRAFT_FRAME_LATENCY=1` (or `--frame-latency`) | Frames the swap chain may queue, 1 to 3 (wgpu's GL backend ignores it). For latency comparisons; shown in the stroke trace |
|
||||
| `PHOTOCRAFT_THEME_FILE=tokens.json` | **Debug builds only:** live design-token overrides, re-read on change |
|
||||
|
||||
### Live design tokens
|
||||
@@ -148,7 +150,7 @@ stroke trace: 62 frames on vulkan NVIDIA GeForce RTX 5090 (p50/p95 ms): interval
|
||||
| `cpu` | eframe's CPU time per frame: UI, tessellation and GPU submission, without the vsync wait |
|
||||
| `wait` | `interval − cpu`: waiting on vsync or the swap chain, or idle |
|
||||
|
||||
What the app can't time is the rest of input-to-photon: the OS input queue before a frame starts (up to one `interval`), frames queued for the display (the surface keeps up to two; the GL backend ignores that setting), and the compositor. So the first line names the backend. The last summary is also in the control channel's `perf.strokeTrace` (`ui.inspect`). The code is `crates/ui-egui/src/stroke_trace.rs`.
|
||||
What the app can't time is the rest of input-to-photon: the OS input queue before a frame starts (up to one `interval`), frames queued for the display (the surface keeps up to two; the GL backend ignores that setting), and the compositor. So the first line names the backend and how frames are presented. To compare presentation settings without rebuilding, launch with `PHOTOCRAFT_PRESENT_MODE` and `PHOTOCRAFT_FRAME_LATENCY` (see [Environment variables](#environment-variables)): with `novsync` the app no longer waits on vsync (`wait` near 0): each refresh shows the newest frame, and where the driver only offers Immediate, the screen may tear. The last summary is also in the control channel's `perf.strokeTrace` (`ui.inspect`). The code is `crates/ui-egui/src/stroke_trace.rs`.
|
||||
|
||||
## Driving the app programmatically
|
||||
|
||||
|
||||
+5
-3
@@ -6,10 +6,10 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
||||
|
||||
| Area | Done | Partial | Missing | Key numbers |
|
||||
|---|---:|---:|---:|---|
|
||||
| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) |
|
||||
| [Performance](#performance) | 3 | 22 | 24 | 58 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 19 not run, 5 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 |
|
||||
| [Tools](#tools) | 33 | 11 | 0 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 166 (target 0) |
|
||||
| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 168 (target 0) |
|
||||
| [Type](#type) | 4 | 3 | 2 | |
|
||||
| [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) |
|
||||
| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 29 of 29 crates; panic_hunt present |
|
||||
@@ -75,6 +75,8 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr
|
||||
| P57 | Chisel bevel size change, 12 MP text paragraph, size 250 (GPU) | track only | – | – | – | – | – | not run | |
|
||||
| P58 | Chisel bevel size change, 12 MP ellipse, size 8 (CPU) | track only | – | – | – | – | – | not run | |
|
||||
| P59 | Chisel bevel size change, 12 MP text paragraph, size 8 (CPU) | track only | – | – | – | – | – | not run | |
|
||||
| P60 | Layer opacity among 150 full-canvas layers, 6 MP (app GPU budget) | ≤ 30 ms p95 | – | – | – | – | – | not run | [#5973](https://github.com/storytold/photocraft/issues/5973) |
|
||||
| P61 | Hide a layer among 150 full-canvas layers, 6 MP (app GPU budget) | ≤ 30 ms p95 | – | – | – | – | – | not run | [#5973](https://github.com/storytold/photocraft/issues/5973) |
|
||||
|
||||
**Not measurable yet** (the code can't do it yet; no number is recorded):
|
||||
|
||||
@@ -177,7 +179,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
|
||||
## Workspace and UI
|
||||
|
||||
**Settings that do nothing: 26 of 166** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
|
||||
**Settings that do nothing: 26 of 168** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
|
||||
|
||||
Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
|
||||
|
||||
|
||||
@@ -518,3 +518,21 @@ id = "P59"
|
||||
title = "Chisel bevel size change, 12 MP text paragraph, size 8 (CPU)"
|
||||
bench = "bevel_bench"
|
||||
row = "bevel size change to 8, text (CPU)"
|
||||
|
||||
[[scenario]]
|
||||
id = "P60"
|
||||
title = "Layer opacity among 150 full-canvas layers, 6 MP (app GPU budget)"
|
||||
issue = 5973
|
||||
bench = "perf_scenarios"
|
||||
row = "opacity of a layer among many full-canvas layers + refresh"
|
||||
budget_ms = 30
|
||||
enforce = false # new; no baseline yet
|
||||
|
||||
[[scenario]]
|
||||
id = "P61"
|
||||
title = "Hide a layer among 150 full-canvas layers, 6 MP (app GPU budget)"
|
||||
issue = 5973
|
||||
bench = "perf_scenarios"
|
||||
row = "hide a layer among many full-canvas layers + refresh"
|
||||
budget_ms = 30
|
||||
enforce = false # new; no baseline yet
|
||||
Reference in new issue
Block a user