Commit Graph
56 Commits
Author SHA1 Message Date
ad96df566b File > Save As / Save a Copy / CLI can write PDF (#3401) (#3441)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation
8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft
saved Lab documents as sRGB TIFFs.

- codecs: a Lab channel layout (Lab, LabA) holding a* and b* as
  PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD
  importer), with its own Lab <-> sRGB conversion (same formulas as
  photocraft-color; codecs stays standalone) for formats without Lab,
  which now warn LabConverted.
- TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the
  tiff crate as RGB and labelled Lab; a* and b* are converted from the
  file's encoding (signed a / 256a, or offset).
- TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab
  is written with 16 bits (DepthReduced).
- io: a Lab TIFF opens as a Lab document; a Lab document with one plain
  layer is written to TIFF in Lab (other formats still use sRGB).

Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its
own RGB conversion matches within one level at 8 bits and exactly at 16
bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same
L*a*b* values.

Fixes #4936

* PSD: a smart object's filter mask shows when the file opens

Photoshop stores a smart object's layer pixels with its filters applied
but not its filter mask. When it composites, it mixes the unfiltered
pixels (cached in FEid) back in through the mask. We showed the stored
pixels as they were, so a PSD whose smart filter has a mask opened with
the filter everywhere. Only after something re-rendered the smart object
did the mask appear, because our re-render mixes it.

- compose::filter_mask holds the mix, moved from the engine, and its
  inverse.
- Import decodes the unfiltered pixels from FEid
  (smart_map::unfiltered_from_item) and keeps the mixed result as the
  smart object's pixels, as the engine's re-render does.
- Export writes the layer record the way Photoshop does: unmixed with
  the unfiltered pixels it writes (or keeps) in FEid, so Photoshop mixes
  back to what the document showed. Transparent pixels stay empty.

Photoshop oracles: sf-filter-mask-hard, sf-filter-mask-gradient and
sf-stack-blend-and-mask pass (145 -> 148 of 258); floor 145 -> 148.
psd-tools and io corpora unchanged.

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

* DNG: decode lossy (JPEG-compressed) DNG tiles (FILE-215-11)

DNG 1.4 lossy JPEG (compression 34892) stores each tile or strip as a
baseline-DCT JPEG of 8-bit samples: a gray stream for CFA data, a YCbCr
stream for three-sample LinearRaw. The decoder arm from #2021 read the
streams but widened every sample by 257, so the values no longer matched
the file's levels: an 8-bit WhiteLevel (or its default, 255) clipped
everything to white and a 256-entry LinearizationTable was indexed past
its end. It also required the JPEG frame to match the clipped row count,
which rejected the padded edge tiles real tiled files carry.

The arm is now `lossy_jpeg()` in sensor.rs. The decoded 8-bit values are
the stored samples, and BlackLevel, WhiteLevel (default 255 for 8-bit
data) and the LinearizationTable apply to them exactly as for every other
compression. A tile may be coded at the full tile size (edge tiles padded,
as the spec says) or at the image's row count; a strip at its row count;
the width must be the segment's. BitsPerSample must be 8 and the JPEG's
component count must equal SamplesPerPixel (1 or 3); anything else is an
error, never a silent conversion. The frame header is checked before any
pixel is decoded and the JPEG decoder's own size cap is the segment the
IFD declares (already charged against the limits), so a hostile SOF cannot
size a buffer past the tile.

testgen writes true 8-bit lossy fixtures (`LossyJpegTiles`, and strips via
the new `LossyJpegStrips`), accepts pre-encoded `lossy_segments` for
hostile-input tests, exposes `baseline_jpeg()`, and leaves WhiteLevel out
when `white` is 0. The DNG tests cover CFA and LinearRaw RGB from tiles
and strips at 37x23 (partial edge tiles, a short last strip), the default
white level, black/white scaling and a LinearizationTable against the
uncompressed path, and hostile inputs: a truncated tile, frame-size and
component-count mismatches, a 65535x65535 SOF under default and tight
limits, a tile over the allocation budget, and 12/16-bit or 4-sample
files. The hostile mutation set gains a lossy CFA and a lossy LinearRaw
file. The parity doc's DNG row is updated; the scorecard row is left for
the coordinating branch.

* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* File > Save As / Save a Copy / CLI can write PDF: one flattened page with the embedded profile (#3401)

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195)

- flatten: a path's flattened points are capped at 1 << 20; past it every
  curve keeps a proportional share of its pieces (at least one), so hostile
  handles from files (vmsk, SVG, effect outlines) cannot exhaust memory.
  fill_rasterizer flattens the whole path under one budget.
- stroke: past the dash step budget (or below coordinate precision),
  dash_polyline keeps the dashes made so far and draws the rest solid
  instead of dropping the stroke. Open polylines are no longer copied.
- raster: coordinates are clamped to +-1e15 and edges with a non-finite
  slope (vanishingly short) are skipped, so huge finite coordinates no
  longer produce NaN coverage. fill_rasterizer sorts edges once instead of
  after every component (stable sort, identical order).
- Cleanups: dead miter `half`, dead loop-length check in trace_mask,
  shared mask render body, map_knots helper, shared straight-segment test.

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

* PSD: cap pattern memory across a parse, reject mixed-depth pattern channels (#5198)

- Patterns: one PatternBudget (decoded bytes + zero padding) is shared by
  every pattern of a block, a .pat file and the pattern blocks of one
  document; .abr patt sections are charged to the samples' budget.
- Patterns: a channel whose depth differs from the first is rejected (the
  parsed pattern could not be written back).
- Slices: descriptor numbers no longer wrap; out-of-range ids are no id,
  coordinates clamp to i32, and a v6 trailing entry without a usable id
  attaches its outsets to no slice.
- LayerInfo::unpadded_len is computed from field sizes instead of
  serializing every record into a scratch buffer.
- abr desc presets are moved out instead of cloned; UnFl and shmd counts
  use the bounded preallocation; ImageData::decode_channel (decoded every
  plane to return one, unused outside its test) is removed.

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

* Vector: bound stroke outline points (#5211)

- stroke_polygons dashes all lines first, then counts round caps and
  joins. When their full-resolution tessellation would exceed 1 << 20
  outline points, every round cap and join gets the same smaller segment
  count (down to 4 per circle, 1 per join; area-exact radius kept).
  Strokes under the budget are unchanged; no piece is dropped.
- The dash step budget (100000) is now shared by all lines of a stroke
  instead of per polyline, so many subpaths cannot multiply the dash
  (and cap) count. Past it the rest is drawn solid, as before.

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

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

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

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers

Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.

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

* fix(history): count snapshot-only tiles against the history budget

History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.

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

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

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

---------

Co-authored-by: Gert Pellin <gert@pellin.be>
Co-authored-by: timothydaly1 <tadaly04@protonmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Fabio Lissi <fabio.lissi@gmail.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
Co-authored-by: Finn Newick <finnnk@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Diego Chagas <drochachagas@gmail.com>
Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
2026-10-15 01:03:27 -04:00
4e1e325b17 PSD: a smart object's filter mask shows when the file opens (#5005)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation
8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft
saved Lab documents as sRGB TIFFs.

- codecs: a Lab channel layout (Lab, LabA) holding a* and b* as
  PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD
  importer), with its own Lab <-> sRGB conversion (same formulas as
  photocraft-color; codecs stays standalone) for formats without Lab,
  which now warn LabConverted.
- TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the
  tiff crate as RGB and labelled Lab; a* and b* are converted from the
  file's encoding (signed a / 256a, or offset).
- TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab
  is written with 16 bits (DepthReduced).
- io: a Lab TIFF opens as a Lab document; a Lab document with one plain
  layer is written to TIFF in Lab (other formats still use sRGB).

Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its
own RGB conversion matches within one level at 8 bits and exactly at 16
bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same
L*a*b* values.

Fixes #4936

* PSD: a smart object's filter mask shows when the file opens

Photoshop stores a smart object's layer pixels with its filters applied
but not its filter mask. When it composites, it mixes the unfiltered
pixels (cached in FEid) back in through the mask. We showed the stored
pixels as they were, so a PSD whose smart filter has a mask opened with
the filter everywhere. Only after something re-rendered the smart object
did the mask appear, because our re-render mixes it.

- compose::filter_mask holds the mix, moved from the engine, and its
  inverse.
- Import decodes the unfiltered pixels from FEid
  (smart_map::unfiltered_from_item) and keeps the mixed result as the
  smart object's pixels, as the engine's re-render does.
- Export writes the layer record the way Photoshop does: unmixed with
  the unfiltered pixels it writes (or keeps) in FEid, so Photoshop mixes
  back to what the document showed. Transparent pixels stay empty.

Photoshop oracles: sf-filter-mask-hard, sf-filter-mask-gradient and
sf-stack-blend-and-mask pass (145 -> 148 of 258); floor 145 -> 148.
psd-tools and io corpora unchanged.

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

* DNG: decode lossy (JPEG-compressed) DNG tiles (FILE-215-11)

DNG 1.4 lossy JPEG (compression 34892) stores each tile or strip as a
baseline-DCT JPEG of 8-bit samples: a gray stream for CFA data, a YCbCr
stream for three-sample LinearRaw. The decoder arm from #2021 read the
streams but widened every sample by 257, so the values no longer matched
the file's levels: an 8-bit WhiteLevel (or its default, 255) clipped
everything to white and a 256-entry LinearizationTable was indexed past
its end. It also required the JPEG frame to match the clipped row count,
which rejected the padded edge tiles real tiled files carry.

The arm is now `lossy_jpeg()` in sensor.rs. The decoded 8-bit values are
the stored samples, and BlackLevel, WhiteLevel (default 255 for 8-bit
data) and the LinearizationTable apply to them exactly as for every other
compression. A tile may be coded at the full tile size (edge tiles padded,
as the spec says) or at the image's row count; a strip at its row count;
the width must be the segment's. BitsPerSample must be 8 and the JPEG's
component count must equal SamplesPerPixel (1 or 3); anything else is an
error, never a silent conversion. The frame header is checked before any
pixel is decoded and the JPEG decoder's own size cap is the segment the
IFD declares (already charged against the limits), so a hostile SOF cannot
size a buffer past the tile.

testgen writes true 8-bit lossy fixtures (`LossyJpegTiles`, and strips via
the new `LossyJpegStrips`), accepts pre-encoded `lossy_segments` for
hostile-input tests, exposes `baseline_jpeg()`, and leaves WhiteLevel out
when `white` is 0. The DNG tests cover CFA and LinearRaw RGB from tiles
and strips at 37x23 (partial edge tiles, a short last strip), the default
white level, black/white scaling and a LinearizationTable against the
uncompressed path, and hostile inputs: a truncated tile, frame-size and
component-count mismatches, a 65535x65535 SOF under default and tight
limits, a tile over the allocation budget, and 12/16-bit or 4-sample
files. The hostile mutation set gains a lossy CFA and a lossy LinearRaw
file. The parity doc's DNG row is updated; the scorecard row is left for
the coordinating branch.

* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195)

- flatten: a path's flattened points are capped at 1 << 20; past it every
  curve keeps a proportional share of its pieces (at least one), so hostile
  handles from files (vmsk, SVG, effect outlines) cannot exhaust memory.
  fill_rasterizer flattens the whole path under one budget.
