blend_text_gamma_setting switched the compositor's process-wide text gamma
(psblend::set_text_gamma) to 1.0 and back while other engine unit tests ran
in parallel threads. turns_at_every_depth_and_mode flattens a document with
a type layer before a turn and again after edit.undo; when the toggle landed
between the two flattens, anti-aliased glyph edges blended under different
gammas and the bit-exact comparison failed (seen once on Windows CI).
Move the gamma test to crates/engine/tests/text_gamma.rs (its own process),
document the hazard on set_text_gamma, and make the undo assertion report
the turn, depth, mode, differing pixel count, max difference and first pixel.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Reserve up to 64 MiB of converted pattern pixels and 64 entries for
one render call, including pending initializers and all export bands.
Initialize outside the map lock on the requesting CMYK-scoped worker.
Budget exhaustion retains the existing uncached conversion path;
the retained-payload budget is not a cap on transient fallback buffers.
Keep OnceLock geometry initialization sequential so Rayon work stealing
cannot re-enter an unfinished slot. Raster coverage and scratch remain
local to each requested rectangle; export bands share only geometry.
Keep full-region neighbourhood effect maps while rendering content only for the output rectangle. Compare complete tiled output at U8, U16 and F32 depths rather than three sampled pixels.
Keep effect and fill sampling separate: empty effects use a gray ramp,
while empty fills stay transparent and interpolate Color.alpha.
Preparation stays inside the active document CMYK scope.
Evidence:
- 24 MP RGB gradient overlay: warm median 362.752 ms to 235.813 ms.
- 24 MP RGB gradient fill: warm median 129.264 ms to 117.642 ms.
- Five release samples, eight Rayon workers, matching pixel digests.
Pass-through groups snapshot only for mixing or visible clipping. Adjustments retain one independent mutable result and read the untouched original backdrop pixel before replacing it.
* fix(ui): brush and eraser show the real stroke while drawing
While dragging, the Brush and Eraser drew a hard, flat egui polyline at
the brush width; the soft brush only appeared on release.
The engine's new `brush_cmds::LiveStroke` shares `paint.stroke`'s setup
and renders incrementally with `StrokeRenderer` onto a copy of the
document. The canvas shows that copy and recomposites only each step's
damage (preview key = stroke key + step). The commit passes the live
stroke's seed, so its pixels match the preview, and reuses the canvas so
only the stroke rect refreshes.
Downsampled CPU textures (documents over 4096 px, the Navigator) now
update partially too, aligned to the downsample factor, instead of
re-flattening the whole document.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Live stroke on the large-image stack: reduced CPU textures, pen tilt, display key
Adapts #64's live Brush/Eraser stroke to #71 (banded/reduced canvas), #66
(colour-managed display) and #74 (stylus tilt):
- compose: `render_reduced_damage` renders just the output pixels of
`render_reduced` whose source area meets a damage rect, with bit-identical
values (same spans, same summation order). The CPU canvas texture of a
document over 4096 px now updates partially from it (strokes, live steps,
any damaged edit) instead of #64's factor-aligned `downsample`, which
didn't match #71's area-reduced texture; uploads still go through
`display_image` (document -> monitor profile).
- ui-egui: the live stroke feeds each point's pen tilt/rotation (the same
values `Stylus::stroke_points` gives the commit), so tilt-driven brushes
preview what they commit; the first points come from the drag after
`Stylus::begin_stroke`.
- Preview keys keep #66's display key folded in; `damage_since` un-folds
the cached keys to compare against the live stroke's steps, and the commit
resets the caches to the document's key (raw 0 ^ display key).
- Stroke counter is masked so live keys stay in their 2^44 band.
6000x4000, 120 px soft brush, release: LiveStroke::push 0.72 ms mean
(p95 1.0), GPU damage refresh 1.6 ms mean (p95 4.7); CPU reduced-texture
rect 0.75 ms vs 135 ms for the full 3000x2000 reduction it replaces.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Compose: render in place and in bands; area-reduced and proxy thumbnails
render_tiled wrote every tile into a Vec and then copied them into the output:
two full-size float copies (6.3 GB for a 14000² document). Tiles are now
written straight into the output band by band.
New streaming APIs so large documents never need a full-size float
composite: render_bands (sink per band of ~8 MP), render_reduced (banded
area average, bit-identical to the old flatten-then-average thumbnail) and
flatten_to_surface (Flatten Image, Merge Visible, Copy Merged and the other
flattened-surface users).
thumbnail() reduces a downsampled proxy (moved from ui-egui to
compose::proxy, now also scaling spot channels) for documents over 16 MP
when that is faithful (no layer effects or vector masks), so navigator,
channel and histogram thumbnails of a 196 MP document cost milliseconds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU canvas: request the adapter's real limits; banded CPU fallback; navigator thumbnail
egui-wgpu asks for an 8192 px texture limit, so the wgpu compositor rejected
every document wider or taller than 8192 px and each refresh fell back to a
full CPU flatten. The desktop and web apps now request the adapter's own
max_texture_dimension_2d and max_buffer_size (gpu_canvas::use_adapter_limits);
the benches use the same limits.
The CPU fallback (documents beyond the GPU limit, or features it lacks) is
GpuCanvas::refresh: it renders and uploads the composite band by band
instead of a full float composite plus a full RGBA8 copy. The CPU canvas
texture is reduced in bands too, and the Navigator on the GPU canvas uses a
cached thumbnail instead of a full-resolution CPU composite per revision.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* IO: banded export and import; no full-size copies in encoders; larger decode limits
- Flat export renders the composite in bands and quantises each band; alpha
is dropped in place when every pixel is opaque. The single-layer native
path copies surface rows a band at a time (no f32 read_region and no
per-pixel Vec). Buffers come from try_reserve: Err, not an abort.
- PSD export encodes the merged image band by band into its planes (no
flatten, no second plane copy); near-opaque documents are re-encoded
without the white matte so the output is unchanged. Layer interleaved
bytes are freed before their planes are compressed.
- Flat import converts in ~32 MB bands (and not at all when already RGBA);
the PSD merged-image un-matte works per tile row.
- Encoders borrow the image when no conversion is needed (Image::converted)
instead of cloning it; PNG only copies 16-bit data for the byte swap.
- Decode limits: 2^30 pixels and 8 GiB on 64-bit targets (a 20000² JPEG
was refused at 2^28).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Filters: write tile results in memory-bounded groups
apply_tiled collected every tile's float result before writing any (3.1 GB
for a 14000² layer). Results are now produced and written in groups of about
256 MB (at least one tile per core).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* History: bound pixel memory by Preferences > Performance > Memory Usage
History::max_bytes caps the current document's tiles plus the tiles only
history holds (layers, masks, alpha channels); History::trim drops the
oldest states beyond it after every recorded step and when preferences
change, always keeping the last step undoable. The engine sets it from
memory_usage_mb, which was unused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Large-image benchmark (14k / 20k): open, refresh, thumbnails, filter, stroke, saves
One operation per run on a cached synthetic photo, with the GPU device created
the way the app creates it, so /usr/bin/time -l reports that operation's peak
memory. Documented in docs/development.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Adjustments: full parameter sets for every kind, Hue/Sat ranges, CMYK/Lab tone channels
- doc: Hue/Saturation gains six colour ranges (HueRange), Gradient Map a dither flag,
Levels/Curves a ToneSpace (RGB, CMYK inks + black, Lab) for mode-native channels.
- engine: new adjust_params module (from_params over a base / to_params) used by
image.adjustments.*, layer.newAdjustmentLayer.* and layer.setAdjustment. Curves,
Color Balance, Channel Mixer, Black & White, Gradient Map and Photo Filter now honour
their params; bad params are BadParams errors; setAdjustment merges (empty = reset).
- compose/gpu: hue-range tables, CMYK/Lab tone channels (CPU; GPU falls back), dithered
Gradient Map, Black & White from grey/secondary/primary parts; GPU parity extended.
- io: hue2 ranges, grdm dither, CMYK/Lab Levels/Curves records round-trip.
- destructive adjustments use the document's tone transfer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Adjustments UI: one editor per kind for Properties and Image › Adjustments dialogs
- adjust_editors: Brightness/Contrast (legacy), Levels and Curves (per channel in RGB, Gray,
CMYK and Lab, histogram behind), Exposure, Vibrance, Hue/Saturation (master + six ranges with
draggable range sliders, colorize, before/after spectrum), Color Balance (tones, preserve
luminosity), Black & White (six weights, tint colour), Photo Filter (presets or colour,
density), Channel Mixer (outputs, constant, monochrome, total), Posterize, Threshold
(histogram), Gradient Map (stops, presets, reverse, dither). Replaces the M7 placeholder.
- Properties previews live (GPU) while a control moves and commits one setAdjustment per gesture.
- adjust_dialog: Image › Adjustments opens the same editor modally; the canvas previews the
real command on the proxy, Preview checkbox, OK = one history step, Cancel leaves the doc.
- Curves (#43): hit-test at the press origin, Delete/Backspace removes the selected point when
the graph has focus, Cmd/Ctrl-click removes, dragging off removes (and back on restores),
arrows nudge; endpoints and the last two points stay; pressing the gradient bars does nothing.
- tone: histograms per channel space, of the image below a layer or of the target layer alone.
- kittests: every adjustment layer kind edited through the Properties panel with real input at
8/16/32-bit RGB, Gray, CMYK and Lab (#12); Curves point interactions; dialog preview/OK/Cancel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* io tests: adjustment round trip covers the new Levels/Curves/Hue-Sat/Gradient Map fields
#60's round-trip tests predate #68's doc-model additions (tone space and black channel,
Hue/Saturation ranges, Gradient Map dither); fill them with their defaults.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* CMYK documents display and export through their embedded CMYK profile
A thread-local CmykSpace (photocraft_color::convert::with_cmyk_space) replaces the built-in
coated CMYK while the CPU compositor, the GPU compositor's uploads and the PSD merged
composite convert a document's CMYK pixels; GPU residents track the profile they were
converted with.
Refs #46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* EXR/HDR are linear: tag imports linear sRGB, linearise exports
Refs #46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Colour-managed canvas: document profile -> monitor profile on GPU and CPU paths
* engine display_color: CanvasDisplay per (document profile, mode, monitor), identity fast
path when they match (sRGB on sRGB costs nothing); linear composites are stored
sRGB-encoded in the 8-bit texture. canvas_lut now always carries the CM transform, with
Proof Colors / Gamut Warning / 32-bit preview on top.
* GPU canvas applies the display LUT whenever the transform isn't the identity (also for
filter/proxy previews); the CPU canvas and Navigator transform the 8-bit composite.
* Edit > Color Settings > Monitor Profile (auto|built-in|.icc). macOS 'auto' reads the main
display's ICC profile at launch via osascript/AppKit (no unsafe); elsewhere sRGB.
* Tests: P3 (hand-computed), Adobe RGB, ProPhoto, Gray Gamma 2.2, synthetic CMYK, linear,
monitor setting; GPU+CPU canvas test via kittest (skips without Rgba32Float).
Fixes#46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* io: tag EXR/HDR linear in import_flat (adapt to the shared image_to_document)
#65 moved flat decoding into image_to_document(name, img) so camera raws can reuse it; the
linear-EXR tagging needs the file bytes for format detection, so it now runs in import_flat.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* PSD: read and write vibA, blnc, blwh, phfl, mixr adjustment layers
Vibrance, Color Balance, Black & White, Photo Filter and Channel Mixer
layers made in PhotoCraft were dropped on PSD save (Photoshop saw an empty
pixel layer), and the same blocks rendered as no-ops on import. They are
now parsed into their Adjustment variants and written per the Adobe spec
(descriptor blocks with version 16; phfl version 2/3; mixr version 1).
A Color Lookup without a table is written as an identity cube and a
Gradient Map with fewer than two stops as the equivalent two-stop
gradient, so every evaluated kind has an encoding.
Tests: golden hand-built blocks for each new key, malformed and fuzzed
input, and a document -> PSD -> document round trip of every adjustment
kind (8 and 16 bit, plain / masked + blended / clipped) and every blend
mode (pixel, clipped, group, pass-through, adjustment layers), asserting
model equality where lossless and composite equality within 1/255.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Corpus: export round-trip step and pass floors; fill pixels in the mask frame
corpus.rs now re-imports every exported file and compares its flatten
with the first import (169/169 render the same). With PHOTOCRAFT_CORPUS
set (corpus dir, or 1 for the default location) it asserts the oracle
pass floor (113) and the round-trip floor (169).
The new step found gradient fills without Photoshop pixels exported in
the canvas frame instead of the frame their masks give them, so they
re-imported shifted (gradient-fill.psd/.psb, 0.26 max error). Export now
renders the fill like the compositor (render_fill_content).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
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>
CI's `cargo fmt --all -- --check` has failed on every run because the
repo had no rustfmt config and was never formatted. Use the same settings
as EffectCraft/FilmCraft (max_width 160, use_small_heuristics Max), which
also keeps the churn small. Formatting only; no code changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Model Blend If (This Layer / Underlying Layer ranges, split points) as
Layer::blend_if, read and write it as the PSD layer-record blending
ranges (previously parsed but dropped on export), save it in .pcraft,
render it in the CPU compositor (premultiplied mix = compositing at
k × alpha), fall back to the CPU on the GPU path, and drive it from
layer.layerStyle.blendingOptions {"blendIf": …}; document.inspect
reports it.
Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Verified our levels() against Adobe Photoshop 2026 (adjustLevels on a 0..255
ramp): exact for gamma<=1 and all input/output ranges, but a pure u^(1/gamma)
has infinite slope at black while Photoshop bounds the shadow toe to initial
slope 2^gamma. Added a soft-min (p-norm) of the power curve with that line,
applied only for gamma>1; reduces to the exact power curve in the body, so
highlights/midtones and gamma<=1 are unchanged. GPU Levels LUT is built from
this fn, so GPU stays in sync (gpu parity 18/18).
Corpus oracle: levels_grayscale.psd max error 0.043 -> 0.0078 (bad px
4.1% -> 0.1%); 113 pass / 30 differ unchanged (no regressions). Golden test
pins the curve to captured Photoshop samples (gamma 2 toe within 4/255).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The non-legacy B/C was a linear scale that hard-clipped; Photoshop's modern
B/C is a pair of smooth curves that pin pure black and white. Reverse-
engineered from Photoshop 2026 ground truth (a 0..255 ramp pushed through the
app via executeAction BrgC, useLegacy=false):
- Contrast: symmetric cubic-Hermite S pivoting at 0.5 (end slope 1-c/128,
centre 1+c/128) -- near-exact, <=0.5/255 vs Photoshop.
- Brightness: line of slope 1.375^(b/50) rolled off to (1,1) by a v^P white
anchor -- close, approximate at the extremes (the real roll-off is a spline).
Applied brightness-then-contrast; matched in the wgsl compositor (mbright/
mcontrast) so GPU == CPU oracle (gpu parity 18/18). Golden tests pin both
curves to the captured Photoshop samples.
Corpus oracle: brightnesscontrast_grayscale.psd max error 0.478 -> 0.086
(bad px 91% -> 29%); 113 pass / 30 differ unchanged (no regressions).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gradients/Patterns/Styles/Shapes/Tool Presets/Clone Source panels with
headless preset commands and a Custom Shape tool; Color card tabs match
Essentials. Character and Paragraph Styles (doc model, .pcraft, PSD style
sheets), Glyphs panel + type.insertText, Check Spelling (SCOWL-50 list).
Checkpoint also includes compiling in-progress work of the remaining agents.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>