* Keyboard Shortcuts: Accept and Go to Conflict / Undo Changes, and reserved keys (K4a)
A key that another command holds now offers Photoshop's two answers under the warning.
Accept and Go to Conflict takes the key from the other command at once (its main key or
an alternate) and selects it for a new one. Undo Changes puts the edited command's main and
further keys back as they were. OK without either still accepts, as in Photoshop.
Keys the system takes before the app sees them are refused with a message: Alt+Tab,
Alt+F4, Ctrl+Alt+Delete, Ctrl+Esc and Ctrl+Shift+Esc off the Mac; Cmd+Tab, Cmd+` and
Cmd+Opt+Esc on it.
* Keyboard Shortcuts: a conflict goes stale when the bindings change after it (Reset All, Use Default, Delete, ×, Escape, a set switch); Accept takes the key from every holder; Linux reserves its own keys; doc comment back on shortcut_text
Smart Sharpen (filter.sharpen.smartSharpen) and Dust & Scratches
(filter.noise.dustAndScratches) smart filters are now mapped both ways in
the PSD filterFX list instead of being dropped on save and opening as
uneditable entries.
- Smart Sharpen: filterID 1131 (numeric), stringID class smartSharpen;
useLegacy bool, Amnt UntF #Prc, Rds UntF #Pxl, noiseReduction UntF #Prc,
blur blurType enum. PhotoCraft removes a Gaussian blur, so it writes
blur GsnB with useLegacy false. Entries with Lens Blur (Photoshop's
default), Motion Blur (with Angl), legacy mode, any other key, wrong
types/units or out-of-range values stay verbatim. presetKind
(presetKindType) is accepted on open but not kept or written: the
engine takes no such parameter, and Photoshop leaves it out of entries
with explicit values.
- Dust & Scratches: filterID / class DstS; Rds and Thsh long (radius
1..500, threshold 0..255; PhotoCraft params are rounded on write).
Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back (Smart Sharpen default / Gaussian / Motion,
Dust & Scratches 1,0 and 4,20). Tests cover the measured shapes, option
round trips, malformed descriptors kept verbatim and hostile params.
Scorecard FILE-215-2 note and file-format parity doc updated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
smart_map.rs maps four more filters both ways in filterFX:
- Diffuse: filterID `Dfs `, Fltr `Dfs ` { Md = DfsM enum Nrml / DrkO / LghO /
anisotropic (long id) } <-> filter.stylize.diffuse `mode`.
- Trace Contour: `TrcC` { Lvl long 0-255, Edg = CntE enum Upr / Lwr }
<-> filter.stylize.traceContour `level`, `edge`.
- Crystallize: `Crst` { ClSz long 3-300 } <-> filter.pixelate.crystallize.
- Pointillize: `Pntl` { ClSz long 3-300 } plus the entry's BckC as the
`background` colour <-> filter.pixelate.pointillize.
Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back. Our random `seed` has no PSD key and is not
written. Missing keys, wrong value types, out-of-range numbers and unknown
enum values keep the entry verbatim; values with no PSD equivalent on save
are reported instead of guessed.
Scorecard FILE-215-2 note and docs/file-format-parity.md updated (stays
partial).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Blur, Blur More, Average, Despeckle, Find Edges, Facet, Fragment, Sharpen,
Sharpen More, Sharpen Edges and Solarize now map both ways in a smart
object's filterFX. Photoshop stores these filters with hasoptions false, no
Fltr descriptor and only the four-char filterID as a long (Blr , BlrM, Avrg,
Dspc, FndE, Fct , Frgm, Shrp, ShrM, ShrE, Slrz). PSD saves write exactly that
item (Nm, blendOptions, enab, hasoptions, FrgC, BckC, filterID) instead of
dropping the filter with a warning, and Photoshop's entries open as the
editable PhotoCraft filter instead of a verbatim pass-through. An entry that
carries a Fltr with settings, or an unknown or mistyped filterID, is still
kept verbatim.
Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back.
Scorecard FILE-215-2 note and docs/file-format-parity.md updated (25 filters
mapped).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Map three more smart filters both ways in filterFX (smart_map.rs):
- Surface Blur: filterID 1135 (numeric), Fltr class `surfaceBlur`,
`Rds ` UnitFloat #Pxl -> radius, `Thsh` long -> threshold.
- Smart Blur: filterID `SmrB`, `Rds ` / `Thsh` doubles, `SmBQ` enum
(SBQL/SBQM/SBQH -> low/medium/high), `SmBM` enum
(SBMN/SBME/SBMO -> normal/edgeOnly/overlayEdge).
- Radial Blur: filterID `RdlB`, `Amnt` long, `BlrM` enum (Spn/Zm ->
spin/zoom), `BlrQ` enum (Drft/Gd/Bst -> draft/good/best).
Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back. Every measured Fltr descriptor is written back
byte for byte (checked against the measured files locally; they are not
committed). Entries with missing keys, wrong types, out-of-range or NaN
values, unknown enum values or keys we don't model stay verbatim, as does
Radial Blur at its defaults (no Fltr stored). An off-centre Radial Blur
centre and unknown enum names are reported on save, not guessed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Offset (filter.other.offset) and Custom (filter.other.custom) applied as
smart filters were left out of PSD saves with a warning, and the same
filters in a Photoshop PSD opened only as verbatim, non-editable entries.
smart_map.rs now maps both ways in filterFX:
- Offset: filterID/class Ofst; Hrzn and Vrtc as long (signed), Fl as the
FlMd enum: Wrp = wrap, Rpt = repeat, Bckg = transparent ("Set to
Transparent" on a layer). Unknown enum values, wrong types or missing
keys keep the entry verbatim.
- Custom: filterID/class Cstm; Scl and Ofst as long, Mtrx as 25 longs,
row-major 5 x 5. Written as the engine renders it (no kernel = identity,
missing cells 0, values clamped to -999..999, 1..9999, -9999..9999);
fractional values have no PSD equivalent and are reported. On open,
values outside Photoshop's ranges or a matrix that is not 25 longs keep
the entry verbatim.
Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back (Offset Wrap 0/0, Set to Background 12/-7,
Repeat 5/3; Custom default sharpen and a blur at Scale 2, Offset 10).
The measured files open as the editable filters and survive export and
re-import unchanged (checked locally, not committed).
Scorecard FILE-215-2 note and the file-format parity doc list the two
filters (still partial).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With Preferences > General > Animated Zoom on (the default), a Zoom tool
press held still past 0.3 s zooms continuously about the press point
(2 doublings a second, frame-rate independent, within the zoom_levels
range) until release; Alt zooms out. The release is then no click step.
A quick click still steps, Scrubby Zoom and the zoom rectangle are
unchanged, and with the preference off a hold is one click step.
The hold logic lives in the new zoom_hold.rs; canvas.rs only calls it.
general.animatedZoom leaves HIDDEN_UNTIL_IMPLEMENTED; prefs that do
nothing 25 of 166.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Paste Layer Style carries Blend If and channels; align to an active selection with several layers
From the layers audit (L2):
- Copy / Paste Layer Style copied the effects, blend mode, Fill and advanced blending but
dropped Blend If and the channel restrictions; Photoshop's layer style carries all of its
Blending Options. Across colour modes only Blend If's Gray range carries over and the
channels reset (RGB channel ranges mean nothing in CMYK). The copied style is now a named
struct (CopiedStyle) instead of a tuple.
- With an active selection, aligning (Layer › Align, "auto") aligned two or more layers to
their own bounds; Photoshop aligns them to the selection (Layer › Align Layers to
Selection), as it already did for one layer.
* Paste Layer Style keeps Blend If normalised across modes; Align docs say it follows an active selection for any count; undo, group and 16-bit tests
* Clip groups: Release Clipping Mask takes the layers above; merges keep clipping; Delete Hidden settles
From the layers audit (L2, silently wrong results):
- Release Clipping Mask on a clipped layer also releases the clipped layers above it, as
Adobe documents ("removes the selected layer and any layers above it from the clipping
mask"). Before, they stayed clipped to the released layer.
- Merge Down of two clipped layers, and Merge Layers of clipped layers of one chain, give a
layer still clipped to their base (it was always unclipped). Merge Down's result also keeps
the lower layer's colour label and link; Merge Layers' keeps the top layer's label.
- Delete Hidden Layers releases the layers clipped to a deleted hidden base (clip_group rule
3, as single Delete already does), instead of leaving them clipped to the layer below.
* Merge Layers keeps the result clipped only when the clipped layers share one clip chain
* fmt
Review of f116c32 was right that the guard did not establish the bound it
advertised. reject_bounds ran on the value stroke_coverage() returned, and that
function calls dense_coverage() internally, so the dense Vec was already
allocated by the time it was checked. reject_huge_stroke, the only check that
ran first, estimated from the raw points and half the tip, which brush dynamics
exceed: Scattering offsets a dab centre by its amount times the tip size.
Split rendering from dense extraction. stroke_coverage_within renders the stroke
into the renderer's tiles, which hold only what the dabs touch, reads the extent
once every dab is placed and refuses before allocating the width x height
buffer. retouch_cmds::coverage wraps it at MAX_STROKE_PIXELS and every stroke
command goes through it, so the bound now holds whatever the dynamics do.
reject_bounds is removed; it could not do the job it was named for.
spot_heal_surface returned a bare Rect and so had nowhere to refuse; it returns
Result<Rect> now. reject_huge_stroke stays as the cheap early-out and pads by
the scatter reach and the dual-brush radius so it no longer falls short.
The allowance is unchanged, so no stroke that worked before starts failing.
Tests: coverage_within takes the budget as a parameter, and a scattering brush
proves the rendered extent exceeds what the points alone suggest, is refused one
pixel under it and fits at it. Every command's refusal now asserts the pixels
and the history step count are untouched, and the ordinary stroke uses a valid
fixture and must succeed rather than merely avoid the size error.
* Check deep EXR tile count tables against max_alloc
A deep tile's sample-count table was decompressed at the header's full tile size with no
budget check, so a 20 KB file could build a 4 MiB table and a scratch copy under a 1 MiB
max_alloc. Try that size only when max_alloc has room for both; otherwise read the clipped size.
Fixes#4958.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep deep EXR decompression within the size it was admitted at
The count-table check admits the table and its scratch copy, 2x the table, but Vec doubling
gave a 4 MiB ZIPS table 8 MiB of capacity (a 3 MiB RLE table 4 MiB), so an admitted table
and its copy could take 3x. Grow the decompressed buffer by doubling capped at the expected
size, inflating straight into it, and allocate it and the scratch copy fallibly.
Tests: decompressed buffers never reserve past the expected size; a near-maximal tile with a
clipped table decodes under every budget; per-tile admission; half and uint channels after
full-size tables; the 8 MiB boundary for uncompressed tables too.
Refs #4958.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tests: show deep tile tables are admitted one tile at a time
The 17x17 image in 16x16 tiles had an interior tile, which never reaches the max_alloc check,
so only three edge tables (6 KiB) were checked against the 8000-byte budget and a check that
summed admissions across tiles still passed. Use a 48x1 image instead: its three tiles are all
edge tiles, and under 4096 bytes a summed check fails at the third tile.
Refs #4958.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Complete deep EXR count-table allocation admission
Account for scratch space on clipped and interior compressed tables while preserving raw-table budgets. Borrow packed input before admission and make initial RLE reservations and raw copies fallible.
Add single-channel admission boundaries, raw fallback controls, and hostile-size borrowed-read regressions.
* ci: run deep EXR regressions independently
* Drop the separate deep EXR CI job
It was added so these tests could run while main's workspace suite was
failing. Main is green again, and the regular test jobs run them.
This reverts commit 5aa4869b45.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
The only limit on an unauthenticated control connection was the 30 s
idle timeout, which starts again with every read. A local client that
kept sending bytes or blank lines without ever completing `auth` kept
its connection, and one of the 16 connection permits, for as long as it
liked; sixteen of them locked every other client out.
AuthDeadlineStream wraps the read half of a connection: until the
connection authenticates, reads fail once AUTH_DEADLINE (10 s) has
passed since it connected, and a blocking read waits no longer than
what is left. Both transports use it (the desktop app's control server
and the headless `photocraft-cli serve`). After `auth` succeeds only
the idle timeout applies, as before.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ruzstd before 0.9.1 lets a crafted zstd input under 128 KB allocate
several gigabytes at once. PhotoCraft decodes zstd when it opens .pcraft
and Affinity files, so such a file could end the process with an
out-of-memory abort.
Raise the minimum in both crates that use it and update the lockfile
from 0.9.0 to 0.9.1.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Dragging layers onto another document's tab landed in #851 (#589), and the
pasteboard colour menu in #2633 (#2462); the TOOL-214-4 note and the
context-menu parity row still listed both as open.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
While a path was being drawn, the Pen's Auto Add/Delete was off, so adding
a point meant finishing the path and switching to Add Anchor Point. Now a
click on a segment of the path in progress adds an anchor, a click on a
middle anchor deletes it, and drawing carries on from the last anchor. The
first anchor still closes the path and the last still breaks its handle.
The hover highlight draws over the path in progress, and the rubber band
hides where a click adds or deletes instead of placing an anchor.
Claude-Session: https://claude.ai/code/session_01JFTxZ57wCmWEDXxCn1Zuni
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Quick Mask: ⌘I inverts the whole mask, unstored black included (#6095)
Image › Adjustments on an alpha channel or the Quick Mask ran on the surface's
stored content only. The black outside a selection-made Quick Mask (or a new
black alpha channel) is the surface default, never stored, so Invert left it
black: the whole mask turned red and leaving Quick Mask selected nothing.
Channel targets now go through `pixels::adjust_mask`, as layer masks do, which
adjusts the default pixel too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Quick Mask: test ⌘I with Color Indicates Selected Areas too (#6095)
A rectangle comes back as everything but the rectangle, and with nothing
selected ⌘I selects everything. Both failed on main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Releasing Alt counted as a tap (keyboard focus to the menu bar, File lit) unless a key, text or
pointer-button press came in between. A mouse-wheel event was not on that list, so zooming with
Alt+wheel and letting go of Alt focused the bar; Space then opened File with New... lit, and a held
Space's key repeat ran it. A wheel event, or the window losing focus while Alt is down (Alt went
to a desktop shortcut), now cancels the tap.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run the existing Clippy, dependency layering and scorecard checks before the test passes so failures are reported earlier.
Preserve the existing commands, test coverage and runtime limits. Linux, Windows, corpus, WASM, web and packaging checks passed.
Choose PSB block length width from the canonical big-endian key before converting the key for little-endian TIFF output. Preserve foreign payloads, padding and conversion of following blocks.
Addresses #5599, finding 2 only.
Fixes#6104.
Cancelling a 24 MP Gaussian Blur r 50 job took up to ~400 ms (P25, budget
200 ms max). Stage timings showed two stretches without a cancel check:
- The transparency-lock pass (keep_alpha) that puts a Background layer's
alpha back after the filter ran serially over every changed tile, about
400-450 ms on 24 MP, with no cancel check. It now fixes the tiles in
parallel, each in its own scratch surface, and checks for cancellation
before each tile. Results are bit-identical to the serial pass (tested
against it at 8, 16 and 32-bit).
- Box-blur column strips checked only before each strip; with ~31 strips
per band and 32 threads one strip is tens of ms. line_passes now checks
between passes.
P25 max: 337-399 ms -> 13-44 ms over five runs. The uncancelled job is
also faster: p50 about 690 -> 400 ms.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Preflight oversized pixmaps and conversion peak before allocating.
Only charge the U8 retained-surface allowance after render succeeds.
Clarify the warning and cover later small raster groups with regressions.
Merge upstream main to refresh the pull request branch.
Tool options and the selected tool were already remembered (tool_memory, #2814), but
each painting tool's own brush (UiState.tool_brushes) was lost at quit, and the tool that
owned the session brush restarted as the Brush, so after quitting with the Eraser its
brush became the Brush's.
- Preferences.toolBrushes {owner, brushes}: saved change-driven when the pointer is up and
on exit, restored at launch with stored tips as references (loaded on swap).
- brush_cmds::remembered_settings / restored_settings: shared with lastBrush; restored
brushes pass the tools.setBrush checks and are clamped (size, hardness, opacity, flow,
smoothing).
- tool_memory::sanitize clamps restored tool options to their options-bar ranges.
- Reset Tool / Reset All Tools clear the persisted state through the same path.
- Scorecard UI-217-1 note updated (stays partial: font recents).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Preferences > File Handling > Save in Background was never read. Saves
already run as a background job; the setting now picks how the app
waits for it, as in Photoshop:
- On (default): you keep working while the file is written; progress
shows in the document tab and the status bar. The modal progress
dialog no longer appears for these saves.
- Off: the same job, worker and atomic write, but the modal progress
dialog shows at once and no shortcut reaches the app until the file
is complete; a close or quit asked for meanwhile waits for it.
The setting now shows in Edit > Preferences. Prefs audit: settings that
do nothing 26 -> 25 of 166.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A shape below 100 % Density renders over the whole canvas, and a Feather
grows its pixels, so Free Transform, the Move tool's transform controls,
snapping and Properties > Transform framed the canvas or the blur. As in
Photoshop the frame now follows the path and stroke whatever the mask
Density and Feather: photocraft_compose::hard_shape_bounds (shape_bounds
without the soft reach) feeds whole_layer_bounds and the Properties
Transform fields. Rendering keeps the soft bounds; other layers and hard
shapes are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>