The reference's source-over computed s * sa / sa over an empty backdrop,
one ulp off the stored ink. Behind a Paint.NET Color Burn of a Posterize
that lands exactly on 73.5/255, the ulp flipped the 8-bit rounding and
random_documents_match_the_reference failed on main. Keep the source ink
when nothing shows through or both inks agree, as cmyk::normal does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A preview at a reduced work scale (4 %) takes a stored 2 % pattern to
0.08 %, which Placement::anchored and the GPU planner's placement treated
as invalid and drew at 100 %. Any positive finite scale now draws,
floored at 1e-6 to keep tile coordinates finite.
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
* 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>
An unclipped adjustment layer in a special blend mode at zero Fill still
round tripped its backdrop through brightness. In 32-bit float documents,
Linear Burn and Linear Light moved the inks by a rounding error (0.7 became
0.70000005), and an ink sharing the source's brightness took the source's
value. Zero Fill is the backdrop, so return before blending.
Thanks @wanikhawar — merging.
**Summary:** Aligns CMYK special Fill / Posterize with the RGB compositor’s clipped vs unclipped split from #9501 (`fill_special=true` for unclipped adjustments so Fill changes the blend; clipped keeps Fill as coverage). Posterize goes through `posterize_in` with sample depth. Regression test covers Levels/Posterize across pass-through, isolated group, clipped-on-base, and combined stacks at 8- and 16-bit.
Prefer this over overlapping #10985.
The preview mapped artboard edges with div_euclid, so a board edge in the
second half of a cell dropped (upper edge) or kept (lower edge) a cell
against its sample at c*k + k/2, and a board narrower than a cell could
vanish. Map each edge to the first cell sampling at or after it.
Addresses CodeRabbit review r4454088367 on #5332.
A partial reduced cell at the document's right or bottom edge sampled
c*k + k/2, past the document, so the provisional preview showed the
layer's default there instead of the edge pixel. Clamp the sample to the
last document pixel (document bounds, so neighbouring pages agree).
Addresses CodeRabbit review r4454088350 on #5332.
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.
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.
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 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.
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.
* perf: reuse large buffers so a pinned glibc trim threshold costs no speed (#10479)
With MALLOC_TRIM_THRESHOLD_ set (#8743), glibc maps every allocation over
128 KiB fresh, so tiles, compositor bands and filter scratch were page-faulted
in on every call: viewport compose 3.6x slower, Gaussian Blur up to 1.8x,
.pcraft save 2.4x.
- photocraft_raster::scratch: a process-wide, capped pool of large Vec
buffers (u8/u16/f32/[f32; 4]). A miss drops the oldest buffers worth its
size down to a 256 MiB floor, so peak memory stays near glibc's defaults.
The engine releases the pool after any command that closes a document,
Headless::close does too, and an idle thread releases it after 2 s unused.
- Tiles, compose Buffer/masks/bands, algo Image and the blur paths, engine
adjustment/filter scratch, GPU tile upload and io import buffers go through
the pool.
- .pcraft save reuses one zstd FrameCompressor per worker (byte-identical).
- interactive_bench reports minor page faults per run.
36 MP, trim threshold set vs glibc defaults: viewport 5.6 vs 6.1 ms (was
22.7), Blur r4 445 vs 437 ms (was 852), Curves CPU proxy 35.0 vs 35.7 ms
(was 50), flatten 146 vs 147 ms (was 237), save .pcraft 251 vs 230 ms (was
545). RSS after closing a 5400x7200 document: 21 MB with trim set.
* fix: corpus tests build again; return remaining pooled buffers (#10479 review)
- Corpus-gated io tests and the Photoshop-oracle test moved `.px` out of a
`Buffer`, which now implements `Drop` (E0509 with `--features corpus`):
use `into_px()`.
- io: masks, the merged image, extra channels, smart-filter masks,
smart-object pixels and patterns give their `interleave` buffers back to
the scratch pool after writing them.
- compose: recycle the mask values of a pass-through group, `effect_shape`
and both `split_parts` callers.
- Styled Free Transform preview copies each band out instead of moving it,
so the band returns to the pool.
- interactive_bench: the fault count covers each whole run, untimed setup
included; say so.
* fix: recycle the masks of adjustment-atop and shape layers (#10479 review)
* fix: recycle effect hiding masks (#10479 review)
* fix: recycle knockout, CMYK and GPU shape masks; release the pool when Split/Merge Channels close a document (#10479 review)
* fix: release the pool when a closed document's cancelled workers exit; idle-release switch for tests; don't pool PSD planes (#10479 review)
* fix: release the pool for workers cancelled before their document closes; take the pool lock for budget and idle-release changes (#10479 review)
* fix: idle release rechecks activity under the lock; try_take_exact releases the pool and retries before failing (#10479 review)
* test: cover a successful try_take_exact miss; name the failed-reservation test for what it checks (#10479 review)
* fix: pool the final GPU effect shape buffer and return it after use (#10479 review)
* docs: describe scratch pool lifecycle regression invariants
* test: verify polling releases cancelled document scratch buffers
* perf: recycle remaining transform selection and PSD scratch buffers
* fix: free PSD gather bands before allocating channel planes
* test: assert exact scratch take reuses pooled storage
#9512 made the RGB compositor and the GPU blend an adjustment layer's Fill
inside the special eight (Fill is part of the blend, Opacity alone mixes it
in). The ink compositor's mix_adjusted still took Opacity x Fill as coverage,
so a Linear Burn Levels layer at Fill 71% exported Y 0.957 where the display
shows 0.988, and the_split_route_agrees_with_the_exact_path failed whenever
the proptest drew such a stack.
mix_adjusted now does what composite.rs does, and the test reference model
does the same. Regression test: the shrunk case plus every special-eight mode
at Fill 40% with a mask, against the reference and the split route.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reverts #8039 ("Compose: Extend Softer outer glows past Size (#7899)"),
which added a range-scaled fading tail beyond Size to Softer outer glows,
on the CPU compositor and the GPU one alike, and widened the effect
margin by it.
The reference application, measured: Outer Glow, Softer, Size 60,
Range 50 % (and 100 % alike), white, Normal, Opacity 100 %, Spread 0,
Noise 0, on an opaque white shape over black (240 x 40 document, shape
over the left 100 px, full height, sampled at row 20): the glow reads 4
at 50 px from the edge, 1 at 54, and 0 from 56 to 70 px. It reaches zero
just before Size; there is no tail beyond it.
The reverted code (the two-box tent) gives, in that setup, 8-bit levels
4, 1, 0, 0, 0 at 50/54/56/60/70 px for Range 50 % (unrounded 3.56,
1.20, 0.49, 0, 0) and 2, 1, 0, 0, 0 for Range 100 % (1.78, 0.60, 0.25,
0, 0); #8039's tail lifted 56-62 px above zero. So the revert stays as
is, CPU and GPU unchanged from before #8039.
Conflicts were formatting-only (later rustfmt of #8039's lines) in
compose effects.rs, effects/paint.rs and gpu fx/builders.rs; resolved to
the pre-#8039 code. The GlowSource/GlowTechnique imports a later commit
kept in gpu fx/tests.rs for #8039's test go with the test.
New test softer_outer_glow_fades_out_before_its_size renders that
document through the CPU compositor at Range 50 % and 100 % and checks
50 and 54 px within 2 levels and 56, 60 and 70 px at exactly 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Port behavior and regression coverage into current modules; preserve current upstream behavior.
Reconcile captured Pen and Freeform targets with current resumed-subpath, Type Path, Path Operation and draft recovery behavior. Valid source-owned tab switches commit to captured original target; invalid snapshots cancel with a diagnostic; refused commits retain draft and suppress automatic retries. Preserve current Saved(index) API and hidden-work-path protections, register all new ownership/gesture tests and add original-target tab-switch/deleted-target cases. Includes verified compile/API corrections from prior checkpoints.
Port behavior and regression coverage into current modules; preserve current upstream behavior.
Current modular compositors already implement spatial Dissolve for adjustments and finished effects. Preserve equivalent implementations and newer Fill/HDR behavior, append approved missing CPU/GPU regression coverage.
Port behavior and regression coverage into current modules; preserve current upstream behavior.
Use shared zoom-corrected brush resize and HUD while preserving immediate press ownership and anchored outline when hover is absent. Keep original resize regressions. Fix duplicate identical Dissolve test registration and missing test-only UnicodeString import reported by 94-head check.
Port behavior and regression coverage into current modules; preserve current upstream behavior.
Current CPU and GPU already implement binary clipped Dissolve at document coordinates. Preserve current equivalent implementation and stronger negative-origin/depth regression; retain original named regression and added GPU parity coverage.
Port behavior and regression coverage into current modules; preserve current upstream behavior.
Integrated independent exact shape paints and atomic pattern clipboard snapshots into current modules, preserving current soft-mask coverage, shape cache keys and gradient stop caps. Shared bounded COW pattern sampling now retains minification filtering; shapes/clipboards resolve lazy catalog entries to full pixels. Preserved CPU/GPU/depth/model regression matrices; also corrected missing glow-test enum imports found by the 80-head check.
Port behavior and regression coverage into current modules; preserve current upstream behavior.
Extended current crop menu with Reset and shield toggles, preserving newer preset management, overlays, ownership checks and no-menu-during-gesture logic. Ported reset-frame no-op/undo semantics into current CropState/release and crop commit; retained original regression cases in split modules.
Port behavior and regression coverage into current modules; preserve current upstream behavior.
Added bounded Softer outer-glow tail in current CPU paint and GPU glow-builder modules, retaining original tent convolution and current program dispatch; registered exact tail/kernel regression and expanded render margin only for outer Softer glows.
Port behavior and regression coverage into current modules; preserve current upstream behavior.
Port responsive three-column Layer Style with owned right-side actions, preserve saved defaults/Blend If and current Curves/modal routing, and keep all original layout/Cancel tests registered.
Port behavior and regression coverage into current modules; preserve current upstream behavior.
Judge Blend If using the adjustment result before Opacity and masks in CPU/GPU, with the original regression in the current test module.
Port behavior and regression coverage into current modules; preserve current upstream behavior.
Combine Fill-aware adjustment blending with stable HDR coverage and add cross-feature CPU/GPU regression matrix.
Main now carries #9031 (Halftone) and #8923; this branch keeps only what it adds on top: the guard for a setting beside a Gallery stack's effect list, its test, and the PSD open warning for patterns that paint nothing.