- stroke: past the dash step budget (or below coordinate precision),
  dash_polyline keeps the dashes made so far and draws the rest solid
  instead of dropping the stroke. Open polylines are no longer copied.
- raster: coordinates are clamped to +-1e15 and edges with a non-finite
  slope (vanishingly short) are skipped, so huge finite coordinates no
  longer produce NaN coverage. fill_rasterizer sorts edges once instead of
  after every component (stable sort, identical order).
- Cleanups: dead miter `half`, dead loop-length check in trace_mask,
  shared mask render body, map_knots helper, shared straight-segment test.

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

* PSD: cap pattern memory across a parse, reject mixed-depth pattern channels (#5198)

- Patterns: one PatternBudget (decoded bytes + zero padding) is shared by
  every pattern of a block, a .pat file and the pattern blocks of one
  document; .abr patt sections are charged to the samples' budget.
- Patterns: a channel whose depth differs from the first is rejected (the
  parsed pattern could not be written back).
- Slices: descriptor numbers no longer wrap; out-of-range ids are no id,
  coordinates clamp to i32, and a v6 trailing entry without a usable id
  attaches its outsets to no slice.
- LayerInfo::unpadded_len is computed from field sizes instead of
  serializing every record into a scratch buffer.
- abr desc presets are moved out instead of cloned; UnFl and shmd counts
  use the bounded preallocation; ImageData::decode_channel (decoded every
  plane to return one, unused outside its test) is removed.

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

* Vector: bound stroke outline points (#5211)

- stroke_polygons dashes all lines first, then counts round caps and
  joins. When their full-resolution tessellation would exceed 1 << 20
  outline points, every round cap and join gets the same smaller segment
  count (down to 4 per circle, 1 per join; area-exact radius kept).
  Strokes under the budget are unchanged; no piece is dropped.
- The dash step budget (100000) is now shared by all lines of a stroke
  instead of per polyline, so many subpaths cannot multiply the dash
  (and cap) count. Past it the rest is drawn solid, as before.

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

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

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

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers

Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.

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

* fix(history): count snapshot-only tiles against the history budget

History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.

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

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

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

---------

Co-authored-by: Gert Pellin <gert@pellin.be>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Fabio Lissi <fabio.lissi@gmail.com>
Co-authored-by: Vikram Bhaskaran <vikram.bhaskaran@outlook.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
Co-authored-by: Finn Newick <finnnk@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Diego Chagas <drochachagas@gmail.com>
Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
2026-10-15 00:58:45 -04:00
5dafbeeafc Codecs: open Cineon (.cin) film frames read-only (#4691)
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* cargo fmt the extension lists

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-15 00:23:03 -04:00
Vikram BhaskaranandBrandon Thomas 7edc7aa03d Raw: measure the Nikon D90 image area (4288x2848 at (10, 10)) (#4323)
Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 00:22:48 -04:00
7e0f48a290 Blur Gallery: Tilt-Shift and Spin Blur on the image (#3373) (#3829)
* Blur Gallery: Field Blur pins on the image (#3373)

Field Blur opened a parameter dialog with one centre, though the engine
already blends any number of pins. It now works as Photoshop's Blur
Gallery workspace: the image under the dialog takes the pointer. A
click adds a pin and selects it, a drag moves a pin, and a drag around
the selected pin's ring turns its blur up or down (one turn is the
0-500 px range; the arc shows the amount). Delete or Backspace removes
the selected pin. The dialog shows the selected pin's Blur and Preview,
and opens at the right edge, beside the image, so the pins stay in view.

The pins live in the dialog's fields (`pins`, `__selected`), so the
existing filter preview follows every change, OK applies
filter.blurGallery.fieldBlur with them in one step, and automation
drives it with ui.dialog.set. Pins are fractions of the selection's
bounds when there is one, else the canvas, as the engine reads them.
Proxy previews now scale Blur Gallery pins' pixel blur like other pixel
params.

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

* Blur Gallery: Iris Blur's pins and handles on the image (#3373)

Iris Blur opened a parameter dialog with one ellipse. It now uses the
Field Blur workspace too, with Photoshop's handles on the selected pin:
the four on its outline resize that axis and turn the shape, the square
at its corner rounds it towards a rectangle, and the four dots inside
set the sharp core, where the blur starts. A click adds a pin with the
selected pin's shape and blur; pins move, their ring sets the blur,
Delete removes them, and the preview follows.

The pins keep the command's own keys (radiusX/radiusY as fractions of
the bounds' shorter side, angle, roundness, feather), and the handles
use the engine's superellipse geometry, so what the outline shows is
where the filter blurs. Values from automation are cleaned before use.

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

* Blur Gallery: Tilt-Shift and Spin Blur on the image (#3373)

Tilt-Shift and Spin Blur opened parameter dialogs; they now use the
Blur Gallery workspace with Photoshop's handles.

Tilt-Shift: the solid lines bound the sharp band (drag to widen or
narrow it), the dashed lines mark where the blur is full (drag to set
the transition), the dots on the solid lines turn the band, the pin
moves it and its ring sets the blur. The engine takes one band, so a
click adds none and the dialog keeps the command's own centerX,
centerY, angle, focus, transition and blur.

Spin Blur: pins like Iris Blur's, with the outline handles resizing and
turning the ellipse; the ring and the dialog set the blur angle (one
turn of the ring is 360 degrees). Pins keep the command's keys
(radiusX, radiusY, angle, blurAngle).

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

* i18n(vi): translate the Field Blur pin strings

main added the Vietnamese catalog after this branch was cut, so
every_tl_literal_is_translated failed on the merge for "No pin
selected" and the pin hint. Terms follow the catalog's own: ghim
(pin), Bấm (click), Delete kept as the key name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(vi): translate the Iris Blur handle hint

main added the Vietnamese catalog after this branch was cut; terms
follow the catalog's own (tay nắm for handle, Kéo for drag).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(vi): translate the Tilt-Shift and Spin Blur hints

main added the Vietnamese catalog after this branch was cut; terms
follow the catalog's own (ghim, tay nắm, Chuyển tiếp, Góc làm mờ).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* Blur Gallery: Alt+Cancel Reset starts again from one pin

main's Alt+Cancel → Reset (#3871) restores a filter dialog's schema
defaults, and the schema has none for Field Blur's pins, so Reset
left the workspace with no pins. blur_pins::reset now puts back the
opening pin (in the middle, selected), and reset_fields runs it for
the Blur Gallery. init is reset plus capturing the bounds, which
Reset keeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(hu, tr): translate the Field Blur pin strings

main added complete Hungarian and Turkish catalogs after this branch
was cut. Terms follow each catalog: tű / Mezőelmosás (informal
Hungarian), pim / Alan Bulanıklığı (formal Turkish).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(hu, tr): translate the Iris Blur handle hint

Terms follow each catalog: fogantyú for handle (Hungarian), tutamaç
for handle (Turkish).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(hu, tr): translate the Tilt-Shift and Spin Blur hints

Terms follow each catalog: tű, fogantyú, Átmenet (Hungarian); pim,
tutamaç, Geçiş (Turkish).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(sr): translate the Field Blur pin strings

Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.

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

* i18n(sr): translate the Iris Blur handle hint

Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.

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

* i18n(sr): translate the Tilt-Shift and Spin Blur hints

Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.

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

* i18n: keep main's catalog row where both added the same label

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

* docs/gaps.md: the Tilt-Shift and Spin paragraph replaces the Field/Iris one

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 15:33:05 -04:00
d56aa35bce Blur Gallery: Iris Blur's pins and handles on the image (#3373) (#3822)
* Blur Gallery: Field Blur pins on the image (#3373)

Field Blur opened a parameter dialog with one centre, though the engine
already blends any number of pins. It now works as Photoshop's Blur
Gallery workspace: the image under the dialog takes the pointer. A
click adds a pin and selects it, a drag moves a pin, and a drag around
the selected pin's ring turns its blur up or down (one turn is the
0-500 px range; the arc shows the amount). Delete or Backspace removes
the selected pin. The dialog shows the selected pin's Blur and Preview,
and opens at the right edge, beside the image, so the pins stay in view.

The pins live in the dialog's fields (`pins`, `__selected`), so the
existing filter preview follows every change, OK applies
filter.blurGallery.fieldBlur with them in one step, and automation
drives it with ui.dialog.set. Pins are fractions of the selection's
bounds when there is one, else the canvas, as the engine reads them.
Proxy previews now scale Blur Gallery pins' pixel blur like other pixel
params.

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

* Blur Gallery: Iris Blur's pins and handles on the image (#3373)

Iris Blur opened a parameter dialog with one ellipse. It now uses the
Field Blur workspace too, with Photoshop's handles on the selected pin:
the four on its outline resize that axis and turn the shape, the square
at its corner rounds it towards a rectangle, and the four dots inside
set the sharp core, where the blur starts. A click adds a pin with the
selected pin's shape and blur; pins move, their ring sets the blur,
Delete removes them, and the preview follows.

The pins keep the command's own keys (radiusX/radiusY as fractions of
the bounds' shorter side, angle, roundness, feather), and the handles
use the engine's superellipse geometry, so what the outline shows is
where the filter blurs. Values from automation are cleaned before use.

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

* i18n(vi): translate the Field Blur pin strings

main added the Vietnamese catalog after this branch was cut, so
every_tl_literal_is_translated failed on the merge for "No pin
selected" and the pin hint. Terms follow the catalog's own: ghim
(pin), Bấm (click), Delete kept as the key name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(vi): translate the Iris Blur handle hint

main added the Vietnamese catalog after this branch was cut; terms
follow the catalog's own (tay nắm for handle, Kéo for drag).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* Blur Gallery: Alt+Cancel Reset starts again from one pin

main's Alt+Cancel → Reset (#3871) restores a filter dialog's schema
defaults, and the schema has none for Field Blur's pins, so Reset
left the workspace with no pins. blur_pins::reset now puts back the
opening pin (in the middle, selected), and reset_fields runs it for
the Blur Gallery. init is reset plus capturing the bounds, which
Reset keeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(hu, tr): translate the Field Blur pin strings

main added complete Hungarian and Turkish catalogs after this branch
was cut. Terms follow each catalog: tű / Mezőelmosás (informal
Hungarian), pim / Alan Bulanıklığı (formal Turkish).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(hu, tr): translate the Iris Blur handle hint

Terms follow each catalog: fogantyú for handle (Hungarian), tutamaç
for handle (Turkish).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(sr): translate the Field Blur pin strings

Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.

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

* i18n(sr): translate the Iris Blur handle hint

Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.

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>
2026-10-14 15:14:12 -04:00
a62ef68ca2 CMYK: Hue/Saturation and Color Balance keep the black ink (#5000)
* Modern Brightness/Contrast: Photoshop's brightness curve

The modern brightness curve was a line rolled off by a v^P term. It sat
up to 4 levels off Photoshop's in the highlights, and up to 2.3 levels
off for lowered brightness.

The curve is refit to the Photoshop ground truth we have:
- the ±50 samples from 4759d12b;
- the photoshop corpus's brightness +25 / contrast +30 files,
  inverted through the (exact) contrast curve.

Raised, it is the line s*x (s = 1.375^(b/50)) up to a knee at 80/255,
then a cubic Hermite to white with end slope s^-6.6. Lowered, it is the
exact inverse of the curve raised by as much (the -50 samples confirm
this to within a level). Every measured point is within about a level.
For strong values the knee moves down so the curve stays monotone
across -150..150.

Brightness then contrast are evaluated through one 4096-entry table,
built in 55 us. The GPU uses that same table through the Levels/Curves
kernel instead of rebuilding the curves in WGSL, so GPU and CPU agree
by construction.

Photoshop oracles: rgb8/16/32 and gray8/16/32 brightness-contrast now
pass (145 -> 151 of 258). The floor goes 133 -> 151.

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

* CMYK: Brightness/Contrast curves each ink, as Photoshop does

Photoshop applies Brightness/Contrast in a CMYK document to each ink's
stored value, 1 - ink, black included. Both the modern and the legacy
curve do this; the cmyk8 brightness-contrast(-legacy) oracles match it
within a level on every pixel of the files' own inks. Raising
brightness takes ink away. We applied it to the screen colour instead.

- cmyk_channel_adjustment gains Brightness/Contrast. apply_in builds
  the channel curve once (brightness_contrast_lut) and runs each
  separated ink through it (ink_through).
- Destructive Image > Adjustments on a CMYK layer curves the stored
  inks directly, so it is exact; a selection mixes them.
  adjusted_channels now takes the per-pixel operation, and works out
  luma only for CMYK Threshold, which compares it.
- The GPU leaves CMYK Brightness/Contrast to the CPU, like the other
  ink rules.

cmyk8/16 brightness-contrast oracles, bad pixels:
- modern: 72% -> 48-57%, max error 0.19 -> 0.09
- legacy: 71% -> 51-53%

The rest is our separation of the RGB composite, not the rule.

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

* CMYK Threshold compares the inks' own luma, as Photoshop does

Photoshop's CMYK Threshold splits on the Rec. 601 luma of the inks' plain
complement, (1 - c)(1 - k) and so on, whatever the document's profile.
On the files' own inks this puts every pixel of cmyk8 and cmyk16
threshold-128.psd on the right side of level 128. The profile's RGB luma
missed 100.

stored_channels works the luma out from the inks itself (ink_luma), so it
no longer takes one; the engine's stored-channel path no longer converts
each pixel to RGB at all. Through the separated RGB composite the cmyk
threshold oracles drop from 1.6% to 0.2% bad pixels.

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

* CMYK legacy Brightness/Contrast pivots at 128 in ink

Photoshop's legacy contrast pivots at level 128 of the ink, which is 127
of 255 of the stored value (1 - ink) that the curve runs on, not 128 as
in RGB. On Photoshop's own 16-bit inks (cmyk16
brightness-contrast-legacy.psd) ours sat a constant 0.43 of a level
above. That is exactly the one-level pivot shift times contrast +30's
slope minus one; with the pivot moved, the inks match within 0.01.

brightness_contrast_lut is now ink_brightness_contrast_lut, since its
legacy curve is the CMYK one.

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

* CMYK: adjustment layers composite on the document's own inks

The compositor works in display RGB. In a CMYK document, an adjustment
layer that Photoshop evaluates on the inks could only act on our
separation of the RGB composite, and that separation's black differs
from the file's. So even exact ink rules missed: CMYK Invert was 10%
off, Levels and Curves 50-65%.

The ink-native compositor (compose::native, until now only for
flattening plain raster stacks) now takes adjustment layers too:
- Invert, Posterize, Threshold and Brightness/Contrast (modern and
  legacy), and Levels and Curves on CMYK channels, prepared once
  (InkStack, InkOp) and applied to the straight inks.
- Opacity and an unfeathered layer mask mix the inks linearly, as in
  Photoshop; raster layers' masks scale their coverage the same way.
- A CMYK stack of plain raster layers and such adjustments renders this
  way for display, export and reduced views (render_cmyk, tiles in
  parallel); flattening and merging keep the adjusted inks exactly.
- Stacks of raster layers alone keep the RGB compositor, which the GPU
  matches. The GPU already leaves these adjustments to the CPU.

Photoshop oracles: cmyk8 and cmyk16 invert, posterize-4, threshold-128,
levels, curves, brightness-contrast and brightness-contrast-legacy all
pass (14 files), 151 -> 165 of 258; floor 151 -> 165. psd-tools and io
corpora unchanged. 24 MP CMYK8 with one adjustment renders in 286 ms on
the inks, against 442 ms through the RGB compositor.

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

* CMYK ink compositing inlines open groups

The ink compositor gave up on any group. Now a group at full opacity
without a mask is inlined:
- a pass-through group's children composite straight onto the backdrop,
  so an adjustment inside it reaches the layers below;
- an isolated (Normal) group of raster layers alone composites as its
  children would, since Normal is associative.

An isolated group with an adjustment, which only sees the group's
content, keeps the RGB compositor. So do a translucent group and an
artboard. Nesting is bounded.

psd-tools' CMYK adjustment files keep their swatches in pass-through
groups. With #4948's fair comparison, curves_cmyk drops from 37% to 0.3%
bad pixels and levels_cmyk from 14% to 0.2%. The photoshop corpus is
unchanged at 165.

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

* CMYK: Hue/Saturation and Color Balance keep the black ink

In a CMYK document Photoshop applies Hue/Saturation (not Colorize) and
Color Balance to C, M and Y as the complement of an RGB colour, and
leaves the black ink alone. On the photoshop corpus's own inks,
cmyk16 hue-saturation.psd follows this on every pixel within half a
level (cmyk8 within two), and color-balance within our RGB Color
Balance's own error. We applied both to the separated screen colour,
which also rewrote K.

- adjust::cmy_adjustment and apply_cmy run the RGB adjustment once per
  band on (1 - c, 1 - m, 1 - y).
- The ink compositor (InkOp::Cmy), the RGB compositor's CMYK branch and
  destructive Image > Adjustments (pixels::adjusted_cmy) use them. The
  GPU leaves these to the CPU in CMYK documents, as it does the other
  ink rules.
- Colorize separates its colour again, black included, so it keeps the
  screen-colour path.

cmyk8/16 oracles, bad pixels:
- hue-saturation: 100% -> 0.4-0.7%
- color-balance: 74% -> 25%

Neither passes yet. Half a level of cyan ink is three levels of red
through the profile at saturated cyan, and our RGB models leave that
much. The pass count stays at 165.

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

* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped

In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
  128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
  255/512 of the constant and is exact on pure red, green, blue, black
  and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
  as the blend mode is there; white tints to (1.05, 1.00, 0.87).

We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.

Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).

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

* Modern brightness: knee at output 126, fit out to -95 and +150

psd-tools' brightnesscontrast_rgb.psd masks four Brightness/Contrast
layers to their own columns. Read through the exact contrast curve, it
gives Photoshop's brightness at -95, -31, +23 and +150, well past the
+/-50 samples the first fit had. The +/-50 curve extrapolated badly
there: 22 levels off at -95 and 25 at +150.

Every setting puts the knee at about the same output level, not input.
The roll-off's end slope falls as 1/(1 + 0.084 b). With the knee at
126/255 and that end slope, the cubic Hermite fits -95, -50, -31, +23,
+25 and +50 within 1.4 levels. Past about +110 no such cubic stays
below white, so there the roll-off is 1 - (1 - y0)(1 - u)^alpha, with
the slope continuous at the knee. That gives +150 within 6 levels,
where every curve family tried bottoms out. Lowered curves are still
the inverse of raised ones.

The photoshop corpus's six brightness-contrast oracles still pass.
psd-tools brightnesscontrast_rgb goes from 0.13 to 0.020 max error and
_grayscale from 0.098 to 0.016. The new measurements are pinned in
modern_brightness_matches_photoshop.

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

* Levels: whole levels at every step and Photoshop's shadow toe

In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).

The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).

Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).

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

* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

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

* Lab: adjustment layers composite on the stored L, a and b

The compositor reads a Lab document's layers through display RGB, which
clips colours outside its gamut, so the adjustments Photoshop evaluates
on L, a and b (Invert, Posterize, Threshold, Brightness/Contrast, Levels
and Curves with Lab records) only saw the clipped colours: the photoshop
corpus's Lab oracles were up to 11 levels off on saturated colours.

compose::native_lab works a Lab stack of plain layers out on the stored
values instead, like the CMYK ink path: Normal mixes L, a and b
linearly, an opaque layer keeps them exactly, a layer mask scales the
mix, and adjustment layers run adjust::lab_stored_op on each pixel.
Stacks with such adjustments render for display this way; flattening a
plain Lab stack keeps its stored values (exports no longer clip).
Anything else (other blend modes, effects, other adjustments) keeps the
RGB compositor.

Photoshop oracles 145 -> 152: lab16 invert, posterize-4, levels, curves
and brightness-contrast-legacy; lab8 invert and posterize-4. lab8
levels and legacy B/C come within a level (their 8-bit rules are #5143
and #5132). psd-tools and io unchanged; every round trip unchanged.

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

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

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

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* CMYK: Posterize works on the stored values (1 - ink)

Photoshop posterizes a CMYK channel's stored value, 1 - ink, not the
ink: with levels whose steps are uneven (3, 7, 13, 21) the two differ,
e.g. 3 levels take an ink of 85 to 128, where posterizing the ink drops
it to none. psd-tools posterize_cmyk.psd goes from 13.8 % of pixels off
to exact; psd-tools 241 -> 242, floor 242.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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

* Raise corpus floors to the results of the landed adjustment fixes

Photoshop-authored 165 -> 185 of 258, io 146 -> 148 of 170, psd-tools 242 -> 249 of 309,
measured with the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and
pass-through PRs merged together.

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>
2026-10-14 14:12:06 -04:00
1e6f565a7b CMYK: adjustment layers composite on the document's own inks (14 oracles pass) (#4945)
* Modern Brightness/Contrast: Photoshop's brightness curve

The modern brightness curve was a line rolled off by a v^P term. It sat
up to 4 levels off Photoshop's in the highlights, and up to 2.3 levels
off for lowered brightness.

The curve is refit to the Photoshop ground truth we have:
- the ±50 samples from 4759d12b;
- the photoshop corpus's brightness +25 / contrast +30 files,
  inverted through the (exact) contrast curve.

Raised, it is the line s*x (s = 1.375^(b/50)) up to a knee at 80/255,
then a cubic Hermite to white with end slope s^-6.6. Lowered, it is the
exact inverse of the curve raised by as much (the -50 samples confirm
this to within a level). Every measured point is within about a level.
For strong values the knee moves down so the curve stays monotone
across -150..150.

Brightness then contrast are evaluated through one 4096-entry table,
built in 55 us. The GPU uses that same table through the Levels/Curves
kernel instead of rebuilding the curves in WGSL, so GPU and CPU agree
by construction.

Photoshop oracles: rgb8/16/32 and gray8/16/32 brightness-contrast now
pass (145 -> 151 of 258). The floor goes 133 -> 151.

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

* CMYK: Brightness/Contrast curves each ink, as Photoshop does

Photoshop applies Brightness/Contrast in a CMYK document to each ink's
stored value, 1 - ink, black included. Both the modern and the legacy
curve do this; the cmyk8 brightness-contrast(-legacy) oracles match it
within a level on every pixel of the files' own inks. Raising
brightness takes ink away. We applied it to the screen colour instead.

- cmyk_channel_adjustment gains Brightness/Contrast. apply_in builds
  the channel curve once (brightness_contrast_lut) and runs each
  separated ink through it (ink_through).
- Destructive Image > Adjustments on a CMYK layer curves the stored
  inks directly, so it is exact; a selection mixes them.
  adjusted_channels now takes the per-pixel operation, and works out
  luma only for CMYK Threshold, which compares it.
- The GPU leaves CMYK Brightness/Contrast to the CPU, like the other
  ink rules.

cmyk8/16 brightness-contrast oracles, bad pixels:
- modern: 72% -> 48-57%, max error 0.19 -> 0.09
- legacy: 71% -> 51-53%

The rest is our separation of the RGB composite, not the rule.

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

* CMYK Threshold compares the inks' own luma, as Photoshop does

Photoshop's CMYK Threshold splits on the Rec. 601 luma of the inks' plain
complement, (1 - c)(1 - k) and so on, whatever the document's profile.
On the files' own inks this puts every pixel of cmyk8 and cmyk16
threshold-128.psd on the right side of level 128. The profile's RGB luma
missed 100.

stored_channels works the luma out from the inks itself (ink_luma), so it
no longer takes one; the engine's stored-channel path no longer converts
each pixel to RGB at all. Through the separated RGB composite the cmyk
threshold oracles drop from 1.6% to 0.2% bad pixels.

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

* CMYK legacy Brightness/Contrast pivots at 128 in ink

Photoshop's legacy contrast pivots at level 128 of the ink, which is 127
of 255 of the stored value (1 - ink) that the curve runs on, not 128 as
in RGB. On Photoshop's own 16-bit inks (cmyk16
brightness-contrast-legacy.psd) ours sat a constant 0.43 of a level
above. That is exactly the one-level pivot shift times contrast +30's
slope minus one; with the pivot moved, the inks match within 0.01.

brightness_contrast_lut is now ink_brightness_contrast_lut, since its
legacy curve is the CMYK one.

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

* CMYK: adjustment layers composite on the document's own inks

The compositor works in display RGB. In a CMYK document, an adjustment
layer that Photoshop evaluates on the inks could only act on our
separation of the RGB composite, and that separation's black differs
from the file's. So even exact ink rules missed: CMYK Invert was 10%
off, Levels and Curves 50-65%.

The ink-native compositor (compose::native, until now only for
flattening plain raster stacks) now takes adjustment layers too:
- Invert, Posterize, Threshold and Brightness/Contrast (modern and
  legacy), and Levels and Curves on CMYK channels, prepared once
  (InkStack, InkOp) and applied to the straight inks.
- Opacity and an unfeathered layer mask mix the inks linearly, as in
  Photoshop; raster layers' masks scale their coverage the same way.
- A CMYK stack of plain raster layers and such adjustments renders this
  way for display, export and reduced views (render_cmyk, tiles in
  parallel); flattening and merging keep the adjusted inks exactly.
- Stacks of raster layers alone keep the RGB compositor, which the GPU
  matches. The GPU already leaves these adjustments to the CPU.

Photoshop oracles: cmyk8 and cmyk16 invert, posterize-4, threshold-128,
levels, curves, brightness-contrast and brightness-contrast-legacy all
pass (14 files), 151 -> 165 of 258; floor 151 -> 165. psd-tools and io
corpora unchanged. 24 MP CMYK8 with one adjustment renders in 286 ms on
the inks, against 442 ms through the RGB compositor.

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

* CMYK ink compositing inlines open groups

The ink compositor gave up on any group. Now a group at full opacity
without a mask is inlined:
- a pass-through group's children composite straight onto the backdrop,
  so an adjustment inside it reaches the layers below;
- an isolated (Normal) group of raster layers alone composites as its
  children would, since Normal is associative.

An isolated group with an adjustment, which only sees the group's
content, keeps the RGB compositor. So do a translucent group and an
artboard. Nesting is bounded.

psd-tools' CMYK adjustment files keep their swatches in pass-through
groups. With #4948's fair comparison, curves_cmyk drops from 37% to 0.3%
bad pixels and levels_cmyk from 14% to 0.2%. The photoshop corpus is
unchanged at 165.

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

* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped

In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
  128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
  255/512 of the constant and is exact on pure red, green, blue, black
  and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
  as the blend mode is there; white tints to (1.05, 1.00, 0.87).

We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.

Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).

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

* Modern brightness: knee at output 126, fit out to -95 and +150

psd-tools' brightnesscontrast_rgb.psd masks four Brightness/Contrast
layers to their own columns. Read through the exact contrast curve, it
gives Photoshop's brightness at -95, -31, +23 and +150, well past the
+/-50 samples the first fit had. The +/-50 curve extrapolated badly
there: 22 levels off at -95 and 25 at +150.

Every setting puts the knee at about the same output level, not input.
The roll-off's end slope falls as 1/(1 + 0.084 b). With the knee at
126/255 and that end slope, the cubic Hermite fits -95, -50, -31, +23,
+25 and +50 within 1.4 levels. Past about +110 no such cubic stays
below white, so there the roll-off is 1 - (1 - y0)(1 - u)^alpha, with
the slope continuous at the knee. That gives +150 within 6 levels,
where every curve family tried bottoms out. Lowered curves are still
the inverse of raised ones.

The photoshop corpus's six brightness-contrast oracles still pass.
psd-tools brightnesscontrast_rgb goes from 0.13 to 0.020 max error and
_grayscale from 0.098 to 0.016. The new measurements are pinned in
modern_brightness_matches_photoshop.

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

* Levels: whole levels at every step and Photoshop's shadow toe

In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).

