Compare commits

..
Author SHA1 Message Date
Tanish Manku e7b5b14898 GPU memory on top of #5234: memory_budget takes the card's memory (vram) and the GPU Memory value; the document's pixels are charged unless the card's own memory is known; a set GPU Memory is capped at the card's share; benches use video_memory 2026-10-15 21:18:28 -07:00
Tanish Manku ea8f55f8f9 GPU memory: typed discrete check; the status and budget follow a GPU Memory change at once (measure_fit); perf rows P60/P61 for whole-document edits among 150 full-canvas layers under the app's budget 2026-10-15 21:06:55 -07:00
Tanish Manku 93c8e49954 GPU memory: a graphics card's own memory isn't charged for the document; GPU Memory preference; GPU Memory Full in the status bar
Many layers hit a cliff: past what the GPU memory budget leaves for layer pages, every edit
that redraws the whole document (opacity, visibility, a nudge) goes to the CPU compositor.
Measured on 3000x2000 full-canvas layers (RTX 5090, 31 GB RAM, Memory Usage 8 GB): 150
layers took 2.6 s per such edit, 300 layers 6 s. The budget was Memory Usage minus the
document's pixels and History, which is right where the GPU shares system memory, but a
discrete card's memory doesn't hold the document, so 150 layers of 6 MP (3.6 GB) squeezed
the budget below what they needed.

- Automatic budget: discrete adapters are no longer charged for the document's pixels
  (integrated and software adapters still are). 150 layers: 2.6 s -> 25 ms.
- Preferences › Performance › GPU Memory (MB, 0 = Automatic; performance.gpuMemoryMb) sets
  the budget outright. 300 layers with 12 GB: 6 s -> 45-72 ms.
- The status bar shows "GPU Memory Full" while the active document's layers don't fit
  (both status bar styles); hovering gives the numbers, clicking opens Preferences ›
  Performance. Its Graphics rows show the budget and what the layers need. The slow CPU
  redraw notice gives the numbers and points at GPU Memory.
- Headless: the preference is a normal pref (prefs.set), and ui.state's perf.timings
  reports gpuLayerFit {doc, needed, budget} next to gpuBudgetMb.

Next (owner-approved plan): after #5370, read the card's real limit from the driver
(wgpu memory budget thresholds) and shrink on out-of-memory; then redraw the visible
area on the GPU first when layers still don't fit.
2026-10-15 21:06:49 -07:00
FinnNkandClaude Opus 5.5 297520b9c8 perf(gpu): cap the GPU memory budgets by a discrete GPU's own memory (#5234)
* perf(gpu): cap the GPU memory budgets by a discrete GPU's own memory

The compositor and canvas budgets came from system RAM only, so a 16 GB card in a 96 GB machine planned for up to 8 GB of compositor pages plus 24 GB of display textures. Detect a discrete adapter's dedicated memory (DXGI on Windows through winsafe, amdgpu sysfs on Linux) and cap each budget at three eighths of it. PHOTOCRAFT_VRAM_MB simulates a smaller card. Fixes #4536.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* perf(gpu): keep a 16-bit canvas on the GPU in 8-bit when floats don't fit

With the canvas budget capped by a small card's memory, a 16-bit document under the float-canvas pixel limit could need more than the budget as Rgba16Float and fall back to the CPU canvas although its Rgba8Unorm set fits. Choose the float canvas only when it fits the budget, which is now shared atomically so format_for can read it while the renderer is locked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* perf(gpu): detect a card's memory once per adapter, and test the Linux reader

