* paint: refuse a sequential-dab stroke whose working copy is too large
apply_dab_stroke read the whole rectangle enclosing a stroke's dabs into
two dense f32 copies (and a third for the mix), sized by the area
between the dabs rather than what they paint. Two distant points asked
for gigabytes on a sparse document.
Check the three planes against a 4 GiB budget (1 GiB on 32-bit) before
reading, and paint nothing past it.
* address review: apply_dab_stroke returns Result so over-budget dab strokes fail with 'stroke too large'
* fix tests: apply_dab_stroke returns a Result
* fix tests: handle the Result of the dab-stroke functions
The brush works in 8-bit documents only: a 16-bit document with one
layer, a layered 16-bit document or a depth change after the source,
and a 32-bit document each get the reference application's alert text,
before anything is painted. A source of another colour mode gets the
reference text too. Tests that expected 16/32-bit painting now run at
8 bits.
The reference's Eraser paints 173 for background RGB 0,200,0 in a Grayscale
document; the document's Dot Gain curve gives 153, so swatch_in_doc now
converts through the sRGB tone curve (sGray, 172-173) instead. Pixel
conversions keep the document's profile. The two Grayscale tests are no
longer ignored, and eraser_locked's Gray case checks the sGray value, since
the Brush's own conversion into Gray (not measured) is unchanged.
A click that runs but changes no pixel (a blue pupil, a transparent area, an
empty layer, Darken Amount 0) now adds one unchanged history state instead of
the "no red-eye pixels" error, as in Photoshop. Non-RGB/Lab documents, channel
and mask targets, hidden layers and clicks outside the selection or the canvas
keep the error and add no state.
* fix: brush hints keep a feathered mask's ring and the effects' reach (#9329)
A command's damage hint (the bare dab) was intersected with the edit's
own damage, which `tile_damage` had grown by the mask's feather and the
layer's and enclosing groups' effects. The intersection cut that growth
off, so painting on a feathered layer mask left a stale soft ring around
each dab, and a dab's drop shadow was outside the reported damage.
`hint_damage` now grows the hint by the same rule first (`grow_hint`,
sharing `grow_in_layer` and `effects_reach` with `tile_damage` and the
group walk). The hint is still checked against the changed tiles as is.
The group-shadow test now cuts a hole at a tile's right edge, so its
shadow crosses into the next tile (it passed without the group's growth
before), and the clip-base test gains a dab at a tile edge.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix: brush hints skip the effects of layers that aren't drawn (#9329)
`grow_hint` grew a dab by the effects of every layer in the document,
hidden ones included, so a large drop shadow on a hidden layer widened
every `paint.stroke` dab elsewhere to all the tiles `step_damage`
reported, and each dab recomposited and uploaded that much.
It now skips layers the compositor skips, by the same rule as
`effect_layers`: a hidden layer, a clipped layer over a hidden base, and
everything inside a hidden group. A change there shows nowhere, and
every drawn layer still grows the hint as before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* test: the group-shadow brush test goes through the hint (#9329)
`a_dab_inside_a_group_with_a_drop_shadow_damages_the_shadow` painted
with a plain `Session::edit`, so its damage came from `step_damage` and
never exercised `grow_hint`. It now paints with `paint.stroke`, and the
child carries a shadow of its own inside the shadowed group: the group's
shadow of the child's shadow lands past either shadow's reach alone, in
the next tile, so dropping the enclosing groups' reach from `grow_hint`
fails it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
paint.colorReplacement and its live stroke refuse the modes and depths
the reference application refuses, with its words: 32 bit per channel
RGB, Grayscale and Bitmap (only full color modes), and Indexed Color.
Nothing is painted and no history is added. RGB/8, RGB/16, CMYK and Lab
paint as before; cases that weren't measured keep today's behaviour.
Resolves the status-line conflicts in docs/gaps.md and
docs/target-app-parity.md (both sides kept) and the module list in
crates/engine/tests/it/main.rs.
Co-Authored-By: arjen <76012639+ArjenS-LA@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main moved the ink formula into `Ink::apply` / `overprint` and prints the
spot channels of RGB and CMYK documents as inks in the channel view instead
of a tint. The Spot dot gain now lives there: `overprint` (the Multichannel
composite and every on-screen ink preview) and the channel view's inks add
`spot_dot_gain()` through `Ink::apply_with_gain`; `Ink::apply` and the saved
PSD thumbnail print inks as stored. The new paged-canvas caller of
`texture_key` (viewport_pages.rs) passes the document, so its pages follow
the ink preview like the other textures.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The remaining commits of the stack, applied on top of the squash-merged #10147:
- PNG decodes in resumable steps of rows (PngDecodeRows, shared with decode_rows)
- PNG imports as a document in resumable steps (io PngImport, text metadata and
document build in bounded steps)
- Jobs can run a slice at a time where there are no threads (StepWork,
cooperative sessions, Session::poll_jobs)
- Place decodes, flattens and resizes in slices (PlaceSteps), reading a path
and keeping the frame, memory gate and typed memory-budget errors main has
- The web build places dropped and picked files a slice per frame, with a
bounded place inbox
- A measurement of the stepped place; docs for jobs without threads
Main's Lab bounds (#10765) refuse RGB samples, subtractPoints and order
included. A test pins that for subtractPoints and order, and panic_hunt
now sends malformed and far-off subtractPoints, points and click orders,
also with Lab bounds, to every command.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- #11458: the straight-crop rows also check that the active layer's pixels
inside the old canvas are the plain crop's, so a fill that recolours them
fails.
- #11295: the Esc case sends the key through shortcuts::handle instead of
calling cancel directly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU plan: cache adjustment LUT rows between plans (#11498)
adjustment_program rebuilt the Levels/Curves tone tables on every plan
(twice per refresh), about 1-2 ms per Levels layer each time. Keep the
rows of Levels, Curves, Color Balance and Hue/Saturation ranges in a
32-entry most-recently-used cache (photocraft_compose::lut_rows, at
most 32 x 3 x 16 KiB = 1.5 MiB), built outside the lock.
Entries are keyed by a u64 hash of exactly what each row builder reads,
fields destructured in full and floats by bits (-0.0 and 0.0 stay
apart). Hue/Saturation master sliders don't add entries; Hue/Sat ranges
and Color Balance don't depend on depth. Edit > Purge > All drops the
cache, and a filled cache enables it.
Release, per call: Levels 8/16-bit 1.10 -> 0.003-0.018 ms, Curves
0.16 -> 0.018, Color Balance 0.11 -> 0.001-0.015, Hue/Sat ranges
0.05 -> 0.008-0.018. The tables are identical, so rendering is unchanged.
Tests: cached rows match fresh tables bit for bit at 8/16/32-bit, hits
and LRU eviction on a local cache, key coverage, and Purge All
releasing the cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* compose: rustfmt the LUT row cache tests (#11498)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU plan: test that plans read cached LUT rows and that curve points key them (#11498)
Plant rows no builder makes under a fresh key and check the planner returns
them, and check that moving a Curves point changes its key (review on #11539).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* compose: prevent in-flight LUT builds from undoing purge
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
* Place decodes, fits and renders in a background job
File > Place Embedded / Linked started with Session::start, and
Session::start_place_bytes for bytes with no path, now run the decode,
flatten, resize and SVG render on a worker (prepare_place) and insert the
layer when the job is applied (apply_place). Inline callers (execute, the
CLI, headless MCP, actions) behave and return exactly as before. Part of
#10031.
* Dropped and placed files no longer freeze the window on desktop
With background jobs on, File > Place and files dropped on the canvas start
the place as a job (JobsUi.place). Free Transform begins when the layer has
landed, a failed or cancelled place is reported and the drop queue goes on,
and a drop that arrives meanwhile waits for it. The place shows its progress
in the status bar instead of the modal dialog, so the view stays usable.
Part of #10031.
* Tests: compare place job results without the layer id
Layer ids come from a counter shared by every session, so a job and an inline place in two sessions never get the same one.
* Dropped files stay with the document they were dropped on
Each queued canvas drop now carries its document's id. A file is placed
only while that document is active: files dropped on a document in
another tab wait until it is back in view, and are discarded with a
message when it is closed. Web drops, read by the shell after the drop,
go to the document the drop landed on.
Before, the next queued file went into whichever document was active
when the previous place finished, failed or was cancelled, in the
background and inline paths alike.
* Remove the unused file_open import
* fix: address background placement review feedback
* fix: preserve drop targets and interrupt placement pixel stages
* fix: qualify placement inbox document identity
* test: wait for background placement in app drop regression
* fix: retain queued drops while external document jobs run
* test: clone rendered pixels from drop-managed buffers
* test: cover band rows channels and queued-drop resumption
* Place decodes and fits in the background: dropping a large file no longer freezes the window
Placing a file decoded, flattened and resized on the UI thread; a large PNG froze the
window for seconds (#10031). `place_bytes` is split into a session read, `prepare_place`
(decode, flatten, fit, translate, SVG render at placement scale) and `apply_place`, and
runs through `jobs::run`, so File › Place and canvas drops run on a worker with progress
and cancel. The window follows the job: status-bar progress, Free Transform when the layer
lands, and drops queued per target document.
Review fixes on the way:
- a path and byte drop queue entry records its target DocId, so files dropped on one
document never land in another after a tab switch, and closed documents discard theirs;
- the closed-document notice stays up for a frame instead of being cleared by the next
queued placement;
- the queue is bounded (32 files / 256 MiB) so drops waiting on another tab cannot grow it
without limit;
- flatten and resize stop at the job's interrupt, and a place skips the modal progress
dialog only when the status bar actually shows its progress;
- the placement's `doc` is kept only for SVG input, through the importer's `is_svg_input`;
- the interrupted-composite test uses an opaque fixture that varies by row and channel, and
checks the composite against the layer's stored pixels.
* Place reads its file in the job, and refuses a huge canvas as before
file.placeEmbedded and file.placeLinked read the whole file in the
command before the job started, so a path dropped on the canvas or
picked in File > Place still blocked the window for the read. The place
now carries the path and reads it in prepare_place: on the job's worker
in the background, on the calling thread inline (wasm, background jobs
off), with the same read_file limit and error text. Whether the source
is a vector one is decided once it has been read.
The job's flatten check returned Other("the document canvas is too large
to flatten"), where the place used to give flattened_checked's
BadParams for file.place naming the canvas size. Both now share
check_flattenable, so the old error type and message are back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Path drops aren't capped, and a discarded drop wakes the queue
The 32-file cap on the drop queue counted path drops, which hold no
file contents while they wait, so dropping 40 files on the canvas
placed 32 and refused 8 where main placed them all. The count cap now
applies to browser drops only (each holds its whole file); the byte
budget still bounds those.
When place_next_dropped discarded a closed document's files it returned
without asking for another frame, so another document's queued files
waited for the next input event. It now requests a repaint when files
remain, keeping the one frame that shows the notice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Raster: ask the allocator whether a big operation fits; PSD save reserves its whole output
New photocraft_raster::headroom: ensure(need) tries to reserve the need plus a margin and frees it
at once (web only; native always passes), below(bytes) tells whether less than that can be
allocated, and simulate_available fakes the answer for tests and diagnostics.
PsdFile::to_bytes reserved only the pixel data, so embedded smart-object data and other tagged
blocks grew the output by doubling, which on the web is one unrecoverable huge realloc. It now
reserves the exact encoded length.
* Big operations that won't fit in memory fail with an error instead of trapping
Place, Image Size, a smart-object re-render and Save now ask the allocator whether their estimated
need can be allocated before they start (photocraft_engine::memory_gate, estimates per operation,
photocraft_io::estimate_export_bytes for saves). A refusal changes nothing and says what the
operation needed and what to do (save, Edit > Purge > All, close documents).
A standing notice warns when less than 512 MiB can be allocated, with hysteresis, so the user can
save before anything traps. Notices can now be sticky, which keeps them past the auto-hide delay.
* Web build: a trap says so instead of freezing the page, and ?free-mb simulates low memory
index.html holds a hidden, static 'PhotoCraft stopped' message with a Reload link. web.rs reveals
it when the module traps (the window error event, or an unhandled rejection from a spawned
future), tells running out of memory from an internal error by the panic message a chained panic
hook kept, and skips the Leave site prompt once stopped. ?free-mb=N makes the memory gates and the
low-memory warning see only N MiB, for checking them in a browser.
* Docs: web build memory handling in the gaps list and the parity assessment
* Engine: use is_multiple_of in the megabyte formatter
* Cargo.lock: the web shell depends on photocraft-raster
* Web boot page: carry the stopped message too, as the test that keeps the two pages equal requires
* Save check counts one copy of the document, and runs once per save
The encoded file's buffer is reserved fallibly, so a save it doesn't fit
in fails with an error instead of trapping; counting it as well refused
saves that would have fitted. On the background-save path the check ran
in write_document and again in start_save, which other callers reach
directly; it now runs only in start_save there.
* Review fixes: warning check allocates nothing, PSD merged image counted, automation saves checked
The low-memory warning's periodic check reads the size of linear memory
instead of reserving a block: wasm memory never shrinks, so a trial every
few seconds raised the high-water mark by up to 768 MiB for nothing. The
operation checks keep their trial reservation.
PSD and PSB saves always build a canvas-sized merged image (a placeholder
without maximum compatibility) and hold it raw and encoded; the save
estimate now counts it. The synchronous automation save checks memory
before encoding, as File > Save does, and its test asserts the memory
error.
The trap listeners go in before the app is built, so a trap while
building it shows the stopped message too, and a memory access out of
bounds reads as unexpected rather than as running out of memory.
* Cover layered PSD preflight limitation
* Fix layered PSD test iteration
* Docs: layered PSD preflight limitation in the gaps list
* Web OOM gate: gate direct exports, truthful advice for unbounded PSD sources (#10024)
* Web OOM gate: estimate layered PSDs per layer instead of refusing every real PSD
The PSD check admitted a file only when its color-mode data, image
resources and layer section were all empty. Every Photoshop file has
image resources (at least the resolution), so on the web nearly every
real PSD was refused for Place, PSD smart objects could not render,
and Image Size was refused for documents holding one, whatever the
free memory (u64::MAX never fits).
The color-mode data and image resources hold no pixels, so they are
now only skipped. A layered file reads its layer count from the layer
info (signed, absolute value) and adds a canvas-sized surface and mask
per layer to the flat estimate. Files that are not 8-bit RGB or
grayscale, keep their layers in a Layr tagged block, or whose sections
can't be read stay unbounded and refused, as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Web stopped page: match trap messages ignoring case
Safari reports the allocation-failure abort as "Unreachable code
should not be executed" and Firefox says "out of memory", which the
case-sensitive matching missed: the trap was not recognised, or was
shown as an unexpected stop instead of running out of memory.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Export estimate: count the merged image OpenRaster writes
The ORA writer keeps the layers but also flattens the document for
mergedimage.png, a 16 B/px composite of the canvas, which the
estimate left out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>