The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).

Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).

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

* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

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

* Lab: adjustment layers composite on the stored L, a and b

The compositor reads a Lab document's layers through display RGB, which
clips colours outside its gamut, so the adjustments Photoshop evaluates
on L, a and b (Invert, Posterize, Threshold, Brightness/Contrast, Levels
and Curves with Lab records) only saw the clipped colours: the photoshop
corpus's Lab oracles were up to 11 levels off on saturated colours.

compose::native_lab works a Lab stack of plain layers out on the stored
values instead, like the CMYK ink path: Normal mixes L, a and b
linearly, an opaque layer keeps them exactly, a layer mask scales the
mix, and adjustment layers run adjust::lab_stored_op on each pixel.
Stacks with such adjustments render for display this way; flattening a
plain Lab stack keeps its stored values (exports no longer clip).
Anything else (other blend modes, effects, other adjustments) keeps the
RGB compositor.

Photoshop oracles 145 -> 152: lab16 invert, posterize-4, levels, curves
and brightness-contrast-legacy; lab8 invert and posterize-4. lab8
levels and legacy B/C come within a level (their 8-bit rules are #5143
and #5132). psd-tools and io unchanged; every round trip unchanged.

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

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

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

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* CMYK: Posterize works on the stored values (1 - ink)

Photoshop posterizes a CMYK channel's stored value, 1 - ink, not the
ink: with levels whose steps are uneven (3, 7, 13, 21) the two differ,
e.g. 3 levels take an ink of 85 to 128, where posterizing the ink drops
it to none. psd-tools posterize_cmyk.psd goes from 13.8 % of pixels off
to exact; psd-tools 241 -> 242, floor 242.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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>
2026-10-14 14:11:42 -04:00
92c7922230 CMYK: Brightness/Contrast curves each ink, as Photoshop does (#4930)
* Modern Brightness/Contrast: Photoshop's brightness curve

The modern brightness curve was a line rolled off by a v^P term. It sat
up to 4 levels off Photoshop's in the highlights, and up to 2.3 levels
off for lowered brightness.

The curve is refit to the Photoshop ground truth we have:
- the ±50 samples from 4759d12b;
- the photoshop corpus's brightness +25 / contrast +30 files,
  inverted through the (exact) contrast curve.

Raised, it is the line s*x (s = 1.375^(b/50)) up to a knee at 80/255,
then a cubic Hermite to white with end slope s^-6.6. Lowered, it is the
exact inverse of the curve raised by as much (the -50 samples confirm
this to within a level). Every measured point is within about a level.
For strong values the knee moves down so the curve stays monotone
across -150..150.

Brightness then contrast are evaluated through one 4096-entry table,
built in 55 us. The GPU uses that same table through the Levels/Curves
kernel instead of rebuilding the curves in WGSL, so GPU and CPU agree
by construction.

Photoshop oracles: rgb8/16/32 and gray8/16/32 brightness-contrast now
pass (145 -> 151 of 258). The floor goes 133 -> 151.

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

* CMYK: Brightness/Contrast curves each ink, as Photoshop does

Photoshop applies Brightness/Contrast in a CMYK document to each ink's
stored value, 1 - ink, black included. Both the modern and the legacy
curve do this; the cmyk8 brightness-contrast(-legacy) oracles match it
within a level on every pixel of the files' own inks. Raising
brightness takes ink away. We applied it to the screen colour instead.

- cmyk_channel_adjustment gains Brightness/Contrast. apply_in builds
  the channel curve once (brightness_contrast_lut) and runs each
  separated ink through it (ink_through).
- Destructive Image > Adjustments on a CMYK layer curves the stored
  inks directly, so it is exact; a selection mixes them.
  adjusted_channels now takes the per-pixel operation, and works out
  luma only for CMYK Threshold, which compares it.
- The GPU leaves CMYK Brightness/Contrast to the CPU, like the other
  ink rules.

cmyk8/16 brightness-contrast oracles, bad pixels:
- modern: 72% -> 48-57%, max error 0.19 -> 0.09
- legacy: 71% -> 51-53%

The rest is our separation of the RGB composite, not the rule.

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

* CMYK Threshold compares the inks' own luma, as Photoshop does

Photoshop's CMYK Threshold splits on the Rec. 601 luma of the inks' plain
complement, (1 - c)(1 - k) and so on, whatever the document's profile.
On the files' own inks this puts every pixel of cmyk8 and cmyk16
threshold-128.psd on the right side of level 128. The profile's RGB luma
missed 100.

stored_channels works the luma out from the inks itself (ink_luma), so it
no longer takes one; the engine's stored-channel path no longer converts
each pixel to RGB at all. Through the separated RGB composite the cmyk
threshold oracles drop from 1.6% to 0.2% bad pixels.

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

* CMYK legacy Brightness/Contrast pivots at 128 in ink

Photoshop's legacy contrast pivots at level 128 of the ink, which is 127
of 255 of the stored value (1 - ink) that the curve runs on, not 128 as
in RGB. On Photoshop's own 16-bit inks (cmyk16
brightness-contrast-legacy.psd) ours sat a constant 0.43 of a level
above. That is exactly the one-level pivot shift times contrast +30's
slope minus one; with the pivot moved, the inks match within 0.01.

brightness_contrast_lut is now ink_brightness_contrast_lut, since its
legacy curve is the CMYK one.

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

* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped

In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
  128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
  255/512 of the constant and is exact on pure red, green, blue, black
  and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
  as the blend mode is there; white tints to (1.05, 1.00, 0.87).

We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.

Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).

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

