* Advanced Blending: knockout, blend as group, transparency shapes, masks hide effects (part of #215)
Blending Options > Advanced Blending now works end to end:
- doc: AdvancedBlending on every layer (knockout none/shallow/deep, blend
interior effects as group, blend clipped layers as group, transparency
shapes layer, layer/vector mask hides effects), saved in .pcraft.
- PSD and layered TIFF: knko, infx, clbl, tsly, lmgm, vmgm read into the
model and written back (in place, or added when not at the defaults);
the TIFF byte-order transcoder handles lmgm/vmgm as byte flags.
- CPU compositor (compose/src/advanced.rs): shallow and deep knockouts
(isolated groups and clipping groups stop a deep knockout), clipped
layers blended individually, effects over the whole layer when
Transparency Shapes is off, effects built from an unmasked copy when a
mask hides them, interior effects under fill opacity.
- GPU: documents using Advanced Blending fall back to the CPU compositor.
- engine: layer.layerStyle.blendingOptions takes channels, knockout and the
five switches; inspect reports them; Copy/Paste Layer Style carries them.
- UI: the Blending Options page laid out like Photoshop's (General,
Advanced Blending, Blend If with split sliders), translated.
psd-tools corpus oracle 229 -> 236 (seven knockout files now pass; floor
raised); other corpora and all round trips unchanged. Scorecard
FILE-215-16 -> partial.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Polish catalog rows for Advanced Blending; regenerate docs/scorecard.md
pl.tsv landed on main and the coverage test needs every catalog to translate new UI strings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Undo damage: compare Layer.advanced in layer_pixels_equal; regenerate scorecard
#810's exhaustive Layer destructure must classify the new advanced-blending
field; it changes the composite, so it is compared.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Regenerate docs/scorecard.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Saturation −100 greyed to the HSL lightness, so a yellow (255, 255, 0) became grey 128 where
Photoshop shows near-white: a yellow logo with Brightness/Contrast +150 and Vibrance › Saturation
−100 clipped to it stayed yellow under Lighter Color. Measured black-box on Photoshop 25.4
(16-bit runs of 7,400-colour charts and HSV sweeps; sRGB, Adobe RGB and ProPhoto documents), both
sliders work in linear light through the document tone curve, Vibrance first, then Saturation:
- Saturation moves each colour from the grey 0.288 R + 0.712 G (blue has no weight, in every
profile) by 1 + s, clipping per channel: within 0.07/255.
- Negative Vibrance is an HSV change in linear light keeping the hue, in closed form: 0.2/255 on
average, the deepest shadows (V < 0.05) drift.
- Positive Vibrance: the brightness term is exact; the saturation boost (stronger for dull and
dark colours, damped on reds and skin) is a fit, 0.4 (+10) to 5 (+100) /255 on average.
CPU (compose::adjust::vibrance_px) and GPU (compose.wgsl) share the constants; the parity test
gains a negative Vibrance and Saturation −100. photocraft-corpus adjustments/rgb8|rgb16/
vibrance.psd (Vibrance +60, Saturation −15): 25.6/255 mean, 89 max → 0.38 mean, 5 max.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Fill 0 % + an Outside stroke is Photoshop's "outline only" look, but on pixel
layers the stroke lay beneath the whole interior at full coverage, relying on
the layer composited on top to hide it. With the fill lowered the interior
showed the stroke colour (a solid disc instead of a ring).
The share beneath an interior pixel is now (1 - a) / (1 - c), with a the
shape's coverage and c the layer's alpha after fill: 1 under an opaque
layer (100 % fill renders exactly as before, so PSD oracles don't move),
1 - a at 0 % fill. CPU compositor and the wgpu shader agree (the layer's
alpha now rides along the coverage seed for pixel layers too).
Tests: compose regression at 8/16/32-bit (0 %, 50 %, 100 % fill), and a GPU
parity case at fill 0 and 0.5 (fails without the shader change).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
- Color Lookup: a stored document's `size` is untrusted. `size³ × 3` overflowed for sizes of
2²² and up: debug builds panicked, release builds wrapped past the table-length guard and
indexed out of bounds. One checked `photocraft_doc::adjust::lut3d_len` now backs the CPU
compositor, the GPU planner and the PSD writer.
- Quick Selection: a stroke point far off the canvas (`select.quick {"points":[[3e9,0]]}`)
saturated to i32::MAX and then overflowed on `+ 1`.
- Link Layers: `max + 1` over stored link group ids overflowed at u64::MAX (debug panic, release
wrap that could merge unrelated layers). A fresh id is now the smallest unused one when the
largest is taken; a selection's first group is read with `first()`.
- Vanishing Point clone footprint: the same `as i32 + 1` saturates (hardening; no reproducer).
* Photo Filter and gradient glows render like Photoshop (#442, #443)
Photo Filter multiplied encoded RGB by the filter colour and rescaled to Rec.601 luma. Photoshop
multiplies the pixel's D50 XYZ (relative to white) by the filter colour's, mixed by density, and
Preserve Luminosity restores the encoded luminosity like the Luminosity blend mode (SetLum +
ClipColor); 32-bit documents keep luminance Y. rgb16 photo-filter.psd now matches within 0.6/255
(was 125/255); rgb8 mean error 0.3/255 (was 20.8).
Outer and Inner Glow gradients were laid out across the canvas (a vertical linear gradient), so
the colour bands ran top to bottom. Photoshop colours the glow at strength v with the gradient at
1 - v (first stop at the edge) and the glow is opaque from v = range^2. outer-glow-gradient.psd:
mean 1.4/255, max 9.9 (was 252).
CPU and GPU paths updated together; photoshop oracle floor 132 -> 133.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Merge origin/main; regenerate docs/scorecard.md
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>
* layer style: instance-aware engine apply (layer.layerStyle.replace) with param overlays, contour presets and factory defaults
* layer style: instance-based dialog state preserves every effect; Preview checkbox gates the live preview
* layer style: Styles swatches and New Style… save the dialog pending state (style.presets.new effects param)
* layer style: Contour picker and Noise slider for shadows and glows (CPU-rendered noise; GPU falls back to the CPU for noisy effects)
* layer style: Make Default / Reset to Default per effect kind (user defaults persist with the presets)
* layer style: double-stroke band regression (manga case) and control-protocol docs for the instance-based dialog
* layer style: make Styles a top-level tab displaying preset swatches in the right panel
* i18n: translate the Layer Style preset strings in de and pt-br
The German and Brazilian Portuguese catalogs landed on main after this
branch added "Style {name} saved", "Make Default" and "Reset to
Default"; every_tl_literal_is_translated needs them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* presets: move the stray BTreeMap import to the top of the module
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>
* 32-bit documents: Levels don't clip the input and output ranges
Photoshop's float Levels keep the curve going past the black and white points
and use a plain power for the gamma. rgb32 and gray32 levels.psd now match
within 0.5/255 (was 10/255).
* Raise the Photoshop oracle floor to 132
* Levels: destructive Image › Adjustments › Levels doesn't clip in 32-bit either; neutral comment wording
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>
* 32-bit documents: Exposure works on the stored linear values
Their samples are linear already, so going through the 2.2 curve applied
the gamma twice. rgb32 and gray32 exposure.psd now match Photoshop within
0.5/255 (was 69/255 and 44/255).
* Raise the Photoshop oracle floor to 130
Rebased onto the paged GPU compositor (#187): the first-frame empty submit moves into encode_inner, and the test GPU lock covers the paged compositor too.
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>
- A failed readback mapping (e.g. device lost) now returns Unsupported,
so the caller falls back to the CPU compositor, instead of
expect("mapped readback buffer").
- Pipeline creation skips kernels without an entry point instead of
expect; resident/effect entries are looked up with let-else after the
insert; effect-program targets are Option instead of indexing +
expect; copies without a source are skipped; the planner returns
Unsupported instead of unreachable! for an adjustment layer.
- gpu denies unwrap/expect/panic/unimplemented/todo/unreachable.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Once Format stopped failing first, the Linux runner reached the GPU tests
for the first time: its software adapter (GL) can't render Rgba32Float or
R32Float. The app already picks Rgba16Float there, but the effect-map
pipelines (R32Float/R16Float) still panicked. They are now built inside an
error scope; where they can't be, documents with layer effects use the CPU
compositor and everything else stays on the GPU.
The exact-parity tests skip on adapters without 32-bit float targets (the
app uses the CPU compositor there), like they already skip without an
adapter; the fallback-path tests build with try_new_with_format.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On D3D12 without DXC, wgpu compiles HLSL with FXC, which aborted on
fs_adjust ("internal error: compilation aborted unexpectedly"). wgpu turns
that into a panic, so every GPU test failed on the Windows runner, and the
app would crash where the same path is taken. A bisect on the Windows
runner pinned it to two cases of adjust()'s switch, Exposure and Selective
Color: both looped over dynamically indexed vector components. They are
now written per channel / per range with the same math (GPU-vs-CPU parity
is unchanged and passes on Metal and on the Windows runner).
Compositor::try_new_with_format catches validation and internal errors
while building the pipelines and returns Err; the canvas then uses the CPU
compositor (once, remembered) instead of crashing.
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>
Some drivers (e.g. Intel Vulkan on Linux/Windows) can't render to Rgba32Float,
so creating the 'fs_content' pipeline panicked with 'Format Rgba32Float is not
renderable' as soon as a document/image was opened. The accumulation format is
now chosen per adapter: Compositor::preferred_acc_format() keeps Rgba32Float
where it's a valid render target and falls back to Rgba16Float otherwise. The
compositor carries the format (pipelines, accumulation texture, byte size and
the readback half-decode all follow it); the app picks it from the eframe
adapter. Capable GPUs are unchanged (parity still exact, 18/18). A new test
forces the Rgba16Float path on any GPU and confirms it renders within display
tolerance (blend + adjustment). wasm green.
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>