* fix(plugins): the install error names the format the file actually is (#10362)
Plug-ins > Install Plug-in pointed at an old Photoshop plug-in (.8bt) answered
with "not a valid plug-in module: not a WebAssembly binary (missing \0asm
header)". That was one fixed message for every file that is not wasm: it said
what we wanted and nothing about the file the person is holding.
format_hint in runtime.rs now reads the magic number and the error says what the
file looks like. For a Windows module the message is (one line in the code,
wrapped here):
not a valid plug-in module: not a WebAssembly binary (missing \0asm header);
this file looks like a Windows native executable or library (a .dll, or a
Photoshop plug-in such as .8bf/.8bt) - PhotoCraft plug-ins are WebAssembly
modules (.wasm)
7F 45 4C 46 says "a Linux native library (ELF)", CE FA ED FE / CF FA ED FE /
CA FE BA BE / BE BA FE CA say "a macOS native binary (Mach-O, thin or fat)",
50 4B 03 04 / 50 4B 05 06 say "a zip archive". Any other header keeps today's
message byte for byte, so nothing keyed on it changes. The signatures are public
format facts, read with starts_with/get so a short or hostile file cannot panic.
The error variant, the enum, the ABI check, check_code_section and the wasmi
setup are untouched, and native-code loading stays out of scope (PR #7907).
Fail-first, before the production change:
cargo test -p photocraft-plugins --lib --tests
lib: test result: ok. 10 passed; 0 failed
abi: test result: FAILED. 19 passed; 1 failed; 0 ignored; 0 measured
assertion line: "windows dll or .8bt: not a valid plug-in module:
not a WebAssembly binary (missing \0asm header)" (the MZ fixture that
was already in the file)
cargo test -p photocraft-engine --lib -- plugin
test result: FAILED. 7 passed; 1 failed; 0 ignored; 0 measured; 2518 filtered out
After:
cargo test -p photocraft-plugins
lib 10 passed; abi 20 passed; doc-tests 0
cargo test -p photocraft-engine --lib -- plugin
test result: ok. 8 passed; 0 failed; 2518 filtered out
The new engine test drives the real plugin.install command with a `path` whose
file starts with MZ bytes, so it covers the menu path (registry::load_file) end
to end, not only the library check.
Gates: cargo clippy -p photocraft-plugins --all-targets -- -D warnings and
cargo clippy -p photocraft-engine --all-targets -- -D warnings (clean; the only
stderr noise is the pre-existing "patch `egui_*` was not used in the crate
graph" cargo warnings); cargo xtask layers ("OK: 31 crates, no layering
violations."); cargo xtask wasm (" ok 24 crates", " ok photocraft-
codecs --features heif", no FAIL line); cargo fmt -p photocraft-plugins --
--check and cargo fmt -p photocraft-engine -- --check (silent, exit 0). The
whole engine --lib suite is not run here: it fails on clean main in this
checkout for font/i18n reasons (no CRAFT_FONTS_DIR). No doc quoted the old
message (git grep "WebAssembly binary" finds only this crate), so nothing to
update there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(plugins): one install-error test per recognised format, tempfile in the engine test (#10362)
Review feedback on #10633. The single looped test in abi.rs is now one test
per signature (PE/COFF MZ, ELF, Mach-O in all four byte orders, zip), one for
an empty or unrecognised file (including signatures cut short, which keep the
old message byte for byte) and one showing a valid WebAssembly module still
loads. The engine test uses tempfile::tempdir (already a dependency of
photocraft-engine) so the directory is removed on a panic, and asserts the
message does not contain the temp path: the hint is static text, so the
message adds no path.
Gates: cargo test -p photocraft-plugins --lib --tests (10 lib, 26 tests);
cargo test -p photocraft-engine --lib -- plugin (8 passed); clippy -D warnings
on both crates; cargo fmt --all -- --check; cargo xtask layers (OK).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(engine): keep the non-wasm install-error test off wasm32, as tempfile is (#10362)
tempfile is a non-wasm32 dependency of photocraft-engine, so the test that
uses it is gated like the crate's other file-system tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): the Stroke Path dialog remembers Simulate Pressure (#10527)
The Stroke Path dialog is a spec-driven Command dialog: `pen_dialog` builds a
fresh `fields` map on every opening (the path name plus each command's own
extras) and never inserted `simulatePressure`, so the checkbox always came back
at the baked-in spec default, off, however often it was applied.
`filter_dialog::remember` cannot cover it either: it bails when a dialog carries
its own `__spec`.
Seed the field from the last applied choice when the dialog opens, and write the
applied choice back when OK runs the stroke. The value lives in the UI-state
preferences' dialogs map under the command id, `path.stroke` - the same key style
as `edit.fill`, `view.newGuide` and `file.print` - so the control channel can
still read it. The read is defensive: anything but an object holding a real bool
falls back to off. Cancel never reaches the write, so it changes nothing, and a
Paths panel Stroke button (no dialog) writes nothing either.
Only Simulate Pressure is remembered: the tool choice still opens on the spec's
default, and the engine's `path.stroke` default stays `false`, so scripts,
Actions and the control channel are unaffected unless they pass the field.
Tests: canvas_tool_menu/tests.rs - the applied choice reopens the dialog, Cancel
leaves the remembered choice alone, a corrupt saved value falls back to off, and
the tool is not remembered. No docs touched: no ui-parity row covers this
behaviour, and the scorecard row that mentions Simulate Pressure is generated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(ui): a failed Stroke Path confirmation does not remember Simulate Pressure (#10527)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* test(ui): pin the options-bar swatch's rounded checker clip (#8741)
The fix itself is already on main: 1505c4260 (the shape Fill/Stroke popover
branch) clips the swatch's transparency checker to the rounded corners with
widgets::checker_rounded, and cites this issue. What main does not have is
anything pinning it, so the white specks dark themes showed past the corners
could silently come back.
This test drives the options-bar Fill swatch through the kittest Harness and
asserts that every paint primitive inside the swatch rect is rounded. With the
square checker its light cells painted past the corner radius, which is what
#8741 reported, so the assert fails on the square version and passes on the
clipped one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(ui): cover both shape swatch callers and assert the rounded checker is painted (#8741)
The test rendered only the Properties panel although named for the options bar. Now it covers both callers of the shared swatch (Properties Appearance row and the shape tools' options bar) and asserts exactly one rounded textured checker inside each swatch, so a swatch that paints no checker fails too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(ui): the swatch checker must be rounded exactly like the fill (#8741)
A checker rounded less than the fill would still show past its corners,
so the test compares the corner radius with the theme's radius_sm, which
the swatch fill uses, instead of accepting any non-zero radius.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): Save for Web Matte takes only whole colours while typed (#8830)
The Matte field wrote every keystroke into the export settings, so a partial
colour reached the previews: mid-type the settings held `#1111`, or nothing at
all after a select-all plus Backspace, and the pane's encode failed (its error
row shows above the panes).
`matte_field` shows exactly what is typed while the field has focus and commits
only `#rrggbb`, normalised through `color_picker_ui::parse_hex`, or `none` -
the pattern the Color panel's hex field already uses; anything else snaps back
when focus leaves.
aedab302b had already moved the encode error out of the pane row, so the dialog
no longer widens on an invalid matte; this removes the cause and brings back
this PR's first version's width test as a guard for that half.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): the Save for Web matte field keeps typed text across window focus (#8830)
CodeRabbit 4431801542 flagged that the Matte field reads its cached text with
ui.memory(|m| m.has_focus(id)) but caches/clears it with resp.has_focus(). The pinned egui
0.37 fork shows the difference is real: Response::has_focus ANDs the OS-window flag
(response.rs 405-407: self.ctx.input(|i| i.focused) && self.ctx.memory(|mem| mem.has_focus(self.id))),
while Memory::has_focus is only self.focused() == Some(id) (memory/mod.rs 913-915), and egui
never clears the focused widget when the window loses focus - Event::WindowFocused(false)
(input_state/mod.rs 443-451) only clears keys and modifiers. So alt-tabbing away deleted the
typed text and the field came back showing the last whole colour.
Both sides now ask the same question. The new test focuses the Matte field, types a partial
#1111, drops and restores RawInput::focused, and asserts the partial text survives and that
finishing the colour still reaches the settings; on the old write side it fails with
the partial Matte is still in the field after the window came back: ["#ffffff"].
Gates: cargo test -p photocraft-ui-egui --lib -- file_ui = 46 passed, 0 failed;
cargo clippy -p photocraft-ui-egui --all-targets -- -D warnings = clean; cargo xtask layers =
OK: 31 crates, no layering violations.
Formatting: CI's `cargo fmt --all -- --check` caught an unwrapped assert in the first version of
the test (tests.rs:586) - I had run test, clippy and layers locally but skipped fmt, so the gate
I left out is the one that broke. The assert is now wrapped as rustfmt wants it and
`cargo fmt -p photocraft-ui-egui -- --check` is clean; that last push is formatting only, no
behaviour or test-logic change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): the Save for Web matte field caches text when focus arrives mid-edit (#8830)
The Matte field asked Memory::has_focus once, before ui.add, and used that one
value for both sides of the cache: what the field shows, and whether typed text
is kept. Those are two different questions. The display side must keep using the
pre-add state: that is the frame as it started. The cache side must ask again
after ui.add, because egui can move focus into the field while the add runs.
In the pinned egui fork (6d518fc) the MiddleClickPaste branch of TextEdit inserts
the pasted text into an unfocused field and only then calls Memory::request_focus
inside the add: text_edit/builder.rs:933-974 (insert at 953-959, request_focus at
968), and Memory::request_focus sets the focus immediately, memory/mod.rs:945-950,
so has_focus is true right after ui.add in the same frame. Reading focus only
before the add left the pasted partial text uncached: the settings parse rejects
the pasted value (the click pos inserts it inside "#ffffff"), the remove branch
ran with the stale focused=false, and the next frame restored the saved colour
although the field now had focus.
Fix: read the pre-add focus for the display value, re-read has_focus after
ui.add for the insert_temp/remove decision.
Regression test drives a real focus-gain-and-text-in-one-frame: hover over the
Matte field, then a single frame carrying egui::Event::MiddleClickPaste
(fork-only event, data/input/event.rs:40) at the field's centre, with
run_steps(1) so the one stale frame is visible. Fail-first output:
the pasted partial must survive into the next frame, not be restored to the saved colour: ["#ffffff"]
test result: FAILED. 46 passed; 1 failed; 0 ignored; 0 measured; 4439 filtered out; finished in 0.49s
After the fix: test result: ok. 47 passed; 0 failed. clippy -D warnings,
cargo fmt --check and cargo xtask layers are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(ui): pin Save for Web Matte short form, case, paste and blur (#8830)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Pages reduced by k > 1 whose source starts on a multiple of k now use the
band/tile reduced render (one-colour tile skipping, parallel tiles) on a
factor-aligned cell grid instead of compositing every pixel at full size and
averaging. Unaligned sources keep the averaging path, which tests use as the
reference (U8/U16/F32, k = 2, 4, 16, max difference < 1e-4). The reduced
render now checks the page's interrupt between bands.
* security(affinity): charge allocation records, empty subpaths, paint lists, tile indexes and shape lookups
Several readers still did work per occurrence of a shared object that no
document-wide budget counted:
- Archive::open walked every allocation table's records, and overlapping tables
could reparse one long run thousands of times. Records are now charged against
MAX_TABLE_RECORDS before each table is walked.
- curves() charged a subpath's records but not the subpath, so a shared curve
full of empty subpaths was walked for free. Each subpath now costs one record.
- Colours and gradient positions decoded their whole float record (up to 64 KiB)
to read two to five components; only those are decoded now.
- Paint selection collected whole BFFl/LIFl/LILn lists without charging their
slots; the lists are charged and walked once without collecting.
- Dash patterns were copied per occurrence with no length limit; they are capped
at 64 entries and charged against the document budget.
- The raster tile index skipped null Idx slots uncharged; every slot walked is
charged now.
- shapes::last rescanned all fields per lookup and CTyp/ShCR were copied whole;
the field index records last occurrences too, and only four corners are read.
* test(affinity): cover the allocation record budget across chained tables
* test(affinity): directory records count against the allocation record budget
The exr crate sizes a preview attribute's pixel buffer from its
declared dimensions (up to 4 MiB at once, zero-filled) before it finds
the attribute too short, and in the non-pedantic mode every reader here
uses, it then skips the attribute and continues. A header with
thousands of tiny malformed previews therefore cost thousands of 4 MiB
allocations before any size check ran.
check_previews walks the raw headers and refuses a preview whose
declared width x height x 4 exceeds its value size. It runs before
every header read: read_meta, read_layer, the Cryptomatte listing and
decode_deep_exr.
The portable data folder's writability check wrote a fixed, PID-named file with
std::fs::write, which follows an existing symlink and truncates its target.
Create the probe with create_new, try another name on a collision, and remove
only a probe this call created.
A document that goes over the canvas budget kept its texture set (and the compositor's
pages for it) resident while drawn from viewport pages, holding the memory the pages are
budgeted against. texture_key is needed outside tests now; fetch_update is deprecated.
Resolve conflicts with #5331 (EXR streaming): the block-by-block EXR import now
reads from the same identity-verified handle as the rest of the open path, and
docs/scratch-disks.md keeps both the EXR streaming pointer and the RAM/scratch
admission notes.
Redesign of the viewport streaming for #5332 on current main:
- Only documents whose display texture set doesn't fit the canvas budget
(GpuCanvas::fits_canvas, from detected RAM/VRAM) use pages, so GPU Memory
on Auto (gpu_memory_mb = 0) no longer streams every document.
- Documents that fit keep main's texture set path (LayerFit, DEFER_PX
deferral, background redraw, admit_output); no pixel-count cutoff.
- Edits dirty only the pages their damage reaches; nothing scans layer tiles.
- Pages are held to half the per-document canvas budget; no change to the
global wgpu resource budget or to gpu_budget's split between documents.
- Bounded workers (2) and uploads per frame, cancellation on pan/zoom/edit,
CPU canvas fallback when a page fails or no page fits.
Main moved the GPU canvas into canvas/gpu_canvas/* and gained its own
budget, LayerFit and background-redraw machinery. Conflicted files take
main's version; the viewport streaming is re-integrated on top of main's
budget in the following commit.
One RAM detector: the cgroup v1/v2, macOS reclaimable and Windows commit providers move from
photocraft-io into photocraft_raster::memory::system (L0), which the residency budget (#5330)
and photocraft_io::memory both read. Unreadable container limits still fail closed, swap is
never counted as RAM. sysinfo is unified on =0.40.1 (raster only).
While the scratch disk is spilling (on, not paused), import admission credits the tile pixels
of an estimate against spill::scratch_capacity() (free space above the reserve, within the
Scratch Size Limit); decode buffers, conversion copies and merged planes stay RAM-only. Spilling
off/paused and the web keep the RAM-only policy unchanged. PsdMemoryEstimate gains tile_bytes;
the flat TIFF streaming check credits its tile surface the same way; engine approveMemory's
fast pre-check uses the credited hard limit and the import re-checks exactly.
Resolve against main's save restructure (Services::save_file/SaveCtl, scratch-integrity checks,
ZipWriter::with_entries, exact encoded_len):
- PSD/PSB saves stream the file model through atomic_write_stream_progress; the length used for
progress and reservations is main's exact encoded_len (the PR's size_bound under-counted lazy
data); export_with/encode_with share checked_stages, so main's scratch checks (previews
included) still refuse unreadable scratch.
- Drop ZipWriter::for_entries in favour of main's with_entries.
- TIFF: take_from_psd moves layer data out of the export's own model (never the document);
body() reserves its exact length, failures are OutOfMemory (not LimitExceeded, which would
trigger the PSB retry).
- Streaming writer: AlreadyExists from the writer is a failed save, not a name collision.
- Remove dead Vec put_widened_rle; channel_data_len is test-only.
- import_file_for_open_with decodes single-part flat scanline EXR block by block into
tiles; headers the band decoder refuses fall back to the whole-file decoder, so files
main opened still open. Band-time failures and cancellation publish no document.
- The RAM tile budget and scratch-disk checks live in exr_native (the PSD stream module
they came from is dropped); over-budget opens fail with IoError::OutOfMemory.
- The band decoder picks colour channels with exr_color_channels, like the byte decoder.
- Tests move into the per-crate it binaries and use import_file; an engine open-job test
covers the OpenSource::Path route. The ui-egui sync open test is dropped: main's
synchronous open reads bytes, and the io test covers the oversized header.
- docs/streaming-io.md, roadmap, scratch-disks and REL-212-1 describe EXR only.
Main already opens PSD/PSB from the file in bands (4915f0970, #5106) through
psd_to_document_owned, which keeps damaged-file flagging, failed-layer drops,
type-layer setup and the PSD pixel budget; PSD limits are handled in #10778.
This drops the second PSD/PSB stream reader (psd::stream, io::psd_stream),
the seekable PSB writer and atomic stream helpers, the authorized-workspace
seekable open plumbing and the working-memory pre-check that refused
ordinary exports. The flat scanline EXR band decoder and its tests stay and
are wired into main's path open after merging main.
This reverts commit 89ed4cb284 from the
#5332 branch so the GPU viewport streaming lands standalone. The import
work is rescoped separately in #5331 (EXR streaming only).
Keep the PSD memory admission work and upstream's scratch storage
together. Conflicts resolved:
- Cargo.lock: both sysinfo versions are required. 0.40.1 serves
photocraft-io's adaptive import budget; 0.37.2 serves
photocraft-raster's scratch volume and memory supervision. Cargo
metadata resolved the lock with no other changes.
- crates/engine/src/lib.rs: Session keeps pending_preflights and
next_pending_preflight beside scratch_runtime and scratch_epoch.
Session derives Default, so neither set needs a manual initializer.
Upstream: 655295b2a2
The automation .pcraft save renders the previews and main's optional
full-size composite before the bundle writer takes its scratch snapshot,
so a tile that failed and recovered in between could be saved as
placeholder pixels. Check the document's scratch health and the read-error
generation around that render, as io's export path already does.
`load_frames`'s single-file path called `file_cmds::import`, which uses default
import options, so the footage command's lowered PSD/layered-TIFF memory limit
never reached a single-page layered TIFF or PSD. Multi-page TIFF pages already
passed their `opts`.
Give the engine import one shared `import_result_with` path and an
`import_with_options` entry that keep the allocation checkpoint, native
`.pcraft` and format dispatch, typed memory refusals and the preview-only
refusal; the existing `import` delegates with default options, so every other
caller behaves exactly as before. The single-file fallback now passes `opts`.
Add a command-level regression: a single-page layered TIFF under a thread-scoped
`psd_max_pixel_bytes` of 1 must reach `file.import.videoFramesToLayers` as a
typed `EngineError::PsdHardBudget` with its name, required and limit fields
intact and the document unchanged, then import as one frame when the limit is
not lowered.
The multi-page TIFF branch of `load_frames` pushed each flattened page
straight into `frames`, bypassing the `keep` closure, so `MAX_FOOTAGE_BYTES`
was never checked for those pages: many large pages could retain more than
the cap without an error. Route every flattened page through `keep`, with the
same file-context error mapping as the single-file and GIF/WebP paths, and
keep typed page admission refusals typed.
Add command-level regressions. A per-thread test scope lowers the footage and
PSD limits, so neither test allocates gibibytes: one proves an over-budget
multi-page TIFF is refused with nothing imported, and one proves a page with
Photoshop layer data reaches `file.import.videoFramesToLayers` as a typed
`EngineError::PsdHardBudget` with its name, required and limit fields intact
and the document unchanged.
This reverts commit b8621fd1ca.
The CMYK adjustment-layer Fill fix is independent of the PSD/TIFF memory
admission work and ships in its own PR. Reverting it here keeps this
branch's compose tree identical to main.
The multipage TIFF import loop mapped every page-import error to EngineError::Other, so a PsdApprovalRequired or PsdImportBudget from a layered page reached the UI, control channel and agents as text only, without the required/limit fields. Route those two through io_open_error and keep the page-specific text for every other error.
Main now takes the manifest envelope from craft-pcraft-format, which has
no full_composite member. The save writes the member after the envelope
when it writes composite/full.png, and read_full_composite reads it back.
Windows keeps delete-on-close (unlinking an open file is not portable there), so a
live scratch file still has a directory entry. Only Unix unlinks it right after
opening. Check that the directory is empty once the file is closed on both.
Main's new profile dither (8a0f959d2) matched on the tile bytes by value inside the
per-pixel loop. On this branch Tile::bytes returns a TileBytes guard rather than a
slice, so borrow it for the loop instead, and compare the test's tiles as owned
bytes since the guard has no Debug or PartialEq.
A scratch write or create failure, or a volume about to drop below max(1 GiB, 5%)
free space, now pauses spilling with exponential backoff (1 s doubling to 60 s)
instead of disabling it for the session. Tiles stay resident, set_budget keeps
the latest budget for the resume, preference changes retry at once, and
spill::epoch lets the engine re-apply History's byte limit while paused.
spill::set_size_cap adds an explicit size limit; free space comes from sysinfo's
disk feature.
One unreadable tile used to block editing, saving and exporting every open document
(spill::check_integrity was process-global). Add Tile/Surface/Document::scratch_health
(free while no tile has failed) and Document::for_each_surface (ops' history walk now
delegates to it). edit_with checks the document before and after, jobs the document they
captured, import the result, save/export the document written; check_since is now
generation-only so a failure or recovery during an operation refuses it once and a retry
works. Caches check their own sources and purge once per new failure instead of every
frame. Fault-injection tests use a duplicated handle (the Unix file has no name).
The spill manager is process-global and never dropped, so the Unix scratch file was only
removed by the next launch's reap. Unlink it immediately after opening and read/write
through the one handle with pread/pwrite (readers can no longer reopen it by path).
Windows keeps delete-on-close and per-thread readers. The abrupt-exit test now asserts
that no file remains on every platform; docs/scratch-disks.md updated.
working_bytes reserved three 256 MiB result groups for every filter, so two
concurrent filters on a 24x18 image could exhaust the transient allowance and
actions.play reported 'not enough working memory'. Bound the result groups by
the area, charge only untiled RSS when resident tiles are evictable, always admit
the transient allowance, and let an operation that fits on its own wait (bounded,
30 s, native only) for concurrent reservations instead of failing.
Replace the deprecated AtomicU64::fetch_update with try_update (stable since
1.95, the MSRV).
Resolve conflicts with main's prefs UI split, session/execute.rs move, GPU memory
budget (gpuMemoryMb, 0 = auto, kept from main) and effective_memory_usage_mb history
budget. Port the Memory Usage % and Scratch Disks preferences into prefs_ui, the edit
integrity guard into session/execute.rs, and adapt main's new pixel readers to the
guarded TileBytes API. Cargo.lock regenerated; scorecard regenerated.