video_memory built a DXGI factory each time it was asked, three times at
startup; it now remembers the answer per (vendor, device), so a GPU retry
that picks another adapter still gets its own. The amdgpu sysfs reader
takes the drm folder as a parameter and is tested against a temporary
folder: the matching card's VRAM, a card without the file, garbage ids, a
malformed size, no match and no folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 21:05:51 -07:00
Tanish Manku 57437ea1a9 fix(ui-egui): canvas textures fit the device's texture limit; GPU tests hold the GPU lock on the app's device (#5954)
- The CPU canvas texture and the crop's beyond-canvas bands were reduced to fit MAX_TEXTURE (4096)
  whatever the device allows. On a device with a smaller limit (2048 on some older GPUs and WebGL
  contexts) a 2600 px document was uploaded at full size: egui asserts and the backend can't
  create it. They now fit the smaller of the two. Test: cpu_canvas_texture_fits_the_device_limit.
- background_redraw and navigator GPU tests built their harness with egui's default wgpu limits
  instead of the app's wgpu_setup (the adapter's own limits), and the GPU canvas lost the device
  on it ("Device is lost", every run on an RTX 5090). They now use wgpu_setup.
- Eleven tests built a wgpu device without photocraft_testkit::gpu_lock, which testkit requires so
  devices aren't created concurrently; they now hold it.
