mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
fix/transform-commit-on-switch
2323
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
98fc6ab072 | fix(cms): write the product name as PhotoCraft in built-in profile descriptions (#5614) | ||
|
|
42fb79e909 |
fix(ui): widen tool-options drag targets (#5135)
Co-authored-by: haranishi <[email protected]> |
||
|
|
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 |
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
2a0bebb49a | fix(engine): validate bounded extended filter parameters (#5586) | ||
|
|
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 |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
0e15fe77f7 |
Fix Nikon NEF lossy levels, packed decoding and metadata crops (#3238)
Co-authored-by: optimalbytee <[email protected]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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 |
||
|
|
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]> |
||
|
|
f1d13ca9c7 | fix(ui): keep the close prompt up when a Save Document script edits the document after saving (#4953) (#5081) | ||
|
|
dbfe8ba412 | fix(history): count fill caches and smart filter masks in undo memory (#4972) (#5075) | ||
|
|
686f3ee944 | fix(raw): reject truncated or oversized Deflate tiles in DNG import (#4977) (#5072) | ||
|
|
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]> |
||
|
|
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 |
||
|
|
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]> |
||
|
|
5c78d0848f | fix: limit Render Video timeline and GIF staging allocations (#5670) | ||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
28e58d20d9 |
fix(ui): snap distorted corners to visible guides (#3928)
Co-authored-by: zenjabba <[email protected]> Co-authored-by: Brandon Thomas <[email protected]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |