* Channels: the panel menu has the channel commands, New Spot Channel among them (#11451)
The Channels panel's menu had no channel items, so New Spot Channel... and
the rest could only be reached from a row's right-click menu. It now lists
New Channel..., Duplicate Channel, Delete Channel, New Spot Channel...,
Merge Spot Channel, Channel Options..., Split Channels and Merge
Channels..., each acting on the targeted channel and greyed where it can't:
Duplicate takes a single colour, alpha or spot channel, the composite of a
one-channel image, the Quick Mask or a targeted layer mask. Channel
Options... in Quick Mask mode now opens the targeted alpha or spot
channel's options, not Quick Mask Options. The panel menus run their
commands through the menu bar's enabled rules, so the Layers panel menu
greys its dialog items the same way too. The labels are in every catalog.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Channels: New Channel... and Merge Channels... ask first, from the panel and row menus (#11451)
The panel menu's Merge Channels... ran channel.merge with no parameters, and
the engine merged the first grayscale documents into RGB and closed them with
no choice; New Channel... added a channel straight away. Both now open a
dialog when called without parameters, as New Spot Channel... and Channel
Options... already did: New Channel... reuses the Channel Options dialog
(name, colour indicates, colour and opacity), and a new Merge Channels
dialog picks the mode and the open grayscale document for each channel. The
channel rows' menu had the same two items and goes through the same path.
With parameters (scripts, the New button) both still run at once.
The Merge Spot Channel test now checks pixels, so a delete couldn't pass it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Channels: Merge Channels waits for three grayscale documents and the dialog offers only modes they fill (#11451)
With two grayscale documents open, Merge Channels... was enabled and its
dialog repeated a document for the third channel; OK then failed in the
engine and the dialog closed. The engine's rule now needs three documents
of one size (no mode merges fewer), so the menus grey the item below that.
The dialog offers only the modes the open documents can fill (RGB and Lab
from three, CMYK from four), greys OK with the reason while the picks
repeat, keeps itself open when OK is refused, and comes back with the
engine's message if the merge itself fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Channels: the Merge Channels dialog keeps its picks by document id (#11451)
The dialog kept the picked source documents by tab index, and background
work can still run while it is open, so a document moved or closed in the
meantime could leave another one at a picked index; OK would then merge,
and close, the wrong document. The picks are now document ids, resolved to
the current tabs when OK is pressed: a closed pick, or one that is no
longer a grayscale document of the same size, is refused with the reason
and the dialog stays open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Since #10867 the non-deleting crop turns a 2000 px layer to 2001 px bounds, so
the budget refusal names 200100 x 200100 px.
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>
* PSD: a flat file's named extra channels are not its transparency
A PSD without layer records had its first extra channel read as the merged
transparency, so the first spot or alpha channel of a flattened document
vanished and its ink punched a transparent hole in the image (psd-tools
cmyk-alpha-spot.psd, cmyk-spot.psd). When the alpha channel names (1045, or
1006 when 1045 is missing) name every extra channel and the first is not
"Transparency", there is none. The import also reads channel names from
1006 when 1045 is missing, and a spot whose DisplayInfo colour space is
unknown takes its ink from the Alternate Spot Colors (1067).
* channel_map: read 1067 records with as_chunks
* Preview spot inks over the RGB/CMYK composite as an overprint
Visible spot channels of RGB and CMYK documents print over the canvas
composite with the Multichannel ink formula, in Channels panel order,
instead of a tint; alpha channels and Quick Mask still tint over them.
Spot channels open visible, and the PSD thumbnail (1036) prints them.
flatten, export and the merged image stay free of spot inks. Ink
colours go through the document's CMYK profile, and into an RGB
document's own profile.
Refs #10249
* channel_view: test solidity over a gray picture
* Thumbnail: matte only inked pixels; test the merged planes directly
Review feedback on #10276: pixels without spot ink keep their
transparency in the thumbnail buffer, and the thumbnail test checks the
saved merged process planes instead of a re-imported flatten.
* Thumbnail spot inks: sample big boxes on a 4x4 grid
Review feedback on #10276: reducing each spot channel no longer scans the
whole document; a thumbnail pixel averages at most 4x4 samples, so a huge
document costs about h x 4 row reads per spot.
* Spot overlay: composite a proxy for the first draw of big documents
The overprint needs the composite under the inks. Rendering the full-resolution
composite to sample every factor-th pixel cost ~1 s for a 24 MP CMYK document.
A proxy document picks the same pixels (nearest-neighbour downsample) for about
1/factor² of the work. Documents with layer effects, vector masks or feathered
masks keep the full-resolution path.
* Proxy: downsample rows in parallel; the overlay proxy leaves out unused channels
The spot overlay's proxy shrank the spot channels, selection and Quick Mask that an
RGB or CMYK composite never reads: about two thirds of its cost. downsample now
reads its rows in parallel, 64 at a time.
* Spot overlay proxy: keep the full-resolution composite under uncached pattern fills
A pattern fill renders its tiles at their own size on a proxy, so the proxy would
sample other pattern pixels. Unless the fill's matching cache is shrunk with the
proxy, use the full-resolution composite.
* Spot overlay proxy: hidden layers don't decide the composite path
Hidden layers and the contents of hidden groups are not composited, so a hidden
feathered mask or pattern fill no longer forces the full-resolution composite
(the same rule as proxy_faithful).
* Update crates/compose/src/multichannel.rs
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
* Spot overlay: only inked pixels replace the canvas; inks through the display transform
Where every visible spot is 0 the overlay stays transparent, so the normal
canvas (display LUT, proofing, 32-bit preview, full resolution) shows; an
empty spot renders no composite. Printed pixels go through the canvas
display transform (sRGB encoding of linear composites, monitor profile,
Proof Colors), keyed into the texture. Ink colours come from one InkSpace
per render: the document's profiles are parsed once, not per spot per
damage rect.
* test(compose): cover hidden spot preview and document approximation
* fix(ui-egui): preserve solo spot channel display when process colours are hidden
Clicking a spot channel row in the Channels panel targets the channel alone
and hides all process colour channels (`vis == 0`). Overprint inks were
previously extracted from `overlays` before checking `vis == 0`, leaving
`overlays` empty and causing the solo channel view to fall back to an active
layer mask (or return `None` if the layer had no mask).
Keep the spot channel in `overlays` so `vis == 0` can select it as the solo
grayscale picture, separating overprint inks only when process colours are
visible (`vis > 0`).
Adds a regression test targeting an existing spot channel in RGB and CMYK on
an unmasked layer and editing it.
* channel_view: document the spot overprint preview as an approximation
* test(ui-egui): solo spot channel wins over an unrelated layer mask
* channel_view: sRGB spot inks, lazy display transform, inked-area composite
Review fixes for the spot overprint preview:
- RGB spot colours are sRGB, like the CMYK, Lab and gray ones: InkSpace now
converts them into an RGB document's own profile too. In a linear (EXR/HDR),
Adobe RGB or ProPhoto document an RGB ink showed the wrong colour (brightened
in a linear one once the canvas sRGB-encoded it).
- The display transform is looked up only when the view is redrawn and inks
are shown, not every frame before the cache check; display_transform_for
borrows the proof state (proof_ref) instead of cloning a default CMYK profile.
- Visible spot channels without ink, with nothing else shown, draw no texture:
render returns None and ensure remembers it, instead of uploading an
all-transparent document-sized texture. A damage re-render that finds nothing
clears its rect, so erased ink never lingers.
- Printing a few inked pixels composites only their bounding box when that is
cheaper than the whole (proxy) composite, and a cached composite is borrowed
instead of copied.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.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>
* Select Subject: keep parts that run off the image edge and detached parts near the subject
The backdrop pass trained its colour model on the whole frame outside the first
result's box and fixed it as background, so a subject cut off by the edge (a
portrait's torso) taught the backdrop its own colour and was dropped. Frame
pixels of a side that the rest of the frame cannot explain are now left out of
the model and left to the cut; a floor or table running up the sides stays
backdrop.
The first pass also dropped every part under a fifth of the largest, losing a
head behind a thin neck or a tail. Non-speck parts next to the main one are kept.
* Select Subject: stray edge pixels stay backdrop; cache test checks the recompute
A lone odd pixel in the frame (noise, a dead pixel) is not the subject running
out of the picture: only patches of at least half a frame square are released.
The #2865 cache test asserted a bit-identical selection after painting a corner
pixel. One changed pixel can move the cut's edge slightly (the original
algorithm already did on other noise), so it now checks that the mask was
recomputed and still selects the subject.
* Test subject selection against edge and nearby clutter
* Keep narrow edge clutter out of subject selection
* Preserve cropped subject components at image edges
* Keep edge poles and diagonal streaks out of subject selection
A detached part counted as near the subject when its bounding box
overlapped the main part's margin box. A thin diagonal streak from a
corner (a cable, a rail) has a box that reaches the margin without any
of its pixels doing so, and was kept as part of the subject. A part is
now near only when some of its pixels lie inside the margin box.
A patch that runs along an image edge for two frame widths was released
from the backdrop model whatever its colour, so a pole or door frame
along a side edge next to the subject's shoulder was left to the cut,
which could grow the selection along it. A wide patch is now released
only where its extent along the edge overlaps the first-pass blob's
margin box. Beside the subject that box is cut out of the frame, so the
pole only remains above or below it and is not released, while a torso
cut off below a head (the case the release exists for) still lies under
the head's box. Requiring the subject's colour for wide patches too, or
requiring them to touch the box, would drop exactly that torso: its
colour is not in the head-only blob and it is far from the head's box.
The margin box computation moves into margin_box so subject_at_edge
and its tests share it. Tests pin the diagonal streak, the pole beside
the shoulder (on all four edges, with the cropped torso still
released) and clutter that is too far or too small.
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>
* Select: Grow and Similar index colours under a byte budget and fail instead of aborting
ColorMatcher::new returns a Result, the colour tree is a flat node array reserved at its exact size, and Grow/Similar's mask and frontier stack use fallible allocation so a failure keeps the selection.
* Select: a 1 GiB colour index on 32-bit, and Sample All Layers reports a failed composite
The web build's 256 MiB index budget (about 4.2M colours) refused Grow and
Similar on a noisy 24 MP photo whose index fits easily; 1 GiB admits every
distinct colour of such a photo. The 64-byte charge per colour is an average,
not a worst case, so the comment now says so, and the point list the tree keeps
gives back its doubling slack once the dedupe set is gone.
Sample All Layers renders the composite inside compose, so the selection tools
now pass an allocation checkpoint before it, like the other commands that hand
big work to another crate, and a failure there keeps the selection.
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>
* Type: a duplicated type layer is renamed after its new text (#9023)
A type layer's name follows its text until the layer is renamed (#483),
but only while the name is exactly the text. A duplicate is "Title copy"
(or "Title copy 2"), so editing its text kept the old name. A name that
is the text plus Duplicate Layer's " copy" or " copy N" now counts as
named after the text too, so the copy takes its new text's name; any
other name is still kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Type: a copy of a "… copy" type layer follows its text too (#9023)
Duplicate Layer names a copy of "Draft copy" "Draft copy 2", so the
check now compares the names' roots (the name before " copy" or
" copy N") rather than adding " copy" to the text's name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Type: a copy keeps its "… copy" name until its text changes (#9023)
A type layer named after its text was renamed after every edit through
the type helpers, so with "Title copy" now counting as named after its
text, Change Type Size, a style-only or kerning edit, Convert to
Paragraph or Point Text, Area Type and Type on a Path renamed the copy
"Title". The name now follows the text only when the text changed; a
layer named exactly after its text gets the same name as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Type: a "… copy" name the user typed is kept when the text changes (#9023)
A type layer named "Title copy 2" by hand, with "Title" as its text,
counted as a Duplicate Layer copy and was renamed after its next text
edit. The document now remembers the names the user typed (Rename
Layer, a name Duplicate Layer's dialog didn't offer, type.edit's name)
for the session, and such a name is kept. A name Duplicate Layer made
still follows the text, and so does one an undone rename brings back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Type: a name given in the Layers panel or by type.create is the user's (#9023)
The Layers panel renames in place with layer.setProps, and type.create
takes a name: neither recorded it in typed_names, so a copy-shaped name
typed there ("Title copy 2" on text "Title") was replaced by the next
text edit. Both now record it, as Rename Layer and type.edit's name do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Type: a name typed for a duplicate into another document is the user's (#9023)
Duplicate Layer into a new document and Copy Layers to Document take a
name but returned without recording it, so a copy-shaped name typed there
was replaced by the next text edit. A name other than the copy's own is
now recorded in the destination document, as the in-document duplicate
does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* Add recipe-driven PSD export checks
* Exercise PSD recipe runner with the headless CLI
* Make PSD oracle tests isolate temporary paths
* Keep inspect output unchanged; PSD write runner as an xtask library
- info --detail adds text runs, anti-aliasing and full layer-effect settings;
the control channel and MCP inspect output is unchanged.
- The CLI test uses xtask's new library target instead of #[path] includes.
- The runner records revision and dirty state as unknown outside a git checkout.
* psd-write-oracle: name the layer in each check, document versioning, add recipe tests
* Clean up PSD oracle test fixtures on failure with tempfile::TempDir and add docstrings
* fix(artboards): move a clipping group between boards as one (#10512 follow-up)
Dragging the base of a clipping mask onto another artboard left its
clipped layers on the old board, released, covering that whole board
(auto_nest moved the base alone and clip_group::settle released the
layers that lost it).
auto_nest now carries the layers clipped to a moving base along, in
order, directly above it on the new board (or at the top level), so
settle keeps them clipped. A clipped layer never changes board on its
own, moved or not: it shows only over its base, so it goes where the
base goes. When any layer of the group can't go (Prevent Auto-Nesting,
the group depth limit), the whole group stays. Still one history step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(paste): keep a paste from off the boards visible (#10538 follow-up)
Copying a layer that isn't on any artboard and pasting with a layer on
board B selected put the paste inside B, beside that layer, outside B's
area, so B hid it. The clip has no source board, so board_shift doesn't
move it onto B, and layer_place::insertion_point only checked the
content centre when the anchor was the board itself.
insertion_point now applies the same centre rule beside a layer on a
board: a layer edited right after it is made (Place::AutoEditable:
Paste, Place, Type, Shape) whose content is centred off the anchor's
board goes above the board at the top level, as it already does with
the board itself selected. Content centred on the board (Paste at the
view centre there) still lands inside it. Place::Auto (New Layer, fill
and adjustment layers) is unchanged: those layers are empty or cover
the canvas.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(paste): shift a mask or channel paste between artboards (#10538 follow-up)
Pasting into a layer mask, alpha channel or Quick Mask (paste_to_target)
still placed the pixels with paste_offset alone, so with the target
layer on another artboard they no longer landed where a layer paste
does: at the old spot on the source board (in place) or the middle of
the view.
paste_to_target now applies board_shift first, as paste does, for Paste
and Paste in Place alike; Paste Into / Outside keep centring on the
selection (board_shift skips clampToCanvas: false).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(artboards): name the auto-nest plan entry type (clippy type_complexity)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep the board shift for a mask paste into a position-locked artboard; an off-board paste above an artboard lands unclipped
- board_shift skips the position-lock guard when the paste adds no layer (a mask paste), since mask edits don't check position locks.
- insert_new: a layer placed directly above an artboard is never clipped to it, and goes past the clipped layers above the slot.
- Tests for both; each fails without its fix.
* Paste into an alpha channel or the Quick Mask keeps document coordinates; only new content above an artboard is unclipped
An alpha channel or the Quick Mask covers the whole document and is on no
board, so the selected layer's board no longer shifts a paste into it. The
unclip-above-an-artboard rule in insert_new now applies only to
Place::AutoEditable (new content placed off a board), so a clipped layer
put Place::Above a board (the layers after a pasted board, a duplicate)
keeps its flag as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
- Batch and Create Droplet dialogs get a Set dropdown (each set once, each
action name once) and send the selected action's set, so a loaded library
with one name in two sets runs the chosen set's action; createDroplet now
honours `set` with a named action (dialogs.rs:512 finding).
- Droplet capture rewrites an ordinary call to the action's name only while
that name is unique in the exported list; otherwise it keeps the exported
index, so a call to the second of two same-named actions still reaches it
(outside-diff finding on actions_cmds.rs:337).
- Save and Close refuses a source whose bytes Batch would write in another
format (a Cineon source is encoded as PSD), instead of writing PSD bytes
under the source's .cin name (batch_run.rs:918 finding).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A direct Batch in a script or a CLI command list stops it when it is cancelled,
cannot write its error log, or ran out of script steps; per-file errors alone
still continue (CodeRabbit, outside-diff findings on automate_cmds.rs and
photocraft-cli document.rs).
- Recorded Save steps count against the script's step budget, and a Batch whose
files failed with the budget spent carries scriptLimitError, so Browse reports
the failure instead of ok: true (yoriswind).
- actions.rename rewrites a named Batch, nested Play or Conditional call only if
it resolved to the renamed action before the rename; a Batch with explicit
steps is left alone (yoriswind: two same-named actions in different sets).
- Shape Blur's run admission counts each worker's f64 prefix sums and, for a
custom shape, the kernel mask kept for the run plus its worst-case coverage
runs (yoriswind).
- Every sampler that takes the primary release (Color Picker, Curves/Levels and
Properties eyedroppers, Curves Cmd-click, Color Range) ends a transform handle
drag and a Move drag begun before it, not only the Color Picker (#11210 review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main fixed the exposure formatting itself in be9f0c250, so what is left
here is the regression coverage the report asked for, asserted through a
real EXIF block: the two reported values (0.031714 s -> 1/32 s,
0.0215 s -> 1/47 s), a reciprocal that rounds to a whole denominator
(1/3 s), and the three-significant-digit decimal fallback for a time too
far from any whole denominator (0.2731 s -> 0.273 s).
Fixes#6958
Co-authored-by: highperfocused <highperfocused@pm.me>
Thanks @wanikhawar — merging.
**Summary:** In headless MCP, waited `command_run` / batch steps run job-capable commands as engine jobs (lock released while polling), with progress notifications and cancel/abandon on request drop. Fixes the “cancel does nothing / inspect blocks” behaviour for long filters. Cap of 16 background jobs; docs updated. Stack base for #8134.
Thanks @wanikhawar — merging.
**Summary:** Sampled brush tip mip pyramids are reused across strokes and symmetry through a bounded LRU (64 entries / 256 MiB); Edit ▸ Purge All releases the cache. Bench + purge/render tests lock the behaviour.
Thanks @wanikhawar — merging.
**Summary:** Grow and Similar now match selected colours via a BVH `ColorMatcher` (L∞ per channel) instead of one AABB that falsely included intermediate hues; only exact-zero alpha is the transparent wildcard. Contiguous Grow seeds from the selection frontier rather than every selected pixel. Tests + bench included.
Engine: droplet dependency capture skips disabled steps; a nested Batch
that was cancelled or could not write its error log fails the caller;
runDroplet forwards overrideOpen; actions.rename follows named Batch
steps; Batch steps inside a script count against MAX_SCRIPT_STEPS;
output claims compare destination identity; Save and Close never
replaces a read-only source. ATN: Md must be a BlnM enumeration,
adjustment-layer names must round-trip, Calculations exports a missing
blending as Multiply, Apply Image refuses unnamed-layer transparency.
Filters: Shape Blur records its resolved geometry, reserves its kernel
memory first, and reports a preparation failure as an error, not a
cancel; Crop keeps an artboard preset when the size is unchanged and
fits the board on a turned crop.
Codecs/cms/algo: zero-axis FITS image HDUs are empty; a GIF walk that
breaks off before the trailer fails closed; two-value .3dl shaper rows
are applied; fragmented custom kernels are refused and their work polls
cancellation.
UI: Conditional branches play through panel playback; paused actions
refuse to resume after being replaced; Playback Options OK validates
its fields; transform double-click ignores a hidden pivot; path boxes
snap to a stationary selection; modal blocking uses the effective tool;
a picker-consumed release clears snap lines and ends a Move drag; the
Crop preset control shows Selection and checks only it; Crop box undo
restores frame_from_selection; Bosnian fill labels.
Tests: timing asserts moved to an ignored test, plus the review's
coverage nitpicks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Art History Brush: Opacity on the options bar (#10263)
The Art History Brush's own brush opacity is shown in its options bar
(0-100%) and sent to paint.artHistoryBrush. It is kept per tool, saved
across launches, set by the 1-0 keys and restored to 100% by Reset Tool,
as the Clone Stamp's is. Tests cover the UI wiring, per-tool persistence,
Reset Tool and stroke output at 8/16/32-bit.
* Update crates/ui-egui/src/panels/options_bar.rs
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
* Art History Brush starts at 100% opacity on first use
A tool picked for the first time takes the outgoing brush; the Art
History Brush now takes it at full opacity, as the issue's default asks,
so a 30% Brush no longer makes the first Art History stroke 30%. Other
tools keep inheriting opacity.
* Art History Brush: document Opacity in the command contract and carry it in tool presets
---------
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
* Reduce Photomerge input and fill memory
* Bounded working-memory budgeting and safe allocation for Photomerge
* Photomerge memory: estimate warped sizes, no RAM ceiling, shared RAM detection
- Budget is half of physical RAM (at least 1 GiB) with no 8 GiB ceiling.
- The pre-flight estimate uses each image's warped extent and the band
grid's exact bound (band_bytes_bound), at the levels seam_blend uses.
- Auto-Blend keeps its old batching with no single-band cap.
- multiband returns a Result instead of a blank image on failure.
- Physical-memory detection moves to photocraft_engine::memory; the GPU
budgets reuse it, so PHOTOCRAFT_RAM_MB means the same in both.
- Document memoryBudget; restore comments; tests for the band cap and the
budget precedence.
* Photomerge: lower the blend's peak and estimate it by phase
- The band build makes its binary coverage straight from the alpha and
writes each Laplacian level in place, so a band holds two fewer planes.
- The weight sums are freed before the collapse, which adds each level
in place and frees it as it goes.
- The estimate is the largest phase (warp, ROIs, bands, collapse, output,
fill) rather than the sum of all of them, and the bands get what is
left of the budget after the ROIs and accumulators.
Every output pixel is unchanged: each change drops a plane or does the
same elementwise operation in place.
* Fix Photomerge band budget validation and fallible pyramid allocation
* Keep reference pyramid test helpers infallible
* Photomerge: bound source loading with the memory budget
Resolve the budget before any source is read, and admit each flattened
source before its surface is allocated. The accounting charges the
retained surface, the same-canvas temporary the flatten writes while the
source document is alive, and a file import's encoded bytes, with
saturating math so an overflow cannot wrap under a finite budget. Merge
to HDR Pro passes no budget and keeps its previous behaviour.
Tests cover the exact retained-plus-temporary charge, a refused source,
the within/over-budget command paths, the post-load estimate path, and
the unbudgeted HDR loader.
* Photomerge: bound source reads and imports by the remaining budget
Read each source file with what is left of the budget as its limit, lower the importer's own PSD/TIFF preflight or the flat decoders' pixel allowance to the remainder after the retained sources and encoded bytes, and refuse formats with no pre-decode estimate. Charge the decoded document's actual pixels (every layer) plus the flatten's working and output surfaces before flattening. Merge to HDR Pro keeps its unbudgeted read-and-import path.
* Remove redundant TIFF decode options update
* Clean up Photomerge budget Clippy findings
* Accept early decoder admission in source budget regression
* Photomerge: report unreadable sources as file errors; never reserve past a read limit
- A budgeted Photomerge reported a missing or unreadable source file as
'not enough memory'. Only a file over the remaining budget, or one memory
cannot hold, is a memory error now; others are file errors, as without a budget.
- read_all_with reserved a whole read chunk before checking the caller's limit
for a file that grew after it was statted. It now refuses first and caps the
reservation at the limit.
---------
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* Move tool: auto-nest layers into the artboard they're dropped on (#10218)
In Photoshop a layer dragged with the Move tool onto another artboard joins
that board, and one dragged onto the pasteboard leaves its board. PhotoCraft
had the Prevent Auto-Nesting lock but not the behaviour, so a layer dragged
onto another board stayed in its old board and was clipped away.
layer.translate takes an opt-in "nest" flag. With it, a moved layer whose
centre now lies on a different board joins that board on top of its stack,
and one moved off every board goes to the top level above the board it left,
in the same undo step. The Move tool's drag release sets it, which covers
Alt-drag copies; nudges, actions and scripts are unchanged. Boards,
the Background, empty layers, over-deep groups and layers locked with Prevent
Auto-Nesting stay where they are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Auto-nesting: true, floored middle for the board under a layer
Review follow-up: board_under found the middle of a dropped layer by halving
each edge first, so a layer at 9..11 counted as centred at 9 and could join
(or stay in) the wrong one of two boards meeting at x = 10.
The middle now comes from one helper, layer_place::centre, shared with the
placement of new and pasted layers: the edges are added in i64 before halving
and the sum is floored, so -4..3 (middle -0.5) is left of a board starting at
x = 0, not on it. Adds regressions for both.
Co-Authored-By: Claude <noreply@anthropic.com>
* Document the auto-nesting rule and exclusions in layer.translate
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* Type: add OpenType Stylistic Sets submenu (Set 1–20)
Type > OpenType > Stylistic Sets toggles ss01–ss20 through new
type.openType.stylisticSets.setN commands on the usual formatting scope
(selected characters while editing, else the selected type layers).
The toggle direction and the menu checkmark follow the style at the
selection start. Updates TYPE-218-5 and G8.
Closes#10285
* Stylistic Sets: round-trip test through a full PSD; scorecard parity count
* Stylistic Sets: test PSD round trip on an opened file; record the new-document PSD gap
Documents made in PhotoCraft get no Txt2 block on PSD save, and the set
mask lives only there, so their sets are not saved to PSD yet. The engine
test keeps the .pcraft round trip, an io test covers the PSD round trip
of a file with type objects, and TYPE-218-5 and G8 record the gap.
* Stylistic Sets: share one tag table between the engine and the menu
The engine exports STYLISTIC_SET_TAGS; the Set 1–20 commands index it and
the menu checkmarks read it, so the 20 tags are listed once.
* Stylistic Sets: check malformed menu ids through the resolver, not the menu list
* Scorecard: TYPE-218-5 notes PSD save of stylistic sets in documents made here (#10308)
---------
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
* Fix duplicate gradient preset names on import and restore
* Preserve addressable gradient names during duplicate migration
* Preserve gradient identities during import and restore
* Assert restored selected gradient retains its stops
* Pass replace in the duplicate gradient names test after #10640
Main now imports a re-imported .grd beside its group unless replace is set
(#10640). The test re-imports over the same group, so ask for that.
* Use the same replace default for gradient name allocation and group insertion
After #10640 the group insertion defaults replace to false, but the name
allocation still defaulted it to true, so a default re-import created a
sibling group with the same preset names. Resolve replace once and add a
regression for the default and explicit replace paths.
---------
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Accelerated / Step by Step / Pause For (whole seconds 1..60) and Create a
single history state are saved in prefs.actions and set together through
actions.setPlayback, validated before any value is stored. Headless
actions.play keeps running the whole action at once.
Co-Authored-By: Zer0C00L01 <12090083+Zer0C00L01@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#10788 adds a preserve_alpha argument to tga_image and makes
flat_transparency_and_channels fallible. The renderer export path the
batch already holds still called the old signatures; it now passes
opts.preserve_alpha and propagates the error like the CPU path.
Both PRs carried the same three tests (scoped source slots, droplet
Batch namespaces, droplet scoped variant limit); git kept both copies.
Keep one of each.
Co-Authored-By: yoriswind <yoriswind@yahoo.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Stop step with its dialog toggle on now shows a Message window with
the recorded text and Continue (when the step allows it) and Stop,
keeping the steps already applied; with the toggle off it ends playback
silently, and Insert Stop turns the toggle on, as measured in the
reference application. Allow Continue is read from the paused playback,
not from the dialog's fields. Agents and headless playback keep the
engine's Stop semantics.
A pause inside a called action now carries its callers (at most 16):
confirming finishes the called action and then each caller after its
call, replayed inside the callers' playback frames so recursion checks
and the single history state still hold. A silent stop ends the callers
too. Fixes#8386.
Co-Authored-By: bommerts <44821497+bommerts@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Paste into another artboard keeps the place on the board (#8788)
Copying from one artboard and pasting with another board (or a layer on it)
selected put the pixels at their old canvas spot on the first board, where
the second board hid them, or in the middle of the view; the new layer still
went beside the selected one, inside the second board.
The clipboard now remembers the artboard a Copy came from (the copied layer's
board, every copied layer's when several are selected, or for Copy Merged the
board under the middle of the copied area). Paste and Paste in Place into a
different board of the same document move the pixels by the offset between
the two boards' origins, so they land at the same spot on the new board, and
the layer goes inside it as before. Pasting on the same board, outside the
boards or into another document is unchanged, and Paste Into / Outside keep
centring on the selection.
Co-Authored-By: Claude <noreply@anthropic.com>
* Paste between artboards: copied layers and true middle for the source board
Review follow-ups:
- With a pixel selection, Copy takes the active layer only, even when several
layers are selected, but the source board was worked out from every
selected layer, so selected layers on another board cancelled it. It now
uses the layers Copy actually takes.
- Copy Merged found the board under the middle of the copied area by halving
each edge first, which rounds 9..11 to 9 and could pick the wrong one of two
boards that meet. The edges are now added before halving.
Adds a test for each, both failing before.
Co-Authored-By: Claude <noreply@anthropic.com>
* Paste between artboards: locked boards and the placement midpoint
Review follow-ups:
- With a locked board itself selected, the pasted layer goes above the board,
not into it, yet it was still moved to the board's spot. The board shift is
now skipped in that case; a layer inside a locked board still lands on it.
- layer_place found the board a new layer lands on by halving each edge
first, so a paste moved to 19..21 had its middle at 19 and was turned away
from a board starting at 20. The edges are now added before halving, as for
the copy's source board.
Adds a test for each, both failing before.
Co-Authored-By: Claude <noreply@anthropic.com>
* Paste between artboards: floored placement middle, pastes join smaller boards
Review follow-ups (FredApps):
- The placement middle truncated toward zero, so content at -4..3 (middle
-0.5) counted as 0 and a board starting at x = 0 took it in. One helper,
layer_place::centre, now adds the edges in i64 and floors the sum; Copy
Merged's source board uses it too, and so does #10512's auto-nesting, with
a byte-identical layer_place.rs so the two merge in either order.
- Pasting onto a smaller board kept the offset from its origin, but the
layer's middle then fell past the board's edge and it was placed outside.
A paste moved onto a board now joins it; the board clips the rest.
Tests: negative-boundary placement, a document without boards, a smaller
board (offset kept, board clips), each failing before where it applies.
Co-Authored-By: Claude <noreply@anthropic.com>
* Paste between artboards: keep a clipped paste's clipping on a board
Review follow-ups (CodeRabbit):
- A paste that joins a board always lost its clipping. It now keeps its own
clipping flag (the copy's, from before placement) while the board holds a
layer it can clip to, and drops it on an empty board, as a new layer does.
- Documented that boards more than the 32-bit range apart can't shift a paste,
which then keeps its usual placement rather than wrapping around.
Adds a test (a clipped group pasted onto a board with a layer and onto an
empty one), failing before.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
An adjustment layer's Make step now loads its `Nm `, `Md ` and `Grup`
keys as the command's `name`, `mode` and `clipToPrevious` params and saves
them back. An unreadable value keeps the step as an unsupported kept step;
`layer.newAdjustmentLayer.*` validates the new params like New Layer and
clips through the clip-group rules. Fixes the remainder of #8384.
Co-Authored-By: bommerts <44821497+bommerts@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A straight Crop tool crop (image.crop with an explicit rectangle) of a
document whose only artboard covers the whole canvas now fits that board
to the new canvas and clears its size preset. Before, the board kept its
old size and hung past the cropped canvas.
Measured in the reference application: its Crop tool in an artboard
document edits the artboard ("Edit Artboard": the board takes the frame,
content stays where it is), so the visible result matches. Image > Crop
with a selection does nothing there, Image > Trim keeps the board's
size, and a turned crop is impossible with artboards.
Left out of the original PR: the fit on selection crops, Trim and turned
crops (they differ from the reference application), so crop_doc and
Export Layers to Files are unchanged; the printer-query commits
(superseded on main) and the shapes.rs hunk (already on main).
Co-Authored-By: durtyrobbin <76844303+durtyrobbin@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Skip compositing for geometric measurements
* Preserve simple PDF shapes and glyph source ranges
* Preserve text and shapes in PDF export (#10269)
- Export topmost simple text layers as vector glyph outlines with selectable text
- Embed ActualText unicode span and invisible text operators (mode 3 Tr) for 100% extraction
- Support stroked shapes in vector PDF export (centered solid strokes, dashes, line caps/joins)
- Retain glyph style indices in text rendering outlines
* Drop a product name from the glyph outline doc comment
* PDF export: keep path text and synthetic bold raster
* Fix PDF Unicode selection and respect font embedding permissions
* Render the text cache in PDF raster fallback fixtures
* Fix PDF vector selection eligibility and finite text matrices
* Preserve upper PDF vectors and reject overflowing curve controls
* Fix text shape import in PDF regression test
* Cover PDF Unicode map blocks and leading selection whitespace