* Modern brightness: knee at output 126, fit out to -95 and +150

psd-tools' brightnesscontrast_rgb.psd masks four Brightness/Contrast
layers to their own columns. Read through the exact contrast curve, it
gives Photoshop's brightness at -95, -31, +23 and +150, well past the
+/-50 samples the first fit had. The +/-50 curve extrapolated badly
there: 22 levels off at -95 and 25 at +150.

Every setting puts the knee at about the same output level, not input.
The roll-off's end slope falls as 1/(1 + 0.084 b). With the knee at
126/255 and that end slope, the cubic Hermite fits -95, -50, -31, +23,
+25 and +50 within 1.4 levels. Past about +110 no such cubic stays
below white, so there the roll-off is 1 - (1 - y0)(1 - u)^alpha, with
the slope continuous at the knee. That gives +150 within 6 levels,
where every curve family tried bottoms out. Lowered curves are still
the inverse of raised ones.

The photoshop corpus's six brightness-contrast oracles still pass.
psd-tools brightnesscontrast_rgb goes from 0.13 to 0.020 max error and
_grayscale from 0.098 to 0.016. The new measurements are pinned in
modern_brightness_matches_photoshop.

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

* Levels: whole levels at every step and Photoshop's shadow toe

In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).

The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).

Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).

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

* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

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

* Lab: adjustment layers composite on the stored L, a and b

The compositor reads a Lab document's layers through display RGB, which
clips colours outside its gamut, so the adjustments Photoshop evaluates
on L, a and b (Invert, Posterize, Threshold, Brightness/Contrast, Levels
and Curves with Lab records) only saw the clipped colours: the photoshop
corpus's Lab oracles were up to 11 levels off on saturated colours.

compose::native_lab works a Lab stack of plain layers out on the stored
values instead, like the CMYK ink path: Normal mixes L, a and b
linearly, an opaque layer keeps them exactly, a layer mask scales the
mix, and adjustment layers run adjust::lab_stored_op on each pixel.
Stacks with such adjustments render for display this way; flattening a
plain Lab stack keeps its stored values (exports no longer clip).
Anything else (other blend modes, effects, other adjustments) keeps the
RGB compositor.

Photoshop oracles 145 -> 152: lab16 invert, posterize-4, levels, curves
and brightness-contrast-legacy; lab8 invert and posterize-4. lab8
levels and legacy B/C come within a level (their 8-bit rules are #5143
and #5132). psd-tools and io unchanged; every round trip unchanged.

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

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

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

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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>
2026-10-14 14:11:19 -04:00
00c16f2c6d Modern Brightness/Contrast: Photoshop's brightness curve (six oracles pass) (#4722)
* Modern Brightness/Contrast: Photoshop's brightness curve

The modern brightness curve was a line rolled off by a v^P term. It sat
up to 4 levels off Photoshop's in the highlights, and up to 2.3 levels
off for lowered brightness.

The curve is refit to the Photoshop ground truth we have:
- the ±50 samples from 4759d12b;
- the photoshop corpus's brightness +25 / contrast +30 files,
  inverted through the (exact) contrast curve.

Raised, it is the line s*x (s = 1.375^(b/50)) up to a knee at 80/255,
then a cubic Hermite to white with end slope s^-6.6. Lowered, it is the
exact inverse of the curve raised by as much (the -50 samples confirm
this to within a level). Every measured point is within about a level.
For strong values the knee moves down so the curve stays monotone
across -150..150.

Brightness then contrast are evaluated through one 4096-entry table,
built in 55 us. The GPU uses that same table through the Levels/Curves
kernel instead of rebuilding the curves in WGSL, so GPU and CPU agree
by construction.

Photoshop oracles: rgb8/16/32 and gray8/16/32 brightness-contrast now
pass (145 -> 151 of 258). The floor goes 133 -> 151.

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

* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped

In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
  128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
  255/512 of the constant and is exact on pure red, green, blue, black
  and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
  as the blend mode is there; white tints to (1.05, 1.00, 0.87).

We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.

Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).

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

* Modern brightness: knee at output 126, fit out to -95 and +150

psd-tools' brightnesscontrast_rgb.psd masks four Brightness/Contrast
layers to their own columns. Read through the exact contrast curve, it
gives Photoshop's brightness at -95, -31, +23 and +150, well past the
+/-50 samples the first fit had. The +/-50 curve extrapolated badly
there: 22 levels off at -95 and 25 at +150.

Every setting puts the knee at about the same output level, not input.
The roll-off's end slope falls as 1/(1 + 0.084 b). With the knee at
126/255 and that end slope, the cubic Hermite fits -95, -50, -31, +23,
+25 and +50 within 1.4 levels. Past about +110 no such cubic stays
below white, so there the roll-off is 1 - (1 - y0)(1 - u)^alpha, with
the slope continuous at the knee. That gives +150 within 6 levels,
where every curve family tried bottoms out. Lowered curves are still
the inverse of raised ones.

The photoshop corpus's six brightness-contrast oracles still pass.
psd-tools brightnesscontrast_rgb goes from 0.13 to 0.020 max error and
_grayscale from 0.098 to 0.016. The new measurements are pinned in
modern_brightness_matches_photoshop.

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

* Levels: whole levels at every step and Photoshop's shadow toe

In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).

The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).

Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).

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

* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

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

* Lab: adjustment layers composite on the stored L, a and b

The compositor reads a Lab document's layers through display RGB, which
clips colours outside its gamut, so the adjustments Photoshop evaluates
on L, a and b (Invert, Posterize, Threshold, Brightness/Contrast, Levels
and Curves with Lab records) only saw the clipped colours: the photoshop
corpus's Lab oracles were up to 11 levels off on saturated colours.

compose::native_lab works a Lab stack of plain layers out on the stored
values instead, like the CMYK ink path: Normal mixes L, a and b
linearly, an opaque layer keeps them exactly, a layer mask scales the
mix, and adjustment layers run adjust::lab_stored_op on each pixel.
Stacks with such adjustments render for display this way; flattening a
plain Lab stack keeps its stored values (exports no longer clip).
Anything else (other blend modes, effects, other adjustments) keeps the
RGB compositor.

Photoshop oracles 145 -> 152: lab16 invert, posterize-4, levels, curves
and brightness-contrast-legacy; lab8 invert and posterize-4. lab8
levels and legacy B/C come within a level (their 8-bit rules are #5143
and #5132). psd-tools and io unchanged; every round trip unchanged.

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

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

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

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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>
2026-10-14 14:10:57 -04:00
4c450b3879 Lab: adjustment layers composite on the stored L, a and b (seven Lab oracles pass) (#5171)
* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped

In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
  128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
  255/512 of the constant and is exact on pure red, green, blue, black
  and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
  as the blend mode is there; white tints to (1.05, 1.00, 0.87).

We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.

Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).

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

* Levels: whole levels at every step and Photoshop's shadow toe

In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).

The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).

Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).

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

* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

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

* Lab: adjustment layers composite on the stored L, a and b

The compositor reads a Lab document's layers through display RGB, which
clips colours outside its gamut, so the adjustments Photoshop evaluates
on L, a and b (Invert, Posterize, Threshold, Brightness/Contrast, Levels
and Curves with Lab records) only saw the clipped colours: the photoshop
corpus's Lab oracles were up to 11 levels off on saturated colours.

compose::native_lab works a Lab stack of plain layers out on the stored
values instead, like the CMYK ink path: Normal mixes L, a and b
linearly, an opaque layer keeps them exactly, a layer mask scales the
mix, and adjustment layers run adjust::lab_stored_op on each pixel.
Stacks with such adjustments render for display this way; flattening a
plain Lab stack keeps its stored values (exports no longer clip).
Anything else (other blend modes, effects, other adjustments) keeps the
RGB compositor.

Photoshop oracles 145 -> 152: lab16 invert, posterize-4, levels, curves
and brightness-contrast-legacy; lab8 invert and posterize-4. lab8
levels and legacy B/C come within a level (their 8-bit rules are #5143
and #5132). psd-tools and io unchanged; every round trip unchanged.

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

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

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

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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>
2026-10-14 14:10:36 -04:00
d24f6e9d5f Levels: whole levels at every step and Photoshop's shadow toe (levels_rgb + stack oracle pass) (#5143)
* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped

In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
  128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
  255/512 of the constant and is exact on pure red, green, blue, black
  and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
  as the blend mode is there; white tints to (1.05, 1.00, 0.87).

We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.

Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).

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

* Levels: whole levels at every step and Photoshop's shadow toe

In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).

The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).

Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).

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

* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

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

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

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

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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

