Commit Graph
2323 Commits
Author SHA1 Message Date
Tanish Manku 0ed723924f Tests: Free Transform applies when another tool, layer or New Layer is picked (#5665, #5667)
Whole-app regression tests for the paths #5665 and #5667 report (a moved box, and a placed image moved or untouched; toolbar tool click, Layers panel row click, New Layer). They pass on main, so these issues don't reproduce here; the tests pin Photoshop's behaviour (apply, never silently cancel) while the reporters narrow down their environment.
2026-10-14 15:58:55 -07:00
wwew 98fc6ab072 fix(cms): write the product name as PhotoCraft in built-in profile descriptions (#5614) 2026-10-14 15:06:35 -07:00
Ikki Nishiharaandharanishi 42fb79e909 fix(ui): widen tool-options drag targets (#5135)
Co-authored-by: haranishi <[email protected]>
2026-10-14 15:06:22 -07:00
Ahmed0_0Mamdouh 1f1e8d734f Fix #5414: Keep created paths visible and editable (#5424)
* Paths: keep work and shape paths visible with any tool (#5414)

* Tests: improve path overlay regression coverage
2026-10-14 15:06:13 -07:00
agent-346022andagent-346022 602344bae0 Fix filter preview live during drag (#5549)
* Filter dialogs: paint the preview computed mid-drag instead of waiting for the pointer to stop

A preview that finished while the slider was still moving was thrown away before it
could reach the canvas, because the worker result had to match the request on every
field including the parameters. Every completion of a drag lands after the value has
moved on, so none of them matched, the worker was handed the newest value instead, and
that one went stale in turn. Nothing was ever painted while the pointer moved: the
effect appeared only once the slider had been still for about a frame of preview
time, which is what "I have to stop the slider and wait" looks like.

The display gate a few lines below already had the opposite rule. It compared the
document, revision, dialog, layer, channel target, command and proxy factor and
deliberately left the parameters out, so that a slightly old frame stays on screen
mid-drag. The publish gate disagreed with it and won, so the frame the display gate
was written to keep never existed.

Publish a successful result when the finished request matches the current one on
everything except the parameters, and let the existing display gate decide whether to
keep it. The two gates now share one predicate, FilterPreviewKey::same_target, so
they cannot drift apart again.

Errors keep the exact match. A value the pointer has already dragged past should not
raise a notice, and a failure is not something to show stale.

What this deliberately does not change:

- A reopened dialog has a new id, a committed document a new revision, a different
  command or channel target a different key, so a result from another preview is still
  never painted. Covered by a_reopened_dialog_discards_the_previous_dialogs_finished_preview.
- The freshly computed request is still what decides whether a new computation starts,
  so the worker still converges on the value the dialog actually holds.
- One preview runs at a time and a stale one is still cancelled only once the values
  settle, so this adds no work to the worker; it only stops discarding completed work.
- OK still recomputes at full resolution, so a frame shown mid-drag is never what gets
  written to the document.

Test: a_preview_computed_mid_drag_reaches_the_canvas starts a blur at one radius, moves
the slider before that preview lands, and requires the completed frame to reach the
canvas while the slider is still on the new value. It fails on the old gate, timing out
after 10 s with no frame published.

* Filter dialogs: three guards on a mid-drag preview, after review

The first version of this let any completed frame through that matched the dialog
on everything but the parameters. Review found three ways that is wrong, and all
three are now closed and pinned by a test.

A stale result with nothing to show. Storing it replaces a good frame with an empty
one, which the display gate paints as "draw nothing": the canvas blinks back to the
unfiltered layer mid-drag. The exact request may still store an empty result, which
is how a filter that no longer applies clears the canvas, so the guard belongs on
the stale arm only. Splitting the match arm is what makes that separable; the
earlier single arm forced a false choice between the two.

A late frame replacing an exact one. Drag the slider away and straight back: the
frame on screen is already exact for the dialog, and the in-flight frame for the
value on the way out would have overwritten it, showing a blur the user has already
undone. A stale result is now dropped when the stored preview is already exact.

Errors were already scoped to the exact request and stay there: a value the pointer
has dragged past should not raise a notice, and the value it comes to rest on will
raise its own when it is recomputed.

Also pinned by test, because both are easy to undo by accident:

- The reopen test was not reaching the gate. discard_closed reclaims a closed
  dialog's job a frame earlier in the real app, and the test called it too, so the
  stale result never arrived and the test passed even with the dialog id removed
  from the comparison. It now drives the gate directly. Verified by mutation.
- The mid-drag test asserted nothing about what the display gate does with the
  published frame, so re-adding params there passed. It now asserts the canvas
  actually returns something to draw, and it keeps polling until a frame for the
  dialog's current value lands, which is what pins fresh to an exact match.

Tests are deterministic: the worker job is gated on a channel and signals when its
result is already in the worker channel, so nothing sleeps and nothing races the
150 ms settle timer that cancels a job whose values stopped changing.

Verified by mutating each guard and confirming a test catches it:
  publish gate back to exact-match ................. killed
  drop the is_some guard ........................... killed
  drop the already-exact guard .................... killed
  same_target ignores the dialog id ............... killed
  display gate compares params again .............. killed
  fresh relaxed to same_target .................... killed
  same_target ignores k ........................... killed

k stays in the comparison on purpose. A 36 MP document previews at k=4, and
preview_factor lowers k as a pixel-sized parameter approaches its minimum, so k does
change under the slider. Relaxing it would paint a frame at a scale the preview
policy exists to avoid, and would loosen the display gate further than upstream
wrote it. The cost is a one-computation pause when the slider crosses a threshold
on a large document; that test keeps the choice visible.

* Filter dialogs: fix a race in the new tests that let CI pass them

The four new tests signalled each other through a channel the worker sends from inside
`compute`. But the worker only puts the result into the channel after `compute` returns:

    let result = compute(&work);
    let _ = tx.send(result);

So a test that woke on the signal and called `ensure_filter_preview` immediately could
reach `poll()` before that send. On a loaded CI runner it did: ubuntu failed
`a_mid_drag_empty_result_leaves_the_preview_alone` with no frame published at all. The
comment in the test claimed the result was already in the channel, which was the one
thing it was not.

The tests now drive the canvas until the state they need appears, which does not depend
on the order of that send. They also check their invariant on every pass rather than
only at the end: a wrongly published frame can be replaced by the next, correct one
before the loop stops, which had already left the reopen, late-frame and empty-result
tests unable to see their own mutation.

Unchanged: the assertions, the production code, and the four mutations that broke each
guard — re-verified, 7 of 7 still caught.

Runs: 30 back-to-back runs of the four tests on 12 cores, no failure; the full crate
twice, 2800 pass.

---------

Co-authored-by: agent-346022 <[email protected]>
2026-10-14 15:06:04 -07:00
David MannandClaude Opus 5.5 64efae47c8 Guide Layout: the disabled Rows field no longer turns Rows on (#5625) (#5626)
The disabled Number field still drew a DragValue with range 1..=1000,
which clamps the default rows 0 to 1 and reports a change; writing that
back made the Rows checkbox's `positive` fallback turn on, so OK added
guides at the top and bottom. Disabled number fields no longer write
back. Kittest regression covers Rows and Columns.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 15:05:55 -07:00
Jason Xiang 2a0bebb49a fix(engine): validate bounded extended filter parameters (#5586) 2026-10-14 15:05:46 -07:00
Sabbir Ahmed 2eabf45ba5 fix(ui): ⇧ marquee square keeps equal sides on the pixel grid (#5472) (#5506)
The ⇧ square (and a 1:1 fixed ratio) is built in continuous document
coordinates, and `marquee_px` then snapped each axis to the pixel grid on
its own: floor the near edge, ceil the far one. A press between pixels,
dragged 100.5 px, snapped x to 101 and y to 102, so the readout and the
committed selection were off by one. `marquee_preview_px` now grows the
shorter side to the longer one, away from the press point, so the square
still covers every pixel the drag touched. Ants, readout and commit share
this path.

Fixes #5472
2026-10-14 15:05:36 -07:00
Juan Antonio RedondoandClaude Opus 5.5 1f33e4f6d7 fix(ui): a held arrow key keeps stepping the focused value field (#5474) (#5497)
Holding Up/Down in a value field (Character panel tracking, size, …)
stepped it a couple of times, then jumped focus to the field above.

Each arrow step re-requested focus on the field that already had it.
egui's request_focus resets the field's event filter, so on the next
frame egui no longer knew the field takes vertical arrows. A key repeat
landing on that frame was read as "move focus up". Single presses
rarely hit that frame; held keys always do.

Only request focus when the field lost it (after a typed sum gives it up
to be worked out). Regression test feeds key repeats on consecutive
frames with a field above and failed before the fix (focus moved after
two steps).

Fixes #5474

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 15:05:26 -07:00
DaktylandNigel Ihza Firdausy 051959ea7b Double-clicking the empty workspace shows File › Open (#5367) (#5429)
Double-clicking the empty workspace shows File › Open (#5367)

Also covers the same fix as #5461; thanks to its author.

Co-authored-by: Nigel Ihza Firdausy <[email protected]>
2026-10-14 15:05:16 -07:00
Nigel Ihza FirdausyandMarco Arnulfo 23dc83928c fix(ui): expose toolbar column toggle in all themes (#3538)
fix(ui): expose toolbar column toggle in all themes

Also covers the same fix as #4680; thanks to its author.

Co-authored-by: Marco Arnulfo <[email protected]>
2026-10-14 15:05:06 -07:00
Sidney Markowitzandnrq6993-a11y d334d32c12 fix(heif): a grid's ICC profile is inherited from its tiles (#5553)
fix(heif): a grid's ICC profile is inherited from its tiles

Also covers the same fix as #5591; thanks to its author.

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-14 15:04:55 -07:00
Sir Clangington III.andLabDennis 914d51f9bb perf(ui): bound carried selection outlines and stabilize clipping (#5699)
Limit whole-selection transform traces to a padded one-million-cell grid
while preserving normal viewport caching and thin boundary samples.
Use double-precision intermediates for clipping long perspective lines.

Add regressions for large/disconnected selections and viewport accuracy
near the projective horizon. Preserve the full-resolution selection and
the transform behavior already landed in #5337.

Related: #5297, #3402.

Co-authored-by: LabDennis <[email protected]>
2026-10-14 15:04:45 -07:00
Vikram BhaskaranandZer0C00L01 0f8c1a4106 Curves: apply to masks, alpha, and Quick Mask (#5335) (#5448)
Curves: apply to masks, alpha, and Quick Mask (#5335)

Also covers the same fix as #5578; thanks to its author.

Co-authored-by: Zer0C00L01 <[email protected]>
2026-10-14 15:04:37 -07:00
optimalbyteeandoptimalbytee 0e15fe77f7 Fix Nikon NEF lossy levels, packed decoding and metadata crops (#3238)
Co-authored-by: optimalbytee <[email protected]>
2026-10-14 15:04:27 -07:00
Michael HigginsandClaude Opus 5.5 7e94c507e9 Smart Objects: Curves preview no longer zooms in on a reduced proxy (#5766) (#5779)
Adjustments and filters on a smart object add a smart filter, so their live
preview re-renders the smart object from its source through its placement.
The preview runs on a proxy document scaled down by k (large documents), and
proxy_document scaled the smart object's cache but left its transform (and
perspective and filter mask) in full-size document coordinates. The re-render
then came out k times too large on the proxy canvas, so the object looked
zoomed in until OK committed the full-size result.

proxy_document now scales the placement and the filter mask with the proxy.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 15:04:19 -07:00
David Mannandnrq6993-a11y 772dfb9e7e Options bar: Brush and Eraser size go to 5000 px in every theme (#5708) (#5709)
Options bar: Brush and Eraser size go to 5000 px in every theme (#5708)

Also covers the same fix as #5724; thanks to its author.

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-14 15:04:09 -07:00
FinnNkandClaude Opus 5.5 2807deb209 fix(psd): write the layer block after a kept merged-transparency marker (#5673)
A 16/32-bit document saved as PSD has its layers in a global Lr16/Lr32 block, which the writer put first among the global blocks. A Mtrn/Mt16/Mt32 marker kept from the opened file then followed it, and Photoshop refuses such a file (verified in Photoshop 2026 with psd-tools' transparentbg.psd re-saved both ways: Mt32 first opens, Lr32 first doesn't). The layer block now goes right after the last kept marker, as Photoshop orders them. Fixes #5068.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 15:03:59 -07:00
David MannandClaude Opus 5.5 1025914b0e Properties / New Document: tiny sizes in physical units no longer resize the document (#5661) (#5662)
The Width/Height fields in Properties > Document and in the New Document
dialog clamped their shown value to at least 0.01 of the unit. A 1 x 1 px
document at 300 ppi is 0.0033 in, so showing it in inches clamped it to
0.01 in, the field reported a change and the document became 3 x 3 px
(two history steps, no user edit). The fields now accept down to 0; the
pixel conversion afterwards already clamps to at least 1 px.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 15:03:50 -07:00
David Mannandnrq6993-a11y d8d592b1bb FITS: clamp 32-bit saves to 0..1 and warn, so they reopen as saved (#5627) (#5628)
FITS: clamp 32-bit saves to 0..1 and warn, so they reopen as saved (#5627)

Also covers the same fix as #5642; thanks to its author.

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-14 15:03:42 -07:00
9a905959ed fix(linux): install the AppImage menu entry when AppImageLauncher skips it (#5551)
AppImageLauncher exports DESKTOPINTEGRATION even after
X-AppImage-Integrate=false makes it skip the AppImage. AppRun treated that
variable as "already integrated" and wrote no .desktop file, so the menu
stayed empty. Skip our entry only when another .desktop file's Exec already
points at this AppImage.

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-14 15:03:32 -07:00
Skyfuller 9cc8be4392 fix(algo): keep clipped warp bounds from overflowing (#5451)
warp() computed the source-centre fallback as (x0 + x1) in i32 and the
inverse-mapped tile footprint as `floor() as i32 - 3` / `ceil() as i32 + 3`.
With a strongly skewed finite affine (for example Homography
[1, 1e12, 0, 0, 1, 0, 0, 0, 1] on a 32x32 clip) the float-to-int cast
saturates at i32::MIN/MAX and the following -3/+3 overflows, panicking in
debug builds and wrapping in release. A source rect with coordinates near
1.5e9 would overflow the centre sum the same way.

Widen the centre sum to f64 and use saturating_sub/saturating_add for the
footprint, which is intersected with src_rect afterwards anyway.

Add crates/algo/tests/warp_clipped.rs covering clipped-vs-full equivalence,
huge scale allocation, the extreme-affine footprint, and the far-origin
source centre.

Refs #3446
2026-10-14 15:03:24 -07:00
Saad AbdullahandSaad Abdullah e1706507b5 fix(geom): clamp huge sizes in Rect::from_xywh instead of wrapping, and saturate TileCoord::rect (#5161)
Co-authored-by: Saad Abdullah <[email protected]>
2026-10-14 15:03:16 -07:00
MarekSpodBiedry f1d13ca9c7 fix(ui): keep the close prompt up when a Save Document script edits the document after saving (#4953) (#5081) 2026-10-14 15:03:07 -07:00
MarekSpodBiedry dbfe8ba412 fix(history): count fill caches and smart filter masks in undo memory (#4972) (#5075) 2026-10-14 15:03:00 -07:00
MarekSpodBiedry 686f3ee944 fix(raw): reject truncated or oversized Deflate tiles in DNG import (#4977) (#5072) 2026-10-14 15:02:52 -07:00
mroxsoandhighperfocused 57d488032c fix(doc): variable definitions follow layer id remapping on copy and load (#5023)
* fix(doc): variable definitions follow layer id remapping on copy and load

A duplicated document (image.duplicate, history.newDocument) or a .pcraft
loaded with remapped ids assigned fresh layer ids but cloned VariableDef.layer
unchanged, so datasets silently drove no layer (or, after remap, a colliding
one). Remap variable targets through the same id map as layer comps and
slices; a dangling target keeps a non-colliding placeholder. A merged-only
duplicate drops the whole variable binding set, matching how it drops comps.

Closes #4969

* style: cargo fmt duplicate_variables test

---------

Co-authored-by: highperfocused <[email protected]>
2026-10-14 15:02:43 -07:00
Nigel Ihza Firdausy 4c8101b576 fix(ui): avoid overflow when allocating layer style instance IDs (#4884)
* fix(ui): avoid overflow when allocating layer style instance IDs

* fix(ui): avoid panicking when layer style IDs overflow

* fix(ui): format non-panicking layer style ID allocation
2026-10-14 15:02:35 -07:00
Nigel Ihza Firdausyandbuivanvu94 13f893f998 fix(export): prompt for a Render Video output directory (#3365) (#3378)
fix(export): prompt for a Render Video output directory (#3365)

Also covers the same fix as #3390; thanks to its author.

Co-authored-by: buivanvu94 <[email protected]>
2026-10-14 15:02:27 -07:00
Nigel Ihza Firdausy 5c78d0848f fix: limit Render Video timeline and GIF staging allocations (#5670) 2026-10-14 15:02:17 -07:00
Valentin KleinandClaude Opus 5.5 bcaadccbfa Vector: bound flattening memory, keep over-budget dashes, cheaper edge build (#5195) (#5204)
* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 15:02:09 -07:00
8d840eab59 i18n(es): complete dynamic UI translations (#4306)
* i18n(es): complete dynamic UI translations

* es.tsv: keep Blur Angle once after merging main

---------

Co-authored-by: gorevyoneticisi <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:36:32 -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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:33:05 -04:00
Sir StarchandBrandon Thomas cd046f4a70 Arrow-key nudges: a run of nudges is one History state, with Tools preferences (#2259) (#2764)
* Arrow-key nudges: a run of nudges is one History state, with Tools preferences (#2259)

Tapping or holding an arrow key nudged the selected layers one pixel per
History state, so one Undo took back one pixel. Nudges of the same thing
that follow each other now share one `coalesce` key (the engine already
folds edits with the same key into one step), so a run is one state and
one Undo takes it back.

- nudge_bundle.rs decides which run a nudge belongs to: the previous nudge
  was of the same thing (layers, selection outline), came within the
  pause, and left the document as it is now (no other edit, Undo/Redo,
  layer selection or save in between).
- move_mods.rs runs layer.translate and select.transformSelection through
  it; Alt-nudges (a copy each) never join a run. Selected pixels float
  until dropped, which is one state already.
- Free Transform keeps its own session history; its box nudges fold into
  one session step (transform_tool.rs).
- Tools preferences: Bundle Arrow-Key Nudges (default on; off gives a
  state per press as before) and Nudge Bundle Pause (ms) (default 1000,
  100..10000, hidden while bundling is off). Labels translated in all
  catalogues.

The two #1428 tests that asserted a state per press for the selection
outline now cover both settings.

* Regenerate docs/scorecard.md after merging main

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:32:39 -04:00
3ebc42a200 Export As: exact pixel size, named presets, and profile options (#5344)
* Export As: exact pixels, named presets, and profile options

Users can set an exact pixel size with the aspect ratio locked or free,
save and reuse named export presets in the preferences document, and
choose whether to convert to sRGB, embed the colour profile, and keep
EXIF or all metadata. Percent scale and the last-used settings stay.

Part of #2289

Co-authored-by: Zer0C00L01 <[email protected]>

* Translate Export As's Color Profile and EXIF labels in every catalog

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:17:44 -04:00
a61df64cc9 feat(ui): select guides, the guide context menu and Edit Selected Guides (#3734)
* feat(engine): view.editGuide changes a guide in one step

Photoshop's Edit Selected Guides (the guide context menu) reopens New
Guide on the selected guide. view.editGuide takes the guide by orientation
and index and changes whatever is given: "newOrientation", "position"
(pixels or a length with a unit, % of the new orientation's extent) and
"color" (a name or "#rrggbb"), as one undoable "Edit Guide" step. A guide
that keeps its orientation keeps its index; one that turns goes to the
end of the other list. Without "color" the guide keeps its own colour, or
none. Bad params are refused without a history step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(ui): select guides, the guide context menu and Edit Selected Guides

As in Photoshop, a click on a guide with the Move tool (or ⌘ with a
selection tool) selects it, drawn in Photoshop's selected-guide blue
(#0000ff); a press anywhere else deselects it, and Delete or Backspace
deletes it, before Edit › Clear can reach the pixels. A guide that is
dragged stays selected, and a click no longer nudges a guide to the
pointer. One guide at a time.

Right-click on a guide selects it and opens Photoshop's guide menu: Edit
Selected Guides, Lock Guides, Clear Guides, Clear Canvas Guides, Clear
Selected Guides and Clear Selected Artboard Guides. Edit Selected Guides
opens the New Guide dialog on the guide (its orientation, its position in
the ruler unit and its colour, Cyan for a guide dragged from a ruler) and
OK runs view.editGuide, sending the colour only when it was changed.

The selection is view state (UiState.selected_guide, readable over the
control channel), keyed by document, orientation and position like guide
colours, so it lapses by itself when its guide goes. The menu's two new
labels are in every catalogue.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(ui): Alt-click turns a guide; double-click opens the guide preferences

As in Photoshop, with the Move tool:
- Alt-clicking a guide turns it: a vertical guide becomes horizontal
  through the point clicked, and the other way round. It keeps its colour
  and stays selected; one undoable Edit Guide step (view.editGuide).
  Locked or hidden guides can't be turned.
- Double-clicking a guide opens Preferences › Guides, Grid & Slices.

docs/ui-parity.md lists the guide interactions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(i18n): Hungarian and Turkish strings for the guide menu

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(ui): the spelling rows call engine_call with the app

main's spelling suggestions (choose_spelling) came in calling
engine_call(menu, command); on this branch engine_call takes the app
first (the guide menu needs it), so the merge didn't compile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(sr): translate the guide context menu items

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(ui): give main's move-hover test guide drag a press point

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:17:10 -04:00
9788e5ee5e Shortcuts: a command can answer a second key (F2/F3/F4, Shift+F7, Cmd++) (#3665) (#3746)
* Shortcuts: a command can answer a second key (F2/F3/F4, Shift+F7, Cmd++) (#3665)

Photoshop's default set gives some commands a second key besides the one
its menu shows: F2/F3/F4 Cut/Copy/Paste beside Cmd+X/C/V, Shift+F7 Inverse
beside Cmd+Shift+I, Cmd++ beside Cmd+= for Zoom In, Shift+Backspace for
Fill. A command could hold only one key here, so these couldn't exist and
a .kys import kept only a row's first key (a set listing Copy as Cmd+C and
F3 could leave Cmd+C unbound).

- prefs::ALTERNATE_SHORTCUTS holds Photoshop's second keys;
  Preferences.alternate_shortcuts (alternateShortcuts) replaces a
  command's defaults, [] removes them. Old settings files load unchanged.
- edit.keyboardShortcuts takes `alternates`; reset, moving a key (taken
  from another command's main key or alternates) and the conflict list
  count every key, and a call's changes now apply atomically.
- .kys import keeps a row's other keys as the command's alternates.
- Dispatch matches every key (a user's main key wins over a default
  alternate); the UI-only SECONDARY table is folded in, so Fill's
  Shift+Backspace survives an override of its main key. Menus still show
  only the main key.
- Keyboard Shortcuts dialog shows each command's alternates, removable
  one by one, with Add Shortcut to add one; Use Default / Reset All
  restore them.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Translate Add Shortcut into Hungarian; regenerate the scorecard

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Scorecard: regenerate for the new alternate_shortcuts preference

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* docs: regenerate scorecard after merging main

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Regenerate docs/scorecard.md after merging main

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Regenerate docs/scorecard.md after merging main

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:16:43 -04:00
Fabio LissiandBrandon Thomas 4cc0518169 Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) (#5091)
As Photoshop's menus on Windows: a tap on Alt (or F10) puts the keyboard
on the in-window menu bar, with File lit; ← and → move along it, ↓, ↩
and Space open the lit menu with its first row lit, Esc or a click
leave. Alt+letter opens the menu with that mnemonic, open or not, and
in an open menu a letter runs the row with that mnemonic or opens its
submenu.

Mnemonics are assigned per menu level from the labels as shown (so
translated), unique within the level: Photoshop's F E I L Y S T V W H
for the top menus where the label has the letter, else the first unused
letter, word starts first; only ASCII letters and digits, since those
are the keys the keyboard reports. They are underlined while Alt is
held, the bar is focused or the menu was opened from the keyboard; a
menu opened with the mouse shows none until Alt, as on Windows. The
underline is a layout job with the placeholder colour, so disabled rows
stay dim; without underlines the text path is unchanged.

The shortcuts defer to the focused bar as they do to an open menu, so E
opens Edit instead of picking the Eraser and the arrows don't nudge the
layer. Rows carry their mnemonic in the navigation state. macOS keeps
the native menu bar, where this doesn't run.

Tests: two unit tests (assignment, key letters) and seven harness tests
(Alt focus and arrows, second Alt / Esc / click, Alt+letter and Enter,
letters in an open menu, underline conditions, the layout job, and the
whole app's shortcut deferral).

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:16:09 -04:00
9b34c49128 Add Shift+scroll snap zoom to Photoshop zoom ladder levels (#3695)
* Add Shift+scroll snap zoom to Photoshop zoom ladder levels

When zooming with the scroll wheel (either via Alt+scroll or via the
"Zoom with Scroll Wheel" preference), holding Shift snaps to the next
Photoshop zoom ladder level instead of the continuous ×1.1 step.

- zoom_with_wheel ON: Shift+scroll snaps to the ladder
- zoom_with_wheel OFF: Shift+Alt+scroll snaps to the ladder

Co-authored-by: Claude <[email protected]>

* Handle Shift+wheel arriving as horizontal delta in snap zoom

Some systems (notably egui on certain platforms) deliver Shift+scroll
as a horizontal delta (x != 0, y = 0) rather than preserving the
vertical axis. Use `raw.y + raw.x` so the snap-zoom path catches
both orientations, matching the existing Shift+Alt pattern on the
line above.

Co-authored-by: Claude <[email protected]>

* Snap zoom: per-viewport fractional accumulation, Camera Raw ladder stepping, and horizontal-delta handling

Port the improvements from TheSeanLavery/photocraft#33:

- `read()` gains `owner: Id` and `hovered: bool` so each viewport
  (canvas, navigator, Camera Raw) accumulates its own snap residual
  without leaking into others.
- Fractional scroll input (sub-notch point events) now accumulates
  across frames with an idle timeout, so precision trackpads and
  high-resolution mice reach the next ladder rung.
- Camera Raw snap zoom steps through the ladder via `step_in_range()`
  instead of being a no-op.
- Shift+wheel arriving as a horizontal delta (x != 0, y == 0) is
  handled by using `raw.y + raw.x` in the snap path.
- `zoom_levels::step_in_range()` extracted for reuse with custom bounds.
- 8 new tests (24 wheel_nav total), 4 new Camera Raw snap tests,
  1 new zoom_levels test.

Co-authored-by: Claude <[email protected]>

* Fix test destructure after merge with main (#5080) and reformat

#5080 added a test that destructures gesture() as a 2-tuple, but this
branch makes it return 3 values.  Update the destructure and run
cargo fmt --all.

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:15:42 -04:00
5cb5ab52fc Crop tool: Content-Aware fill of the area a rotated or extended crop uncovers (#3823) (#3827)
* Crop tool: Content-Aware fill of the area a rotated or extended crop uncovers (#3823)

image.crop gains contentAware (bool, default false). After a turned crop or one that extends
past the canvas, the area the original image doesn't cover is filled from its surroundings on
the Background (else the active pixel layer), in the crop's one undo step. Each connected piece
(corner, edge strip) gets its own Content-Aware sampling window and samples only covered pixels;
the patch search is Edit > Fill > Content-Aware's (fill_cmds::content_aware_native, shared). A
crop that uncovers nothing is unchanged; one with nothing to sample from fails and rolls back.

Crop options bar: Fill: Background (Default) / Content-Aware Fill (ToolOptions
crop_content_aware, ui.set cropFill). Generative Expand is not offered.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Crop: adapt to Document::background after main removed is_background

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Crop: translate Background (Default) for Hungarian and Turkish

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Fix the merge with main: UI_SET_FIELDS length, Serbian Background (Default)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Merge main: UI_SET_FIELDS counts both new fields

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:15:15 -04:00
9df067b297 Shapes: on-canvas corner radius widgets for live rectangles (TOOL-214-12) (#5276)
* Shapes: on-canvas corner radius widgets for live rectangles (TOOL-214-12)

As in Photoshop, with Path Selection or the Rectangle tool the active shape
layer's live rectangle shows a round widget at the centre of each corner arc
(a few screen points inside a sharp corner). Dragging one rounds every corner
alike, Alt drags that corner alone; the radius follows the move along the
corner's diagonal from the press, within half the shorter side. The drag
previews the new outline and commits one shape.edit with radii on release,
one history step; a click changes nothing.

Docs: ui-parity row done, TOOL-214-12 note updated (still partial: shape
feather), target-app-parity, regenerated scorecard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Corner radius widgets: every selected rectangle, Move tool, cursor and readout

As in Photoshop:
- every selected live rectangle shows its widgets, also with the Move tool
  when Show Transform Controls is on;
- dragging any widget gives every selected rectangle the same radius (each
  capped at half its own shorter side); Alt changes that corner on each;
  all the shape.edit calls share one history step (coalesce);
- the pointer over a widget is the arrow with a rounded-corner badge, and a
  drag shows an "R: 25.0 px" readout;
- while dragging, the transform box and the Move rollover highlight hide, and
  the Move tool's snapping started by the press is dropped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:14:49 -04:00
Brandon ThomasandClaude Opus 5.5 1c6f138763 Posterize and Threshold commands use the 16-bit rules on 16-bit layers (#5711)
#5267 and #5263 gave 16-bit adjustment layers Photoshop's measured Posterize and
Threshold rules, but the destructive commands still passed no depth for 16-bit
layers and used the 8-bit rules. The live preview (an adjustment layer) then
disagreed with what OK wrote by up to 92/255, failing ui-egui's
adjust_preview::preview_composite_matches_the_destructive_command on main.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 15:14:30 -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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:14:12 -04:00
21c7cf637a Curvature Pen: edit finished paths (drag, add, smooth/corner) with an adaptive cursor (#5197)
* Curvature Pen: drag an anchor of the active path without a modifier

As in Photoshop, the Curvature Pen with no path being drawn shows the active
path's anchors (work path, active shape layer or targeted vector mask), and a
press-drag on one moves it with no Ctrl/Cmd. The curve reflows: every smooth
knot of that subpath gets its handles recomputed with the Curvature Pen's own
curvature_knots, so it keeps flowing through all its anchors; corners keep
their handles. The drag previews live and commits one history step.

- vector: edit::move_anchors_curvature
- engine: path.moveAnchors takes "curvature": bool=false
- ui-egui: direct_select routes the idle Curvature Pen press (Edit::Curvature)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Curvature Pen: add anchors, toggle smooth/corner and adapt the cursor on finished paths

As in Photoshop, with no path being drawn the Curvature Pen also:
- adds an anchor where a segment of the active path is clicked
  (path.addAnchor), and a drag pulls it like any anchor; adding and dragging
  are one history step;
- toggles an anchor between smooth and corner on a double-click
  (path.convertPoint with the new "curvature" param, vector
  edit::toggle_curvature_point): a Pen-drawn corner rounds off and the
  segments around it curve through it; a second double-click makes it a
  corner again;
- shows what a press does in its cursor: + over a segment, four arrows over
  an anchor (also an anchor of the path being drawn).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* rustfmt

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:13:46 -04:00
524d608b50 feat(psd): editable Filter Gallery smart filters (Chrome) (#3426)
* feat(psd): map Filter Gallery smart filters to filter.filterGallery (Chrome)

A PSD smart filter from Filter > Filter Gallery (`GEfc`) was kept verbatim
as psd.unsupportedFilter, so it could not be edited. Its effect stack
(`GEfs` list of `GEfc` effects with `GEfk` effect enum and `GELv`
visibility) now maps to `filter.filterGallery {effects, foreground,
background}` when every effect and setting is modelled, and is written
back as `GEfc` on export. Layout taken from a Photopea-authored PSD.

The table starts with Chrome (`Chrm`: `Dtl ` detail, `Smth` smoothness).
Stacks with other effects or unknown settings stay verbatim as before.

* feat(smart-filters): edit Filter Gallery smart filters in the gallery dialog

Double-clicking a Filter Gallery smart filter in the Layers panel did
nothing. It now opens the Filter Gallery dialog with the recorded effect
stack and colours, previewing on the pixels below that filter; OK updates
the filter in place with layer.smartFilter.setParams (one history step).

* fix(psd): read and write Photoshop's inline Filter Gallery effect

Photoshop 27 writes a Filter Gallery smart filter with a single effect
inline in `Fltr` (`GEfc { GEfk, <settings> }`) rather than as a `GEfs`
list, so such files still opened as psd.unsupportedFilter. Both layouts
are read now. A single visible effect is written inline as Photoshop does;
several or hidden effects keep the list, which is the only layout with a
visibility flag (`GELv`).

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 15:13:20 -04:00
28e58d20d9 fix(ui): snap distorted corners to visible guides (#3928)
Co-authored-by: zenjabba <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:12:49 -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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:11:19 -04:00
852d2a1dfd Resize cursors turn with the box and with a rotated or flipped view (#5188)
* Type and Content-Aware Move boxes: resize arrows follow a rotated view

The type box (paragraph text handles, Ctrl/⌘ transform corners) and
Content-Aware Move's Transform On Drop box chose their resize arrows with
type_transform::resize_cursor, which mirrored the direction for a flipped
view but ignored a rotated one: on a view turned 90°, the side handles
showed ↕ although they resize left and right on screen.

The helper now takes the view and maps the handle's direction through it
(ViewXform::map_vec: rotation, flip and pixel aspect) before rounding to the
nearest of egui's four resize arrows. It moves to the canvas cursors as
tool_cursor::resize, so the other tools with resize handles can use it too.

The new test, resize_follows_the_view, checks views turned 0°, 45° and 90°
and a flipped view, with the expected arrows written out by hand; it fails
when the view's rotation is ignored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Free Transform, Warp, Artboard, guides, Crop: arrows follow the view

The remaining tools with resize arrows chose them without the view:

- Free Transform picked the arrow from the handle's index alone (corners 0
  and 2 ↖↘, 1 and 3 ↗↙, edges 0 and 2 ↕, 1 and 3 ↔), so a rotated box showed
  wrong arrows (all 8 handles at 45°, 90° and 135°), and so did a rotated or
  flipped view.
- Warp's Split Vertically/Horizontally cursor was always ↔/↕, though the
  split line follows the grid (turned when Warp starts on a rotated box) and
  the view.
- The Artboard tool used a fixed table, ignoring a rotated or flipped view.
- Guides turn with a rotated view (#3277), but the cursor over them stayed ↔
  for a vertical guide and ↕ for a horizontal one.
- Crop followed the frame's angle and a rotated view, but not a flipped one:
  its corners showed the other diagonal.

Free Transform and the Artboard tool now get the canvas's view and use
tool_cursor::resize: Free Transform with the direction from the opposite
handle, the Artboard tool with the handle's outward direction.
rulers::guide_cursor uses it for the direction across a guide, and Warp's
split_cursor for the grid's own direction across the split line at the
pointer (split_cursor and quick_split_kind take the grid position, so the
cursor looks it up once). Crop keeps its own resize_icon, which now mirrors
the turned direction when the view is flipped. On an upright, unflipped view
every arrow is as before.

New tests, with the expected arrows written out by hand, fail on the old
code: resize_cursors_turn_with_the_box_and_the_view (Free Transform),
warp_split_cursors_turn_with_the_grid_and_the_view,
resize_cursors_turn_with_the_view (Artboard),
guide_cursors_turn_with_a_rotated_view, and flipped-view checks in Crop's
cursor_follows_the_frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:11:10 -04:00