Commit Graph
789 Commits
Author SHA1 Message Date
callmeBallochandClaude Opus 5.5 a8d7c6ef3f AF.3: put the Affinity fuzz targets and corpus back in CI
The maintainers approved the new crate, its fuzz targets and the corpus
upstreams in principle (storytold/photocraft#1607), so this undoes the
interim opt-in: ci.yml fuzzes preview and import_preview again, corpus
--all fetches the Affinity set, and its test runs under the normal
corpus feature. The provenance section stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 06:38:40 +00:00
callmeBallochandClaude Opus 5.5 edb42922c1 AF.3: keep Affinity out of CI's corpus and fuzz jobs; state the clean-room provenance
Review on storytold/photocraft#1607 asked maintainers to decide on new
fuzz targets in ci.yml and four third-party corpus upstreams in
corpus --all. Until they do, neither touches CI: ci.yml is main's again
(the targets stay in crates/affinity/fuzz, run locally), and the
Affinity corpus is fetched only by corpus --affinity and tested with
--features corpus-affinity, so --all and test-corpus are unchanged.
The plan is to move those files into photocraft-corpus.

The crate README now spells out the provenance: no Affinity software,
afread's MIT description re-described without translating code, every
structure confirmed on public files (CRC-32 per entry, self-typed
fields), semantics fitted to Affinity's own thumbnails, and how the
sample documents were licensed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 05:19:56 +00:00
callmeBallochandClaude Opus 5.5 ade877f8e9 AF.3: count distinct sample documents in the value-budget note
The census behind the limit counted duplicate copies of the same public
files: there are 189 distinct documents, the largest still decoding 3.3
million values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 05:13:22 +00:00
callmeBallochandClaude Opus 5.5 92207eb7c2 AF.3: the read-only Save test follows the asynchronous file dialog
Main now answers File › Save on an unsaved document with a file dialog
the next frame shows, rather than a blocking cancel. The test checks the
same thing that way: an Affinity document asks for a new file and
nothing is written over the source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 05:07:29 +00:00
callmeBallochandClaude Opus 5.5 e2ad28838e AF.3: bound the values a document stream decodes
A value takes about 40 bytes in memory however few it took in the file,
and a bool array packs eight in a byte, so a small, highly compressed
doc.dat could declare billions of values and make the import abort on
allocation (review on storytold/vectorcraft#667). The parser now counts
fields and array elements against a 2^24 budget before allocating, and
reserves array storage fallibly, returning Error::Limit either way.
The largest of 333 public documents decodes 3.3 million values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 04:31:42 +00:00
callmeBallochandClaude Opus 5.5 57ae2fb1a3 Merge storytold/main into AF.3 native Affinity import
Conflicts: File › Open and batch extension lists (SVG, swatches and
Affinity together); the import dispatcher (upstream's type-layer
preparation with the new result fields); the pinned EXR and Affinity
corpora side by side; README, book and development docs.

Affinity type layers now draw through text_import::prepare, so they get
the same raster bound as other imported type, and SVG import fills the
two new ImportResult fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 04:26:18 +00:00
Willy Foo 64496178d0 fix(ui): release closed-document canvas and navigator textures 2026-10-09 11:12:52 +07:00
Willy Foo 83d1f3115d perf(algo): specialize photo blur and resample channel loops 2026-10-09 11:12:52 +07:00
Nigel Ihza Firdausy d80455ad25 feat(ui): edit independent rectangle corner radii with narrow-panel controls and history tests 2026-10-09 11:12:49 +07:00
Nigel Ihza Firdausy ce35c1455b fix(pen): reshape the final outgoing handle while preserving incoming geometry and linkage 2026-10-09 11:12:49 +07:00
Claude cff8daf185 automation test: as_chunks for the preview pixel check (clippy 1.99 chunks_exact_to_as_chunks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
(cherry picked from commit 7406c26bd4)
2026-10-09 11:12:46 +07:00
harrisonoconnorhover 0e0311d245 Reject unsupported document indices in MCP screenshots 2026-10-09 11:12:46 +07:00
Harrison O'Connor-Hoover 79e783bcdf Record Transform Again as one concrete action step (#1440) 2026-10-09 11:12:38 +07:00
MarekSpodBiedry fb71daf2c6 Control channel: ui.click rejects a count above 256 instead of queueing unbounded input (#982) (#1446) 2026-10-09 11:12:34 +07:00
MarekSpodBiedry eb06c9e105 Opening a file with different horizontal and vertical resolutions says the vertical one is not kept (#1018) (#1445) 2026-10-09 11:12:30 +07:00
MarekSpodBiedry 746f2530e6 Plug-in names reject bidi controls, zero-width spaces and other invisible format characters, and need a visible character (#1011) (#1444) 2026-10-09 11:12:27 +07:00
MarekSpodBiedry 9ce06090b1 Plug-ins: a missing parameter's default is clamped to the declared min..max (#1012) (#1443) 2026-10-09 11:12:24 +07:00
kayrockscreenprintingandClaude Opus 5.5 7ad869eb32 Canvas: a huge view centre draws the selection outline without overflow (#980) (#1441)
`ui.set` takes any finite centre, and the selection outline quantised the
visible document region with unchecked i32 arithmetic. The float-to-i32
casts saturate, then the padding overflowed: `attempt to multiply with
overflow` for a large positive centre and `attempt to subtract with
overflow` for a large negative one, panicking the canvas frame outside any
dispatch guard. Release builds wrapped to an inverted, empty region.

The visible corners are now clamped to ±2³⁰ before the cast. No document
reaches that far, and the padding (at most 2 · 256 · 64 px when zoomed far
out) can't overflow i32 from there. The fix stays in canvas.rs, so ui.set
(which #820 rewrites) is unchanged.

The test draws a canvas frame with a selection after `ui.set` centres of
±1e30 and (3e9, -3e9), at 100 % and at 1/64 zoom (the largest quantisation
step), and checks that a centre on the selection still traces the outline.
It panicked at the multiply before the fix.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:12:21 +07:00
kayrockscreenprintingandClaude Opus 5.5 e486262d3e Divide Slice: one division makes at most 10000 parts (#1020) (#1504)
slice.divide clamped each axis to 1..1000 but checked no total, so one
request made up to a million slices, and resolving the slice list
(slices::resolve, used by slice.list and export) is quadratic in the slice
count: about 5e11 rectangle visits at a million.

A division over 10000 parts (100 x 100) is now refused with a bad-params
error before any part is created; the params doc says so. Resolving 10000
slices is about 5e7 visits, and the test lists them in well under a second.

The test refuses 101 x 100, 100 x 101 and 1000 x 1000 with the slices and
history unchanged, then divides 100 x 100 into 10000 parts and lists them.
Before the fix 101 x 100 was accepted (10100 parts).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:12:18 +07:00
David MannandClaude Opus 5.5 62fd1342dd Font menu keeps its height and focuses its search field (#1369) (#1505)
egui measures a popup once, when it opens, and never lets it grow back,
so filtering the font list left the menu short after the query was
cleared. While searching, the list now keeps the height it measured
unfiltered.

The search field was only focused while the query was empty, so a
reopened menu with a leftover query had no focus; it is now focused on
every open. The ComboBox closed on any click, including on the search
field; it now closes on clicks outside, and picking a font closes it.

Fixes #1369

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 11:12:15 +07:00
David MannandClaude Opus 5.5 48f78a9e1e Help › Search: keep the field focused after a click opens Help (#1447, #1438) (#1507)
Both reports were filed against v0.5.0, which predates e63ff519 (#1027):
there the Help menu used egui's CloseOnClick, so any click inside it,
including on the search field, closed it. The egui menu bar is what
Windows (and Linux) show; macOS uses the native menu bar, hence "on
Windows". That close is fixed on main but not yet released.

What remained on main: menus open on the press, and the search field
asks for focus then, but the release a frame later completes a click on
the Help title, outside the field, so egui (SurrenderFocusOn::Clicks)
took the focus away again. After clicking Help, typing went nowhere
until the field was clicked. The field now asks for focus again on that
release, as on a fresh opening.

The new whole-app kittest drives real clicks (press and release in
separate frames) with and without the custom title bar and at 1.25x and
1.5x scale. It checks the field is focused after Help opens, that typing
searches at once, and that clicking the field and typing keeps Help
open. It fails without this change (focus) and with v0.5.0's
CloseOnClick for Help (menu closes).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 11:12:09 +07:00
UnemarsandClaude Opus 5.5 6054a8a019 Crop tool: show the pixels past the canvas while a frame is edited, as Photoshop does (#1508)
A crop with Delete Cropped Pixels off keeps what it cuts away (and a moved layer can reach past
the edges), but the Crop tool drew only the canvas, so a frame couldn't be pulled back out over
those pixels by eye. Photoshop 25.4 (observed) grows the view to every layer's bounds and the
frame from the first press on the frame: hidden pixels under the shield, transparency as the
checkerboard, no canvas border or shadow, until the crop is committed or cancelled.

- crop_ui: `CropState::editing`, set by the press (before egui's drag threshold) and cleared when
  a new default frame is made (commit, Esc) or the tool is left; `shows_beyond_canvas`.
- canvas: `draw_beyond_canvas` composites only the ring between the canvas and every layer's
  pixels (`extra_cmds::reveal_all_bounds`, shared with Image > Reveal All), once per document
  state, at most MAX_TEXTURE texels per side (from a downsampled proxy past that), and draws it
  with the checkerboard out to the frame under the canvas, reaching a point under its edge so the
  edge's anti-aliasing has no seam. The drop shadow and the Line border are off meanwhile.
- compose: `render_reduced_rect`, the reduced render of any area of the document plane.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 11:12:05 +07:00
David MannandClaude Opus 5.5 284724af37 TIFF: alpha after an unspecified extra sample opens transparent (#1349) (#1513)
TIFF 6.0 allows the alpha at any ExtraSamples position, but the decoder
only looked at the first extra sample, and for RGB/CMYK the tiff crate
drops every sample after its colour type. With ExtraSamples [0, 2] the
alpha was silently lost (and gray [0, 2] opened as plain gray).

The alpha is now the first extra sample marked associated (1) or
unassociated (2), wherever it is. When it is not the first extra sample
of an RGB or CMYK image, the crate is asked for every sample (the
photometric is patched to multiband gray, as for WhiteIsZero) and the
colour and alpha samples are picked per chunk; planar files read the
alpha's own plane. Associated alpha in that position is made straight.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 11:12:00 +07:00
David MannandClaude Opus 5.5 a9fa35ab96 PSD: warn that Color Lookup tetrahedral interpolation is saved as trilinear (#1347) (#1515)
Photoshop's clrL descriptor (lookupType, NM, Dthr, profile, LUTFormat,
dataOrder, tableOrder, LUT3DFileData, LUT3DFileName) has no interpolation
setting, so a tetrahedral Color Lookup layer reopens trilinear and renders
differently. PSD save now reports that in its warnings instead of changing
the composite silently. Trilinear lookups save without a warning.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 11:11:55 +07:00
David MannandClaude Opus 5.5 4dcb2e4088 Undo: a paint stroke's undo keeps the painted layer active (#1356) (#1523)
Selecting a layer is not a history step, so each state kept the target it
had when it was created. Undoing a step therefore jumped back to whatever
was selected when the previous step was taken: rasterize a shape with the
Background active, select the rasterized layer, paint, Ctrl+Z, and the
Background became active again.

The state a step leaves behind now targets what was selected just before
the step (Session::edit and compound steps set the current state's target
before recording), as in Photoshop: undo of a stroke or fill keeps the
painted layer, undo of New Layer or Merge goes back to the layers selected
before it.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 11:11:50 +07:00
Harrison O'Connor-Hoover 672c0775de Reject control requests that expire before dispatch (#1525) 2026-10-09 11:11:47 +07:00
Harrison O'Connor-Hoover de9cc5f12c Do not replay bridge requests after an uncertain result (#1527) 2026-10-09 11:11:44 +07:00
David MannandClaude Opus 5.5 9b08da8d98 Free Transform: the reference point moves with the box when scaling (#1329) (#1535)
In Photoshop the reference point is attached to the bounding box: scaling
from a corner or edge, skewing, distorting and perspective carry it along
so it keeps its place within the box (the centre stays the centre, a
corner reference point stays on its corner). PhotoCraft left it at its old
canvas position for all of those drags.

After each of these drags the reference point is now mapped through the
box: its position in the old box's unit frame is mapped onto the new box
(carry_pivot). Alt-scaling, which scales about the reference point, and
rotating about it still leave it put; moving and the numeric options bar
already behaved correctly and are unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 11:11:40 +07:00
BFlatandClaude 3e8d54896d Fix #1531: duplicate artboards beside the original, drag boards with the Move tool (#1584)
- Layer > Duplicate on an artboard stacked the copy exactly on the original, so
  nothing visible happened. The copy now goes right of the board (100 px gap,
  sliding past boards in the way) with its contents, and the canvas grows to
  show it, in one undo step (single and multi-layer duplicate). `inPlace: true`
  keeps the old behaviour; the Move tool's Alt-drag and Alt+Cmd+T use it.
- Move tool Auto-Select ignored artboards: a press on an empty spot of a board
  picked the Background under it, so the drag never moved the board. A click
  there now picks the board (layers on it first, clipped to the board; Group
  mode stops inside the board), and the drag moves the board with its contents.
- Moving a board past the right/bottom edge grows the canvas, as a new board
  does, instead of cutting it off.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 10:54:15 +07:00
BFlatandClaude b693d690e1 docs: MCP tool names and job tools, codec README JPEG metadata and deep EXR (#1552, #1553, #1559) (#1573)
- architecture.md listed dotted MCP tool names (doc.open, command.run...) that the server
  rejects; it now uses the advertised underscore names, adds the missing tools, and points
  out that dotted names belong to the JSON control protocol.
- development.md's MCP tool inventory gains jobs_list and jobs_cancel.
- crates/codecs/README.md: oversized JPEG EXIF/XMP is dropped with a warning (XMP first sheds
  its layered-document properties), not an error; supported deep EXR opens flattened with a
  DeepFlattened warning, and only deep ZIP and mip/rip-map tiles are unsupported.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 10:23:19 +07:00
Claude 26de59f54c automation test: new fill and adjustment layers' path is a vector path (#1419)
#1539 gives layer.newFillLayer.* and layer.newAdjustmentLayer.* a "path" parameter (the
vector mask), which registry_filesystem_path_params_are_classified took for a file path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 10:16:12 +07:00
David MannandClaude Opus 5.5 99b6aff26b Fill and adjustment layers take the active path as their vector mask (#1419)
In Photoshop, when a path is selected in the Paths panel (the work path
just drawn with the Pen or made from a selection, or a saved path), Layer
> New Fill Layer > Solid Color/Gradient/Pattern and every new adjustment
layer get a vector mask made from that path: a Solid Color fill with a
vector mask is a shape layer, editable as a vector. The path wins over an
active selection; with no path selected the selection becomes a layer
mask (#1250), else there is no mask. PhotoCraft always used the selection
and ignored the path, so Solid Color filled the whole canvas.

Engine: the new fill and adjustment layer commands take an optional
"path" param ("work", a saved path's name or a path object). The shared
helper mask_new_layer turns it into the layer's vector mask and falls back
to the selection mask without it; an unknown name is an error, not a
panic.

UI: run_command passes the path selected in the Paths panel as "path"
(callers can opt out with "path": null). Drawing a work path with the Pen
and Make Work Path select it, as in Photoshop, and clicking the panel's
empty area deselects it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 10:16:12 +07:00
Xus BadiaandClaude Opus 5.5 bfc59c784f macOS DMG: branded window background and icon layout (#1201)
The DMG opens on a 660x400 Finder window with the PhotoCraft artwork, 128 pt icons and the
Applications link beside the app. packaging/macos/dmg/ holds the SVG source, a reproducible
generator, an 87 KB HiDPI palette TIFF (was 1.0 MB) and the .DS_Store layout. The volume is
named "PhotoCraft" (the background alias resolves by volume name); the DMG file name keeps
the version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:11:00 -04:00
Filip PłachecińskiandClaude Opus 5.5 f0ae12dd22 Self-host the web app with Docker (nginx, community-maintained) (#1334)
A multi-stage Dockerfile builds photocraft-web with Trunk and serves dist/web from an
unprivileged nginx on :8080, with /healthz, precompressed gzip, the wasm MIME type and
immutable hashed assets. Rust and Trunk follow the release workflow, and the
PHOTOCRAFT_BUILD_SHA/DATE build args stamp About. Documented in packaging/web/README.md;
not built in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:10:55 -04:00
BFlatandClaude 9c554e2ead Fix #1544: Image Size refuses a resample past the raster budget; resampling writes bands as it goes (#1569)
image.imageSize accepted 64x48 -> 300000x300000 (90 billion pixels, each side within the
300000 px limit) and started allocating destination-sized f32 bands. The command now
refuses a resampled canvas whose raster alone would pass the decoders' default allocation
budget (8 GiB on 64-bit), with an actionable message, before touching the document;
resolution-only changes (Resample off) are unaffected. The resampler also writes its bands
into the output a group at a time instead of collecting every band first, so the f32 rows
held at once are bounded by the thread count, not the destination size.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 10:03:08 +07:00
BFlatandClaude f82a55f056 Fix #1543: oversized Satin and Bevel & Emboss sizes render instead of panicking (#1563)
A Satin size or a Bevel & Emboss size or softness of 1e308 reached the
box blur unclamped: the kernel radius saturated to ~2^62 taps and the
render panicked with "capacity overflow"; 1e6 ran for minutes. Drop
Shadow survived because its reach is clamped in margin(). The blur width
is now capped at MAX_REACH (and NaN is 1) in one place, box_width, which
both the CPU tent and the GPU's tent_kernel go through, so values from
commands and from files are bounded alike.


Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 09:47:13 +07:00
Harrison O'Connor-Hoover 47ad748638 Use rotated preview dimensions on the GPU canvas (#1459) 2026-10-09 09:02:10 +07:00
StreetblockandGPT-6 7fd610741b Fix PNG text metadata decode budget bypass (#1460)
Bound decoded PNG text and XMP by one shared max_alloc budget before
growing output, retaining CRC validation and malformed ancillary handling.
Cover compressed, plain, Unicode and post-IDAT metadata with small
synthetic regression fixtures, and document the budget's scope.

Opt Streetblock into public GitHub profile-name credits at their request.

Co-authored-by: GPT-6 (Codex) <noreply@openai.com>
2026-10-09 09:02:07 +07:00
kayrockscreenprintingandClaude Opus 5.5 fd74419aeb Crop: an explicit rectangle past 300000 px per side is refused (#960) (#1470)
image.crop took any explicit width and height, so a crop to 100000000 x
100000000 succeeded (the tiles are lazy) and left a canvas that a following
full-canvas flatten, such as image.trim or image.duplicate with mergedOnly,
could only abort on allocating.

An explicit crop now refuses a side over 300000 px with a bad-params error,
before the document changes. That is the limit file.new, Image Size and
Canvas Size already use. Extending the canvas up to it still works.

The test checks 1e8 x 1e8, 300001 x 10 and 10 x 300001 are refused with
the document and history unchanged, and that 300000 x 2 still extends the
canvas. It failed before the fix (the 1e8 crop returned Ok).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:02:04 +07:00
optimalbyteeandoptimalbytee c7dbb37b3c Speed up wide Motion Blur with adaptive FFT convolution (#1471)
Keep the merged upstream row kernel for shorter streaks and unsupported inputs.
Use bounded overlap-save convolution of its merged bilinear coefficients when
channel pairs, parallelism and memory estimates favor FFT. Preserve alpha cutoff
behavior, cancellation, selections and depth/model-neutral filtering.

Validate 303 algorithm tests in debug and release, plus native checks and full
image comparisons. Repeated current-upstream comparisons show 2.31x at 24 MP
and 4.15x at 1.5 MP for angle 30 / distance 1000 on the measured host.

Co-authored-by: optimalbytee <optimalbytee@users.noreply.github.com>
2026-10-09 09:02:00 +07:00
Nigel Ihza Firdausy 0d058f4c29 fix(psd): write Photoshop-compatible Photo Filter adjustment blocks (#1473)
* fix(psd): emit Photo Filter phfl block with required four-byte alignment (#1455)

* test(psd): golden phfl length, padding, legacy compatibility and Photo Filter variants

* test(psd): compare quantized Photo Filter channels with encoding tolerance

* fix(psd): normalize legacy 18-byte Photo Filter blocks on re-save

* test(psd): full exporter/importer roundtrip and repair of legacy invalid phfl blocks

* style(psd): format Photo Filter golden-block regression test

* style(psd): apply rustfmt layout to exporter compatibility tests
2026-10-09 09:01:57 +07:00
UnemarsandClaude Opus 5.5 d6aeb970ee Camera RAW: a measured Nikon D4 profile (image area, white level, colour matrix, tone curve) (#1475)
NEF carries no colour description, so D4 frames developed with the camera channels taken as
sRGB primaries and no tone curve (dark, pale), the sensor's optical-black columns showed as a
purple stripe on the right, and clipped highlights turned pink (green saturates first).

crates/raw/src/cameras.rs holds camera profiles measured from the cameras' own output (no
third-party tables), applied to TIFF/EP raws by make, model and sensor size. Sensor gains
tone_curve (like DNG ProfileToneCurve, per channel in linear ProPhoto), folded into the gamma
LUT; other cameras develop exactly as before.

Nikon D4, from 148 uncompressed NEFs of one body (ISO 100–10000):
- image area 4928x3280 at (8, 6): where the camera's own full-size JPEG sits, found by aligning
  the two (2 columns of optical black on the left, 50 + 8 on the right);
- black 0 (the data is black-subtracted in camera: the darkest image pixels reach 0);
- white 15520: green saturates at 15546–15672, red and blue at 15687–16383, so one level just
  below green makes clipped highlights white without moving the white balance;
- ForwardMatrix (white-balanced camera → XYZ D50) and tone curve fitted to the embedded JPEGs
  (Picture Control Standard, Active D-Lighting off; 52 frames, smooth areas). On 15 held-out
  frames PhotoCraft's development is 7.0/255 per channel from the camera's JPEG on average,
  against 34 before.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:01:54 +07:00
kayrockscreenprintingandClaude Opus 5.5 b2003a4329 .pcraft: a tile index outside the i32 pixel range is a corrupt file (#938) (#1483)
The loader accepted a manifest tile at any i32 index. TileCoord::rect
multiplies the index by the tile size, so an index past ±2²³ loaded fine
and then panicked ("attempt to multiply with overflow", geom/src/lib.rs)
wherever the layer's bounds were asked for: the status bar, Properties,
edit.transform, the compositor. Release builds wrapped to a rectangle
unrelated to the tile.

Loading now refuses a tile whose rectangle doesn't fit in i32 on both edges
(indices -8388608 ..= 8388606) with FormatError::Corrupt, as it refuses a
duplicate tile. In-app editing can't produce such an index; only a damaged
or crafted manifest can.

The test moves a raster layer's tile to just past each end of the range and
to i32::MAX / i32::MIN (refused), and to the extreme valid indices (loaded,
bounds computed). Before the fix the out-of-range bundles loaded, and asking
the layer for its content bounds panicked at the multiply.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:51 +07:00
David MannandClaude Opus 5.5 17be581390 Liquify: Alt reverses Twirl and swaps Pucker/Bloat, as in Photoshop (#1442) (#1484)
Holding Alt (Option) at pointer-down draws the stroke with the selected
brush's opposite: Twirl Clockwise twirls counterclockwise (and back),
Pucker bloats and Bloat puckers. The selected tool is left unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:01:48 +07:00
Jeremy 3955bf4f19 Canvas: checker moves with the image; pixel grid from 500%, over pixels only (#1491)
The GPU canvas anchored the checker to the top-left of the canvas viewport,
while the pixel grid sits on document pixel edges, so panning or zooming
slid the checker under the image and out of step with the grid. The checker
is now measured from the document origin; its 8 screen pixel cell is unchanged.

The pixel grid appeared from 800% (1200% on the CPU fallback), was faint and
was drawn across empty pixels. It now shows above 500%, lightens a dark pixel
by a quarter, and is skipped where the pixel is transparent (GPU canvas).

New offscreen render tests in tests/checker_canvas.rs cover cell size at nine
zooms, the phase, the grid threshold and strength, and the grid over empty
checker.
2026-10-09 09:01:45 +07:00
kayrockscreenprintingandClaude Opus 5.5 3f7d8afc78 Puppet Warp: pins that stretch the content too far are refused (#1019) (#1492)
edit.puppetWarp accepted pin destinations that are finite but arbitrarily
far away, and the triangle renderer sizes its tile, bin and lookup
collections from the deformed mesh's bounds: a pin dragged to (2^20, 2^20)
meant 17.6 million tiles (at least 846 MB of bookkeeping) before a pixel
was drawn.

- algo: MAX_WARP_PIXELS (2^30, about 16000 tiles; the Select > Modify
  budget). puppet_warp returns None when the deformed mesh's bounding box
  exceeds it or isn't finite, checked after the solve and before render.
- engine: edit.puppetWarp returns a bad-params error and the edit is
  discarded (layer unchanged, no history step). run_on_layer's and
  deform_selected's warp callbacks now return Result (a LayerFn alias);
  Liquify and Perspective Warp wrap theirs in Ok.
- A stored Puppet Warp smart filter too far to render leaves the smart
  object's pixels as they were.

Tests: algo (far pins refused, a near pin renders) and engine (the issue's
command refused at 8/16/32-bit, with and without a selection, layer and
history unchanged; a rigid move and a 4000 px pin still work; smart object
unchanged).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:42 +07:00
Harrison O'Connor-Hoover 5355221f81 Preserve document depth when splitting vector shapes (#1495) 2026-10-09 09:01:39 +07:00
David MannandClaude Opus 5.5 086d78a023 Apply the transform when another layer is selected (#1400) (#1496)
While Free Transform was open (Ctrl+T, or after dragging a Move-tool
transform control), selecting another layer in the Layers panel left the
box and the lifted preview on the old layer. end_if_left only ended the
session when its layer or document went away or the tool changed.

It now also commits the session when the active layer is no longer the
one being transformed, like Photoshop applying the transform on a layer
switch; the next transform (or the Move tool's controls) then starts on
the newly selected layer. Transform Selection is not tied to a layer and
stays open.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:01:36 +07:00
kayrockscreenprintingandClaude Opus 5.5 6c4d4e9dc9 Mesh warp of a gray surface writes the old and warped regions separately (#995) (#1500)
warp_mesh_gray (select.transformSelection with a warp mesh) cleared the
source and warped regions as one dense buffer over their union, so a mesh
that moves a selection far away allocated a buffer growing with the square
of the distance: about 37 GiB of floats for a 48 px selection moved
100000 px. b70fbd4 fixed the same thing in the homography path
(transform_cmds::warp_gray); this does the same here: clear the old region,
then write the warped one. Surface is tile-sparse, so a distant region
costs only its own tiles.

Moving a 48 px selection 8192 px, peak RSS of a test binary went from
346 MB to 3 MB (0.41 s to under 0.01 s).

The test warps 8- and 32-bit gray selections by ±100000 px and 300 px and
checks the result is the translated block with the old region cleared.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:01:33 +07:00
David MannandClaude Opus 5.5 2aa1eeadcc Selection tools: arrow keys nudge the selection outline (#1428) (#1502)
With a marquee, lasso, wand or other selection tool active, the arrow
keys now nudge the selection outline 1 px (Shift: 10 px) through
select.transformSelection, one undo step per press; the pixels stay put.
A floating piece (Cmd-dragged pixels) moves instead. Before, the arrows
did nothing unless the Move tool was active.

Also fixes the Move tool's arrow-key modifiers: egui's consume_key
ignores extra Shift/Alt, so the plain arrow matched first and Shift+arrow
nudged 1 px instead of 10 and Alt+arrow did not duplicate. The most
specific modifiers are now tried first.

Dragging inside the selection already moved the outline, with the move
cursor on hover (#686); a regression test now covers the cursor.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 09:01:30 +07:00