2026-10-15 21:05:39 -07:00
skylar taylor-barrick 7053f703a5 Merge pull request #2933 from 1Git2Clone/ci-wallclock
CI: cut wall time, starting with the Windows test job
2026-10-15 21:38:18 -06:00
skylar taylor-barrick fd1faf4e13 Merge pull request #6381 from dylanmtaylor/ci/shared-freebsd-workflow
ci: delegate FreeBSD workflow to craft-common-actions and add nix check
2026-10-15 21:34:22 -06:00
skylartaylor 8f58ea4629 ci: integrate current main while retaining strict test gates 2026-10-16 05:25:24 +02:00
Dylan M. Taylor 0cc161e2a8 ci: delegate FreeBSD workflow to craft-common-actions and add nix check 2026-10-15 22:04:09 -04:00
PerchenkoAnton 3cb3580a46 fix(psd): recover layered imports with damaged merged data (#6170) 2026-10-15 19:14:05 -06:00
skylar taylor-barrick b630d7cb9a Security: enforce the automation read boundary on stored smart-filter params (#5684)
* Security: enforce the automation read boundary on stored smart-filter params

Smart filters store a command id and params inside the document, so a
crafted .pcraft could resolve stored ambient paths (displace mapPath,
color-lookup LUT files) at render time without the authorize gate:
synchronous smart-object rendering has no Session argument, spawned job
threads drop the requesting session's authority, and a trusted UI
render could warm the process-wide stack cache with a stage the gate
never saw.

Thread the requesting session's gate through a thread-local in jobs.rs
(entered around command execution, reinstalled on spawned workers and on
job apply, restored on drop), re-check stored filter commands in
render_in before source resolution and any cache lookup and in
apply_filter_to_surface, and re-authorize layer.smartFilter.setParam
with the complete merged params after merging stored document params —
including edits whose request contains no path and actions.play steps.
Denial is conservative: the document is unchanged and no worker starts.

Regression tests in the automation crate cover rerender, cached-stage
reuse, merged-param edits, action playback, background jobs, and filter
transfer, for both the displace map and color-lookup LUT vectors.

* Review: carry render authority through Session::edit and surface denials

Session::edit is the engine's universal mutation entry (linked-file
refresh, transforms, place); renders reached through it ran with no
authorization scope installed, so stored smart-filter params read
ambiently there even after the execute-path fix. Install the scope in
Session::edit as well, keyed on the session's authorize gate.

apply_filter_to_surface also conflated a denied filter with an unknown
one: authorize_render_command's Result was discarded with .ok()?, so a
denial silently rendered unfiltered pixels and the stage entered the
process-wide stack cache. The signature becomes
Result<Option<Surface>>: Err for denials (callers propagate), None for
ids that aren't pixel filters (pass-through semantics unchanged), and an
unreadable map keeps its deliberate pass-through. apply_smart_filter and
apply_smart_filters propagate instead of cur.clone().

Two tests: a refresh driven outside command execution (Session::edit)
is denied and leaves the document unchanged; a denied render caches no
stage. Nine authorization tests pass, engine suite and strict Clippy
clean, panic hunt green.

* ui_set description: name the layerFilter field main added

The Layers-panel filter dropdown (#5048) added layerFilter to the
control method's UI_SET_FIELDS without extending the ui_set tool's
description, so the drift guard (mcp.rs, ui_set_description_names_every_
accepted_field) fails on Windows CI for any branch built on main. Name
the field with its patch shape: by (kind, name or mode), kinds (a list
of layer kinds), name (matches part of the layer name), mode (a blend
mode name). The 40 MCP tests pass; strict Clippy clean.

* wasm size: compose and algo join cms at opt-level "z" to clear the 24 MiB gate

Main's browser build is 171 KB over the 24 MiB wasm gate in CI
(25,337,343 bytes), so every branch built on current main fails the
web size check. Demote three pixel crates in the wasm-release profile:

- cms: transforms are built once and cached (transform::cached),
  so "z" costs little runtime (measured -42 KB).
- compose: 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 (measured -122 KB).
- algo: filters run per pixel on every interactive filter application.
  "z" there is the lever of last resort (measured -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.

Final size 24,811,949 bytes locally, 354 KB under the gate; the native
profiles are untouched. If a maintainer prefers keeping algo at 3, the
alternative is raising the self-imposed 24 MiB early-warning limit
(Cloudflare's hard cap is 25 MiB) - a policy call in packaging/web/
package.sh, not a code fix.
2026-10-15 18:19:26 -06:00
Tanish Manku 2b3d05636f Follow-ups to #5801/#5859: precheck docs name the group nesting limit; precheck test covers valid ids and null; Free Transform refusals translated; stranded-backup error tested (#6365) 2026-10-15 15:32:08 -07:00
Tanish Manku fdaa9b8316 Free Transform: New Layer applies the transform first; tests for switching tool, layer or New Layer (#5665, #5667) (#5859)
* Tests: Free Transform applies when another tool, layer or New Layer is picked (#5665, #5667)

Whole-app regression tests for the paths #5665 and #5667 report (a moved box, and a placed image moved or untouched; toolbar tool click, Layers panel row click, New Layer). They pass on main, so these issues don't reproduce here; the tests pin Photoshop's behaviour (apply, never silently cancel) while the reporters narrow down their environment.

* New Layer while Free Transform is open applies the transform first (History: the transform, then New Layer; a placed layer's step no longer swallows New Layer); tests: a real placed fixture, the New Layer button, History order

* Free Transform + New Layer: check the command can run before applying the transform; a refused transform keeps the box and the placed layer and returns the refusal

* Free Transform + New Layer: Session::precheck validates the call (params shape, New Layer's values, precondition) before the box is applied

* Free Transform + New Layer: commit-first only for PRECHECKED commands; precheck shares dispatch's admission checks (named-layer precondition, jobs, hidden target) and checks a named layer exists; Group from Layers documents its layer param; test: precheck agrees with execute

* Free Transform + New Layer: precheck resolves a layer given by name as the command does; Layer via Copy/Cut leave PRECHECKED (their failures show only when they run); rebased (lib.rs mod lines)
2026-10-15 15:26:13 -07:00
Tanish Manku 8376363d8c Shortcuts without the UI: shell commands and tools in the engine, reserved keys, set files (#5801)
* Shortcuts without the UI: shell commands and tools in the engine, reserved keys, set files

A retro against "everything is drivable headless; the UI is a layer" found the shortcut
work leaning on the UI. Fixed:

- The shell's command table (UI_COMMANDS: View › Zoom In, the panels…) moves into the
  engine as data (shell_commands::SHELL_COMMANDS; menus::UI_COMMANDS is now an alias), with
  the tools (shell_commands::TOOLS: id, Photoshop name, default key; a test keeps it equal
  to the toolbar). edit.keyboardShortcuts binds, lists, imports, exports and checks them for
  conflicts with no UI: a headless .kys import takes tool keys, and an export is the whole set.
  Tools may share a key (Shift cycling); a command given a tool's key takes it from them.
- Reserved system keys are refused by the engine (the dialog uses the same check) and left
  out of imported sets.
- importKys / exportKys / summarize take {"path": …} to read or write files, like
  actions.save; the automation gate refuses those paths for MCP.

* Keyboard shortcuts: bindings commit only after their exports are written (a bad target changes nothing); docs: the command knows the tools

* Keyboard shortcuts: both export targets are resolved and checked before either file is written; a failed later write removes the earlier one

* docs: reserved keys per platform

* Keyboard shortcuts export: files are staged next to their targets and moved into place only when all are written; a failure leaves the user's files untouched

* Keyboard shortcut exports all or nothing: unique create_new staging and backups beside each target, restored on any failure; the same target twice refused; docs: Ctrl+Shift+Esc

* Shortcut exports: a failed backup move removes its placeholder; undo restores backups without deleting first and names any it couldn't; read-only targets refused up front
2026-10-15 15:26:09 -07:00
Tanish Manku b09aeae2e2 Low-Latency Drawing preference (vsync off, opt-in), plus present-mode overrides for testers (#1343) (#5274)
* PHOTOCRAFT_PRESENT_MODE / PHOTOCRAFT_FRAME_LATENCY launch overrides for latency A/B tests (#1343)

Linux traces put the remaining stroke latency in presentation queueing (the app's frame fits
in one refresh; the rest waits on the swap chain), and an NVIDIA user cured it by forcing
vsync off. Testers can now compare without rebuilding: PHOTOCRAFT_PRESENT_MODE (fifo,
mailbox, immediate, autovsync, autonovsync) and PHOTOCRAFT_FRAME_LATENCY (1-3), applied over
the platform default. Bad values are logged and ignored. The active mode is logged at start,
kept in GpuInfo.present and shown in every stroke trace line.

RTX 5090, Vulkan, 120 Hz: default autovsync/latency 1 waits 6 ms per 8.3 ms frame; mailbox
renders every 2.3 ms with 0.2 ms wait, so each vsync shows the newest frame.

* docs: present mode and frame latency overrides

* Present mode overrides never request a mode the driver may lack (Mailbox crashed on X11/NVIDIA)

* Preferences › Performance › Low-Latency Drawing, on by default: no vsync wait while drawing (#1343)

Testers' traces put the ~50 ms stroke lag in the swap chain's vsync wait. With vsync off it
disappears (X11/NVIDIA; Windows/NVIDIA via the driver), and a shorter queue doesn't help. The
surface now presents with AutoNoVsync by default: wgpu picks Immediate, else Mailbox, else
Fifo by what the driver offers, so no unsupported mode is ever requested. Turning the
preference off brings vsync back (smooth, but late). It applies at the next launch, like the
other graphics settings. Where only Immediate exists the canvas may tear while it changes;
frames render as fast as input arrives during a stroke and stop when idle.

* Runtime and launch controls: ui.set strokeTrace, --stroke-trace, --present-mode, --frame-latency

The retro's remaining gap: the stroke trace and the presentation overrides were env-only. The trace now switches at runtime over the control channel (ui.set {"strokeTrace": true|false|"stderr"|path}) and with --stroke-trace; --present-mode / --frame-latency sit beside their env vars and win over them. Bad values are reported and ignored; none fails a launch.

* docs: strokeTrace beside the other ui.set fields

* docs: regenerate the scorecard after rebasing onto main

* Low-Latency Drawing: frames capped at 240 fps without vsync (after #4472); ui.set strokeTrace takes on/off only (a CSV path is launch-only); tearing documented as usual (Immediate); pref hidden on web; doc comment order

* stroke_trace: from_value doc says ui.set passes only on or off

* Low-Latency Drawing ships off (opt-in), per the owner's decision

* docs: present mode default is vsync (Low-Latency Drawing ships off)
2026-10-15 15:23:47 -07:00
IvanandClaude Opus 5.5 691a022b12 CI: skip the test (windows-latest) gate in cancelled runs
With always() the gate also ran when a newer push cancelled the run,
taking a runner slot only to report a red check on the superseded
commit. !cancelled() still runs it when a test leg fails (a skipped
required check would count as passing), but not in a cancelled run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:29:16 +03:00
IvanandClaude Opus 5.5 f0f0d6f58a CI: the gpu test group covers the other ui-egui wgpu tests
The canvas|gpu|mip|type_tool name filter missed ui-egui unit tests that
build a Harness with a real wgpu device: background_redraw's GPU tests,
panel_docking's floating_group_titles_are_opaque_in_studio_themes and
swap_arrow's rendered_glyph_has_a_left_head_and_a_lower_right_head. Name
them in both overrides, so they share the 4-thread cap and get the CI
retries. The other .wgpu() harnesses only render when a fixture env var
is set, or are #[ignore]d.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:22:15 +03:00
IvanandClaude Opus 5.5 3bd9bc72a7 Tests: monitor_macos answers nextest's --list
monitor_macos is harness = false, so nothing answered cargo-nextest's
discovery: `--list --format terse` ran the whole test and nextest failed
to parse its output on macOS. List the one test, and nothing for
`--ignored`, as mac_menu_appkit already does; `monitor_macos --exact`
still runs the body.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:22:14 +03:00
IvanandClaude Opus 5.5 b23a01aa8f Tests: postscript_only_style waits for the font scan
selected_style reads the font engine through try_shared, which returns None while the system
font scan is still running. Under cargo test the test passed only because an earlier test in the
same process had already finished the scan; run on its own (nextest runs every test that way, and
`cargo test -- --exact` does too) it saw no engine and got "Regular" instead of "SemiBold". It now
waits for the scan with photocraft_text::shared(), as every_theme_shows_the_type_options does.
On main, alone, it failed before this and passes after, under both cargo test and nextest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:03:09 +03:00
IvanandClaude Opus 5.5 4a93095939 CI: keep a test (windows-latest) check that reports the test result
Splitting the Windows tests renamed their check from `test (windows-latest)` to one per leg. No
status check is required on main today, but if one is added under the old name it would wait
forever. A small job under that name now fails unless the platform pick and the whole test matrix
passed (`needs` gives one result for all legs, so a failing Linux leg fails it too). It checks the
result instead of exiting 0 under `if: always()`, which would pass with every leg red. Suggested
by Zach in review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 14:29:13 +03:00
IvanandClaude Opus 5.5 e99f09b306 Tests: mac_menu_appkit answers nextest's --list
The test runs without a harness (AppKit needs the main thread), so nothing answered the `--list`
that cargo-nextest sends each test binary: on macOS the binary ran the whole test as its listing
and nextest failed on the output. It now lists itself in libtest's terse format, and nothing for
`--ignored`; a run passes `--exact <name>`, which falls through to the test as before. Found by
Zach running `cargo nextest run --workspace` on a Mac.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 14:28:41 +03:00
IvanandClaude Opus 5.5 e5bf9c22c9 CI: run the Windows tests on two runners instead of three
The integration tests run as one leg again. On the Windows runner (EPYC 7763, 3 runs) that leg
took 14.3-15.3 min, against 10.5-10.8 for each half, so the slowest leg is now the integration
one at ~15 min instead of the unit one at ~12.8. In exchange each PR that runs Windows takes two
Windows runners instead of three and ~7 fewer runner-minutes: every leg pays its own setup
(~1 min) and builds the workspace libraries its tests link against (~4 min even from sccache).
That matters more while PRs queue for the account's concurrent-job limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 01:48:31 +03:00
IvanandClaude Opus 5.5 39904a27ce Docs: CI runs nextest, the craft-fonts tests have their own job
Comments and docs still said CI runs cargo test, that the craft-fonts tests run a second time in
the Linux job, and that CI's debuginfo matches the dev profile everywhere. development.md also
notes that test builds compile ui-egui unoptimized.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 11:32:35 +03:00
IvanandClaude Opus 5.5 110d58bea9 CI: split the Windows integration tests over two runners; retry flaky GPU tests
nextest --partition splits the integration tests in two, so Windows runs three legs: on the
Windows runner (EPYC 7763, 2 runs) the integration halves took 9.8 and 10.8 min against 13.8 for
one, leaving the unit leg (~12.8) as the longest.

A ci nextest profile retries the GPU tests: on WARP, under load, one hit wgpu's poll timeout once
in ~40 runs while measuring. A test that passes on retry is reported as FLAKY.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 10:06:39 +03:00
IvanandClaude Opus 5.5 60d682d65d CI: cache the Windows workspace libraries with sccache
rust-cache keeps only the dependencies, so every run recompiled the workspace crates. sccache
caches their libraries (test executables can't be cached and still link). Warm, on the Windows
runner (EPYC 7763, 2-3 runs), the unit leg built in 7.1 min instead of 11.5 and the integration
leg in 4.3 instead of 8.8. Only main writes (PRs use SCCACHE_GHA_RW_MODE=READ_ONLY); a full set
of entries was ~90 MB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 08:58:53 +03:00
IvanandClaude Opus 5.5 6382148eb8 CI: run the Windows tests as two parallel legs
Unit tests (--lib --bins, plus doctests) and integration tests (--test '*', plus Clippy and the
layering check) now run on two Windows runners at once. The feature selection is the same, so
both legs restore one dependency cache; the integration leg saves it, so Clippy's metadata stays
in it. On the Windows runner (EPYC 7763) the job took 28.3 min; the legs take 18.2 and 18.3,
running the same 6095 tests between them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 07:48:54 +03:00
IvanandClaude Opus 5.5 2ff756b51a Tests: build ui-egui without optimization in the test profile
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 heavy pixel code of their own. On the
Windows runner (EPYC 7763, 3 runs vs 7): build 18.9 -> 15.4 min, test run 10.0 -> 10.5. Only test
builds change; cargo run and cargo build use the dev profile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 06:36:32 +03:00
IvanandClaude Opus 5.5 fa17b6f4b1 CI: run the Linux without-heif and craft-fonts builds in parallel
They ran after the main tests in the Linux test job and added ~10 min to its critical path (and
were why that job ran out of disk). Now a two-leg job restores the test job's cache read-only
(shared-key: test) and saves nothing, so the cache budget (#2688) doesn't grow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 05:08:46 +03:00
IvanandClaude Opus 5.5 7b1dd35b7b CI: report every failing test, not just the first
With the binaries running in parallel, running the rest after a failure costs little, and a red
main no longer hides other failures behind the first one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 05:07:17 +03:00
IvanandClaude Opus 5.5 bf93459200 CI: no debuginfo for the Windows test build
Writing PDBs for ~200 test binaries took 2.2 of ~21 build minutes on the Windows runner (3 runs
each on the same CPU model). A panic still reports its file and line; Linux keeps
line-tables-only for backtraces with lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 05:06:06 +03:00
IvanandClaude Opus 5.5 b9aa935d19 CI: run tests with nextest and skip building examples (#2204)
cargo test runs the ~200 test binaries one after another; on Windows that took 14 of the job's
37 minutes, 5 of them in gpu's parity tests on the software adapter. nextest runs them in parallel
with the GPU group from .config/nextest.toml. The examples were built and linked but never run;
Clippy's --all-targets still checks them. Doctests get their own step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 01:41:45 +03:00
82 changed files with 2824 additions and 398 deletions

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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:
+7 -35
View File
@@ -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
+33
View File
@@ -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
View File
@@ -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
+25
View File
@@ -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;
+41 -1
View File
@@ -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}")),
+121
View File
@@ -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");
}
}
+12
View File
@@ -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();
+5 -4
View File
@@ -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,
}
+10
View File
@@ -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");
}
}
+16 -10
View File
@@ -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> {
+42
View File
@@ -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, &params)?;
}
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
+14 -13
View File
@@ -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()] }]);
+10 -4
View File
@@ -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,8 +422,10 @@ pub struct CopiedStyle {
pub mode: photocraft_color::ColorMode,
}
/// The commands whose [`Session::precheck`] is complete: it accepts exactly what `execute` does
/// (Free Transform applies its box before these, and only when they will run). Layer via Copy and
/// 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"];
@@ -670,8 +673,8 @@ impl Session {
/// 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 exactly what `execute`
/// accepts (a test holds the two together). For others it checks only what they share.
/// 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()) {
@@ -776,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)?;
+367 -31
View File
@@ -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, &sections, mac));
let html = crate::shortcut_sets::summarize(set_name, &sections, 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
),
+88
View File
@@ -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);
}
+194
View File
@@ -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);
}
}
+26 -14
View File
@@ -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");
}
}
+31 -13
View File
@@ -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]
+5 -1
View File
@@ -1,4 +1,5 @@
//! `Session::precheck` accepts exactly what `execute` accepts for the commands it covers
//! `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};
@@ -47,6 +48,9 @@ fn params() -> Vec<Value> {
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}),
+146
View File
@@ -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);
}
+5
View File
@@ -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 {
+5 -1
View File
@@ -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)?;
+12
View 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
};
+1 -1
View File
@@ -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 {
+125
View File
@@ -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
View File
@@ -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))
}
+2 -1
View File
@@ -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() {
+43
View File
@@ -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(_))));
}
}
+10
View File
@@ -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| {
+1 -1
View File
@@ -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
+10 -2
View File
@@ -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() }));
+66 -1
View File
@@ -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),
@@ -539,6 +544,66 @@ fn layered_scenarios(b: &mut Bench, sz: &Sizes) {
}
/// #209: arrow-key nudge in the 150-layer document; #1771: a new layer there.
/// 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()) {
let pixels = st.history.pixel_bytes(&st.doc) 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)?)
});
}
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.
let name = "nudge 1 px in the many-layer document + refresh".to_string();
+15 -10
View File
@@ -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");
+56 -10
View File
@@ -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,13 @@ 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);
}
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());
@@ -1626,6 +1639,7 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible:
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 +1680,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 +4545,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 +6550,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 +6586,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 +7505,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();
+51
View File
@@ -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 {
+38 -1
View File
@@ -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));
+264 -39
View File
@@ -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,69 @@ 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 (to simulate a smaller card).
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 +1465,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 +1735,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 +1777,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 +2394,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 +2502,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");
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). 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 (Automaticky se řídí Využitím paměti, nejvýše čtvrtinou paměti tohoto počítače). 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.
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). 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 (Automatisch folgt der Speichernutzung, bis zu einem Viertel des Arbeitsspeichers dieses Computers). 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.
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Ορίστε τη Μνήμη GPU στις Προτιμήσεις › Επιδόσεις σε όσο χωράει η κάρτα γραφικών σας (το Αυτόματο ακολουθεί τη Χρήση μνήμης, έως το ένα τέταρτο της μνήμης αυτού του υπολογιστή). Όσο δεν χωρούν, η γραμμή κατάστασης δείχνει Η μνήμη 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.
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). 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 (Automático sigue Uso de memoria, hasta una cuarta parte de la memoria de este equipo). 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.
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). 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 (Automatique suit Utilisation de la mémoire, jusqu'au quart de la mémoire de cet ordinateur). 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.
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). 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 (az Automatikus a memóriahasználatot követi, legfeljebb a számítógép memóriájának negyedéig). 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.
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Atur Memori GPU di Preferensi › Performa sesuai kapasitas kartu grafis Anda (Otomatis mengikuti Pemakaian memori, hingga seperempat memori komputer ini). 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.
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). 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 (Automatico segue Uso memoria, fino a un quarto della memoria di questo computer). 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.
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 環境設定 › パフォーマンスの GPU メモリを、グラフィックカードが保持できる量に設定してください(自動はメモリ使用量に従い、このコンピューターのメモリの4分の1までです)。収まらない間、ステータスバーに「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.
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 환경 설정 › 성능에서 GPU 메모리를 그래픽 카드가 감당할 수 있는 양으로 설정하세요(자동은 메모리 사용량을 따르며 이 컴퓨터 메모리의 4분의 1까지입니다). 맞지 않는 동안 상태 표시줄에 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.
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Stel GPU-geheugen in bij Voorkeuren › Prestaties op wat uw videokaart aankan (Automatisch volgt Geheugengebruik, tot een kwart van het geheugen van deze computer). 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.
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). 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 (Automatycznie podąża za Użyciem pamięci, maksymalnie do jednej czwartej pamięci tego komputera). 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.
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). 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 (Automático segue Uso de memória, até um quarto da memória deste computador). 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.
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Задайте «Память GPU» в разделе Настройки › Производительность по объёму, который вмещает ваша видеокарта («Автоматически» следует параметру «Память», но не больше четверти памяти этого компьютера). Пока слои не помещаются, в строке состояния показано «Память 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.
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). 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 (Automatski prati Korišćenje memorije, do četvrtine memorije ovog računara). 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.
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). 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 ayarlayın (Otomatik, Bellek kullanımını izler; en fazla bu bilgisayarın belleğinin dörtte biri). 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.
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Задайте «Пам'ять GPU» в Налаштування › Продуктивність відповідно до того, скільки вміщує ваша відеокарта («Автоматично» стежить за використанням пам'яті, до чверті пам'яті цього комп'ютера). Поки шари не вміщуються, рядок стану показує «Пам'ять 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.
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). 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ự động theo mức sử dụng bộ nhớ, tối đa một phần tư bộ nhớ máy này). 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.
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 在 首选项 › 性能 中将 GPU 内存设置为显卡能容纳的大小(自动跟随内存使用量,最多为本机内存的四分之一)。在图层放不下期间,状态栏显示“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.
+13 -1
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 在 偏好設定 › 效能 中將 GPU 記憶體設為顯示卡能容納的大小(自動會跟隨記憶體使用量,最多為本機記憶體的四分之一)。在圖層放不下期間,狀態列會顯示「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.
+1 -2
View File
@@ -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
+29 -3
View File
@@ -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(),
@@ -1053,10 +1061,10 @@ impl PhotocraftApp {
self.session.precheck(id, &params).map_err(|e| e.to_string())?;
transform_tool::commit(self);
if transform_tool::committing(self).is_some() {
return Err("the transform is still being applied; try again when it has finished".into());
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() { "the transform couldn't be applied".into() } else { self.ui.status.clone() });
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, &params) {
@@ -1774,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) {
@@ -1782,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`.
@@ -2019,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.
+3 -85
View File
@@ -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.
+20 -14
View File
@@ -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 {
+11 -2
View File
@@ -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 (Automatic follows Memory Usage, up to a quarter of this computer's memory). 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();
+3
View File
@@ -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 {
+36 -28
View File
@@ -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))
+35 -3
View File
@@ -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 }];
+1
View File
@@ -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);
});
+14
View File
@@ -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");
}
}
+1
View File
@@ -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() {
+1
View File
@@ -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(
+21
View File
@@ -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();
}
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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`.
+18
View File
@@ -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