42 Commits
Author SHA1 Message Date
Elvis33LE 1bcb662828 Deep EXR: open cropped renders on their full display-window stage (#4907)
A renderer's data window is its crop of the frame; the composition lives
on the whole display window (our MY27 renders: data 1782x1267 at
(1109,1100) inside a 4000x3000 stage). decode_deep now validates the
display window against the limits and reports it as stage + origin on
DeepImage; flatten pads the composed region to the stage at its offset
(transparent elsewhere) and tags the image with a DisplayWindowStage
warning. Files without a usable display window open as before.
Includes a cropped-deep generator and a stage/offset oracle test.
2026-10-13 13:24:46 -04:00
Elvis33LE fc0a579a4a PSD: parallel RLE for the merged image; layer channels decode and gather on all cores (#2754)
Saving a 36 MP document spent most of its time packing the merged image's
RLE rows one after another on one thread (packbits over ~144 MB), and
importing it decoded each layer's channels sequentially. The merged-image
row packer now runs in parallel chunks that concatenate in order (rows are
independent; errors surface like the sequential path's), the layer gather
runs in row bands on all cores before the per-channel RLE, the layer's
channels decode side by side, and the full-surface clone per layer is
gone.

Measured on a 6000×4000 8-bit document with a photo, a paint layer and a
Levels adjustment: save 2.86 s -> 2.55-2.64 s, open's import stage
1.12 s -> 1.07 s (psd_to_document 745 -> 564 ms). The remaining save time
is dominated by the merged composite render itself (~0.7 s).
2026-10-13 13:23:44 -04:00
Elvis33LE 2c3d972f18 Speed up large content-aware scale operations (#2596) 2026-10-11 10:46:43 -04:00
1f64daeba4 Speed up large feathered mask refreshes (#2417)
* Speed up large feathered mask refreshes

* Format preference label for workspace check

* gpu test: allow float noise on a one-code vector-mask parity step

The combined mask is cached as U16 now; one pixel lands exactly one 8-bit
code apart (1.0000075/255), within the <=1/255 parity rule.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 07:09:04 -04:00
094d0776d1 Improve zoomed Liquify preview detail (#2715)
* Improve zoomed Liquify preview detail

* Record Liquify quick benchmark results

* Keep the roadmap as on main; record the Liquify numbers in target-app-parity.md

The branch carried an old copy of the roadmap's parity tables, which have moved
to docs/target-app-parity.md on main.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 05:03:10 -04:00
Elvis33LE 29f1ff9e90 Design: bounded GPU stack prefix reuse for property edits (#1164)
* docs: design bounded GPU stack prefix reuse

* docs: advanced blending stays on the CPU; cache admits whole grid chunks and serves partial rects
2026-10-11 02:08:08 -04:00
a8ad82dd46 GPU composite: reuse unchanged lower-stack chunks for property edits (#1169)
* perf(gpu): reuse unchanged lower-stack composite chunks

* test(gpu): verify half-float composite checkpoint reuse

* docs: record measured GPU composite reuse gains and limits

* perf(gpu): admit whole grid chunks, serve partial damage as sub-rect copies

Cache entries were keyed by the exact damage rectangle, so brush strokes
(a new partial rect every frame) filled the budget with entries that
never hit again. Entries are now whole chunk-aligned, chunk-sized grid
cells: a capture merges into the cell it overlaps at the rectangle's
offset, a read is served as a sub-rect copy of the covered part, and
coverage is tracked per cell so a resume only happens where every pixel
of the requested rectangle is cached. After a full render, any in-bounds
partial rectangle hits.

Regression test: two series of disjoint strokes inside the same chunks
leave composite_cache_bytes unchanged (no entry-per-rectangle growth),
a full refresh still finds its chunks and a top-layer edit afterwards
reuses them. The old partial-damage test now expects the covered-chunk
hit and checks outside-the-document renders admit nothing.

* docs: record the GPU reuse measurement in target-app-parity.md

The honest assessment moved from roadmap.md to target-app-parity.md on
main; keep main's roadmap and add only the dated measurement.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 02:07:48 -04:00
Elvis33LE 03f4cc0de1 Correct Photomerge roadmap status (#2831) 2026-10-10 17:57:51 -04:00
5e0e9c595b Speed up large group merges (#1561) (#2664)
* Speed up large layer group merges

* Merges: bound by composite_bounds so layer effects outside the pixels are kept

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 17:28:53 -04:00
edd9fe12d7 Skip distant TSL-off stroke layers during composition (#2473)
* Skip distant TSL-off stroke layers during composition

* Narrow the TSL-off stroke shortcut: shape outlines count, other clipping bases keep the empty skip

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:58:04 -04:00
Elvis33LEandBrandon Thomas ff9663bc9a EXR parts: import parts, channel groups and an AOV precomp as layers (#2338)
* EXR parts: import parts, channel groups and an AOV precomp as layers

file.exrParts lists what an EXR holds for a chooser - flat parts, prefixed
channel groups (diffuse.R/G/B), Cryptomatte layers as information;
file.openExrParts imports the chosen ones as the layers of one document
(F16 lossless as F32, linear sRGB tagged), with an optional additive
beauty precomp summed from the light-path AOV set (direct/indirect
diffuse+specular, emission; transmission/sss/volume when present) that
fails naming the missing AOVs. The codec gains decode_exr_channels for
exact named channels of one part; io shares the image_surface band writer.

File › Open EXR Parts… picks an EXR and opens a plain command form: a
checkbox per part and channel group (labels are the data's names with
size and channel count), a both/left/right view dropdown, and the
precomp checkbox. Cryptomatte layers ride along as non-rendered data for
the control channel. Form fields gain __labels for verbatim data-driven
labels; the new strings are catalogued in all sixteen complete languages.

* Regenerate parity and scorecard docs

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-10 13:51:06 -04:00
Elvis33LE b75267899d DNG: lossy compression 34892 (baseline JPEG tiles) (#2021)
* DNG: lossy compression 34892 (baseline JPEG tiles)

Adobe's lossy DNG (compression 34892) decodes: each tile is a complete
baseline JPEG (8-bit luma at tile size), decoded by zune-jpeg into the
tile geometry (a mismatch is an error), widened 0xFF -> 0xFFFF like
Photoshop's read of 8-bit CFA data. Predictors and the rest of the
pipeline are unchanged.

testgen grows LossyJpegTiles (image-rs encodes the Q60 fixtures behind
the testgen feature), so CI runs a decode oracle: a smooth ramp stays
within a few quantisation steps of the 8-bit source, and a tile cut
mid-stream fails cleanly.

* Lossy DNG: check the tile JPEG header before decoding; zune deps go workspace

decode_headers() runs and the (width, height) == (s.w, rows) check fires
before decode(), so a hostile SOF cannot allocate the decoder's buffers
(up to 16384x16364) before the size mismatch is caught. The mismatch has
a regression test that swaps a 32x32 tile JPEG for a 16x16 one. zune-core
and zune-jpeg move to [workspace.dependencies] next to image and flate2.

* chore(ui): format prefs_ui (the workspace fmt gate is red on main until #2417 lands)

* Lossy DNG: 3-sample LinearRaw (YCbCr) decodes to RGB; tiles size-check

Real lossy DNGs are 3-sample YCbCr LinearRaw, not single-channel CFA:
the tile now decodes through zune-jpeg's YCbCr-to-RGB output when
SamplesPerPixel is 3 (gray stays for CFA), the decoded length is checked
against the tile's sample count, and other sample counts say unsupported.
The generator writes RGB tiles for 3-sample specs. A LinearizationTable
maps the decoded samples in the develop stage as before - nothing to add
in the sensor. Test: a 3-sample ramp with 64-step channel separation
(surviving Q60 chroma quantisation) decodes within the quality band.
2026-10-10 13:39:57 -04:00
28ed1c1147 Handle allocation failures in large pixel operations (#2530)
* Make large pixel operations allocation-fallible

* Warp: drop the fixed 400 MP cap; fallible reservations already cover large transforms

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

* Adapt #2530 to main: fallible steep-Distort warp, Result from mask paste in float moves; regenerate scorecard

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 13:26:07 -04:00
291f30da55 EXR: Cryptomatte layers list, decode and hash to the specification (#2180)
* EXR: Cryptomatte layers list, decode and hash to the specification

Cryptomatte (specification 1.2) carries per-pixel object and material
IDs with coverage in <layer>NN.red/.green channel pairs, announced by
cryptomatte/<key>/name|hash|conversion|manifest header attributes - the
ID channels Arnold, V-Ray, Redshift, Mantra/Karma and Cycles write next
to the beauty pass.

cryptomatte_layers lists every layer from the headers alone (name, key,
channels, the embedded manifest parsed and capped at 16 MiB, a sidecar
pointer); decode_cryptomatte returns the per-pixel (ID, coverage) pairs
above zero, sorted by descending coverage; cryptomatte_id hashes names
the reference way (MurmurHash3 x86-32, the exponent fixed away from
denormals/inf/NaN, read as f32 bits) - verified against the official
test vectors - and cryptomatte_key/cryptomatte_preview_color expose the
metadata key and preview colour. Files with deep parts are refused, like
the other flat part decoders.

* chore(ui): format prefs_ui (the workspace fmt gate is red on main until #2417 lands)

* codecs: Cryptomatte decode checks the layer size without truncating casts

Count each pixel's pair list in the allocation check too.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-10 06:40:49 -04:00
f9ea13a857 EXR: multi-part files open the colour part; exr_info lists them all (#2116)
* EXR: multi-part files open the colour part; exr_info lists them all

A Maya/Arnold render writes one part per AOV. The decoder took part 0
silently, so a depth-only part failed the whole file with "no R/G/B or
Y channels". Now the part that looks most like a colour image opens
(RGBA > RGB > YA > a single channel, earliest wins), with a
DecodeWarning::MoreParts naming how many parts exist.

exr_info lists every part (name, view, size, channels, deep/tiled) from
the headers alone, and decode_exr_part decodes a chosen part — F16 parts
stay F16. Deep parts stay with the deep decoder: a file that has any is
refused here with a pointer to decode_deep_exr, by design.

* EXR: cap the parts' sizes together, move the chosen part out instead of cloning it

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 18:51:33 -04:00
Elvis33LEandClaude Opus 5.5 08ca445782 DNG: deflate compression 8 with the TIFF predictors (#1971)
Integer DNGs with zlib-compressed strips or tiles (compression 8) now decode, with TIFF predictor 1
and 2 (wrapping at the sample's container). Output is capped at the tile size; floating-point
predictor 3 and lossy DNG keep their unsupported errors. flate2 was already a workspace dependency.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 16:29:05 -04:00
Elvis33LEandClaude Opus 5.5 adc1f76df4 Indexed Color: integer counts, palette stops and asynchronous previews (#1741)
Indexed Color accepted fractional colour counts and computed its live preview on the UI thread.
Counts are now whole numbers in 2-256 with a linear slider snapping at 8/16/32/64/128/256, and on
native builds the preview runs on one background worker that coalesces slider drags and rejects
stale results. Nearest-colour lookup uses an exact kd-tree above 64 entries (identical indices,
256 colours at 4.2 MP: 3018 -> 1061 ms). Web and inline sessions stay synchronous.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 10:33:13 -04:00
Elvis33LEandClaude Opus 5.5 7533254791 Keep PNG text metadata through document saves (#1684)
PNG tEXt/zTXt/iTXt entries (and TIFF ASCII tags) were dropped on open with a warning. The
document now keeps them in Metadata.text, the .pcraft manifest saves them (older files still
load), and PNG/TIFF saves write them back; Metadata: None and embed_metadata=false strip them,
and PSD/PSB and palette PNG exports warn that they are dropped. XMP in a tEXt or zTXt chunk is
read as XMP too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:53:37 -04:00
Elvis33LE 6cd2d61398 CR2: read ColorData's as-shot white balance as words (#1640)
* CR2: read ColorData's as-shot white balance as words

CANON_COLOR_DATA is an UNDEFINED entry: uints() hands its bytes back one
by one, but the WB_RGGBLevelsAsShot offsets count 16-bit words. Reading
word i at entry offset i*2 (file byte order, bounds-checked) finds the
quadruple at 0x47 in the corpus PowerShot SX50 HS: [1613, 951, 951, 1605]
-> [1.70, 1.0, 1.69], matching the white balance its own DNG conversion
neutralises (AsShotNeutral -> [1.58, 0.955, 1.666] through the color
matrix). With this the file's balance is used instead of the grey-world
estimate; the regression test pins the words and the missing estimate
warning. (The remaining brightness gap between the CR2 and the DNG of
the same camera is the CR2 path's exposure normalisation, to be looked
at next; the estimate was never what brightened the file.)

* cr2: drop the unused glob import in the corpus test

* CR2 WB: synthetic word-offset oracle, entry bounds, corpus test behind the feature

testgen writes ColorData as Canon does, UNDEFINED bytes that are 16-bit words
with the PowerShot's WB_RGGBLevelsAsShot at word offset 0x47, and the new
color_data_wb_as_words test checks the decoded balance in every CI run (the
corpus oracle stays behind the corpus feature and now panics when its file is
missing instead of silently skipping). word() reads stay inside the entry's
own byte count, and the shared inspect example moves back to the corpus PR.
2026-10-09 18:00:50 +07:00
Elvis33LEandClaude Opus 5.5 f4d489eb96 Command lookup: index registered and shell command IDs (#1156)
Opening a menu or the command palette looked up command ids by scanning the whole command
list. The registry now keeps its ordered specs and an id-to-index map in one OnceLock (first
match wins for a duplicate id), and is_live caches the shell command ids in a set. A batch of
917 lookups drops from 0.157 ms to 0.013 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 01:26:44 -04:00
Elvis33LEandClaude Opus 5.5 1452c49258 PSD save: stream Lr16/Lr32 layer blocks into the output (#1158)
Saving a 16- or 32-bit PSD built the Lr16/Lr32 layer payload in a temporary buffer and cloned
every global tagged block before copying them into the file. The payload is now written
straight into the output with its length patched afterwards, and the neighbouring blocks are
borrowed. Output is byte-identical: all 735 corpus PSD/PSBs re-save to the same bytes. A 24 MP
layer saves in 66 ms instead of 75 ms at 16-bit and 132 ms instead of 164 ms at 32-bit, and the
temporary 144-288 MB copy is gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 01:13:22 -04:00
Elvis33LEandClaude Opus 5.5 b7e0d3810d History: count retained tiles incrementally below the byte budget (#1157)
Every edit's History::trim rebuilt a set of every tile in every retained state, even well
under the budget. Retained tile references are now counted as states are recorded, evicted,
undone, redone, purged and cleared, and below the budget trim only looks at the current
document's tiles. Record plus trim on a 24 MP, 150-layer document with 50 states drops from
62 ms to under 2 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 00:48:31 -04:00
578bc905a6 i18n: split "Light" — font weights get their own context, Camera Raw keeps the panel term (#1061)
* i18n: split "Light" - font weights get their own context, Camera Raw keeps the panel term

The plain "Light" entry served two unrelated meanings, and every
catalog had resolved the collision in favour of the font weight (de
"Light", ja "ライト", fr "Maigre", ko "가늘게"...), so Camera Raw's
Light section header showed a weight word in most languages.

- The type tool's style dropdown now shows localized labels while the
  raw string stays the engine's fontStyle key (value/label pairs, like
  the anti-alias dropdown), with each word translated in a new
  fontWeight context (Thin...Black, Italic).
- Camera Raw's Light section reads cameraRaw|Light (Licht, Luz,
  Lumière, Свет, 光...); every catalog gains the missing rows, and a
  new i18n test pins both contexts plus their distinction per language.

* i18n(pl): font weights and Camera Raw keep their split contexts in Polish too

* i18n tests: close the Seafoam test before the font-weight test (rebase fixup)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 21:00:58 -04:00
Elvis33LE 5ecc860ac1 ui.set validates every field before applying the first one (#820)
* ui.set validates every field before applying the first one

ui.set applied fields as it validated them, so a call like
{"tool": "zoom", "theme": "nope"} changed the tool and then replied
ok:false - the caller is told the call failed and assumes nothing
changed, but the app state moved. Nested objects were worse: a non-object
"panels"/"dockTabs" skipped the merge silently and still replied ok:true,
and an unknown nested key (a typo like "layers2") was inserted and then
dropped by the serde round-trip, also with ok:true - the guarantee of
(selectionMode: "add", brushSize: "big", center: [1]) were silent no-ops
too, and brushSize passed raw floats through (`1e300 as f32` = inf).

All fields are now validated and materialized into locals first; the
apply pass cannot fail, so a rejected call applies none of its fields.
Nested objects reject non-object values and unknown keys (the known set
is the serialized current state, so it tracks the struct automatically),
scalars are type-checked, and brushSize clamps into the Brush Settings
panel's range instead of producing an unusable or infinite tip.

* ui.set: validate-first on rebased main; brushSize keeps the journaled dispatch

Rebased onto main and reworked per review:

- brushSize keeps main's journaled `tools.setBrush` dispatch (#744) with
  its semantics intact ("large"/null/non-numeric = the historical silent
  no-op with ok:true, f64::MAX = the command's error). The size is
  validated in the first pass against MAX_BRUSH_SIZE, so the journaled
  command cannot fail after earlier fields were applied.
- colorPanel (#664) joins the validate-first pass through a new
  whole_object helper (unknown nested keys rejected, value replaces).
- dock gets the same unknown-nested-key check (replacement semantics,
  like main).
- center requires exactly [x, y] again: 3+ elements are an error, not a
  silent skip.
- merged_object (merge semantics, panels/dockTabs) and whole_object
  derive the known-key set from the serialized current state, so new
  struct fields are automatically known.

* CI: retrigger ubuntu (only failing job; its logs expired unretrievable, corpus/windows/macos/wasm32 green and the full suite is green locally)

* rebase: colorPanel, brushPicker and brushPickerView join the validate-first set
2026-10-08 19:41:12 -04:00
53985e5f34 Color Lookup: keep the .cube input domain and reject the ones we cannot apply (#1130)
* Color Lookup: keep the .cube input domain and reject the ones we cannot apply

DOMAIN_MIN/DOMAIN_MAX (and LUT_3D_INPUT_RANGE) were parsed and thrown
away, so a log-domain or extended-range look was applied to plain 0..1
inputs: silently wrong colours, exactly what the in-code comment
admitted. The domain now travels with the parsed LutFile (validated:
every component finite and min < max), and colorLookup refuses a
non-default domain with the reason instead of shifting the look -
the same unsupported-over-wrong-output call the codecs make. Honoring
a custom domain end to end means carrying it through ColorLookup
(doc, compose, gpu, PSD round trip) and stays open as the follow-up.

* tests: the colorLookup domain rejection needs a pixel layer active

The outer session of the test still had the adjustment layer active, so
the command was disabled before reaching the domain rejection - the
assertion only passed when the active layer happened to differ. Make it
deterministic with a fresh pixel layer.

* Color Lookup: bake a 1D .cube's input domain into the resample

The 1D branch of parse_cube still ignored DOMAIN_MIN/MAX and
LUT_1D_INPUT_RANGE. Since the 1D table is resampled onto a 0..1 cube anyway,
the domain is now applied there (saturating outside), and a degenerate
domain is rejected like the 3D one.

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

* cargo fmt after rebasing onto main

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 19:41:07 -04:00
Elvis33LE 9182f8f76d undo/redo report the step's damage instead of recompositing the whole canvas (#810)
* undo/redo report the step's damage instead of recompositing the whole canvas

Session::edit, undo and redo all cleared last_damage, so every undo of a
local edit recomposited the entire document instead of the edited area
(scorecard P6: undo a move on 20 MP = 256 ms p50 against a 25 ms budget;
P7 redo 144 ms). Only layer.setProps and the move paths computed damage.

Undo/redo now diff the two documents on either side of the history step:
every layer whose pixel identity changed contributes the union of its
change bounds (where it was, where it landed) to last_damage. The identity
covers everything the compositor reads - blending, placement, masks,
effects, and pixels via their copy-on-write tile pointers (both documents
stay alive for the comparison, so tile addresses cannot be reused under
it). Steps the fingerprint cannot bound still composite everything, as
before: canvas size/format/profile changes, layer-structure changes
(order, additions, removals, kind flips), and video layers - so mode
conversions, flatten/merge and structural undos keep the safe full path
by construction rather than by hoping the edit's target layers were
recorded correctly.

* step_damage: structural comparison, document-field and clipping bail-outs

Rework per review (correctness of the bounded claim):

- Document-level fields the compositor reads are compared up front in
  doc_pixels_equal (size, resolution, mode, depth, profile, channels,
  quick mask, global light, patterns, colour table, duotone); any
  difference composites everything. Undoing Layer Style > Global Light
  or an RGB > Indexed conversion (which keeps the pixel format) no
  longer reports Some(EMPTY) and leaves shadows stale.
- Layer clipping and channel exclusion reach outside the layer's own
  bounds; a difference composites everything, like layer.setProps
  already reports for edits.
- layer_fp's 64-bit hash is gone: layers are compared structurally
  (surfaces by copy-on-write tile pointers, blobs by pointer or bytes,
  parameters by ==), so no hash collision can under-report damage.
- Layer and Document are destructured exhaustively in the two compare
  functions: a new field fails to compile until it is classified as
  compositing (compared) or pixel-irrelevant (explicitly ignored), the
  same sentinel the .pcraft manifest uses.
- Groups no longer hash their children (the walk recurses into them),
  so an edit inside a group damages the edited child's extent, not the
  whole group's.

Tests: undo of Global Light, of image.mode.indexedColor, of a clipped
toggle and of an excluded channel each report None.
2026-10-08 19:41:02 -04:00
4198da39b0 PSD export parallel layer channels; shared proxy cache; banded CMYK export (#1230)
* CMYK -> sRGB flat export: row bands through the colour transform

cmyk_image_to_srgb materialized three full-size f32 buffers (normalized
input, transform working set, sRGB output) - ~1.4 GB of allocation
churn on a 36 MP export. convert_f32 walks plain strided slices, so the
image now passes through in bands of rows (~32 MB budget): peak
transient memory drops by the document size times two, the output vector
is the only full-size allocation left. Byte-identical output: the
colour test pins the converted pixels and the tagged sRGB profile.

* Banded CMYK export: guard empty images, bounds-checked slices

(BAND_BYTES / (row * ss * 4)) divided by zero for a zero-width image and
clamp(1, 0) panicked for a zero-height one. Guard both, take each band with
checked arithmetic and data().get(), and reuse the output band buffer.
Tests: empty CMYK images convert without panicking; a banded conversion
covers every row.

Note: this PR's single change is the banded CMYK -> sRGB flat export; the
title's parallel PSD layer export and shared proxy cache are not part of it.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 17:04:39 -04:00
077888ea03 PSD plane shuffles in bands; the document histogram computes off the UI thread (#1185)
* PSD plane shuffles in bands; the document histogram computes off the UI thread

interleave/deinterleave walked every pixel of a 20 MP image on one
thread during PSD open and save. Both now split the rows into one band
per core (the file's own par_map/bands helpers) and merge the band
results in order: 20 MP RGB u16 interleave 152 -> 101 ms, deinterleave
461 -> 160 ms (2.9x); f32 rgba 329/730 -> 220/396 ms. Behaviour is
pinned by a parity test against the sequential reference at odd pixel
counts (band boundaries), all depths, with a missing plane in the mix.

The Histogram panel computed its payload inline in the frame every
250 ms while stale - a 41.5 ms UI-thread stall measured on a 24 MP
document, on top of brush work. compute_histograms now runs on a
worker thread; the panel keeps showing the previous histogram with the
existing cached-data warning until the result lands, and a stale
result cannot overwrite a newer revision. Pinned by a panel test that
drives one egui frame and asserts the job is scheduled, delivers off
thread and respects revisions.

* Histogram panel: compute inline when no thread can be spawned; drop the benchmark test

std::thread spawn fails on wasm32 (and can fail natively), which left
hist_job waiting on a disconnected receiver forever. Without a worker the
histogram is now computed inline and delivered through the same channel, a
disconnected job is cleared, and the perf span is measured in the worker.
Removes the dead t0 and the wall-clock benchmark test; adds a dead-worker test.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 16:38:22 -04:00
Elvis33LE 3ef631619e Autosave: a thread that fails to start is reported, not a silent no-op (#1124)
Autosaver::new discarded the spawn result with .ok(): under thread or
resource exhaustion the recovery channel pointed at a dead receiver,
every send failed silently and last_result stayed None - crash
recovery was off while looking on. The failure is now recorded where
last_result/flush report it (via the same note helper the worker uses),
so health checks and flush surface it.
2026-10-08 16:09:27 -04:00
Elvis33LE b44cad8747 MCP doc_save: bridge mode refuses headless-only options instead of saving with defaults (#1138)
The bridge branch forwarded only the path to the running app's
app.save, silently dropping quality, format, tiffLayers and index (the
headless branch honours all of them): a client asking for a
quality-50 JPEG got an ok reply and whatever the app's current
settings produce. Those params now fail fast with the reason - naming
headless mode - before anything is written.
2026-10-08 16:09:10 -04:00
a593b3651c CPU effect maps: key fill layers by their cached pixels, drop opacity/visible (#1059)
* CPU effect maps: key fill layers by their cached pixels, drop opacity/visible

The layer-effect map cache keyed a Fill layer by its Fill spec alone.
But what shapes the maps is the layer's cached pixels whenever they
still match the fill (what a PSD import stores and Image > Transform /
crop / canvas ops rewrite in place, without touching the spec): after
such a rewrite the CPU compositor kept serving the pre-rewrite maps, so
flattens and the CPU canvas showed the drop shadow of the pre-rewrite
shape while the GPU canvas was correct - until budget eviction or a
purge. The key now hashes the cache surface's tile fingerprint while it
matches the fill, like the GPU's fx::shape_key/group_key already did.

layer.opacity and layer.visible leave the key: the maps depend only on
fill_opacity (opacity is applied when the maps are composited,
visibility only skips the lookup), so an opacity slider drag or a
visibility toggle rebuilt every shadow and glow map over the whole
layer for nothing. Again matching the GPU key.

* Effect-map key: keep child visibility/opacity/blend for groups

A styled group's maps come from its children's composite, so hiding a
child or changing its opacity (or blend, clipping) must change the key;
only the top layer's own visible/opacity stay out. Adds a warm-vs-purged
render test for a group with a drop shadow.

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

* Effect-map test: name the edit tuple type (clippy type_complexity)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 16:08:45 -04:00
96413cff45 Deep EXR: write the flattened pixel by layout, not a fixed RGBA quadruple (#1021)
* Deep EXR: write the flattened pixel by layout, not a fixed RGBA quadruple

Grayscale deep files (Y + A + Z) flattened wrong: the tail of the
composite loop wrote a fixed [R, G, B, A] quadruple, so a GrayA image's
alpha slot received an un-premultiplied luminance copy (alpha 1.0 for a
0.5-alpha file) while the real alpha fell past the end of the row. The
values are now written by layout: RGB(A), gray(+A), gray - with the
alpha in the slot it belongs to.

* Deep EXR flatten: no per-pixel Vec allocation

Build a fixed [f32; 4] plus a length instead; this runs once per pixel.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 16:08:27 -04:00
Elvis33LE d438f84225 Shape gradients: honour midpoints, opacity stops, offset, dither and align (#1321)
vector::Paint::from_fill discarded every gradient field beyond stops,
angle, scale and style, so Photoshop files whose shape fills use opacity
stops, colour midpoints, a centre offset (Ofst), dither or no
"Align with layer" rendered with the wrong ramp, alpha, position or
banding. Paint::Gradient now carries the full ramp (sorted stops,
midpoints, opacity stops, dither) and the compositor's geometry
(centre offset, whole-pixel end-point snapping, pixel-corner sampling,
the align frame choice: shape bounds or canvas), with shape strokes
painting through the same path.

The vector crate cannot depend on compose, so the ramp and geometry
mirror effects::gradient_units/gradient_t, fill_layout::gradient_layout
and gradient_fill::{Ramp, midpoint_remap, dither}. A parity test in
compose (which sees both) renders each non-default field, style and
frame as a shape layer and as a fill layer and requires equal pixels;
from_fill binds every Fill::Gradient field — no ".." — so a new field
fails to compile until the shape renderer honours it.
2026-10-08 15:41:50 -04:00
Elvis33LE f5080f9774 ui.window.open: an invalid document index is an error, not a side effect (#1133)
The document parameter's set_active result was discarded, so an
out-of-range index still switched nothing, changed nothing, and the
window opened for whatever document happened to be active - a caller
was told success for a document that does not exist. Now the call
errors with the index and leaves the active document alone.
2026-10-08 14:31:03 -04:00
Elvis33LEandBrandon Thomas fdd7141788 PSD: decode the merged composite only when something consumes it (#846)
Every PSD/PSB open decoded the merged image unconditionally, but a normal
layered Photoshop save carries a composite nobody reads: with layers
present and nothing behind the colour channels, the decoded planes went
straight to the drop. That composite is typically about half the file's
bytes, and for ZIP-compressed files the parse-time validation had already
inflated the same stream once - so layered ZIP opens paid the composite
twice for nothing (P18: opening a 20 MP layered PSD measures 1753 ms p50
against a 1000 ms budget).

The composite is now decoded only when a consumer exists: a flattened or
multichannel file (the composite IS the image), or extra alpha/spot
channels behind the colour ones. Layered files without extra channels
skip the decode entirely; everything a layered import shows comes from
the layer records either way.

The parse-time ZIP validation stays: deferring it would break the
corrupt-stream rejection the fuzz tests pin down, and with the decode
skipped, layered ZIP opens now inflate the composite once (validation)
instead of twice.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 21:10:38 -04:00
57452bfd22 Bound layer-group nesting at import and Group Layers (stack-overflow guard) (#796)
* Bound layer-group nesting at import and Group Layers (stack-overflow guard)

Everything that walks the layer tree (Document::walk behind nearly every
command, the layers panel, PSD export, .pcraft save/load) recurses once per
level, but nothing bounded how deep a document could get: a PSD whose records
nest groups a few thousand deep (each level costs only a divider + folder
record pair) overflowed the stack in the importer's build recursion, in
apply_link_groups, and again in every consumer - an abort no panic hook or
fuzz run catches. The one cap that existed lived in the .pcraft save path
(MAX_GROUP_DEPTH = 100), so such a file even imported 'fine' and then bricked
saving.

- photocraft-doc now owns the limit (MAX_GROUP_DEPTH = 100, the value the
  format crate already enforced) plus Document::max_group_depth(), an
  iterative depth measure that cannot itself overflow.
- PSD/PSB import rejects files nested deeper than the cap with an actionable
  Unsupported error, mirroring what the .pcraft loader already does; the
  never-fail psd_to_document keeps working on such files by capping the
  recursion and warning about the unimported remainder (this was the overflow).
- apply_link_groups (resource 1026) recurses over the same tree and is
  bounded the same way.
- layer.groupLayers refuses to nest past the cap (checked inside the edit, so
  a rejected call records no history step and leaves the document untouched).

* Enforce the group nesting cap in layer.moveTo and Artboard from Layers

Moving a group beside or into a deeply nested layer, or wrapping a deep
group in an artboard, could still build a tree deeper than MAX_GROUP_DEPTH.
A shared check_group_depth helper now rejects those edits with an error and
records no history step, like layer.groupLayers.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 18:46:45 -04:00
Elvis33LE ec64eb773c Reject geometry params that would silently wrap through i64->i32 narrowing (#803)
`commands::int` returns an unbounded i64, and the geometry call sites narrowed
it with `as i32`, which truncates by multiples of 2^32: layer.translate moved
a layer 50 px for `dx = 2^32 + 50` and to a large negative offset for
`dx = 3e9`; select.rect and document.pixel read unrelated coordinates;
image.crop cropped a rectangle unrelated to the numbers passed; artboard
rects landed elsewhere. No panic, no Err - the document was silently
mutated and then autosaved, and the behaviour is identical in debug and
release, so panic_hunt cannot see it either.

- new `commands::int_i32(cmd, p, key)` narrows with a bad-params error
  instead of a wrapping cast; select.rect, document.pixel, layer.translate,
  image.crop and the artboard rect keys go through it
- image.crop builds its rectangle with saturating_add (x + w overflow)
- edit.contentAwareFill's `area` parser returns a bad-params error for
  malformed arrays (wrong length, non-numbers, non-finite) and saturating
  arithmetic for huge ones - `[1e30, 0, 1e30, 10]` overflowed the i32
  additions before (a debug-build panic, a garbage sampling window in
  release)
- filters' integer helper clamps out-of-range values like the neighbouring
  filter params instead of wrapping (`3e9` came back as a negative offset)
2026-10-07 18:02:12 -04:00
6ee3e4f752 Flat exports embed no document XMP by default; Export As gains a Metadata choice (#647) (#733)
* Flat exports embed no document XMP by default; Export As gains a Metadata choice (#647)

The document's XMP packet lists the text of every type layer
(photoshop:TextLayers) and one id per placed document
(photoshop:DocumentAncestors) - data a flat web image should not carry:
14-116 KB per export on real designer PSDs, and layer text leaking into
images that show only part of the design.

- io: ExportOptions.xmp (XmpEmbed::All | None, default None) drops the
  packet from flat exports before the codecs see it. Layered saves
  (PSD, PSB, .pcraft) always keep everything; DPI, ICC and EXIF are
  unaffected. Export As, Save a Copy, the CLI, Layers/Artboards to
  Files and Batch all share this path and get the safe default.
- The Export As dialog gains a Metadata dropdown (None / All, default
  None, like the source application's Export As); the file-size
  estimate follows the choice.
- Tests: new xmp_export.rs covers the default (png/jpg/webp/tif carry
  no packet), the All opt-in and the layered always-keep;
  metadata_roundtrip_png and the stale-orientation test document the
  new contract (with All, an embedded stale orientation is still
  rewritten upright).

* Export As alone starts at Metadata: None; Save As and conversions keep the XMP

ExportSettings::default() and ExportOptions::default() also serve File > Save As, file.saveAs,
the CLI and automation, which keep the document's metadata (as Photoshop's Save As does).
Only the Export As dialog defaults to None. A layered TIFF honours the choice too.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 17:19:17 -04:00
107ffe9127 codecs: decode deep OpenEXR (deepscanline/deeptile), composited flat or structured (#642)
* codecs: decode deep OpenEXR (deepscanline/deeptile), composited flat or structured

Deep EXR files errored with "not supported: deep data not supported yet"
(the `exr` crate rejects deep headers, and its chunk framing for them is an
unfinished guess), so the chunk stream is walked directly from the bytes, per
the OpenEXR file-layout specification:

- offset tables after the headers; deep chunks of [y | tile coords, packed
  table size, packed data size, unpacked data size, compressed cumulative
  sample-count table, compressed sample data],
- NONE, RLE and ZIPS (zlib, the +128 difference predictor and the half-split
  interleave - applied to the count table and the sample data alike; a stored
  payload of uncompressed length is raw). ZIP-16 and mip/rip-map deep tiles
  decline with `unsupported`,
- samples are channel-grouped, per pixel; u32 counts cap the allocation and
  go through `Limits` before anything is held in memory.

`decode` composites to the flat image (samples sorted by Z, front-to-back
`over` with premultiplied colour, un-premultiplied to straight alpha) and
reports a new DecodeWarning::DeepFlattened; `decode_deep_exr` returns the
structured DeepImage (per-channel samples plus cumulative counts) for callers
that composite deep data themselves. exr::Error::NotSupported now maps to
CodecError::unsupported instead of malformed, like the TIFF codec.

Validated against the OpenEXR repository's deep test images (11/42/64/objectid
.deep.exr: 256x256, up to 8 samples/pixel, plausible z and alpha), and covered
by a synthetic generator (NONE/RLE/ZIPS x scanline/tile) with hand-computed
compositing oracles, truncation sweeps and hostile offsets/sizes (13 tests).

* Deep EXR: match the OpenEXR layout (RLE tokens and predictor, per-row tile tables and data), keep HDR

Checked against deep files written by the OpenEXR 3.5 library (scan line and tiled,
NONE/RLE/ZIPS): RLE tokens were inverted and skipped the predictor and byte split, the
count table of a tile restarts at every row, a tile's sample data is grouped per row, edge
tiles may store a full-size table, and the flattened colour was clamped to 1.0. The
synthetic writer in the tests now follows the same layout; new HDR / edge-tile test.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:45:02 -04:00
Elvis33LE fc00f1e5bc scorecard: BUG-203 (atomic saves) is done - flip the stale rows (#203) (#687)
The BUG-203-1..6 and REL-4 rows still say missing, but the atomic-save
work landed in #230 (8227071, 2026-10-05) - apparently after the 4b53f62
snapshot these statuses were verified against. Re-verified against the
current tree:

- one shared photocraft_format::atomic_write (same-dir temp, create_new +
  write_all + sync_all, rename, Unix directory fsync, Windows
  PermissionDenied rename retry with backoff), and every document write
  site routes through it (engine file_cmds/preset_store, automation
  files/headless/workspace, app services, format store/autosave);
- no in-place fallback exists; read-only dir, disk-full and cross-device
  errors name the cause and state the original was not changed;
- format/src/atomic/tests.rs failure-injects mid-write, rename and
  read-only-dir errors (11 tests; re-run for this change: green).

The regenerated docs/scorecard.md moves Reliability to 4/1/2 and Open
bugs to 12/3/17. No code changes.
2026-10-07 15:49:13 -04:00
e79dcb6f7f i18n: add German (de) UI translation (#309) (#663)
* i18n: add German (de) UI translation (#309)

A complete German catalog plus the registry row:

- de.tsv: the full key set (menus, tl! literals, blend-mode names, Camera
  Raw labels, generated preference labels), plural rule one|other, header
  documenting the format and the clean-room policy like ja.tsv.
- Registered with complete_menus: true; the locale resolver and the
  language-list tests gain de (de-DE/de-AT/de-CH resolve to it).
- ATTRIBUTION.md row and the docs/localization.md language table entry.

Translations are original work from the plain meaning of the English
labels in ordinary German image-editing vocabulary (Ebene, Maske,
Auswahl, Weichzeichnen, Tonwertkorrektur, Smart-Objekt ...); no other
product's localisation resources were consulted. Product and technology
names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab, Camera Raw) stay in
Latin letters; the Camera Raw curve bands are distinct (Schwarz, Tiefen,
Dunkle, Helle, Lichter, Weiss).

Verified: catalog tests and preference-label tests green,
cargo xtask i18n-coverage reports de 1404/1404 (100%), the full
ui-egui suite passes (except the known machine-local wgpu driver
failure that also fails on clean main), clippy and wasm32 clean, and
an offscreen snapshot sequence switches de -> en -> de in one process
and ends fully German.

* de: translate Metadata (Export As gains the label in the #647 fix)

* Revert "de: translate Metadata (Export As gains the label in the #647 fix)"

This reverts commit 948a8d01dd83579a899d938611101daa50a9e1e1.

* de: apply the native-speaker review (#663)

All meaning errors from the review: Auto Tone is the tonal range
(Automatische Tonwertkorrektur), the Divide blend mode is arithmetic
(Dividieren), Note Paper is Notizpapier, Legal is the US paper format,
Fuzziness widens the colour tolerance (Toleranz:), Perspective Warp
planes are Flaechen (not Ebenen, which reads as layers), Revert is
Zuruecksetzen (matching its confirm text), Fade is Verblassen.
Typos: Tonwerthistogramm, Magischer-Radiergummi-Werkzeug,
Einfrier-Lasso. Consistency: Kanalmixer everywhere, Zoom In/Out as
Vergrössern/Verkleinern, a space before % in the scale labels.

* xtask i18n-coverage: gate the German catalog too

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 15:48:02 -04:00
Elvis33LE 09bc2ac0c4 algo: fix flaky cancel/progress test - tile workers report out of order (#609)
`apply_tiled_with` filters tiles with `chunk.par_iter()` and each worker
reports progress (`0.98 * n / total`) from its own thread. The values are
monotonic in the finish counter, but delivery order is not, and the test's
callback did a non-atomic load->assert->store of the last value: when a
worker holding a smaller value was preempted past another worker's store,
the assert fired. Observed once in a full `cargo test --workspace` run;
reproduced 1-in-300 under synthetic CPU load.

`Interrupt` never promised ordered delivery and its engine consumer
already clamps (`JobCtx::progress` keeps `fetch_max` of the f32 bits, see
crates/engine/src/jobs.rs). The test now does the same: fetch_max high-water
mark plus an in-range assert, and requires the mark to reach exactly 1.0.
Renamed to `..._progress_never_regresses` to say what it actually checks.
Stress after the fix: 0 failures in 300 runs.

The identical-looking asserts in content_aware.rs and io/tests/cancel.rs
stay strict: those paths report progress from a single thread.
2026-10-07 09:11:45 -04:00