38 Commits
Author SHA1 Message Date
Omar SaidahmedandBrandon Thomas afd5127dc8 perf: skip redundant smart object resamples and per-pixel reads (#6406)
* skip redundant smart object resamples and per-pixel reads

* resample smart objects only when their re-render fails

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-23 18:36:54 -04:00
Dylan M. Taylor 3729c3f664 security(raster,doc): count tile grids without walking them; share rebuilt tiles; index slice merges
Rect::tile_count counts a rect's tiles in u64; try_take_tiles uses it (no usize overflow on 32-bit). Surface::translated reuses one rebuilt tile for equal source-tile windows. auto_slices merges vertically adjacent runs through a hash index instead of searching the output.
2026-10-23 14:41:44 -04:00
SimoneandLucenx9 98de85c64e fix(raster): prevent overflow in strided row sampling (#8668)
Keep scan coordinates and tile boundaries in i64 so valid reads at the final tile and large strides do not overflow. Bound each run by the remaining output and return the surface default beyond the i32 coordinate range. Add differential regressions across all sample depths and missing/present tiles.

Co-authored-by: Lucenx9 <185146821+Lucenx9@users.noreply.github.com>
2026-10-22 17:40:47 -04:00
aiongg 3d71c3a1c0 Merge PR #8013: perf(raster): read 16- and 32-bit rows into RGBA with the colour model resolved per row 2026-10-22 02:23:07 +00:00
aiongg f32f882f3a Merge main into integration-10
# Conflicts:
#	apps/photocraft-web/src/web.rs
#	apps/photocraft/src/services.rs
#	crates/engine/src/file_cmds/tests.rs
#	crates/engine/src/float_cmds.rs
#	crates/engine/src/float_cmds/tests.rs
#	xtask/src/corpus.rs
#	xtask/src/corpus_pins.rs
#	xtask/src/main.rs
2026-10-21 21:56:26 +00:00
aiongg b0833a893c Merge PR #7694: perf(format): reuse .pcraft writers between document saves 2026-10-21 21:56:10 +00:00
Rene Leonhardt aa715535f3 perf(format): reuse save cache ownership safely
Move cached keys and compressed buffers between saves, and compare weak
allocation pointers without temporary strong-reference updates. Keep
payload budgets and archive bytes unchanged.

Allow fallible tile edits after a cached save. Move pixel storage when
only weak references remain; use a fallible copy for shared storage and
restore the original tile if the copy fails.

Cover cache key reuse, edits after saves at every depth, and weak/shared
tile ownership. Name the fixed revisions behind the existing timings.
2026-10-21 17:49:09 +02:00
Roberto Moura 8352b26c1a Fix lag when dragging a floating selection with the Move tool
- Replace whole-region Vec<f32> read_region/write_region in CutParts::new
  with tile-parallel cut_piece + try_fill_surface/try_clear_surface,
  sharing interior Arc<Tile> allocations in O(1) and activating the
  solid_tile hole-fill fast path on Background and mask targets.
- Cache piece_bounds on CutParts so CutParts::place avoids scanning tile
  content bounds on every drag frame.
- Parallelize composite_over across top tiles with Rayon and share fully
  opaque top tiles by Arc<Tile> clone in O(1), preserving COW tile
  identity for GPU canvas uploads.
- Parallelize Surface::translated across target tiles with Rayon and
  prune cleared tiles in-place inside edit_tiles.
- Skip zero-offset floating preview generation on pointer-down in
  move_ui::floating_doc.
- Exclude SnapKind::Selection when moving selected pixels with the Move
  tool so small initial drags do not snap back to (0, 0).
- Keep navigator_plain active during Move/Brush/Patch drags on a GPU
  canvas so navigator_gpu is not evicted into a synchronous full-document
  CPU thumbnail composite on the first drag frame.
- Parallelize row normalization in compose::render_reduced_in_bands and
  add a direct read_rgba_into fast path for unmasked Normal layers over
  an empty backdrop.
2026-10-21 13:21:10 +00:00
xsvm 331c7f38a5 perf(raster): read 16- and 32-bit rows into RGBA with the colour model resolved per row 2026-10-21 01:17:03 +08:00
xsvm 67a9c53f26 test(raster): build the test pixel without u8 overflow 2026-10-20 23:45:38 +08:00
xsvm a27fa7a19b perf(raster): tile content bounds compare rows block by block 2026-10-20 23:45:38 +08:00
aiongg b8e856dffe Integration: #7022 write_region stays serial; the text rasterizer uses the parallel variant 2026-10-19 17:18:46 +00:00
aiongg 3544721eb0 Merge PR #7692: perf(raster): Avoid redundant default pixel work 2026-10-19 16:40:42 +00:00
Rene Leonhardt 83aff80f35 fix(raster): support sample counts beyond stack capacity 2026-10-19 17:19:18 +02:00
David Mann c30f9180e8 Merge PR #7692: perf(raster): Avoid redundant default pixel work 2026-10-19 16:51:48 +02:00
Rene Leonhardt dd552f76b1 docs(raster): document touched read helpers 2026-10-19 13:33:01 +02:00
Rene Leonhardt 78a9982eec perf(raster): share contiguous region reads 2026-10-19 13:15:06 +02:00
Rene Leonhardt f6e7fc3c2a perf(raster): Avoid redundant default pixel work
Preserve default sample bits while skipping absent zero tiles and redundant default-pixel copies. Add the previous read path as the oracle.
2026-10-19 11:31:59 +02:00
ebosido444-ai fcd337e1fc perf(text): rasterize a type layer's pixels on all cores, bit for bit the same
A type render spends most of its time after the glyphs are drawn: composing each
colour's coverage over the premultiplied accumulator, dividing it back to straight
colour, and converting the tile bytes. All three are per pixel - no pixel reads a
slot another wrote - so they ran one after another on one core for no reason.

Measured on a 3.75 MP paragraph (4000 characters, 14 pt at 300 dpi, 1851 glyphs),
release, Windows 11, i5-10300H: rasterize 98.3-103.9 ms before, 60.3-61.8 ms after
(1.6-1.7x). Whole-render stage profile before: ink rect 4.1, draw 18.6-22.9, prefix
sum 7.0-8.1, composite 22.6-33.4, unpremultiply 11.2-14.0, write_region 23.8, prune
3.1 ms.

The glyph drawing, the winding prefix sum and the ink-rect pass stay on one thread:
the first two carry a running accumulator across pixels, and the band split that
would keep them bit-identical costs a second glyph traversal per band.

Native only (rayon, as the other crates): the web build keeps the serial loops.
2026-10-17 17:42:50 +03:00
Elvis33LE 3e74c4d8d3 raster: a shifted surface copies in one pass per row without pre-filled tiles (#2667)
* raster: a shifted surface copies in one pass per row without pre-filled tiles

Surface::translated's misaligned path allocated every target tile through
Tile::filled (a per-pixel default fill when the default is nonzero)
before overwriting nearly all of it, and addressed spans through
per-row closures. The path now writes each stored row directly: content
spans copy as single memcpys crossing source tiles as needed, vacated
edges fill from one prebuilt default row, and filled() builds its
default rows in bulk. Same output (the read-back oracle covers every
depth, sign and tile crossing), cheaper for nonzero defaults and one
less pass over each tile.

* raster: a missing source tile in the shifted content reads the default pixel

The misaligned path skipped absent tiles inside the content rect,
leaving zeros where the surface's default pixel belongs (a white mask
with sparse tiles turned black after a move). The span now fills from
the prebuilt default row. echelon's snippet is a regression test, and a
randomized oracle compares the whole path against a per-pixel reference
gather across formats, default pixels, sparse tiles and tile-boundary
crossing shifts.

* ci: retrigger wasm32 (the full workspace wasm check is clean locally; the job failed before writing a log and the run is stuck queued)
2026-10-14 13:28:48 -04:00
deadmeuandClaude Opus 5.5 533b9a1c62 perf(raster): moving a layer no longer stores tiles of only the default pixel (#4844)
Surface::translated, under every layer move and the Move drag preview, allocated
every target tile that a moved source tile reached. For a move that isn't a whole
number of tiles, the transparent margins of a layer's edge tiles landed on target
tiles of their own, and those were stored though they hold only the default pixel
(256 KB each at 8-bit RGBA). #4414's shared uniform tiles were also stored when
their value was the default. Moving two 300 px blocks by (100, 56) left 10 of 18
stored tiles empty; nudging back and forth kept adding more.

A target that ends up holding only the default pixel is no longer stored, as
prune() would drop it. A copied tile is compared row by row with a row of the
default pixel after the copy (one memcmp per row; a tile with content stops at
its first row), and a shared uniform tile of the default value is skipped. Moves
by whole tiles still re-share the existing tiles unchanged.

Release, 24 cores, cargo xtask perf --quick (perf_scenarios, p50, HEAD vs this,
two runs each): move layer on 16-bit 15000x10000 + refresh 5.3 -> 3.9 ms; move
layer on 32-bit 20 layers + refresh 11.9 -> 5.9 ms; the other Move rows within
noise. translated() alone on 36 MP: full layer unchanged (27.0 -> 27.6 ms RGBA8);
40 sparse blocks 11.8 -> 10.6 ms RGBA8, 44.0 -> 39 ms RGBA16, 308 -> 190 tiles.

The new test covers RGBA8, RGBA16, RGBA32F and GRAY8 over a zero and a non-zero
default pixel, and both paths; it fails on the old code.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:07:20 -04:00
c758ebffac perf(raster): mode and depth conversion keep shared tiles shared (#4531) (#4725)
Surface::convert / try_convert and map_color converted tile by
coordinate, and the ICC path (color_cmds::convert_surface) staged and
converted every coordinate's tile. A tile shared across the grid, such
as a filled background (one Arc for all of it), therefore came out as
one fresh tile per coordinate: a white 2048×1024 background went from
1 to 32 tile allocations, a 24 MP one to 384.

Each distinct tile is now converted once and the result shared by
every coordinate that shared the original. Pixels are unchanged; tests
compare against a per-pixel conversion and check the sharing for
convert, try_convert, map_color and Image › Mode › CMYK.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 02:46:54 -04:00
SckraTandClaude be43d58f1a perf(compose): stop sweeping the bounds cache on every call once it is large (#4415)
compose::bounds caches per-tile content bounds and drops entries of dead tiles when it holds
more than 65,536 of them. Once more than 65,536 tiles were alive (a large document plus its
history), every call (every render tile, every GPU plan) walked the whole map again and kept
it all. The sweep now runs past a high-water mark: twice the entries that survived the last
sweep, and at least 65,536.

The per-tile scan moves to raster as Tile::content_bounds, used by both
Surface::content_bounds and the cache instead of two copies of the loop; an empty pixel
yields no content instead of a chunks_exact(0) panic.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-12 19:54:57 -04:00
SckraTandClaude 97448b27f3 perf(raster): moved fills keep sharing one copy-on-write tile (#4414)
Surface::translated copied every target tile of a move that isn't a whole number of tiles,
so a solid fill (one shared tile) became one unique tile per coordinate after its first
nudge: each later move, undo and refresh then rescanned and re-uploaded all of them, and
history kept them all.

A target tile whose window reads only tiles holding one single pixel value (each source
tile is scanned once per call; one tile is kept per value) now shares that tile. Fully
covered targets also skip the default-pixel pre-fill they are about to overwrite.

12 MP RGBA fill nudged by +-(10, 7) twenty times (release, 4-core container): unique tiles
192 -> 76; RGBA16 move p50 55 -> 21 ms; RGBA8 unchanged within noise. Pixels are unchanged
(the test compares every depth with the copied pixels, and checks that a shared but
non-uniform tile is still copied).


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-12 19:54:50 -04:00
MrCloudy2andClaude Opus 5.5 04d2874e38 fix(image): 32 Bits/Channel holds linear light, as in Photoshop (#3792)
PhotoCraft treats 32-bit documents as linear light (Transfer::for_document,
Exposure, Photo Filter, PSD import), but Image > Mode > 32 Bits/Channel only
rescaled the samples, so a converted 8-bit grey 128 stayed 0.502. Photoshop
stores 0.2159 (measured in Photoshop 27.11: 32-bit float TIFF of an sRGB
chart, every sample = the sRGB-to-linear value). The conversion now decodes
colour samples through the document's tone curve after widening to 32 bits,
and encodes them before narrowing (so no linear value is quantised to 8 or
16 bits). Alpha, masks and alpha channels keep their values. RGB and
Grayscale only (the 32-bit modes).

With it, Photoshop's 32-bit results match exactly for the conversion itself,
Levels, Black & White (default), Brightness/Contrast and Colorize (were 40-80
levels off in sRGB terms).

Adds Surface::map_color (colour samples only, including the default pixel)
and makes Transfer::decode/encode public.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:45:48 -04:00
4030096482 perf: Edit > Clear works tile by tile on large selections (#3463) (#3482)
Clear (and the background-colour fill it does on a Background layer)
read the whole selection bounds into one float buffer, looked the
selection up in its tile map once per pixel and wrote every tile back,
single-threaded. On an 8000x6000 layer that took about 2 s (3 s on the
Background). It now edits the encoded tiles in parallel, drops fully
cleared tiles, shares one solid tile for fully covered opaque fills and
copies a tile only when a pixel in it changes. Results match the old
pass exactly; 20-110 ms on the same documents.

Fixes #3463

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:29:25 -04:00
optimalbyteeandoptimalbytee da3f5ffd54 Optimise computed brush coverage and native raster row writes (#3098)
* Speed up raster writes with contiguous sample rows

* Specialise coverage rasterisation for computed brush tips

---------

Co-authored-by: optimalbytee <optimalbytee@users.noreply.github.com>
2026-10-11 18:08:44 -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 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:26:07 -04:00
Rene Leonhardt 5290930e4a fix: Preserve CMYK solid and gradient fills through PSD round trips (#2628)
* fix(compose): Preserve native CMYK fills through PSD round trips

Share depth-quantized ink samples between rendering and PSD caches.
Use the document profile for RGB fill colours and preserve valid imported
caches. Uncached CMYK fills use CPU rendering.

Cover all sample depths, gradient styles and tiled renders. Fix the real
16-bit gradient round trip and raise the psd-tools floor from 307 to 308.

* perf(raster): Reuse exact 8-bit CMYK conversions within rows

Cache the previous ink bytes and ICC result per row, with alpha decoded
independently. Keep the 16/32-bit conversion loops unchanged.

Matched 1 MP reads improve by 77% for smooth ramps, 33% with dithering
and 10% for random colours. Bitwise regression coverage includes all
depths, alpha and tile boundaries.
2026-10-10 13:11:54 -04:00
BFlatandClaude Opus 5.5 f0208fd798 Move tool: layers overhanging the canvas keep their effect maps; moves copy 2x faster (#761, #128) (#893)
- A layer about the canvas's size keeps its whole effect region even where it overhangs the
  edge, so the region keeps its size while the layer is dragged and the GPU moves its effect
  maps with it instead of rebuilding the shape, distance fields and every effect each frame.
- Surface::translated moves a surface by copying encoded tile bytes tile to tile (no float
  round trip, no full-size intermediate; whole-tile moves share the tiles): 2.3x faster than
  to_interleaved + write_interleaved. Used by layer.translate, Move tool drags and
  algo::resample::translate_surface.
- perf_scenarios: Move tool drag frame rows (styled layer, full-canvas layer with and without
  a drop shadow).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:09:16 -04:00
Ian Swope 874867461d file.new at 300000x300000 no longer fills ~360 GB of tiles before returning (#705) (#900)
Surface::fill_rect materialized every tile it touched, so a new document's
background cost one 256x256 tile per tile slot: ~360 GB for the 300000x300000
size the command accepts. Fully covered tiles now share one Arc'd tile, so a
solid fill costs one tile plus the tile index; edge tiles are written as
before, and copy-on-write splits a shared tile off when it is edited.
2026-10-08 15:04:57 -04:00
Brandon ThomasandClaude Opus 5.5 3b7870bee1 Background jobs: engine job API, progress and cancel (part of #210) (#289)
* Test: retry the GPU startup sentinel check while a forked child briefly shares the lock

monitor_profile tests spawn osascript in the same binary; between fork and exec the child holds
a copy of the marker file descriptor, so flock reports WouldBlock and begin() returns Busy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Background jobs: engine job API, progress and cancel (part of #210) (#289)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:41:38 -04:00
Brandon Thomas 7db46219ce Pencil tool, Edit › Fill… dialog and "Rasterize?" prompt (#213) (#279) (#279) 2026-10-05 19:48:03 -04:00
Brandon ThomasandClaude Opus 5.5 296ced38d6 Never crash: text, color, doc, paint, geom, raster, compose, vector (#39)
- Real crash: build_engine_data took its template from the PSD's text
  engine data and did expect("EngineDict"), so a text layer whose engine
  data lacked EngineDict (or wasn't a dictionary) crashed PSD export.
  The template is now repaired; regression test
  engine_data_template_without_engine_dict.
- color: the built-in CMYK<->sRGB transforms fall back to the naive
  formulas instead of expect if they ever fail to link.
- doc: insert_above falls back to the top of the root instead of
  expect("valid path"); next_layer_name searches a bounded range.
- paint: Mips::new loops with while-let instead of last().expect.
- All eight crates deny unwrap/expect/panic/unimplemented/todo/unreachable.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:25:44 -04:00
Brandon ThomasandClaude Opus 5.5 1c09936154 Clippy 1.99: chunks_exact with a constant size -> as_chunks; drop a no-op Option filter
Rust 1.99's clippy adds chunks_exact_to_as_chunks and some_filter, which
broke the Clippy step on every CI runner (it uses the latest stable). All
sites are mechanical: as_chunks drops the remainder exactly like
chunks_exact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:33:14 -04:00
Brandon Thomas 536a7346fb more algorithms, progress 2026-10-01 08:25:06 -04:00
Brandon Thomas ae92ac20bf continued wip 2026-09-30 09:37:18 -04:00
Brandon Thomas 16941f7198 one-shot 2026-09-30 05:02:08 -04:00