* Empty commit to resync the pull request head

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 14:10:14 -04:00
aad56285ad Batch: Include All Subfolders (AUTO-219-5, #3753) (#3758)
* Batch: Include All Subfolders (AUTO-219-5, #3753)

File > Automate > Batch and File > Scripts > Image Processor take an
optional "includeSubfolders" (default false): a folder input also yields
the images in its subfolders, walked depth first in name order, bounded at
32 levels and 100000 files, with a seen-set of canonical folders against
symlink loops and the output folder left out. Each result keeps its
relative subfolder under the output folder, so same-named files in
different subfolders no longer collide. A non-boolean value is BadParams.

Both dialogs gain an "Include All Subfolders" checkbox (translated in
every complete catalog). Scorecard AUTO-219-5 stays partial (naming
template, progress/cancel, error log file); gaps.md G11 updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Batch: translate Include All Subfolders for Hungarian and Turkish

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 14:08:20 -04:00
620bccef42 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped, as in Photoshop (#5011)
* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped

In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
  128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
  255/512 of the constant and is exact on pure red, green, blue, black
  and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
  as the blend mode is there; white tints to (1.05, 1.00, 0.87).

We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.

Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).

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

* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

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

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

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

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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>
2026-10-14 14:03:03 -04:00
eb26e8f0d7 Masks: feathers blur with exactly the feather's variance (mask-parameters-no-real-channel passes) (#5163)
* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

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

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

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

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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>
2026-10-14 14:02:40 -04:00
9954c2935e Threshold: split on Photoshop's luminosity of whole samples (threshold_rgb, threshold_16bits_rgb pass) (#5263)
* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

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

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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>
2026-10-14 14:02:26 -04:00
3feebb9b13 Groups: a pass-through clipping base with an adjustment renders isolated (passthrough_clipping_mask_adjustment passes) (#5272)
* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

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

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

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>
2026-10-14 14:01:50 -04:00
8504dc615e Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does (#5267)
At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 14:01:01 -04:00
93a16218de Gradient Map is driven by the W3C luminosity, as in Photoshop (#5018)
Photoshop's Gradient Map picks each pixel's place on the gradient by
0.3 R + 0.59 G + 0.11 B, the luminosity of the blend modes. We used the
Rec. 601 grey (0.299, 0.587, 0.114). That is a few thousandths off, but
it is enough to land two levels away on steep stretches of a gradient.
The photoshop corpus's rgb8 and rgb16 gradient-map.psd now pass (they
were 2 levels off on 0.8-1.7% of pixels). Sampling the gradient through
a 256- or 1024-entry table didn't help; the weights did.

CPU (compose::adjust) and GPU (the shader's lum) both change. Lab
documents keep their Gradient Map driven by L, and grey pixels map as
before.

Photoshop oracles 145 -> 147 of 258; floor 145 -> 147.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 12:44:59 -04:00
LorenzoFasolino d11be6de78 docs: drop stale missing-tool rows, only Art History Brush is missing (#5063)
Task T-0002 del lab artcraft-lab.

File toccati:
   docs/brush-parity.md      | 5 +++--
   docs/gaps.md              | 6 +++---
   docs/target-app-parity.md | 5 +++--
   docs/ui-parity.md         | 7 ++++---
   4 files changed, 13 insertions(+), 10 deletions(-)

Evidenze: artifacts/T-0002/ (nel lab, non in questo repo).
2026-10-14 01:58:18 -04:00
Timothy DalyandClaude Opus 5.5 be7a1f80fc Blur Gallery: Field Blur pins on the image (#3373) (#3801)
* Blur Gallery: Field Blur pins on the image (#3373)

Field Blur opened a parameter dialog with one centre, though the engine
already blends any number of pins. It now works as Photoshop's Blur
Gallery workspace: the image under the dialog takes the pointer. A
click adds a pin and selects it, a drag moves a pin, and a drag around
the selected pin's ring turns its blur up or down (one turn is the
0-500 px range; the arc shows the amount). Delete or Backspace removes
the selected pin. The dialog shows the selected pin's Blur and Preview,
and opens at the right edge, beside the image, so the pins stay in view.

The pins live in the dialog's fields (`pins`, `__selected`), so the
existing filter preview follows every change, OK applies
filter.blurGallery.fieldBlur with them in one step, and automation
drives it with ui.dialog.set. Pins are fractions of the selection's
bounds when there is one, else the canvas, as the engine reads them.
Proxy previews now scale Blur Gallery pins' pixel blur like other pixel
params.

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

* i18n(vi): translate the Field Blur pin strings

main added the Vietnamese catalog after this branch was cut, so
every_tl_literal_is_translated failed on the merge for "No pin
selected" and the pin hint. Terms follow the catalog's own: ghim
(pin), Bấm (click), Delete kept as the key name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* Blur Gallery: Alt+Cancel Reset starts again from one pin

main's Alt+Cancel → Reset (#3871) restores a filter dialog's schema
defaults, and the schema has none for Field Blur's pins, so Reset
left the workspace with no pins. blur_pins::reset now puts back the
opening pin (in the middle, selected), and reset_fields runs it for
the Blur Gallery. init is reset plus capturing the bounds, which
Reset keeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(hu, tr): translate the Field Blur pin strings

main added complete Hungarian and Turkish catalogs after this branch
was cut. Terms follow each catalog: tű / Mezőelmosás (informal
Hungarian), pim / Alan Bulanıklığı (formal Turkish).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(sr): translate the Field Blur pin strings

Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:10:11 -04:00
jonathanwxh-cellandjonathnwxh 01b354a340 Add filename templates to Batch export (#4466)
* Add filename templates to Batch export

* Keep Batch naming evidence out of the shared gap-summary header

* Avoid catalog append conflicts for Batch naming labels

* Keep Batch naming labels clear of catalog append conflicts

* Keep Batch naming details in the automation parity row

* Separate Batch documentation from evolving Actions details

---------

Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
2026-10-13 13:03:02 -04:00
f43d019a72 Brush Settings: use an amount slider for Hue Jitter (#4874)
Use the shared percentage row so the jitter amount does not look like a hue selection. Cover the rendered themed track, numeric and pointer edits, neighboring settings and engine journal replay.

Addresses the Hue Jitter appearance item in #3825; other assessment items remain open.

Co-authored-by: augrclk <217665348+augrclk@users.noreply.github.com>
Co-authored-by: GPT-6 <noreply@openai.com>
2026-10-13 12:11:44 -04:00
Fabio Lissi c7a34b04ec Scorecard: Smart Object corner skew and perspective is done (FILE-215-6, #426) (#5094)
#426 gave smart objects a projective placement (`SmartObject.perspective`,
with `transform` as its affine approximation): Distort and Perspective set
it, the layer re-renders from its source through the homography, Warp
composes with it, and the PSD placement reads and writes
`nonAffineTransform` / `Trnf`. The scorecard row still said "missing" with
the pre-#426 note, as did the ui-parity row and the #579 mention in gaps.md.
2026-10-13 11:46:42 -04:00
36112e35d4 Brush Settings: disable minimum inputs when Control is Off (#4775)
Keep configured minimum values across Control changes and cover disabled input, independent controls and reactivation with UI regressions.

Refs #3825 (minimum input state only).

Co-authored-by: augrclk <217665348+augrclk@users.noreply.github.com>
Co-authored-by: GPT-6 <noreply@openai.com>
2026-10-13 02:49:44 -04:00
becc27ac1c CMYK: Invert, Posterize and Threshold on the inks; 16-bit Lab Invert keeps greys neutral (#3930)
* Lab and CMYK: Invert, Posterize and Threshold work on the document's channels

Photoshop applies these to each stored channel, not to RGB:
- Lab: Invert and Posterize work on L, a and b, and Threshold cuts on L
  to a neutral black or white.
- CMYK: Invert and Posterize work on each ink, black included, so
  paper inverts to 400% ink. Threshold gives paper or 100% black ink.

Adjustment layers now do the same:
- compose::adjust::apply_mode goes through the document's Lab or CMYK
  channels.
- The GPU falls back to the CPU for these, as it already does for
  Lab/CMYK Levels and Curves.
- The destructive Image > Adjustments path works on the stored samples
  directly, so it is exact.

Photoshop oracles (lab/cmyk 8 and 16 bit), bad pixels before -> after:
- lab threshold: PASS (was 12%)
- lab invert: 72-99% -> 1%
- lab posterize: 89% -> 0.1%
- cmyk invert: 77% -> 10%
- cmyk posterize: 71% -> 9%
- cmyk threshold: 37% -> 1.6%

Floor 133 -> 139 (137 already passed on main).

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

* CMYK: Invert, Posterize and Threshold on the inks; 16-bit Lab Invert keeps greys neutral

Builds on main's adjust::apply_in (#4038):
- apply_in's mode dispatch gains CMYK. Invert and Posterize work on each
  ink, black included, so paper inverts to 400% ink. Threshold gives
  paper or 100% black ink alone. cmyk_channel_adjustment says which
  adjustments do this.
- Lab Invert mirrors a and b around neutral at 16 bits; 8-bit bytes
  still become 255 - v. stored_channels holds both rules, and
  apply_lab uses it.
- Destructive Image > Adjustments on a Lab or CMYK layer works on its
  stored channels directly, so it is exact; a selection mixes the
  stored values.
- The GPU leaves CMYK Invert, Posterize and Threshold to the CPU.

Photoshop oracles, bad pixels:
- cmyk invert: 77% -> 10%
- cmyk posterize: 71% -> 9%
- cmyk threshold: 37% -> 1.6%
- lab16 invert: 52% -> 1.2%

The count stays at 145/258, which main already passes, so the floor
goes 133 -> 145.

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>
2026-10-13 01:38:52 -04:00
3a307a757e Play real Photoshop action sets: TrueGrit .atn mappings and fixes (#4356, #219) (#4698)
Third-party action sets that load since 3204231 now play. Four TrueGrit
Texture Supply sets (Debaser, Distress Press, Halftone Zine Machine,
Transparency) go from 223 to 3 unmapped steps of 533 and play end to end.

- .atn import, 108 -> 112 events: Select and Mask (refineSelectionEdge),
  Filter Gallery Spatter (GEfc), Ripple, Crystallize; targeting the layer
  mask or composite channel, loading the targeted channel, Step
  Backward/Forward, removeFromSelection (it silently imported as a plain
  select), Pattern Fill layers (by name), nudges in points, Layer From
  Background, Grayscale/Bitmap mode objects, Image Size by resolution.
- Engine: layer.translate units pt; layer.select mode remove;
  select.loadSelection channel targeted; image.imageSize keepPrintSize;
  layer.new.layerFromBackground ifPresent/select; pattern.import data.
  New from Clipboard keeps the copied document's resolution (was 72 ppi).
- Filters: Spatter's Smoothness smooths the displacement (torn but
  attached edges, as Photoshop's); Crystallize fills a cell from its point
  instead of the average, so masks keep hard edges.
- UI: .pat opens/drops into the Patterns panel (no UI import existed).
- Docs: gaps G11, file-format and target-app parity.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 01:10:58 -04:00
4fd08b5395 Free Transform: H and V skew fields in the options bar (#4699)
* Free Transform: H and V skew fields in the options bar

Photoshop's Free Transform options bar has H and V skew fields after the
angle; ours stopped at the angle (ui-parity: "Options bar H/V skew
fields | missing", gaps G5).

The bar now reads the box as Photoshop does: the frame scaled (W, H),
skewed (H, V) and rotated about the reference point (`Parts`). Typing a
skew leans the box about the reference point and keeps W, H and the
angle. A box can be read with any V skew, so the session keeps the typed
one (`TransformSession::skew_v`, serde default). Skews stop at ±89°.

A sheared box used to read as taller (H = 100/cos h %); it now reads as
an H skew with H at 100 %. Readouts without skew are unchanged, flips
included.

Docs: ui-parity row done, G5 and target-app-parity notes updated.

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

* i18n(sr): translate the Free Transform skew field tooltips

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>
2026-10-13 00:11:54 -04:00
575f8eb2bc Allow toolbar tool shortcuts to be remapped (#4194)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:16:06 -04:00
0002d861ee fix(io): read CS2 placed layers that carry only a PlLd block (#2465) (#4268)
Photoshop CS2 predates SoLd: its smart objects have a PlLd (plcL) block
alone, with the embedded file in a global lnkD block. Import only read
SoLd/SoLE, so such layers got an empty source id and Edit Contents said
"the smart object's contents are unavailable".

Parse PlLd from the Adobe PSD spec (unique id and the 8 corner doubles).
PlLd stores no source size, so the transform takes it from the embedded
file's header (linked::source_size_probe: PSD, TIFF, PNG and JPEG
headers, any other image decoded flat by photocraft-codecs). It never
imports the file as a document: a layered TIFF can carry PSD layer data
whose lnkD holds another file, so that would nest without bound. Without
the file, 100 % scale is assumed. source_geometry moves to linked.rs. Non-parallelogram corners give the projective placement as
for SoLd. An unreadable PlLd is reported as a warning.

The PlLd corners are the same placed quad as SoLd's Trnf, so they frame
the same source-space box (#3677, blocks::placed_frame): the bounding
box of a single-patch custom warp's 16 control points, which may spill
past the source, else the source rect. corners_affine now maps such a
frame onto the corners and is shared by parse_smart and plld_transform,
which is the inverse of smart_map::quad_points (what plld_bytes writes):
affine corners frame placed_frame(warp), projective ones the source
rect. A custom warp's box needs no source size, so a spilled mesh is
placed right even without the embedded file. corners_affine refuses
non-finite corners or an empty frame (identity, as before).

CS2 can warp a smart object, and PlLd keeps that warp after the corners
(u32 warp version, then a versioned warp descriptor). It is read with
the same code as the SoLd warp (warp_of), so a re-render from the now
available source (Free Transform, Image Size, mode changes) stays
warped. A stored warp that can't be read leaves the contents
unavailable, keeping the warped pixels, with a warning.

An edited PlLd-only layer (moved, transformed, warped, filtered or given
other contents) is saved like any other smart object, SoLd plus a
regenerated PlLd, with its current placement; an unedited one keeps its
record verbatim, as does one whose file isn't embedded (its id is never
read as a disk path). plld_edited compares against plld_transform with the
record's own warp, so it uses the same frame as import and as the
writer. A layer with both SoLE and PlLd is read from SoLE.

Tests: PlLd round trip and malformed input (truncation, bad magic,
oversized Pascal length, NaN) in blocks.rs; PlLd warps round trip
through plld_bytes (preset, custom mesh, identity, none) and unreadable
warps are reported as such; a PlLd whose custom mesh spills past the
source places like the equivalent SoLd, with and without the source
size; PlLd-only layers with a missing file, a broken record, a warp or
an unreadable warp; a spilled-mesh PlLd-only layer imports with the
SoLd layer's placement and an unedited one saves its PlLd verbatim (no
SoLd) and reopens in place; and engine tests that rewrite an export
into the CS2 layout (no SoLd, lnkD with version-2 liFD items) and check
transform, contents, re-render, Edit Contents, and that a warped smart
object re-renders warped.

source_size_probe has header tests for each format and malformed JPEG
marker walks.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:44:49 -04:00
96f4d68db7 feat(ui): spring-loaded tool letters (#2672) (#4238)
In Photoshop, holding a tool's letter gives a temporary tool: releasing the
key returns to the tool you had. A tool letter still switches tools when
pressed; if the key then stays down for 300 ms or more, or the canvas is
used while it's down, the switch was temporary and the release gives the
previous tool back with its options. A quick tap stays a permanent switch.

- Shift+letter (cycling the group) and Alt+letter never spring; nor do
  letters while typing in a field, editing type or under a dialog.
- The current group's own letter cycles the group, as before.
- A drag in progress when the key comes up finishes with the temporary
  tool; the previous tool returns once the button is up.
- The newest held letter wins; the original tool returns when all are up.
- A tool picked another way while a letter is down (the toolbar, a menu,
  automation) stands.
- Key repeats of tool letters are dropped instead of cycling the group.
- Z is one case: hold Z to zoom, add Alt to zoom out.

The state is UiState::spring, readable over the control channel and never
restored from settings. Tests that pressed tool letters without releasing
them now release them, as a keyboard does. docs/ui-parity.md and
docs/gaps.md mark #2672 done.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 20:23:56 -04:00
af9316b97e Docs: refresh gaps.md against main (#4412) (#4413)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 20:03:29 -04:00
f48a72cbe3 Codecs: open and save DPX frames (#1049) (#4104)
Read SMPTE 268M DPX in both byte orders (SDPX, XPDS): the first image
element, descriptor 6 (luma), 50 (RGB) or 51 (RGBA), at 8, 10, 12 or 16
bits, with 10-bit packing 0, 1 (method A) or 2 (method B) and 12-bit
packing 0 or 1. 10- and 12-bit code values open as U16
round(c * 65535 / (2^b - 1)) and come back exactly when written at their
depth. No colour transform: the transfer characteristic and colorimetric
specification are reported as import notes; extra image elements are
reported; the orientation is applied on import.

Write big-endian, one unencoded element, at 8, 10, 12 or 16 bits
(EncodeOptions::dpx_bits; `dpxBits` on the save commands, MCP and RPC;
`--dpx-bits` in the CLI), following the document depth by default. Gray
with alpha is written as luma. Creator, project, copyright, creation
time, transfer, colorimetric, frame position, frame rate and time code
travel as DPX:* text metadata and are written back.

Unsupported descriptors, signed or run-length-encoded data and other bit
depths return CodecError::Unsupported; malformed and truncated files
return errors.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:44:04 -04:00
61c52c08db Brush Settings: toggle switch rows without losing smoothing settings (#4463)
Noise, Wet Edges, Build-up, Smoothing and Protect Texture toggle from
anywhere on their row while keeping the selected controls open. Keep
lock clicks independent and expose each switch as one accessible checkbox.

Give Smoothing a default-on enable flag so disabling it preserves the
amount and modes. Old brush JSON stays compatible; toolbar amount edits
and explicit stroke overrides enable it, and ABR imports retain disabled
smoothing values.

Cover row, checkbox, lock and keyboard gestures, old section selections,
command replay, presets, legacy JSON and 8/16/32-bit rendering.

Fixes #4283

Co-authored-by: augrclk <217665348+augrclk@users.noreply.github.com>
Co-authored-by: GPT-6 <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 19:23:17 -04:00
Fabio Lissi 90c42f5555 Map Place, Open, Image Size in percent, the Paint Bucket and Insert Conditional (#219) (#4470)
- image.imageSize takes percent (both sides; an explicit side wins).
- actions.conditional {if, then, else}: Photoshop's Insert Conditional as a step, 33 conditions
  on the document (shape, mode, depth, selection, layers, alpha channels, unsaved) and the
  active layer (kind, locks, visibility, masks, effects); a failing branch stops the caller.
- .atn events: placeEvent → file.placeEmbedded/placeLinked (path, scale), open → file.openAs,
  imageSize in percent, fill from a point → paint.bucket (pixels), conditional. 105 → 108.
- Panel labels conditional steps as 'If cond → then / else'.
2026-10-12 19:12:35 -04:00
Fabio Lissi 13f5c104f6 Map the remaining frequent Photoshop action events (#219) (#4465)
- edit.transform gains the relative form (scaleX/scaleY %, rotate, dx/dy about the frame
  centre), as a Photoshop action records a Free Transform; image.applyImage takes a source
  layer by name.
- .atn events: transform (layer → translate or relative transform; selection →
  transformSelection), applyImageEvent, export (Save for Web, with the txtu path record
  decoded and written back; kept verbatim without a destination), newPlacedLayer /
  placedLayerEditContents / updatePlacedLayer, close with the Save answer → save + close,
  delete of the target channel → layer mask delete, Quick Mask on/off, polygon selections →
  select.lasso, Gradient Map (direct and as an adjustment layer). 92 → 105 events.
- Export tries every event listing a command id (Quick Mask on → set, off → clearEvent).
- Docs: event counts in the parity docs.
2026-10-12 19:02:15 -04:00
Fabio Lissi 9862dfbb09 Actions panel: set folders, dialog toggle, Insert Stop (#219) (#4456)
* Actions panel: set folders, dialog toggle column, Insert Stop (#219)

- Engine actions_edit_cmds: actions.insert (any command at a position; Insert Stop… uses it
  with actions.stopMessage), actions.dialog (the per-step dialog flag), actions.assignSet.
  Flag sets follow inserted steps; nothing edits during playback.
- Panel: loose actions first, then each set as a collapsible folder row with its count
  (ui.actions.collapsedSets); dropping an action among another set's rows moves it there;
  Save Set… on a folder's context menu; a dialog toggle column next to the check marks;
  Insert Stop… in the panel menu with a small message/Allow Continue dialog.
- Shell playback pauses on a dialog-toggled filter or adjustment step: the command's dialog
  opens seeded with the recorded values; OK runs the dialog's values and resumes from the next
  step, Cancel stops. Agents play headlessly. The play result reports paused: {step, id, dialog}.
- Docs: control protocol, scorecard AUTO-219-2 done, gaps G11.

* Translate the Actions panel's set, dialog-toggle and Insert Stop strings

* Resume a paused action only after the dialog's background job lands

A filter confirmed from a dialog runs as a background job in the desktop app; the resumed
steps met a locked document and failed. Wait for the job first (test covers background jobs).
2026-10-12 18:51:53 -04:00
Fabio Lissi 3204231d7d Load and save Photoshop Actions (.atn) (#4356, #219) (#4434)
* Load and save Photoshop Actions (.atn) (#219)

- photocraft-psd: `atn` parser and writer for version-16 action sets, exact round trip,
  truncation/garbage safe, fuzz target and `atn_dump` example. Descriptor fix: `indx` and
  `Idnt` reference items carry a class before the number (real action files failed to parse).
- photocraft-engine: `atn_cmds` maps 92 Photoshop events to commands and back (filters,
  adjustments per channel, image/canvas/crop/trim/rotate/flip/mode, make/set/select/delete/
  hide/show/move/duplicate, fill/stroke, selection, clipboard, purge, profiles, play, stop).
  Other steps load as `actions.photoshopStep` (descriptor kept, written back by save, fails at
  playback by name); tool selections load switched off. Commands `actions.load`,
  `actions.save`, `actions.stopMessage`, `actions.photoshopStep`. `Action` gains `dialogs`
  and `set`. `layer` params accept a layer name, so loaded steps replay in any document.
- UI: Actions panel menu › Load Actions… / Save Actions…; Photoshop steps labelled.
- CLI: `batch --actions set.atn`. Automation policy covers the new path params.
- Docs: file-format parity (.atn R/W), gaps G11, target-app parity, scorecard AUTO-219-2
  partial, development guide.

* Translate the Actions panel's Load/Save strings in every catalog

The eight new panel strings (Load/Save Actions…, the loaded/saved counts, the kept-step label)
were missing from all 19 catalogs; the languages that were complete are back at 100%.

* Show the Actions entries in the default dock's panel menu

The legacy dock renders its own group menu and only knew the Layers and Swatches entries, so
Load Actions… and Save Actions… were missing there.
2026-10-12 18:39:57 -04:00
4736a4bcbe Character panel: OpenType feature buttons (TYPE-218-5, #3752) (#3757)
The Character panel (and the Properties Character section) gains
Photoshop's row of eight OpenType buttons: Standard Ligatures, Contextual
Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling
Alternates, Ordinals and Fractions. Each shows pressed while its feature is
on at the selection (else the layer) and toggles it through the existing
type.openType.* command, on the same scope as the other Character controls
(the selected characters while typing, else every selected type layer).

Scorecard TYPE-218-5 stays partial: the Numerals and Stylistic Sets
submenus are still missing.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 04:52:23 -04:00
7fd53f0e3f Puppet Warp: the mesh follows the object outline (#3105) (#3117)
The mesh was every square grid cell touching the layer's opaque pixels:
a staircase up to a cell outside the shape, and shapes closer than about
a cell shared cells, so pinning one dragged the other along.

build_mesh now traces the outline of the opaque region grown (or shrunk)
by Expansion from a signed distance field, simplifies it (Douglas-Peucker,
corners kept), splits its sides to the Density spacing, fills the inside
with a triangular lattice and triangulates the points (Bowyer-Watson
Delaunay, outline sides recovered by midpoint splits), keeping the
triangles inside the outline. Separate shapes get separate meshes, so an
unpinned one stays put, as in Photoshop. Vertices are numbered in reverse
Cuthill-McKee order to keep the solver's banded factor narrow, and the
density's spacing counts the grown region so a large Expansion on a small
layer no longer builds tens of thousands of vertices.

Scorecard TOOL-214-6 done; docs/gaps.md G7 updated.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 21:40:06 -04:00
aa1febaffb Keep fitted Camera Raw colour during tuning and crop Nikon D5100 borders (#3201)
* raw: fit non-DNG colour to the camera's embedded JPEG

* raw: preserve fitted Camera Raw look and crop D5100 borders

---------

Co-authored-by: Gert Pellin <gert@pellin.be>
Co-authored-by: optimalbytee <optimalbytee@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:12:19 -04:00
b26256faee Type: triple-click selects a line, quadruple a paragraph, five clicks all text (#3493) (#3496)
While editing type, a run of clicks at one spot now selects like Photoshop:
two clicks the word, three the laid-out line (a column in vertical type),
four the paragraph up to its hard return, five all text; the next quick
click starts over with a caret. egui stops counting at three, so the Type
tool keeps its own count per layer (next_click_count) and the ranges come
from a pure helper over the text and its layout (click_selection).

Scorecard TYPE-218-8 is done; docs/gaps.md G8 updated.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 17:55:18 -04:00
72bf3e13b6 i18n(cs): translate the dynamic labels the Czech catalog was missing (#3454)
* i18n(cs): translate dynamic labels the literal scanner can't see

The Czech catalog covered every enforced key (menus, tl! literals, blend
modes, preferences) but not the labels built at run time: filter and
Filter Gallery parameter names and choices humanized from their keys,
Camera Raw controls, the Flame / Picture Frame / Tree render filters,
measurement columns, file info fields and the macOS application menu.
Those showed in English under Czech.

Adds 853 entries (2,441 -> 3,294) for keys that the other catalogs
already translate and that still exist in the code. Existing entries are
unchanged. Translated clean-room from the plain meaning of the English
labels, with the terminology already used in cs.tsv; no proprietary
localisation was consulted.

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

* i18n(cs): credit the Czech catalog in ATTRIBUTION.md and LICENSE-translations.txt

cs.tsv is a bundled translation asset but had no row in ATTRIBUTION.md
and was not named in LICENSE-translations.txt. Add both, worded like the
other catalogs (PhotoCraft contributors, original translations, MIT OR
Apache-2.0).

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

* i18n(cs): terminology review fixes for the new dynamic labels

Changes from an independent review pass over the added block:

- "Create By" (De-Interlace: how the missing lines are made) reads
  "Vytvořit pomocí", not "Vytvořil" (author)
- luminance is "jas" in Camera Raw, as in the existing "Luminance" label
  ("Detail jasu", "HSL – jas", "Jas")
- slider labels are nouns, like the existing "Redukce šumu" (Reduce Color
  Noise, Sharpen Details, Lighten Grout)
- grain and flame choices agree in gender with their neighbours; "Shadow"
  matches "Highlight"/"Světla" as "Stíny"; "At" is "Pozice"; "Water Paper"
  is "Mokrý papír"; "Leaves" is "listů" everywhere

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

* contributors: add my own people.toml entry

Own entry only, as AGENTS.md describes: credited by the name on my public
GitHub profile (sync_github_name), otherwise by @username.

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

* i18n(cs): drop three rows that main added in the meantime

"Columns", "Margin" and "Automatic" were added to cs.tsv on main while this
branch was open, with the same translations, so the appended block no longer
needs them (the catalog has no duplicate keys).

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

* i18n (cs): translate two strings that are still missing from cs.tsv

"Drag (lasso) or click points (polygonal) ..." and "The current gradient
(pick one in Window > Gradients)" exist in the other catalogs but not yet
in cs.tsv on main.

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

* i18n (cs): translate context-menu, channel and layer-style labels

These labels are rendered through tl!() with a variable, so the literal-based coverage test does not see them.

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

* Localize Czech status messages and notice wrappers

Translate shell messages before interpolating file names and engine errors.
Use the existing plural API for counted objects and preserve English fallback.

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

* Localize Czech UI labels and hardware information

Translate brush controls, accessibility names and rasterization prompts at
render sites. Preserve adapter, driver and display/profile identifiers.

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

* Localize Czech composed labels and distinguish Navigator zoom

Translate profile, preset and shortcut messages before interpolation, and use
a Navigator context for the zoom level. Keep shortcut warning state separate
from translated text and preserve document names and shortcut codes.
Cover remaining visible counts and add rendering/glyph regression tests.

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

* Fix Czech UI localization review follow-ups

Keep copied System Info and API diagnostics in English while localizing the
on-screen text. Avoid double translation of accessible names, honor command-id
shortcut labels, and translate stored auto backend values only for display.

Refine Czech warning punctuation and catalog wording, simplify template blocks,
and correct the documented catalog count to 3,522 rows. Add four regression tests.

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

* people.toml: keep axlecoffee's sync_github_name with their entry

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

---------

Co-authored-by: Losminek <301412321+Losminek@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 17:23:27 -04:00
28874bbfad Select and Mask: Output To New Document, with or without a layer mask (#2998) (#3007)
* Select and Mask: Output To New Document, with or without a layer mask (#2998)

select.refineEdge takes output newDocument / newDocumentWithMask: a new
document like the source (size, mode, depth, resolution, profile) with
the active layer cut out by the refined edge, or with the matte as a
layer mask. The source document is left untouched. The dialog's Output
choice lists all six targets, translated in every catalog.

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

* i18n: drop the duplicate pt-br New Layer row (main already has it)

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>
2026-10-11 15:16:45 -04:00
Valentin KleinandClaude Opus 5.5 c1bd296dcf Select and Mask: Refine Edge Brush (#2972) (#2989)
* Select and Mask: live preview in Photoshop's view modes (#1754)

The Select and Mask dialog had no preview of the refined selection. It now
previews live in Photoshop's view modes: Onion Skin (default), Marching
Ants, Overlay, On Black, On White, Black & White and On Layers, with a
per-view Opacity, Show Edge (J) and Show Original (P). F cycles the views,
O/M/V/A/T/K/Y pick one and holding X shows the image without a view.

The view state lives in the dialog's fields, so ui.dialog.set drives it.
The preview runs select.refineEdge on the proxy with output=selection and
builds the view from it (veil fill layers, a layer mask, or the refined
selection, whose marching ants the canvas draws scaled back up), through
the existing filter-preview worker.

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

* Select and Mask: Refine Edge Brush (#2972)

Select and Mask had no Refine Edge Brush. select.refineEdge now takes
refineStrokes: [{"points":[[x,y],...],"size":px,"mode":"add|subtract"}],
applied in painting order. Added areas become unknown and take a colour
estimate: each pixel's colour projected on the line between the mean known
foreground and background colours around it (normalised convolution over
a box of the brush size, four times as far where none are near), so hair
or fur beyond the edge-detection band joins the selection. Subtracted
areas keep the original edge. Malformed or oversized strokes are rejected
(stroke / point / swept-area limits), never a panic.

The dialog gets the brush: Refine Edge Brush (R), Mode (Expand Detection
Area / Restore Original Edge, Alt swaps), Size ([ and ]) and Clear Brush
Strokes. With it on, dragging on the canvas paints strokes into the
dialog's refineStrokes field, so the live preview, Show Edge and OK use
them, and ui.dialog.set / agents drive them like any other param.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:00:25 -04:00
3360f6e442 Speed up Tilt-Shift with existing per-level halo trimming (#2880)
Co-authored-by: optimalbytee <optimalbytee@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 11:58:04 -04:00
5025fd2895 Color Sampler tool (#1046) (#2753)
* Color Sampler tool (#1046)

Add the Color Sampler to the Eyedropper (I) flyout: click to pin numbered
sample points to canvas pixels, drag to move, Alt-click (or drag off-canvas)
to remove, Clear All in the options bar, at most 10 per document. Each point
reads one composite pixel (RGB 0-255 and naive device CMYK, same as the
pointer readout) and the Info panel lists them under their number with the
position in the ruler unit; the readouts refresh when the document changes.

Points are per-document engine view state (DocState.color_samplers): they
belong to their document, are discarded on close, and are not saved to
.pcraft or PSD. They are driven by commands shared by the UI, CLI, control
channel and MCP (view.colorSamplers.add/.move/.delete/.clear/.list); the
mutations change no pixels and add no History step.

Points follow the canvas geometry (crop, Canvas Size, Image Size, Image
rotation/flip, rotated crop) through a pending Session.sampler_map that
Session::edit applies before culling the points that land outside the new
canvas; the count removed is reported in the status bar.

- engine: new sampler_cmds.rs (commands + shared readout helpers) and the
  sampler_map wiring in lib.rs, image_cmds.rs, mode_cmds.rs, commands.rs,
  analysis_cmds.rs (compound rolls points back on error).
- ui-egui: Tool::ColorSampler + original sampler.svg icon, toolbar slot,
  Info panel list, options bar, pointer handling, overlay markers, cursor.
- docs/scorecard.md regenerated (TOOL-1649-1 no longer says the tool is
  missing). No menu item, so parity is unchanged (627/627).

* Color Sampler follow-up: tool count, README, i18n, roadmap

The full ui-egui suite (not just the filtered tests) surfaced these gaps:

- state.rs: Tool::ALL.len() assertion 52 -> 53.
- README.md: "52 tools" -> "53 tools" and add Color Sampler to the list,
  so readme_tool_list_matches_tool_all passes.
- i18n: the options-bar hint, "{n} of {max} sample points" and
  "No document open" need entries in every catalog that claims complete
  menus (every_tl_literal_is_translated); added to all 16 (coverage
  1584/1584). The translations were drafted alongside the tool.
- docs/roadmap.md: drop Color Sampler from the missing-tools list and note
  it landed.

* Color Sampler: fit main's new tools and Image Size resample type

Reset Tool covers ColorSampler, the test ViewXform sets aspect, and
Image Size checks Method::None instead of an Option.

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-11 11:43:09 -04:00
d8d8d9f3d2 Tools: Convert Point tool in the Pen flyout (#3204) (#3211)
Convert Point is a tool of its own, built on the existing path.convertPoint
edit that Pen + Alt already drives. A click makes a smooth anchor a corner, a
drag from an anchor pulls out smooth handles, a drag on a handle moves it
alone. The tool selects the anchor it converts so its handles show; a press on
empty canvas or a segment does nothing, and Cmd/Ctrl is Direct Selection, as
with the Pen. It shares the Pen's key (P).

Original glyph with an ATTRIBUTION.md row; tool name in all catalogs; README
tool list and the G6 / ui-parity / roadmap counts updated (tools 56 of 68, the
earlier 53 was stale).

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:09:38 -04:00
Valentin KleinandClaude Opus 5.5 0aaf5b60fe Select and Mask: live preview in Photoshop's view modes (#1754) (#2970)
The Select and Mask dialog had no preview of the refined selection. It now
previews live in Photoshop's view modes: Onion Skin (default), Marching
Ants, Overlay, On Black, On White, Black & White and On Layers, with a
per-view Opacity, Show Edge (J) and Show Original (P). F cycles the views,
O/M/V/A/T/K/Y pick one and holding X shows the image without a view.

The view state lives in the dialog's fields, so ui.dialog.set drives it.
The preview runs select.refineEdge on the proxy with output=selection and
builds the view from it (veil fill layers, a layer mask, or the refined
selection, whose marching ants the canvas draws scaled back up), through
the existing filter-preview worker.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:48:26 -04:00
Valentin KleinandClaude Opus 5.5 42e4af6416 Tools: Frame tool (#2914) (#2915)
The Frame tool (K, its own toolbar slot after the Crop group) draws a
rectangular or elliptical frame; the options bar picks the shape, Shift
constrains to a square or circle and Alt draws from the centre. A frame
drawn over the selected pixel layer's content takes that layer in;
otherwise it is an empty frame.

Frames are built as Frame from Layers builds them: a group clipped by a
layer mask (rectangular or anti-aliased elliptical). The new engine
command layer.new.frame does it in one Session::edit, so one undo step.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:20:15 -04:00