Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 c699e3cda6 Release 0.9.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 10:16:05 -04:00
Brandon ThomasandClaude Opus 5.5 7250e6e29a Tests: autosave retry waits for the finished write, not a mid-write listing
Since ed7df56a, list_recovery also lists a committed bundle whose sidecar
is not yet published (revision 0). The worker commits the manifest before
it writes the sidecar, so the retry test could catch the bundle between
the two and read revision 0 (seen on windows-latest; 215/300 local runs
on Linux under load). Wait until the entry carries the expected revision.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:21:47 -04:00
MarekSpodBiedry ed7df56a24 fix(format): recover autosaves with missing sidecars
Discover committed autosave bundles whose JSON sidecar was never published. Rebuild recovery metadata from the readable manifest and retain these bundles during orphan cleanup.

Fixes #4967
2026-10-14 02:02:34 -06:00
Valentin KleinandClaude Opus 5.5 706638b284 Tests: bad-layer-id checks use an id that can't exist (#3343) (#3351)
* Tests: bad-layer-id checks use an id that can't exist (#3343)

Layer ids come from one process-wide counter (shared with document and
smart-object ids, and bumped by ensure_ids_above on file loads). A full
`cargo test` run passes ids like 9999, so a layer a test just created can
get the "bad" id, the command succeeds and the assertion fails at random
(seen on CI in type_extra_cmds::tests::vertical_orientation_renders_columns_inside_the_canvas).

Use u64::MAX, which the counter never reaches, for every "no such layer"
sentinel in the engine and ui-egui tests. Test-only change.

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

* Tests: use u64::MAX for bad-layer-id sentinels added on main since (#3343)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 01:59:43 -04:00
Valentin KleinandClaude Opus 5.5 74812f4874 IO: abr fit and gradient eval check their own bounds (#5218)
fit() computed (y + 1) * h in u32, which passes u32::MAX for a plane
over 2^32 / max rows; the ABR and pattern parsers' edge caps keep real
inputs below that, but the guard lived in another crate. Spans are now
computed in u64. gradient_bake::eval indexed stops[0] and stops[i + 1]
relying on bake's two-stop check; it now handles an empty or lone stop
itself. Same results for every input that worked before.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 01:59:38 -04:00
Timothy DalyandClaude Opus 5.5 ff57499774 PSD: Mt16/Mt32 blocks mark the merged image's transparency (#5178)
The merged image carries its transparency in the first extra channel
when the layer count is stored negative, or when a "Saving Merged
Transparency" global block (Mtrn, Mt16, Mt32) is present. Photoshop
saves 32-bit documents the second way: a positive layer count, Mt32,
and a fourth channel named "Transparency" (psd-tools transparentbg).
merged_has_alpha only knew the first, so opening such a file added a
"Transparency" alpha channel to the document, and the oracle compared
it as if it were opaque white (83.7 % of pixels off; now 0.9 %, all on
the shape's edge).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 01:59:32 -04:00
7ad75b5db2 Layer masks: Reveal Selection drops the selection, as Photoshop does (#5251)
A mask made from the selection takes the selection with it in Photoshop: after
Layer > Layer Mask > Reveal Selection (the Layers panel's mask button with a
selection) the marching ants are gone. PhotoCraft kept the selection, so the
usual next step, Invert (Cmd/Ctrl+I) on the new mask, inverted only the part
inside the old selection and left the whole mask black, instead of flipping
the mask. Hide Selection (the Alt-click variant) already dropped it.

Reveal Selection now clears the selection in the same history step as the
mask: one undo brings the selection back and removes the mask.

Tests: adjust_cmds::tests::reveal_selection_drops_the_selection_so_invert_flips_the_whole_mask
(8/16/32-bit). Tests that deselected right after Reveal Selection no longer
need to (Deselect is disabled without a selection); layer_mask_depth and the
automation agent tasks now expect the selection to be gone.

Co-authored-by: abc2cb <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 01:59:27 -04:00
c7366b8938 Drop shadow: knocked out beneath the whole shape when the layer's mode blends with it (#5304)
Layer Knocks Out Drop Shadow only knocked the shadow out through see-through fill: at
100 % fill the layer was taken to cover it. That holds in Normal mode, but a layer in
Exclusion, Difference, Multiply, Screen and the rest blends with what's beneath it,
shadow and all: a white Exclusion bar with a drop shadow over black type came out as a
blank white bar instead of white type on black (Photoshop's merged image of the same
file shows the shadow kept out from under the bar), and a Multiply layer was darkened by
its own shadow. shadow_see_through() now knocks it out wholly in those modes, for the CPU
compositor and the GPU plan alike.

Co-authored-by: Daniel van Lith <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 01:59:23 -04:00
c0cb78090b panic_hunt: count panics that Session::execute contains (#4982) (#5219)
Session::execute catches a command's panic and returns an ordinary Err (the
last-resort guard in jobs.rs), so the unwind never reached panic_hunt's own
catch_unwind and a panicking command counted as a pass.

The harness now installs a panic hook that counts every panic in the process,
including on rayon workers, and a run fails if the count moved. Runs are
serialised so a panic is charged to the run that caused it. No production
code changes.

Test: contained_panics_count_and_graceful_errors_do_not. A contained panic
fails the run and a graceful Err passes; the first assertion fails without
the hook. The full --ignored fuzz stays green, so no command panics today.

Co-authored-by: prekabreki <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 01:59:18 -04:00
Nicolas FalesyandClaude Opus 5.5 7a0e60c104 test(engine): add Color Range fuzziness 0 exact-match test (TOOL-214-3) (#3581)
* test(engine): add Color Range fuzziness 0 exact-match test (#214)

Adds an explicit test for Sampled Colors at Fuzziness 0 (point sampling
and hex colors) asserting that exact matches are fully selected and
adjacent shades are rejected. Updates scorecard TOOL-214-3 to done.

* docs: regenerate scorecard (TOOL-214-3 done)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017WeVmjnnPPKdxVTpr2Vjtw

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 01:59:13 -04:00
Gert Pellin - GPE 7e28900df3 layer.setProps: refuse opacity and fill outside 0..1 (#5242) (#5291)
Opacity and Fill are fractions in layer.setProps but percentages in the
Layer Style commands, and a value above 1 was clamped silently: 55 gave
an opaque layer. Out-of-range or non-numeric values now fail with an
error that names the range, and the layer is left alone.
2026-10-14 01:59:09 -04:00
Gert Pellin - GPE 15f7618504 Object Selection: say so when the sampled layer is empty (#5246) (#5298)
select.object samples the active layer by default. On an empty layer
(scripts often add one above the photo) it found nothing and returned
without an error; the failure only surfaced at the next command that
needed a selection. It now fails with an error that names the cause and
the sampleAllLayers option, also when the layer's pixels lie outside the
rectangle or point.
2026-10-14 01:59:04 -04:00
Gert Pellin - GPE 4bbcede572 Fibers: fill an empty layer, as Photoshop does (#5241) (#5289)
Fibers renders from the foreground and background colours without
reading the layer, but its output area was the layer's content grown by
a zero halo: on an empty layer that is empty, so nothing was drawn (and
a following transform found nothing to transform). On a partly filled
layer only the content's bounding box was filled. It now writes the
reference bounds (the selection's, else the canvas) like the global
filters, without reading the whole layer as they do.
2026-10-14 01:59:00 -04:00
Gert Pellin - GPE 2da2889546 Background: an opacity, fill, blend mode or Select and Mask layer mask makes it Layer 0 (#4837)
The Background has none of them. layer.setProps applied them to it as it
was, and Select and Mask's Layer Mask output gave it a mask. Like
Photoshop (and like PhotoCraft already does for moving, transforming,
erasing, styles and Layer > Layer Mask), it now becomes a normal layer,
"Layer 0", first. Values that leave its look alone (opacity and fill
100 %, Normal) keep it the Background.

Fixes #4836
2026-10-14 01:58:55 -04:00
Vikram Bhaskaran 7c916d9ecd Validate selection modification limits before allocating the canvas mask. (#4591) 2026-10-14 01:58:51 -04:00
Vikram Bhaskaran 79e2107f71 Report channel copy allocation failures without losing the clipboard. (#4587) 2026-10-14 01:58:46 -04:00
33b360d5ee Reject clipboard origins that cannot form a new document (#4586)
* Reject clipboard origins that cannot be moved into a new document.

* Fill Clip.resolution in the unrepresentable-origin clipboard test.

main added resolution to Clip; the synthetic overflow fixture omitted it and failed engine lib tests.

* Format the clipboard origin test (cargo fmt)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 01:58:42 -04:00
Gert Pellin - GPE 077e513686 Color Range presets: weigh dark colours like Photoshop (#5243) (#5293)
Reds/Yellows/Greens/Cyans/Blues/Magentas selected hue closeness times
chroma. Measured against Photoshop 2025 on 126 colours per preset
(Reds, Yellows, Blues; hue +-40 degrees, saturation 5..100 %,
brightness 33..100 %), Photoshop also multiplies by (2 - max): bright
colours were already exact, darker ones came out up to 61 levels short.
With the factor every sample matches within one level. The muted navy
#3d3f54 from the issue now gets 35/255, as in Photoshop (it was 21):
Photoshop also leaves it below 50 %.
2026-10-14 01:58:36 -04:00
Ivan · VanTan e12b7f7352 fix(prefs): preserve selected themes during legacy migration (#5179) 2026-10-14 01:58:31 -04:00
Zer0C00L01andCursor Agent a954194277 PSD import: ResolutionInfo stays pixels per inch when the display unit is cm (#5280)
hRes and vRes are stored as pixels per inch. The unit that follows only
controls how that number is shown, so a 300 ppi file with unit cm opened
at 762 ppi. Export already writes the document ppi and does not divide
by 2.54.

Fixes #5201

Co-authored-by: Cursor Agent <[email protected]>
2026-10-14 01:58:27 -04:00
Michael HigginsandClaude Opus 5.5 2388d82165 Docs: oracle floor, open issues, workspace size and layer filters match main (#5376) (#5382)
- ROADMAP.md: the oracle floor is 145 of 258 (crates/io/tests/corpus.rs,
  gaps.md G1), not 133 of 256; 953 open issues, not 579.
- docs/architecture.md: ~539,000 lines in ~1,310 files and ~8,040
  #[test] functions, recounted 2026-10-13.
- README.md: "a layer kind filter" instead of "layer filters"; only the
  Kind filter exists.
- AGENTS.md: drop an empty bullet at the end of section 7.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 01:58:22 -04:00
LorenzoFasolino d11be6de78 docs: drop stale missing-tool rows, only Art History Brush is missing (#5063)
Task T-0002 del lab artcraft-lab.

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

Evidenze: artifacts/T-0002/ (nel lab, non in questo repo).
2026-10-14 01:58:18 -04:00
wwew 9df59c96b8 docs: write product name as PhotoCraft in development guide (#5228) 2026-10-14 01:58:14 -04:00
skylar taylor-barrick 369704ac55 Merge pull request #4599: native Windows printer settings and RGB printing
Share native Windows driver settings and RGB printing through craft-print
2026-10-13 22:38:25 -06:00
Tanish Manku 583425b4df Stroke latency trace: PHOTOCRAFT_STROKE_TRACE times each frame of a brush stroke (#1343) (#4403)
Testers report ~50 ms input-to-pixel while painting on Linux and macOS, and asked for a
per-stage breakdown. With PHOTOCRAFT_STROKE_TRACE=1 the app prints, per stroke, p50/p95 of
the frame interval, pointer moves per frame, the live stroke's render, the canvas refresh
(composite + upload), the UI build, eframe's CPU time (UI, tessellation, submission) and the
remaining vsync/swap-chain wait, with the GPU backend; =<file.csv> also appends every frame.
The last summary is in the control channel's perf.strokeTrace.

RTX 5090, Vulkan, 120 Hz: interval 8.33, cpu 2.2 (ui 0.9, stroke 0.8, canvas 0.4), wait 6.0 ms.
2026-10-13 18:09:37 -07:00
skylartaylor a9ac922fc1 Align printer menu regression with the merged system-printer cache 2026-10-14 02:21:09 +02:00
skylartaylor 6c1a3590f5 Remove obsolete app-owned Windows printing layer comment 2026-10-13 21:33:44 +02:00
skylartaylor 235d4e22fa Merge current main while preserving native printing and system printer menus 2026-10-13 21:18:34 +02:00
skylar taylor-barrick 3a2d657b9a Keep custom paper for Windows PDF exports and repair merged catalogs 2026-10-13 20:43:45 +02:00
Scott C Hughes 99e1138877 Stroke layer effect: the outer edge stays round at wide sizes (#3646) (#3650)
* fix(compose): a wide stroke effect keeps a round outer edge (#3646)

A stroke's outside distance came from the 5x5 chamfer, whose level sets are polygons: at a
245 px outside stroke the boundary wanders ~10 px into visible flat facets. Use the exact
Euclidean distance (edt_nearest, already used for glows and chiselled bevels) for the outer
edge; the inside edge and spread/choke keep the chamfer, whose sub-pixel edge seeding is
fitted to Photoshop at small offsets. A point source dilated by 60 px now stays within 0.5 px
of a circle (was 2.5 px).

Tests: a_wide_outside_stroke_is_round_not_a_polygon; stroke_edges_use_the_exact_euclidean_distance
(a 3 px outside stroke's (1,3) corner coverage is 0.838, the exact distance; it was 0.764).
Full photocraft-compose suite: 206 pass; fmt and clippy clean.

* fix(gpu): a wide stroke keeps its exact outer edge on the GPU (#3646)

1080008a fixed the CPU compositor's stroke outer edge to use the exact
Euclidean distance instead of the 5x5 chamfer, whose level sets are
polygons and facet a wide stroke. The GPU compositor builds the outer
stroke field from FieldKind::StrokeOutside, which still maps to the
chamfer, and the canvas is drawn on the GPU, so the fix did not reach the
app.

Add FieldKind::StrokeOutsideExact (dist_outside_by Euclidean) used only by
the stroke. The spread of drop shadows and softer outer glows keeps the
chamfer on purpose (fitted to Photoshop, #224), so StrokeOutside keeps its
meaning. StrokeOutsideVector is exact too, matching the CPU vector outline
path.

Tests: crates/gpu/src/fx.rs a_raster_stroke_uses_the_exact_outer_field
(stroke uses the exact field, shadow spread keeps the chamfer), and the
banded/whole field test covers the new kind.

* fix(stroke): a stroke keeps the chamfer narrow and the exact edge wide (#3646)

The exact Euclidean outer edge (#3646) fixed a wide stroke's faceting but
regressed Photoshop's own composites against the PSD flatten oracle: the
fitted 5 × 5 chamfer matches Photoshop at small offsets, and only at a large
outside width do its octagon facets show. Pick the metric by the stroke's
outside width instead: below `STROKE_CHAMFER_MAX` (20 px) keep the chamfer and
its sub-pixel edge seeding, at or above it use the exact Euclidean transform.

The measured chamfer deviation from a point source is 0.027 · r px — 0.48 px at
r = 18, the widest outside stroke any Photoshop oracle matches with the chamfer,
and 1.6 px at r = 60, where the boundary visibly facets. Half a pixel is reached
at r ≈ 19, so the switch sits at 20. Inside stroke edges keep the chamfer.

The CPU and GPU compositors pick the same field by the same width
(`stroke_outside_exact`); `FieldKind` gains `StrokeOutsideVectorChamfer` for a
narrow shape-layer outline, while the shadow / glow spread keeps its chamfer.

io corpus: 140 → 148 (floor 146) and psd-tools 231 → 237 (floor 236), both back
to main's counts with no file newly differing; a 60 px outside stroke stays
round (`a_wide_outside_stroke_is_round_not_a_polygon`).
2026-10-13 13:24:53 -04:00
Elvis33LE 1bcb662828 Deep EXR: open cropped renders on their full display-window stage (#4907)
A renderer's data window is its crop of the frame; the composition lives
on the whole display window (our MY27 renders: data 1782x1267 at
(1109,1100) inside a 4000x3000 stage). decode_deep now validates the
display window against the limits and reports it as stage + origin on
DeepImage; flatten pads the composed region to the stage at its offset
(transparent elsewhere) and tags the image with a DisplayWindowStage
warning. Files without a usable display window open as before.
Includes a cropped-deep generator and a stage/offset oracle test.
2026-10-13 13:24:46 -04:00
Ikki Nishiharaandharanishi 2571423bb3 fix(gpu): contain errors in unwatched compositor work (#4894)
* fix(gpu): contain errors in unwatched compositor work

* test(gpu): gate recovery fixtures on format capabilities

---------

Co-authored-by: haranishi <[email protected]>
2026-10-13 13:24:37 -04:00
Gert Pellin - GPE 5064d80b1e TIFF: write and read EXIF as the file's own Exif and GPS directories (#4876)
A flat TIFF dropped EXIF on export ("EXIF not supported") and ignored it
on import. TIFF is EXIF's own container: IFD0 points to the Exif (34665)
and GPS (34853) directories and holds the descriptive tags.

- Export: the EXIF block's Exif and GPS directories are written as
  directories outside the page chain, IFD0 points to them, and IFD0's
  descriptive tags (ImageDescription, Make, Model, Software, DateTime,
  Artist, Copyright) are added unless the text metadata already wrote
  them. Values are copied as bytes, reordered to the file's byte order,
  so unknown tags survive.
- Import: a classic TIFF with an Exif or GPS directory gets an EXIF
  block built from them and IFD0's descriptive tags.
- TIFF's format caps now include EXIF. Layered TIFFs keep EXIF as
  resource 1058 as before (read back unchanged); the layered importer
  already preferred the TIFF's own EXIF when there is one.

exiftool and Photoshop 2025 read the Exif and GPS tags of such a TIFF.

Fixes #4875
2026-10-13 13:24:27 -04:00
David MannandClaude Opus 5.5 b41e34c20f Rulers: drag the zero point from the ruler corner, double-click resets it (#2955) (#3505)
Drag out of the rulers' upper-left corner to move the zero point: cross hairs mark where it
lands, Shift snaps it to the ruler ticks and View > Snap To targets apply otherwise; dropping it
back on the corner cancels. Double-clicking the corner puts it back at the image's top-left.
The rulers then read 0 at the zero point (negative left of and above it) and the Info panel's
X/Y measure from it.

The zero point is a document field (`ruler_origin`, document pixels), saved in .pcraft with
`#[serde(default)]` so older files open at the top-left. It is set by two undoable engine
commands, `view.setRulerOrigin {x, y}` and `view.resetRulerOrigin`, so agents can drive it.
It is not written to PSD: the public PSD spec documents no resource for it.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 13:24:17 -04:00
Gotya-OFFCIALandGotya-OFFCIAL 34ce5766d2 raw: decode Panasonic RW2 RawFormat 3 and 4 (vendor-compressed) (#3286)
The compressed RW2 layouts of earlier Lumix bodies (e.g. the DMC-GX7)
decode at full sensor resolution instead of falling back to the camera's
embedded JPEG preview. The layout was established by observation of
sample files (fixed 16 bytes per 14 pixels, predictor groups with
per-parity predictors, page-permuted downward bit order); the black
level of these files reads 15 counts low.

Adds round-trip tests through a mirrored synthetic encoder, cursor and
predictor-group unit vectors, and updates the support matrix.

Co-authored-by: Gotya-OFFCIAL <[email protected]>
2026-10-13 13:24:09 -04:00
optimalbyteeandoptimalbytee 50c4f72af7 Speed up large-radius Unsharp Mask with guarded FFT convolution (#2987)
* Speed up large-radius Unsharp Mask with guarded FFT convolution

* Address Unsharp Mask review feedback

---------

Co-authored-by: optimalbytee <[email protected]>
2026-10-13 13:23:55 -04:00
Elvis33LE fc0a579a4a PSD: parallel RLE for the merged image; layer channels decode and gather on all cores (#2754)
Saving a 36 MP document spent most of its time packing the merged image's
RLE rows one after another on one thread (packbits over ~144 MB), and
importing it decoded each layer's channels sequentially. The merged-image
row packer now runs in parallel chunks that concatenate in order (rows are
independent; errors surface like the sequential path's), the layer gather
runs in row bands on all cores before the per-channel RLE, the layer's
channels decode side by side, and the full-surface clone per layer is
gone.

Measured on a 6000×4000 8-bit document with a photo, a paint layer and a
Levels adjustment: save 2.86 s -> 2.55-2.64 s, open's import stage
1.12 s -> 1.07 s (psd_to_document 745 -> 564 ms). The remaining save time
is dominated by the merged composite render itself (~0.7 s).
2026-10-13 13:23:44 -04:00
ragtuxandClaude Fable 5.1 995bc89c40 Auto-Blend Stack Images: a depth map by depth from focus, for Lens Blur (#2073)
* Auto-Blend Stack Images: a depth map by depth from focus, for Lens Blur

`edit.autoBlendLayers` with `method: stack` and `depthMap: true` adds a hidden gray Depth layer, the layer each pixel is sharpest in (black = the bottom layer, white = the top), from `photocraft_algo::dff`: the ring difference filter as focus measure, guided-filter aggregation with the merged picture as guide, a streaming peak search with Gaussian interpolation and a confidence, edge-aware weighted least squares solved by multigrid-preconditioned conjugate gradients, and guided upsampling. Once on a layer mask it drives Lens Blur's depth map.

Closes #2067.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* Depth from focus: a guide or plane of the wrong length is the empty map

Review follow-up on #2073: `depth_from_focus` is public and indexed
`guide[i]` without checking that `guide` and every `luma` plane are
`w × h` long. It now returns the empty map (depth 0, confidence 0) for a
mismatch, before anything is read, and the test covers a short guide, a
short plane and a size that matches neither.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-13 13:23:35 -04:00
DomiLuebbenandClaude Sonnet 5.5 d35725200d i18n(de): translate the labels that were still English (344 entries) and fix three display bugs (#4008)
* i18n(de): translate the labels that were still English

A walk through the German UI with a lookup recorder (every menu item, every
tool's options bar, every dialog it opens) found 344 texts that fell through
to English. They were invisible to the existing i18n test, which only scans
tl!("literal") calls: the labels of the schema-driven filter dialogs are built
from the engine's parameter names ("centerX" -> "Center X"), and status and
system-info templates are looked up at run time.

- de.tsv: 344 new entries. Filter, Render (Flame, Picture Frame, Tree, Lighting
  Effects), Blur Gallery, Lens Correction, Color Lookup, Shadows/Highlights,
  Apply Image / Calculations, Layer Style blending options, Find and Replace,
  Document Info, the keyboard shortcut editor's commands, system info and the
  tool names that were missing (Color Sampler, Vertical Type, Patch).
  Written from the plain meaning of each label, in the vocabulary the catalog
  already uses (see its header: no proprietary localisation resource consulted).
- "Light" was translated as "Light": the appearance mode in Preferences and the
  toolbar tooltip read "Light" next to "Dunkel". It is "Hell" now (Camera Raw's
  Light section and the font weight have their own contexts).
- Export As: the dialog title was built with format!() and could not be
  translated; it goes through the catalog now.
- Names such as "iPhone 14 Pro Max" (the artboard dialog's preset choices) were
  split like camelCase keys and read "I Phone 14  Pro  Max"; a name with a
  space is shown as it is. Test: display_names_are_not_split_like_camel_case_keys.

Left English on purpose: product and file-format names, units, document and
layer names the engine creates ("Untitled", "Background": other code compares
the latter), brush and swatch preset names (data), and words that are the same
in German (Filter, Info, Zoom, Median...).

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

* i18n(de): drop the process note and give the new sections English headings

The first heading described how the entries were found (and when), which is not
catalog content, and the other headings were German while the rest of the file
uses short English section comments. Only comment lines change.

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

* i18n(de): US spelling in a section heading

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

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-13 13:21:38 -04:00
Timothy DalyandClaude Opus 5.5 be7a1f80fc Blur Gallery: Field Blur pins on the image (#3373) (#3801)
* Blur Gallery: Field Blur pins on the image (#3373)

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

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

Co-Authored-By: Claude Opus 5.5 <[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

* 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(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]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 13:10:11 -04:00
kayrockscreenprintingandClaude Opus 5.5 50631b54f7 Profile prompts on open: one file at a time, before it opens, and the answer goes to that file (#3471)
* Profile prompts on open: one file at a time, before it opens, like Photoshop

With Ask When Opening on, files opened together put up every Embedded
Profile Mismatch prompt at once, each answer went to the active document
rather than its own file, and no prompt named its file. Checked in
Photoshop 27.11: one prompt at a time, naming the file, before that file
opens; OK opens it and asks about the next; Cancel opens neither it nor the
files still to come.

A file that asks is now held back until answered: the engine's background
open parks the decoded document (ColorState::open_question,
Session::take_held_open) and the shell queues it (open_prompt). The answer
is applied to that document (ColorState::resolve_open) before it opens.
Files that finish reading meanwhile wait their turn. A developed raw has no
embedded profile, so it doesn't ask.

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

* Profile prompts on open: the prompt's text is localized in every catalog

The two messages are templates now, one line each so the catalog check covers
them: the sentence naming the file and the mode, "Embedded: {embedded}" and
"Working: {working}". The mode reads as the catalogs name it (French "RVB"),
and the file's name goes in last, so braces in it are never taken for a
placeholder. Rows added to all 17 catalogs; main's two "colour profile"
templates (Czech and Italian only) are removed, nothing uses them.

The Camera Raw control test from main expected a mismatch prompt when a raw
opens. With this change a developed raw is not asked about (it has no embedded
profile), so the test now checks that no prompt stands before Camera Raw.

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

* Profile prompts: rows for the hu, sr and tr catalogs; tests hold for any read order

The prompt's four templates join the Hungarian, Serbian and Turkish catalogs
that landed on main.

Files read in the background ask in the order their reads finish, which is the
order given unless a later read overtakes an earlier one. Three tests assumed
the order given and one failed once under a loaded test run; they now follow
whichever file the prompt names (and still require the order given for files
read on the spot).

New test: a file held for its prompt still reports type in a font that isn't
installed, once.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 13:10:04 -04:00
jonathanwxh-cellandjonathnwxh 01b354a340 Add filename templates to Batch export (#4466)
* Add filename templates to Batch export

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

* Avoid catalog append conflicts for Batch naming labels

* Keep Batch naming labels clear of catalog append conflicts

* Keep Batch naming details in the automation parity row

* Separate Batch documentation from evolving Actions details

---------

Co-authored-by: jonathnwxh <[email protected]>
2026-10-13 13:03:02 -04:00
jonathanwxh-cellandjonathnwxh 5f8733c74d Speed up retouch blur convolution with exact-output coverage (#4511)
Co-authored-by: jonathnwxh <[email protected]>
2026-10-13 12:56:28 -04:00
25a82978b2 fix(segment): let Remove Background's cancel stop Select Subject's min cuts (#4878)
Select Subject ran every graph cut (the GrabCut iterations and the
full-resolution band re-cut in `refine_band`, tile by tile on rayon)
through the non-cancellable `grid_cut`, so the Remove Background job only
saw Cancel/Esc before and after the whole "Finding subject" step.

Add `select_subject_with`, `grabcut_with` and `finish_region_with`, which
take an `Interrupt` and return `Err(Cancelled)`: each band tile checks it
before reading pixels and its cut runs through `grid_cut_cancellable`, so
in-flight tiles stop at the max-flow's next poll and unstarted ones never
start. Remove Background runs the step under `JobCtx::stage`. The old
functions keep their signatures and results (they pass `Interrupt::NONE`),
so `select.subject`, Object Selection and the layer menu are unchanged.

Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:56:21 -04:00
Sir Starch 3260f6abb9 fix(psd): prioritize 16-bit and 32-bit global layer blocks over compatibility section (#4928) (#4950)
* fix(psd): prioritize 16-bit and 32-bit global layer blocks over compatibility section (#4928)

In 16-bit and 32-bit PSD files saved by Photoshop, the Layer Info section
either contains 0 layers or 8-bit compatibility layers, while the actual
16-bit/32-bit layers are stored in the global Lr16/Lr32 tagged blocks.

Previously, candidate_seen was initialized to layer_info.is_some(), which
caused PsdFile::from_bytes to skip the Lr16/Lr32 block whenever the section
was non-empty. This left the document with empty layer lists or 8-bit records
that failed channel decoding at depth 16/32, causing all layers to be empty
and invisible.

Prioritize the depth-specific tagged block (Lr16 for 16-bit, Lr32 for 32-bit)
over the compatibility section.

* test(psd): improve 16-bit multi-layer compatibility regression tests
2026-10-13 12:56:15 -04:00
MrCloudy2andClaude Opus 5.5 57624bd02e PSD: Backgrounds survive a round trip through PhotoCraft (#3560)
* PSD: Backgrounds survive a round trip through PhotoCraft

Photoshop opens a PSD's bottom layer as its Background when that layer's
record has no transparency (-1) channel. PhotoCraft always wrote one, so
every file with a Background came back from PhotoCraft with an ordinary
"Layer 0"-style bottom layer instead. Measured by opening PhotoCraft's
re-exports of the 427 corpus files in Photoshop 27.11: 317 lost their
Background this way.

- Export: the bottom top-level layer is written without the -1 channel
  when it is a locked (transparency and position), unclipped, unmasked
  raster that is opaque over the whole canvas at full opacity and Normal,
  as a Background always is; flags as Photoshop writes them (bit 4 marked
  meaningful, and "pixel data irrelevant" on adjustment layers).
- Import: like Photoshop, a bottom raster layer whose record has no -1
  channel becomes the Background (locked, named "Background") whatever
  the file names it: psd-tools' "Фон" and ag-psd's "bg" or "Layer 1" open
  as Backgrounds in Photoshop.

Of the 227 corpus files whose bottom layer is a Background, PhotoCraft's
export now writes all 227 the way the original does (it was 0), and no
other bottom layer turns into one. Corpus oracles and round trips are
unchanged (io 148/170, psd-tools 237/309, Photoshop 137/258 on this
commit; every round trip renders the same).

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

* PSD: a flat Bitmap / Indexed file's image opens as the locked Background

Probe 7 (PhotoCraft's re-exports opened in Photoshop 27.11): with the
Background fix, 314 of 318 Backgrounds survived; the other four were
psd-tools' flat 1-bit Bitmap and Indexed files. Photoshop opens a flat
file's image as its Background, but PhotoCraft's import of these modes
left the "Background" layer unlocked, so the export wrote it as an
ordinary layer. An opaque flat image now opens locked (as flat PNG/JPEG
and layered-but-flattened PSDs already did) and saves as a Background.

Corpus unchanged (io 148/170, psd-tools 237/309, Photoshop 137/258;
every round trip renders the same).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:56:08 -04:00
Ian Swope 74cb293570 Large document: full CPU canvas redraws run in the background, visible part first (#4024) (#5001)
When the wgpu compositor can't draw a refresh (layers over its memory
budget, or a feature it doesn't cover), the CPU compositor redrew the
whole document inside the draw call: minutes of Not Responding on a
multi-gigabyte PSB. With background jobs, a refresh of 4 MP or more is
now composited by a worker in 1024 px blocks, the visible ones first;
each frame uploads the finished blocks and the canvas keeps its previous
contents until they land. An edit while it runs draws its own damage and
restarts the worker on the edited document with the blocks still to
draw. The status bar shows Redrawing N%. Web and tests keep the
synchronous path.
2026-10-13 12:49:58 -04:00
9a4d925b34 Brush: remember the last selected brush across restarts (split from #2516) (#3472)
* Brush: remember the last selected brush across restarts (split from #2516)

The persistence half of #2516 on its own, so it can merge and be maintained
apart from the brush-outline half. The tool's brush (settings, tip
references, and the name of the preset it was picked from) is saved once the
pointer is up and restored at launch: options bar, painting cursor and
Brushes panel highlight all start where the session left off.

Review fix from #2516 -- no per-frame work:
- `ToolState::brush_rev` is bumped by every brush/preset setter
  (`tools.setBrush`, preset save/delete/rename/setCurrent/deleteGroup,
  `brush.defineFromSelection`, `brush.presets.importAbr`, the store's tip
  re-linking, and the shell's tool-switch/reset paths); `remember_brush`
  serialises only when it moved since `brush_saved_rev`, so an unchanged
  brush costs a counter compare per frame, not a clone and a JSON dump of
  the whole brush (sampled tip previews included).
- Restoring counts as saved: the launch value is never rewritten.

Bitmaps stay out of the preferences document: remembered tips and patterns
are references (a loaded stored tile drops its full bitmap), and a sampled
bitmap too big to embed is skipped, keeping the previous value so a preview
never enters the prefs file. A malformed remembered brush falls back to the
default.

docs/scorecard.md regenerated with `cargo xtask scorecard` (34 of 154).

Tests: engine 1267 lib (5 new: revision gating, rejected changes, picked
preset, restore round trip, no embedded bitmaps), ui-egui 1982 lib; clippy,
layers, wasm green.

* rustfmt the StoredTile line and regenerate the scorecard after the main merge

* engine tests: destructive Levels keep the signed float overshoot at 32 bits (#2592)

* Scorecard: regenerate after merging upstream/main

* Export As: compare batch paths without the Windows separator

* font fix ?

* fix(ui): only Filter menu dialogs get the in-dialog preview window

#3877 drew the preview window in every schema dialog with a Preview
checkbox, including Image > Rotation > Arbitrary. Photoshop's Rotate
Canvas and adjustment dialogs have no preview window, and the taller
dialog covered the canvas, which broke
arbitrary_rotation_preview::size_changing_rotation_preview_is_visible_and_preserves_the_panned_center
on main. The window and its reserved height now apply to filter.*
commands only.

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

* cargo    fmt --check max_width = 160 fix

---------

Co-authored-by: JJ <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:48:41 -04:00
jonathanwxh-cellandjonathnwxh d0fc974aae Wrap Clone Stamp strokes and sampling in Pattern Preview (#4997)
* Wrap Clone Stamp sampling and strokes in Pattern Preview

* Preserve per-dab flow and source alpha across pattern seams

---------

Co-authored-by: jonathnwxh <[email protected]>
2026-10-13 12:48:33 -04:00
David MannandClaude Opus 5.5 a666ab5a85 History: jump to a state in one step (history.selectState) (#2966) (#3533)
Clicking a History panel state ran Edit > Undo / Redo once per state. Each
step bumped the document revision, so the canvas could not tell what the
jump changed and recomposited the whole document (re-uploading every
changed layer on the GPU path).

history.selectState {state} returns the document to a state (numbered as
the panel lists them, redo states included) in one command: the history
moves by pointer swaps, the document becomes current once, with one
revision and the damage rect between start and target, and the targeted
layers restored as Undo restores them. The panel runs it once per click;
an open Free Transform refuses it (panel guard kept, and the transform
intercept greys and refuses it for every caller). A floating piece goes
back as with Undo instead of being dropped as a new step.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:48:26 -04:00
Abdul karim mia ca5755f5b0 Layer: Duplicate Layer to a new document, as in Photoshop (#4805)
The Duplicate Layer dialog gains Photoshop's Document: New destination
with a document-Name field (As stays the copy's name, multi-select keeps
names, single group gets the Duplicate Group title). layer.duplicate
takes document index-or-new plus optional documentName, mirroring
channel.duplicate: the new document matches the source canvas, mode,
depth, resolution and profile, holds just the selected layers at their
positions, and becomes active. Smart-object contents are remapped within
the batch so copies are independent of the source. The dialog is fixed to
its content width so no dead strip remains.
2026-10-13 12:48:18 -04:00
ce6fccc3f5 fix(type): missing-font checks follow layout's PostScript resolution; split hyphenless style suffixes (#608)
* fix(text): split hyphenless PostScript style suffixes

A PostScript name without a hyphen kept its style in the guessed family:
`GmarketSansBold` became the family "Gmarket Sans Bold" at weight 400.
The guess now drops a trailing capitalised style word (Gmarket Sans, 700).
A lowercase ending ("Daylight") is not a style, and a style word after the
vendor tag MT stays in the legacy family ("Arial Rounded MT Bold").
Hyphenated names keep the last-hyphen parsing from #3878.

PSD import stores this guess when the exact face is absent. A legacy family
that ends in a style word without MT before it ("CooperBlack") is stored as
the shorter family (Cooper, 900) until the face is installed.

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

* fix(type): missing-font checks follow layout's PostScript resolution

When a run's stored family is not installed, layout draws it with its exact
PostScript face, or with the family guessed from that name if installed.
Missing-font detection compared only the stored family label, so such a run
was reported missing, and Replace Missing Fonts rewrote it whenever an
unresolved run shared the label. missing_fonts, missing_fonts_in_layer and
the replacement now use layout's decision.

#1406 already corrects the label when a PSD or Affinity file is opened with
the face installed, and #1844 finds exact faces outside the family prefix.
The label still stays on a run reopened from .pcraft after its font was
installed, on fonts that arrive after opening, and on a run created with
both "font" and "postscriptName". The tests take the .pcraft and command
paths.

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

---------

Co-authored-by: kijoong <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:48:09 -04:00
DeWarePebandClaude Opus 5.5 d740ea638d Puppet Warp: a linked layer mask moves with the pixels (#4910) (#4989)
Edit > Puppet Warp warped only a layer's pixels and left a linked mask
where it was, so the mask no longer lined up with the content. Free
Transform, Warp and Perspective Warp (#3083) already move a linked mask.

`puppet_warp_with_mask` solves the mesh once and moves the mask on the
same mesh: the mask under the mesh is lifted, deformed like the pixels
and laid over the rest; the area it leaves takes the mask's default and
the mask away from the mesh keeps its values. With a selection the mesh
covers only the lifted pixels, so only that part of the mask moves. An
unlinked mask stays put.

Tests: the moved stripe at 8/16/32-bit, undo, an unlinked mask, a
selection, and a hide-all mask in the algo crate. The engine tests fail
before the fix.


Claude-Session: https://claude.ai/code/session_01NPyyabA5efedMqVWMu3fiq

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:48:02 -04:00
Nicolas FalesyandClaude Opus 5.5 4b76f10072 perf: encode and quantize canvas pixels in parallel (display_color, Buffer::to_rgba8) (#4768)
* perf(display): encode linear documents for the screen in parallel

CanvasDisplay::texture_buffer (every CPU-path canvas upload of a linear
document, which every 32-bit document is since #3792) and to_rgba8's sRGB
encode ran one pixel at a time on one thread. Both now split the pixels into
16K-pixel pieces across rayon off wasm; results are bit-identical.

24 MP linear document, median of 5 (22-thread laptop, dev profile):
- texture_buffer: 348.5 ms -> 29.6 ms
- to_rgba8:       107.8 ms -> 10.6 ms

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J

* perf(compose): quantize Buffer::to_rgba8 in parallel

The plain 8-bit quantize behind the CPU canvas, previews, thumbnails and
8-bit exports ran on one thread. Split it into 16K-pixel pieces across
rayon off wasm; results are bit-identical.

24 MP buffer, median of 5 (22-thread laptop, dev profile): 122.3 ms -> 11.9 ms

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J

* perf(canvas): build canvas images in parallel, fused with the quantize

display_image (CPU canvas updates, filter previews, the navigator, the crop
pasteboard) quantized to RGBA8 and then ran egui's single-threaded
ColorImage::from_rgba_unmultiplied over every pixel. The identity path now
quantizes straight to Color32 in one parallel pass; the colour-managed path
converts in parallel. Results are identical to egui's conversion.

24 MP buffer, median of 5 (22-thread laptop, dev profile): 50.9 ms -> 12.7 ms

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:47:55 -04:00
Gert Pellin - GPE b007b1d355 Replace Color: Localized Color Clusters (points, localized, range) (#4477)
* Replace Color: Localized Color Clusters (points, localized, range)

image.adjustments.replaceColor takes the Localized Color Clusters of
Select › Color Range: "points" (where the colour was picked),
"localized" and "range" (% of the canvas's longer side). With clusters
on, the colour only matches near the points, through the same
color_range_samples as Color Range, so the same colour elsewhere in the
photo (a near-white background next to a cream garment) is left alone.
Without "localized" nothing changes. localized without points, and
malformed points, are refused.

The dialog part follows once the canvas eyedropper of #4247 is in.

Refs #4457

* Replace Color dialog: Localized Color Clusters and Range

The dialog gets Photoshop's Localized Color Clusters checkbox and its
Range slider (greyed while the box is off), and the canvas eyedropper
(#4247) now also records where the colour was picked (points), so the
localized match centres on that spot. The command's params list
localized, range and points as real keys in Photoshop's order.

Refs #4457
2026-10-13 12:47:47 -04:00
FinnNkandClaude Opus 5.5 b3df06b8c4 perf(engine): lasso and polygon selections rasterise only the rows they span (#4532) (#4773)
polygon_selection rasterised every lasso over the whole canvas (a
w×h f32 mask) and combined it over the whole canvas, whatever the
polygon's size.

Without feather it now rasterises the full-width band of rows the
polygon spans and combines over that band; Add and Subtract keep the
old selection outside it, Replace and Intersect leave it empty there,
exactly as the whole-canvas combine does. Full-width rows keep
`polygon`'s coverage bit-identical (its x arithmetic is relative to the
area's left edge, its y samples whole rows plus the same fractions);
a feathered lasso keeps the whole-canvas path, whose blur reaches past
the polygon. A test compares 24 random polygons, partly off the canvas,
in every mode, with and without anti-aliasing, against the old path.

Measured, a 2000-point lasso of a 400 px circle on 5472×3648: Replace
15–16 ms → 5 ms; Add to a selection unchanged (about 30 ms).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:47:40 -04:00
David MannandClaude Opus 5.5 55ae9bc11b Docs: document the engine.commands reply shape (#3684) (#3750)
* Docs: document the engine.commands reply shape (#3684)

engine.commands, the engine command command.list and the MCP tool
command_list reply with a bare JSON array of command objects, which the
docs never said. Document the shape (keys, types, an example element,
how filter/enabled_only narrow it) in control-protocol.md and mcp.md,
state it in the MCP tool description, and pin it with an rpc test so
the docs cannot drift. The shape itself is unchanged, so existing
agents and scripts keep working.

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

* Command list docs/test: include main's disabledReason and needsDocument keys

Main added `disabledReason` and `needsDocument` to every command list row
(#3664), so the documented shape and the pinning test now list all eight keys.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:47:32 -04:00
Sean Hellwig 1460187522 Layers: Cmd/Ctrl-click New Layer adds the layer below the current one (#4475)
Like Photoshop, Cmd/Ctrl-clicking the Layers panel's New Layer button adds the
layer below the selected layer instead of above it. The Background stays at the
bottom: below it, the layer lands above it.
2026-10-13 12:47:24 -04:00
.CooCoo 0a5cc0d1a0 Swatches: importing an .ase file numbers repeated group names in linear time (#5087)
swatches.import named each imported group with unique_group, which
compares a candidate name with the library's groups one by one and
tries "Name", "Name 1", "Name 2" and so on until one is free. Groups
without a name all take the import's group name, so a file of n
unnamed groups cost about n^3/6 string comparisons. In a test build,
importing a file of 9,994 unnamed groups took about 4.5 minutes.

The import now builds the set of the library's group names once and
keeps the next number to try for each repeated name, as it already
does for swatch names. Group numbers still start at 1, and groups get
the same names as before.

unnamed_groups_are_numbered_in_linear_time imports such a file on a
thread of its own and fails when the import takes more than 20
seconds. repeated_group_names_take_the_first_free_number checks the
names when a group in the file has a name that the import already gave
to an earlier group.
2026-10-13 12:47:17 -04:00
F4LCON ef8dc9ea30 Layer locks: locking the pixels clears the transparency lock, as in Photoshop (#4892) (#4898) 2026-10-13 12:47:10 -04:00
Gert Pellin - GPE 1bac788706 info: a smart object embedded in a PSD is reported as embedded (#4843)
PSD import keeps every placed layer as SmartSource::Linked { path:
<Idnt uuid> } so duplicates share the file and export stays
byte-stable, and info reported that as a link whose path is the uuid.
When the uuid has an embedded file (a liFD item in the preserved
lnk2/lnk3/lnkD blocks), the document's info now says
{"kind": "embedded", "fileName", "uuid"}; the new
linked::embedded_file_name finds it without copying the file.

Fixes #4842
2026-10-13 12:47:01 -04:00
jonathanwxh-cellandClaude Opus 5.5 ecf54d1baf Count document patterns in history budgets (#5105)
A pattern fill or layer style copies its pattern into the document, but
the tile visitor behind History's byte budget skipped Document::patterns.
The pattern's pixels were left out of the budget, of trimming and of
Edit > Purge > Histories, including after an undo left them only to the
redo states.

Related to #4972.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:46:53 -04:00
fec0573e71 fix(window): reopen at the saved window position instead of re-centring (#4826) (#4850)
eframe applies `NativeOptions::centered` after restoring the saved window
geometry, so the window moved back to the centre of the primary monitor on
every launch. Centre only when ui.ron holds no saved position.


Claude-Session: https://claude.ai/code/session_01KrteAUDjpXgQZJ7659DUro

Co-authored-by: Leel Wasanatha <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:46:46 -04:00
Gert Pellin - GPE 663863e62b Smart Objects > Export Contents: another extension exports the contents in that format (#4847)
Export Contents wrote the smart object's file as it was whatever the
path, so out.png held .pcraft or .psb data. With the contents' own
extension (or none) it still copies the file; with another one it opens
the contents and exports them in that format, as File > Export does
("converted": true, with the export's warnings). jpg/jpeg and tif/tiff
count as the same; an unknown extension is an error.

Fixes #4846
2026-10-13 12:46:40 -04:00
Nigel Ihza Firdausy 0df333f623 fix(gpu): require consent to persist CPU recovery (#3539) 2026-10-13 12:46:33 -04:00
FinnNkandClaude Opus 5.5 52fcca883e perf(paint): sampled tips share their samples, so cloning brush settings is cheap (#4534) (#4798)
A stroke clones its brush settings on every pointer event
(StrokeRenderer::tail_preview clones the dab generator and the context,
each holding a BrushSettings), and a sampled tip's GrayTile carried its
samples in a Vec<u16>. With a large sampled tip, common in imported ABR
brushes, every pointer event copied the tip: measured 1.1 ms per event
for a 1500² tip and 3.8 ms for 2500², at 120+ events a second.

GrayTile's samples are now an Arc<[u16]>: a clone copies a pointer.
Nothing mutates a tile's samples after building it, so only
construction changes (`.into()`), and equality, serde and every read
are unchanged. Measured the same way: under 0.01 ms per event at any
tip size. Textures and patterns stored as GrayTile share alike.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:46:25 -04:00
Tarek Mohamed 2307acef92 feat(select): preserve existing mask metadata and allow explicit target in refineEdge (#4499)
- Preserve existing layer mask metadata (density, feather, linked, enabled) during select.refineEdge when outputting to layerMask.
- Honor explicit mask targeting (target: "mask") in refine_edge when both selection and mask exist.
- Add tests for mask metadata preservation.
2026-10-13 12:46:17 -04:00
Vikram Bhaskaran b52115fc0a Preserve document and history when Fade cannot allocate its buffers. (#4593) 2026-10-13 12:46:10 -04:00
Gert Pellin - GPE f389d4ee64 info: adjustment layers are reported with the params their command takes (#4871)
document.inspect dumped an adjustment's internal model (enum name as
key, snake_case fields, 0..1 values), which no adjustment command takes.
It is now {"kind": "levels", "params": ...}: the kind and parameters
from adjust_params::to_params, which from_params reads back.

Fixes #4865
2026-10-13 12:46:03 -04:00
Persi 207d05a0e2 Channels: keep the cached composite across view-only changes (#4915)
* Liquify: simplify the brush-resize gesture condition

Same logic; clears clippy's nonminimal_bool on 1.96.

* Channels: keep the cached composite across view-only changes

Channel eye toggles bump the revision the cache was keyed on, so it was always dropped. The test only passed when the freed buffer came back at the same address, and failed under load.
2026-10-13 12:45:56 -04:00
Vikram Bhaskaran 213770008a Return allocation errors when clearing a color channel. (#4589) 2026-10-13 12:45:49 -04:00
Vikram Bhaskaran 759780e40d Clip Fill source reads to the canvas before allocating pixels. (#4590) 2026-10-13 12:45:42 -04:00
Gert Pellin - GPE 0bdaf905cc Curves: points outside 0..255 are refused, as the message says (#4868)
A point outside 0..255 was moved onto the edge without a word, while the
error for bad points says they must be in 0..255; Photoshop refuses
them too. The error also named the command twice-quoted (`adjustment
`curves``); it is now `curves adjustment`.

Fixes #4867
2026-10-13 12:45:35 -04:00
Antonandkuvshin888 911881880d Web: show the brush preset storage banner only while a change is unsaved (#4918)
When the browser denies IndexedDB from the start (a sandboxed iframe without
allow-same-origin, blocked site data), the banner "Brush preset changes are not
saved: SecurityError … Browser storage is unavailable" stays above the app for
the whole session, before any preset was touched and with nothing to dismiss.

Show it only while a preset change is unsaved. Nothing is lost before the first
change; that change brings the same banner with the reason. Write errors and
rejected records keep showing it, as both leave changes unsaved.

Co-authored-by: kuvshin888 <[email protected]>
2026-10-13 12:45:26 -04:00
David MannandClaude Opus 5.5 523f1f06bc Preferences › Interface: Photoshop's Appearance / Presentation / Options layout (#2532) (#2589)
* Preferences › Interface: Photoshop's Appearance / Presentation / Options layout (#2532)

Replace the ~300 px theme preview cards with Color Theme swatches (a light
and a dark row, since we follow the system appearance) and group the page
like Photoshop: Appearance (mode, themes, canvas colour and border),
Presentation (UI language, font size, scaling) and Options (checkboxes).
At 1000×750 the language now shows without scrolling, in Photoshop's
place rather than moved to the top.

The swatch rows come from LIGHT_THEME_OPTIONS / DARK_THEME_OPTIONS, so
Breeze Light and Breeze Dark show up. Each swatch keeps the favourite star
(#2445) as a badge on its corner, shown on hover and kept for a favourite,
with the same "Add to favorites: <theme>" / "Remove from favorites: <theme>"
labels. Preferences search filters the grouped page: rows that don't match
are hidden and a group left empty is skipped.

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

* Preferences: rustfmt the grouped Interface page

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

* Tests: 32-bit destructive Levels keep the HDR range (main is red since #2592)

#2592 made float Levels keep values outside 0..1 and updated compose's
levels_dont_clip_in_32_bit, but the engine-side copy still expected 0 and 255
at 32 bits, so test (ubuntu/windows) and corpus tests fail on main.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
(cherry picked from commit 1ebaf7612e)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 12:41:08 -04:00
Aurosandluy 2e242bde67 Fix Export As freeze on large layered PSDs (#5104)
Co-authored-by: luy <[email protected]>
2026-10-13 12:41:01 -04:00
bazandClaude Opus 5.5 1e916a4e4a Pen: Ctrl/Cmd edits the path being drawn in place (#4022) (#4526)
Ctrl/Cmd-pressing an anchor, a shown handle or a segment of the Pen (or
Curvature Pen) path in progress now edits it like Direct Selection does,
and drawing carries on from its last anchor once Ctrl/Cmd is released,
as in Photoshop. A Ctrl/Cmd-press anywhere else still ends the path.

The path in progress is a new Direct Selection candidate, PathRef::Pen,
built from ui.pen; its edits apply to ui.pen directly (no command, no
history step) since the path isn't in the document until it's finished.
The Add/Delete Anchor Point tools handle it the same way.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:34:23 -04:00
jonathanwxh-cellandjonathnwxh 907752daf2 Give Image Statistics a source-file dialog and stack mode selector (#4778)
* Give Image Statistics a source-file dialog and stack mode selector

* Keep stack sources in vertical rows and translate the mode label

* Place the new label alongside stack-mode translations

---------

Co-authored-by: jonathnwxh <[email protected]>
2026-10-13 12:34:16 -04:00
Gert Pellin - GPE d3d86da130 Select › Color Range: Skin Tones (#4473)
select.colorRange takes {"select": "skinTones"}: a soft skin-tone model
in the YCbCr chroma plane (an ellipse along the skin run of Chai & Ngan,
1999) with a luminance gate, so near-black and near-white pixels drop
out. Fuzziness widens or narrows it as it does for Sampled Colors; the
selection modes, Invert and Sample All Layers work as for the other
presets. The dialog lists Skin Tones with Fuzziness enabled.

Detect Faces needs a face detector, which there is none of yet:
detectFaces: true is refused with a message instead of being ignored.

Fixes #4455
2026-10-13 12:34:04 -04:00
F4LCON 96c0241b26 Distort: a quad dragged past the horizon draws only its front side, in the preview and the result (#4863) (#4870) 2026-10-13 12:33:53 -04:00
Tarek Mohamed c4f0b8aa8b fix(compose): redraw shapes crossing canvas bounds and include vector bounds in damage (#3946) (#4493) 2026-10-13 12:33:47 -04:00
Marco Arnulfo e4f005d3dc Palette PNGs open in Indexed Color mode with their colour table (part of #4804) (#4817) 2026-10-13 12:33:39 -04:00
Valentin KleinandClaude Opus 5.5 efb67fc826 Text: binary-search the run lookup and probe smcp once per layout (#4430) (#4432)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:33:33 -04:00
Ikki Nishiharaandharanishi 66e07cbf79 fix(gpu): refresh patterns when defaults or formats change (#5013)
Co-authored-by: haranishi <[email protected]>
2026-10-13 12:33:27 -04:00
FinnNkandClaude Opus 5.5 752f611264 perf(compose): the shape stroke split cache keeps to a byte budget and empties on Purge (#4530) (#4774)
shape_split caches a stroked shape's fill and stroke rendered over the
canvas, for shapes with clipped layers: up to 32 entries of two
canvas-sized surfaces each, with no byte cap (several GB at 24 MP) and
nothing in Edit › Purge › All to clear it.

It now evicts least recently used entries until a new one fits both
the 32-entry cap and a 256 MiB pixel budget (an entry larger than the
whole budget is used and not kept), and Purge › All clears it as
"shape stroke cache", reporting the bytes like the other caches.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:33:21 -04:00
MrCloudy2andClaude Opus 5.5 5f2c23e142 Filters: Photoshop's Blur More kernel, Find Edges strength and Polar Coordinates direction (#4652)
Measured against Photoshop 27.11 on a test chart: Blur More uses the
[1 2 1; 2 2 2; 1 2 1]/14 kernel, Find Edges the full Sobel magnitude, and
Polar Coordinates turns counter-clockwise from 12 o'clock with repeated
edges and Photoshop's sampling grid. All four now match within 2 levels.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:33:15 -04:00
FinnNkandClaude Opus 5.5 cdfec72f71 perf(ui): the Navigator draws the GPU canvas's texture instead of a CPU thumbnail (#4518) (#4701)
With the GPU canvas, the Navigator still composited the document on
the CPU on the UI thread after every edit: render_reduced_damage over
the full-resolution damage, or a full thumbnail_buffer composite when
the document has effects. On the #209 layered 20 MP document that was
126–137 ms after a small opacity edit and about 700 ms for a
full-canvas layer, against 3.4 ms for the canvas itself.

While the main canvas shows the plain document on the GPU canvas, the
Navigator now paints that same texture through the canvas's own
shader (GpuCanvas::paint) at its size: no composite, no readback, with
the display transform the canvas uses. Everywhere else (a CPU canvas,
a flipped view, an adjustment proxy or preview) it keeps the CPU
thumbnail. Until the canvas has drawn a new document it waits up to
three frames rather than build a CPU thumbnail it would use once.

The preview is resampled through the display mips instead of the CPU
thumbnail's area sampling: thin lines average to even grey instead of
aliasing, and edges are a little softer.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:29:25 -04:00
Dan HiornsandClaude Opus 5.5 13697cf5f5 Preset panels: the footer stays in view in a short dock group (#4790) (#4824)
The Swatches, Gradients and Patterns browsers capped their list with a
fixed allowance for the footer (30 or 40 px) and kept a minimum list
height (60 or 90 px, and egui's 64 px minimum for a scroll area). In a
short dock group the list took that room anyway and pushed the footer
(size slider, New Group, New, Delete) out of view: Swatches' slider was
cut in half, Patterns' footer was hidden entirely.

The shared browser now measures its footer and gives the list only the
height left after it, so the list scrolls and the footer stays visible,
as in Photoshop.

Fixes #4790

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:29:19 -04:00
JGFandClaude Opus 5.5 033ec991c2 fix(ui): show a magnifier cursor for the Zoom tool on Windows (#3735) (#3783)
winit maps CursorIcon::ZoomIn/ZoomOut to the plain arrow on Windows, so
the Zoom tool showed no cursor feedback there. Draw the magnifier (plus,
or minus while Alt zooms out) as an OS cursor bitmap, like the Hand
tool's #2196 fix; macOS and Linux keep their stock zoom cursors. Camera
Raw's preview zoom uses it too.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:29:13 -04:00
Dan HiornsandClaude Opus 5.5 ef2905ea8e Control: list and answer the unsaved-changes prompt (#4920) (#4924)
When a document with unsaved changes was closed, reverted, or the app was
asked to quit, the Save / Don't Save / Cancel prompt showed in the window
but not in ui.inspect, and a control client had no way to answer it: the
app stayed open until someone clicked.

ui.inspect now lists the prompt among its dialogs with the id "discard":
the waiting command, the document it asks about, how many are left and
the choices. ui.dialog.confirm {"dialog": "discard", "choice":
"save"|"dontSave"|"cancel"} answers it through the same code as the
buttons (one document at a time when quitting), and ui.dialog.cancel
cancels it. The automation policy sees the parked command before a save
or don't-save answer runs it.

Fixes #4920

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:24:35 -04:00
b21956aef7 fix(ui): Match Color's preview reads the Source document (#4851) (#4886)
The filter preview runs the command in a fresh session holding only a proxy
of the edited document, so a document param such as Match Color's Source
(or Displace's map) pointed at nothing there: with another document as the
Source the preview failed and the canvas kept showing the unchanged image,
and with the first open document it matched the image to itself. OK then
applied a different result from what the dialog showed.

The preview now adds each referenced open document (as a proxy at the same
factor) to its session and points the param at it, so it shows what OK
applies.


Claude-Session: https://claude.ai/code/session_01J5ysoK8nmS7grqCY1wGvhy

Co-authored-by: Leel Wasanatha <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:24:28 -04:00
avdyliisni9-pngandZulejhaAvduli cd35577c45 Polygonal Lasso: drop the polygon in progress when another document becomes active (#5015)
Follow-up to #4021, which drops the polygon when another tool is selected. The polygon now also remembers the document it was started on (UiState::polygon_doc) and is dropped when another document becomes active, as the Lasso and the Magnetic Lasso already do; before, its outline showed on the other document. Adds tests that every selection tool and Move/Brush drop the polygon (#4021 tests Quick Selection only), that a document switch drops it, and that a held Space or Cmd temporary tool keeps it.

Co-authored-by: ZulejhaAvduli <[email protected]>
2026-10-13 12:24:22 -04:00
F4LCONandpersi-man 62bf7391ce New Document: a custom background colour, from Custom… or the swatch beside the menu (#4789) (#4801)
* New Document: a custom background colour, from Custom… or the swatch beside the menu (#4789)

* New Document: test the swatch's colour for each Background Contents choice (#4789)

Co-authored-by: persi-man <[email protected]>

---------

Co-authored-by: persi-man <[email protected]>
2026-10-13 12:24:16 -04:00
f4b3766b91 Smart Filters: drag a filter up or down its stack to reorder it (#4897)
In Photoshop the order of smart filters matters (the bottom one is applied
first: Unsharp Mask over a Gaussian Blur sharpens the blurred image, under it
the sharpening gets blurred), and a filter row is dragged up or down the stack
to change it. The engine already had `layer.smartFilter.move` and re-renders in
the new order, but the Layers panel had no way to reach it: a filter row could
only be dragged onto another smart object or onto Delete (#4271).

Dragging a filter row over another row of its own stack now draws an insertion
line above or below that row (the Smart Filters row: on top of the stack), and
the release runs `layer.smartFilter.move`, one undo step. The moved filter ends
up selected with its smart object active, as after a click. Drops next to the
filter's current place, or on itself, do nothing.

Tests: `smart_ui::tests::a_filter_dropped_above_or_below_a_row_lands_next_to_it`
(the index math) and `smart_filter_rows_tests::dragging_a_filter_up_or_down_its_stack_reorders_it`
(whole app: above, below, onto the Smart Filters row, selection, undo).

Co-authored-by: abc2cb <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:18:42 -04:00
Dan HiornsandClaude Opus 5.5 33d9cb6ac4 UI fonts: draw a variable CJK fallback at Regular, not Thin (#4820) (#4830)
On systems with only Noto Sans CJK's variable collection (Fedora's
google-noto-sans-cjk-vf-fonts), the UI's CJK fallback was registered at
the font's default instance, which is Thin (wght 100), so CJK labels and
layer names came out hairline next to Latin text.

A fallback face with a weight axis whose default isn't Regular is now
registered with wght 400 (clamped into the axis) through egui's
FontTweak::coords. Static faces and variable faces that already default to
Regular are unchanged.

Fixes #4820

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:18:36 -04:00
OctaNebulaandClaude Opus 5.5 026de5e2a3 fix(layers): Ctrl+Alt+G releases the clipping mask when pressed again (#4927)
Cmd/Ctrl+Alt+G only ever ran Create Clipping Mask, so pressing it on a
clipped layer did nothing. In Photoshop the Layer menu has one item that
reads Create or Release Clipping Mask with the same shortcut, so the key
toggles the clip.

The key now runs Release Clipping Mask when the layers Create would clip
are all clipped already (the selected layer, or every selected layer above
the lowest), next to the Delete key's redirect in shortcut_dispatch. The
engine commands are unchanged, so agents still get exactly what they call.

The Layer menu follows: its Create Clipping Mask slot reads Release
Clipping Mask (and runs it) with the same shortcut while the key would
release, and the separate shortcut-less Release item that sat at the
bottom of the menu is gone.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:18:28 -04:00
Dan HiornsandClaude Opus 5.5 ad62f350e5 Symmetry: Transform Symmetry gets commit/cancel buttons and its own cursors (#4495) (#4508)
Transforming the painting symmetry could only be applied with Enter or
dropped with Esc, which a pen or touch user may have no key for, and the
canvas kept the brush outline although a press there doesn't paint.

While the transform is open, the options bar now shows "Transform
Symmetry" with commit and cancel buttons, like Free Transform's bar. The
canvas shows the move cursor over the centre, the rotate cursor over the
rotation handle and the arrow elsewhere.

Test: transform_has_commit_and_cancel_buttons_and_its_own_cursors.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:18:21 -04:00
Michael HigginsandClaude Opus 5.5 c72dda1914 Assign Profile: list only profiles of the document's colour mode (#4951) (#5020)
The profile menu offered every built-in and installed profile, and picking
one of another colour space only produced "profile is Rgb; the document is
Cmyk" in the status bar. The menu now lists the profiles the command accepts
for the document's mode, and a remembered choice that doesn't fit falls back
to the working space.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:11:56 -04:00
imSlicedBread a0627311ab Fix window dragging with modal dialogs (#4913) 2026-10-13 12:11:50 -04:00
f43d019a72 Brush Settings: use an amount slider for Hue Jitter (#4874)
Use the shared percentage row so the jitter amount does not look like a hue selection. Cover the rendered themed track, numeric and pointer edits, neighboring settings and engine journal replay.

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

Co-authored-by: augrclk <[email protected]>
Co-authored-by: GPT-6 <[email protected]>
2026-10-13 12:11:44 -04:00
Dan HiornsandClaude Opus 5.5 05d361a367 Shortcuts: ⌥⇧⌘N makes a new layer without the dialog (#4794) (#4807)
⇧⌘N opens the New Layer dialog, as Layer › New › Layer… does in Photoshop.
Photoshop's companion shortcut, the same keys with ⌥ (Ctrl+Alt+Shift+N on
Windows and Linux), makes the layer at once; PhotoCraft didn't handle it,
so there was no way to get 0.7.0's dialog-free behaviour from the keyboard.

New Layer's shortcut with ⌥ added now runs layer.new.layer directly. It
follows a rebound New Layer shortcut, and a command bound to that key
itself keeps it.

Fixes #4794

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:11:37 -04:00
Dan HiornsandClaude Opus 5.5 e4d281eb20 Brushes: preset card size numbers stay clear of the tips (#4454) (#4485)
In the Brush Preset picker's cards the tip thumbnail grew to 44.8 pt in a
42 pt cell while the size number was anchored to the cell's bottom, so
the number of any medium or large brush (Hard Round's 30, Soft Round's
45) was drawn under the tip and couldn't be read.

card_tip_rect now keeps a band for the number at the bottom of the cell
and fits the tip above it, still scaled by the preset's thumb_scale so
big brushes read bigger than small ones. Compact cards (no number) are
unchanged.

Test: card_tips_never_cover_their_size_number checks every footer scale
that shows numbers, in all three card kinds.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:11:31 -04:00
Marco Arnulfo 2ca12e17b5 Direct Selection: arrow keys nudge the selected anchors (fixes #4486) (#4500) 2026-10-13 12:11:24 -04:00
FinnNkandClaude Opus 5.5 33728243fb perf(gpu): closing a document frees what the compositor holds for it (#4529) (#4772)
GpuCanvas::retain called forget_doc only for documents still in its
texture map. A document whose entry had been dropped earlier (texture
creation failed, a fresh render failed after uploading, or the canvas
outgrew its budget after Canvas Size) kept its resident pages, effect
maps and lower-stack composite after it was closed, until another
document pushed the budgets over; with no other GPU document, for good.

Compositor::retain_docs forgets every document the caller doesn't list
as open, whatever the texture map says, and retain calls it each time.
The open documents' ids, their preview ids and the adjustment proxies
are already in that list, so nothing in use is dropped.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:07:27 -04:00
deadmeuandClaude Opus 5.5 533b9a1c62 perf(raster): moving a layer no longer stores tiles of only the default pixel (#4844)
Surface::translated, under every layer move and the Move drag preview, allocated
every target tile that a moved source tile reached. For a move that isn't a whole
number of tiles, the transparent margins of a layer's edge tiles landed on target
tiles of their own, and those were stored though they hold only the default pixel
(256 KB each at 8-bit RGBA). #4414's shared uniform tiles were also stored when
their value was the default. Moving two 300 px blocks by (100, 56) left 10 of 18
stored tiles empty; nudging back and forth kept adding more.

A target that ends up holding only the default pixel is no longer stored, as
prune() would drop it. A copied tile is compared row by row with a row of the
default pixel after the copy (one memcmp per row; a tile with content stops at
its first row), and a shared uniform tile of the default value is skipped. Moves
by whole tiles still re-share the existing tiles unchanged.

Release, 24 cores, cargo xtask perf --quick (perf_scenarios, p50, HEAD vs this,
two runs each): move layer on 16-bit 15000x10000 + refresh 5.3 -> 3.9 ms; move
layer on 32-bit 20 layers + refresh 11.9 -> 5.9 ms; the other Move rows within
noise. translated() alone on 36 MP: full layer unchanged (27.0 -> 27.6 ms RGBA8);
40 sparse blocks 11.8 -> 10.6 ms RGBA8, 44.0 -> 39 ms RGBA16, 308 -> 190 tiles.

The new test covers RGBA8, RGBA16, RGBA32F and GRAY8 over a zero and a non-zero
default pixel, and both paths; it fails on the old code.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:07:20 -04:00
Timothy DalyandClaude Opus 5.5 f1233bb43a io: a tagged CMYK file's merged image reads through its own profile (#4948)
merged_composite showed a CMYK merged image through the built-in coated
profile, while the compositor reads the file's layers through the
embedded one. Every oracle comparison of a tagged CMYK file therefore
measured two different profiles. psd-tools' CMYK files were 88-100% off
even with nothing to adjust: colormodes/4x4_8bit_cmyk.psd, a plain
image, was 100% off.

It now converts inside the document's CMYK space (compose::cmyk_space).
The test reads the merged inks back and checks them against the embedded
and built-in conversions.

psd-tools, bad pixels (pass count unchanged at 213/309):
- invert_cmyk: 98% -> 15%
- levels_cmyk: 95% -> 14%
- curves_cmyk: 98% -> 37%
- threshold_cmyk: 65% -> 7%
- posterize_16bits_cmyk: 88% -> 13%
- 4x4_8bit_cmyk: 100% -> 6%

With #4945's ink compositing, invert_cmyk and
brightnesscontrast_legacy_cmyk pass (215/309).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:07:14 -04:00
Gert Pellin - GPE 7b0dbf182b PNG: a whole-ppi pHYs reads back whole (300 ppi stays 300) (#4857)
pHYs holds whole pixels per metre, so 300 ppi is stored as 11811 and
read back as 299.9994 (72 as 72.009). The ppi read is now the whole
number that rounds to the stored value when there is one, as Photoshop
reads it; other values stay as they are.

Fixes #4855
2026-10-13 12:07:08 -04:00
Nigel Ihza Firdausy 5320cecb6d fix(adjust): preserve midtone luminosity with multiple sliders (#4941) (#4990) 2026-10-13 12:07:03 -04:00
Dan HiornsandClaude Opus 5.5 87aed12d62 Brush import: Dual Brush spacing comes from its tip (#4809) (#4889)
Photoshop CC brush presets store the Dual Brush spacing the panel shows in
the dual tip (dualBrush > Brsh > Spcn), and also write a Spcn of 100 % on
the dualBrush object beside it. The importer read that outer value last,
so every dual brush came in at 100 % spacing (the reporter's 10 %, 18 %
and 24 % presets all did), which changes the stroke a lot.

The tip's spacing now wins; the outer value only stands in for a tip
without one.

Fixes part 1 of #4809

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:06:56 -04:00
F4LCON 1168a9dd73 Pen: a new path joins the selected path instead of replacing the Work Path (#4721) (#4730) 2026-10-13 12:03:43 -04:00
Nigel Ihza Firdausy 625141c2ad fix(ui): display uppercase hex values in the color picker (#4859)
* fix(ui): show uppercase RGB hex values in shared color picker

* test(text): expect uppercase hex in text style color picker

* fix(ui): uppercase displayed hex without changing color storage

* test(text): keep color serialization independent of picker display casing
2026-10-13 12:03:37 -04:00
F4LCON 5adba14cd6 Navigator: one zoom row as in Photoshop, the percentage, Zoom Out, the slider and Zoom In (#4840) (#4854) 2026-10-13 12:03:30 -04:00
Valentin KleinandClaude Opus 5.5 cca24dd0d8 Type options: the wheel over the font menu steps through the families (#3350) (#3524)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:03:23 -04:00
jonathanwxh-cellandjonathnwxh f20605219e Keep applied guide layouts visible after preview closes (#5084)
Co-authored-by: jonathnwxh <[email protected]>
2026-10-13 12:03:16 -04:00
Nigel Ihza Firdausy 35710a94ad Fix: native gesture fallback dedupe (#5080)
* fix(input): avoid double-applying native pinch during legacy fallback

* test(input): cover native trackpad rotation and preference gating
2026-10-13 12:03:11 -04:00
Alon 77d3478df1 fix(gradient): options-bar Opacity updates the live gradient layer (#5012) (#5045) 2026-10-13 12:03:05 -04:00
Marco Arnulfo 997406a584 Layer Style applies an open Free Transform first, so its preview shows (fixes #4821) (#4835) 2026-10-13 12:02:58 -04:00
Marco Arnulfo 78b70df227 Canvas: the CPU canvas shows crisp pixels when zoomed in (fixes #4810) (#4825) 2026-10-13 12:02:51 -04:00
Marco Arnulfo 665cb581ad Smart Filters: Delete removes the selected filter, not its smart object (part of #4751) (#4764) 2026-10-13 12:02:44 -04:00
Ivan · VanTan eae87bacfa i18n(ru): translate dynamic selection, theme and measurement labels (#4838) 2026-10-13 12:02:38 -04:00
Sir Starch 2ec1c8c0aa Themes: default light theme to Pro Light to preserve the Pro layout (#5016) (#5029)
When appearance mode was toggled between dark and light with default
settings, PhotoCraft switched from Pro Medium (Pro layout) to Studio Light
(Studio layout), altering the UI and displaying a Fit button in the status
bar.

Now that the Photoshop Pro light brightness levels exist (#2661), default
the saved light theme to Pro Light so the Pro interface layout and status
bar are preserved when toggling appearance. Also map the "light" alias to
Pro Light and list the Pro options first under Light Theme preferences.

Fixes #5016.
2026-10-13 12:02:32 -04:00
Dan HiornsandClaude Opus 5.5 334d378f0f Toolbar: clicking a tool's button again closes its open flyout (#4932) (#4937)
A right-click (or long press) on a tool button opens the flyout of the
tools grouped with it. Any press outside the flyout closed it, but the
press-outside check leaves out the tool button itself, so clicking that
button again, with either mouse button, left the flyout open.

A click on the button of its open flyout now closes it, as in Photoshop:
a right-click just closes it, a left click closes it and picks the tool
as any click does. The release that ends the long press that opened the
flyout still keeps it open.

Fixes #4932

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:02:26 -04:00
F4LCON 3d54e8cd68 Alerts: Enter is OK, but not the Enter that raised the alert (#4872) (#4880) 2026-10-13 12:02:12 -04:00
F4LCON e50b25f45c Shapes: test that the options bar Fill swatch recolours the shape just drawn (#3997, #3999) (#4510)
In v0.7.0 the options bar's Fill swatch only previewed the foreground
colour, so a drawn shape's fill could not be changed from it while Stroke
could. Main fixed that in #3019 and #3370; this test covers the reported
steps (draw a rectangle, pick a colour from the Fill swatch): the fill
changes, the stroke does not, and it is one undo step.
2026-10-13 12:02:07 -04:00
Marco Arnulfo 78dea7c6e4 fix(ui): an open Free Transform box takes Zoom tool drags and clicks (part of #4799) (#4808) 2026-10-13 12:02:01 -04:00
Marco Arnulfo 9a4efabe22 A file dropped on the canvas applies the open Free Transform and is placed at once (fixes #4833) (#4849) 2026-10-13 12:01:54 -04:00
mroxsoandhighperfocused 26e003059b fix(ui): a save name without an extension gets the suggested one (#5039) (#5049)
Co-authored-by: highperfocused <[email protected]>
2026-10-13 12:01:47 -04:00
Michael HigginsandClaude Opus 5.5 6f9227efa3 Brush Settings: no section title above the options pane (#3477) (#5017)
* Brush Settings: no section title above the options pane (#3477)

The options pane repeated the selected section's name as a title line.
The selected row in the section list already names it, as in Photoshop,
so the title is gone and the options start at the top of the pane.

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

* i18n test: the brush section name is drawn once, without a heading (#3477)

dynamic_brush_sections_have_translations_and_draw_in_the_selected_language
still expected the translated section name twice: the list row and the
options pane heading this branch removes. It now expects the row alone.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:01:40 -04:00
Jyrki VesterinenandClaude Sonnet 5.5 1304765067 fix(canvas): Fit View opens small images at 100%, not at the display scale (#5089)
fit_view capped the fit at 1.0 in egui points and then multiplied by
pixels-per-point, so on a 150% display a small image opened at 150%. Since
View::zoom became physical pixels per document pixel (27263bb16, #1978) the
cap has to apply after the conversion: scale by ppp first, then min(1.0).

The test for fit_view asserted the old value (zoom 2.0 at ppp 2.0); it now
checks a small image opens at 1.0 for ppp 1.0, 1.5 and 2.0 and that a large
image still shrinks to fit.

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-13 12:01:34 -04:00
Nigel Ihza Firdausy ccb33741f9 fix(psd): reserve imported layer IDs when allocating section dividers (#4885) 2026-10-13 11:56:55 -04:00
Nigel Ihza Firdausy 40c9c3c451 Validate merged PSD channel indices before decoding (#4994)
* fix(psd): validate merged channel index before decoding (#4981)

* test(psd): compare decoded channel bytes as vectors
2026-10-13 11:56:47 -04:00
mroxsoandhighperfocused 41e578b4d3 fix(io): a version 3 Photo Filter colour is XYZ relative to the D50 white (#4987)
Photoshop CS2 stores the phfl version 3 colour as XYZ divided by the D50
white point (X / 0.9642, Y, Z / 0.8249), so white is (32768, 32768, 32768).
We read those values as absolute D50 XYZ, giving every CS2 Photo Filter a
slightly wrong colour: the Yellow filter preset (24191, 25002, 3827) decoded
to #FFE03A instead of #F9E31C, and Warming Filter (85) (15548, 12029, 0) to
#F18600 instead of #EC8A00. Multiply by the white point before converting;
decoded this way, every version 3 colour in the reporter's CS2 files is
exactly a Photoshop preset (#4940).

Fixes #4940

Co-authored-by: highperfocused <[email protected]>
2026-10-13 11:56:40 -04:00
1735c81e67 fix(segment): stop push/relabel looping forever on unreachable nodes (#4869)
Remove Background (and Select Subject's full-resolution refinement) could
hang at "Finding subject" on some photos. One refinement tile's max-flow
never finished, and the job ignores Cancel there.

Push/relabel's admissibility test was `dist[i] == dist[j].saturating_add(1)`.
A global relabel gives nodes reachable from neither terminal the height
u32::MAX, and MAX.saturating_add(1) == MAX, so two such neighbours counted
as admissible for each other. When f32 rounding strands excess on them
(source capacities near 3e8 have an ulp of 32), they pushed it back and
forth forever. Use checked_add so an unreachable node is never a push
target; its excess stays put and the loop ends.

The regression test is a 6-node graph that looped before and now matches
the Boykov-Kolmogorov cut.

Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 11:56:34 -04:00
Marco Arnulfo 5c73f4fffa TIFF: save uncompressed by default, like Photoshop (fixes #4484) (#4491) 2026-10-13 11:56:27 -04:00
Gert Pellin - GPE cb6ccaafc4 Flat export: a Lab document with one layer isn't reported as flattened (#4853)
Lab skips the native single-layer path (the codecs have no Lab layout)
and goes through the sRGB composite, which always warned that the
layers were flattened. With a single plain layer nothing is flattened:
only the sRGB conversion is reported now.

Fixes #4852
2026-10-13 11:56:21 -04:00
Gert Pellin - GPE 88ed837a86 Save for Web: JPEG chroma is full (4:4:4) above quality 50, as in Photoshop (#4823)
Save for Web subsampled the chroma (4:2:0) up to quality 89. Photoshop
2025's Save for Web writes 4:2:0 up to quality 50 and 4:4:4 from 51
(measured on its output; its Save As keeps 4:2:0 at every quality, as
PhotoCraft's Save As already does).

Fixes #4822
2026-10-13 11:56:16 -04:00
Ufuk Efe fe04f6acaa docs: credit Ufuk Efe in people.toml (#3809) (#5056) 2026-10-13 11:46:50 -04:00
Fabio Lissi c7a34b04ec Scorecard: Smart Object corner skew and perspective is done (FILE-215-6, #426) (#5094)
#426 gave smart objects a projective placement (`SmartObject.perspective`,
with `transform` as its affine approximation): Distort and Perspective set
it, the layer re-renders from its source through the homography, Warp
composes with it, and the PSD placement reads and writes
`nonAffineTransform` / `Trnf`. The scorecard row still said "missing" with
the pre-#426 note, as did the ui-parity row and the #579 mention in gaps.md.
2026-10-13 11:46:42 -04:00
shmuel billerandskylar taylor-barrick 2d5a7497fd fix(format): harden directory-bundle cleanup
Protect Unix bundle cleanup with pinned directory handles and remove only recognized content-addressed files. Keep legitimate linked roots and write/search-only directories working, with regression coverage for GC races and permission failures.

Co-authored-by: skylar taylor-barrick <[email protected]>
2026-10-13 09:20:31 -06:00
Nicolas FalesyandClaude Opus 5.5 6f85054288 fix(clipboard): paste JPEG, BMP and other non-PNG images on Wayland (#2546) (#4752)
arboard 3.6 asks the clipboard for image/png only, so a screenshot tool or
clipboard manager that offers just image/jpeg, image/bmp or image/webp pasted
nothing ("the clipboard holds no image"). When arboard finds no image, read
the other image/* types through wl-clipboard-rs (already in Cargo.lock via
arboard's wayland-data-control) and decode them with photocraft-codecs.
Reads are capped at 1 GiB; TGA is trusted only when its type says so.


Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 03:06:34 -04:00
Nigel Ihza Firdausy c0e62673f8 fix(macos): default droplets to a writable user directory (#4746) 2026-10-13 03:06:27 -04:00
5c579ba81a Channels: Channel Options dialog to make alpha channels spot colours (#3761) (#3784)
* feat(channels): Channel Options dialog to make alpha channels spot colours (#3761)

Double-clicking an alpha channel's thumbnail in the Channels panel now opens
Photoshop's Channel Options dialog (double-clicking the name still renames it):
Name, Color Indicates Masked Areas / Selected Areas / Spot Color, the overlay
or ink colour (Color Picker) and Opacity, which becomes Solidity for spot
channels. A saved selection can now be turned into a spot channel with its own
ink for screen-print separations. The row context menu gains Channel Options…
and Quick Mask Options…, and `channel.options` without params from a menu
opens the dialog on the targeted channel. OK runs the existing engine command,
so agents can still call it directly with params.

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

* style: rustfmt module order in ui-egui lib.rs

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

* feat(channels): New Spot Channel dialog (#3761)

Channels › New Spot Channel… now opens Photoshop's dialog instead of adding
a channel straight away: Name (prefilled with the next free "Spot Color N"),
and Ink Characteristics: Color (opens the Color Picker) and Solidity. OK
creates the spot channel from the selection, as before, and remembers the ink
and solidity for next time. `channel.newSpot` with params still runs the
engine directly for agents, the CLI and MCP. The engine's `next_alpha_name`
helper is now public so the dialog's name matches the engine's default.

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

* i18n: keep main's Czech rows, add Vietnamese strings (#3761 review)

- cs.tsv: main has removed its duplicate copies of Tool, Stroke Path…,
  Clone Stamp, History Brush, Dodge, Burn and Sponge, so this branch keeps
  main's single rows; the only Czech change is the Channel Options / New Spot
  Channel block.
- vi.tsv: the Vietnamese catalog is complete since #2636, so it gets rows for
  Channel Color, Color Indicates:, Solidity: and Ink Characteristics.
- it.tsv: main now translates Masked Areas and Selected Areas, so this
  branch's duplicate rows are dropped.

Every catalog now only adds lines relative to main.

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

* Channel Options: Serbian, Hungarian and Turkish rows for the new strings

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:59:12 -04:00
04f0b0e86b Brush: the Mode dropdown shows in every theme, not only Pro (#4537)
* Brush: the Mode dropdown shows in every theme, not only Pro (fixes #4512)

* Options bar scroll test: wheel over Studio's bar clear of the Brush Mode dropdown

The Brush's new Mode dropdown takes the wheel to step through blend modes, so
the test's pointer at the bar's centre landed on it at 800 px in Studio.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 02:52:14 -04:00
36112e35d4 Brush Settings: disable minimum inputs when Control is Off (#4775)
Keep configured minimum values across Control changes and cover disabled input, independent controls and reactivation with UI regressions.

Refs #3825 (minimum input state only).

Co-authored-by: augrclk <[email protected]>
Co-authored-by: GPT-6 <[email protected]>
2026-10-13 02:49:44 -04:00
6cbf4bd24d fix(input): support precision trackpad pinch zoom on Linux (#4786)
* fix(input): translate Linux precision Ctrl-wheel pinch gestures

* fix(input): decode Wayland high-resolution point pinch events

* docs(input): describe precision pinch support on Windows and Linux

* rustfmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 02:49:40 -04:00
Ivan · VanTan a999ce4a77 i18n(ru): fix counted status agreement and font weight labels (#4787) 2026-10-13 02:49:34 -04:00
Nigel Ihza Firdausy 00802b03de feat(new-document): add ISO B3 and B4 print presets (#4784)
* feat(new-document): add ISO B3 and B4 print presets

* test(new-document): compare dimensions without signed integer conversion
2026-10-13 02:49:27 -04:00
null 00000111 67a3db229a i18n(pt-br): fix 12 Brazilian Portuguese strings (#4796)
Typos, European Portuguese spellings, grammar slips, and terms that
disagree with the catalog's own wording elsewhere.
2026-10-13 02:49:23 -04:00
isaiahoadeoye-starandClaude Opus 5.5 d3b2dd1aef feat(print): Printer menu lists the system's printers (file.printers) (#4748)
The Print dialog's Printer field was free text, so you had to type the
spooler's exact queue name (e.g. HP_OfficeJet_3830_series). It's now a
dropdown, like Photoshop's: "Default printer (<name>)" first (value "",
as before), then every printer craft_print::printers() reports (CUPS
queues and driverless destinations, or Win32_Printer on Windows).

- engine: new query command `file.printers` → {printers:[{name, default}],
  default}, no document needed, not journaled (same shape as DesignCraft's).
- ui: the dialog asks once per opening and keeps the list in `__printers`,
  which never reaches file.print's params. Labels show `_` as spaces; the
  exact queue name is what's sent. A remembered printer the spooler no
  longer lists stays selectable.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 02:49:04 -04:00
6ddb0e0b0d Fix print fuzzing and Windows crop GPU test fixtures (#4726)
* fix(tests): bound print fuzzing and serialize Windows crop fixtures

* Crop tests: hold the shared GPU lock in every WARP-backed test; drop the print fuzzing change main already has (#4467)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 02:48:36 -04:00
5b1b6b075e feat(export): offer ICO in Export As with dimension validation (#4744)
* feat(export): offer ICO in Export As with format-size guidance

* fix(export): use theme warning token for oversized icons

* i18n: translate the Export As ICO size warning

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 02:48:25 -04:00
Nigel Ihza FirdausyandBrandon Thomas cb95e17fc1 fix(type): preserve keyboard focus when searching for fonts (#4745)
* fix(type): leave keyboard input to focused font search

* test(type): focused font search cannot alter the edited type layer

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:48:13 -04:00
4fdf1a723b fix(batch): skip recorded Quick Export steps when writing batch output (#4747)
* fix(batch): ignore recorded Quick Export and use batch output

* fix(batch): ignore recorded Quick Export and use batch output

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 02:48:04 -04:00
29f3656fe6 perf(algo): filters read the selection once per tile when mixing it back (#4533) (#4754)
mix_selection looked up the selection one pixel at a time
(`sel.pixel(x, y)`, which allocates a Vec and walks the tile map) for
every output pixel of every tile of a filter applied through a
selection: about 24 million allocations and map lookups at 24 MP.

It now reads the selection over the tile once (`read_region`) and
indexes it. The result is bit-identical: the old per-pixel version is
the test oracle, with fully selected, unselected and partial pixels,
with and without alpha, on a tile crossing the selection's tiles.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:47:53 -04:00
f2a1af86e1 perf(camera-raw): convert both previews in parallel bands (#4682) (#4757)
* perf(camera-raw): convert the refined preview in parallel bands, not per row (#4682)

#3427 colour-managed the refined (zoomed-in) Camera Raw preview by calling
the display transform once per row. A row is below photocraft-cms's 16K-pixel
parallel threshold, so every edit ran the whole conversion on one core of
the UI thread, plus two allocations per row. Read and convert 64K-pixel bands
instead, in parallel off wasm (as channel_view does); output is identical.

2560x1600 visible crop, median of 15 runs (22-thread laptop, dev profile):
- sRGB document:      31.5 ms -> 3.9 ms
- Adobe RGB document: 125.0 ms -> 12.5 ms
- ProPhoto document:  117.5 ms -> 12.7 ms

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J

* perf(camera-raw): the main preview converts in parallel bands too, without a full copy

CameraRawDialog::image copied the whole proxy into a Buffer and colour-converted
it on one thread on every edit. It now goes through the same banded, parallel
display_image as the refined preview, reading bands straight from the slice.

Median of 21 runs (22-thread laptop, dev profile):
- 858x572 sRGB:        4.72 ms -> 1.00 ms
- 858x572 Adobe RGB:   7.39 ms -> 2.06 ms
- 1716x1144 sRGB:     18.58 ms -> 2.48 ms
- 1716x1144 Adobe RGB: 28.78 ms -> 6.88 ms

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:47:42 -04:00
Vikram BhaskaranandBrandon Thomas 753b610105 Keep gradient stops closer than one LUT step on the CPU. (#4712)
The GPU ramp is 4096 entries, so stops at 0.4999 and 0.5 interpolated to gray while the CPU kept the step. Stops that share an entry now use the CPU composite. Fixes #3053.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:47:31 -04:00
Marco ArnulfoandBrandon Thomas 25e29e2583 Filters: ⌘Z steps the open dialog's settings instead of the document (part of #4697) (#4715)
Filter Gallery, Adaptive Wide Angle, Vanishing Point and every parameter dialog (Lens Correction, Image › Adjustments, …) now keep a history of their own settings: ⌘Z / ⇧⌘Z step through it, one drag is one step, and the document's History beneath is left alone. Camera Raw is left to #4136.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:47:18 -04:00
6af9e1d977 feat(ui): bidirectional hover highlighting between Layers panel and canvas (#4718) (#4719)
Co-authored-by: safvanedge <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:47:07 -04:00
c758ebffac perf(raster): mode and depth conversion keep shared tiles shared (#4531) (#4725)
Surface::convert / try_convert and map_color converted tile by
coordinate, and the ICC path (color_cmds::convert_surface) staged and
converted every coordinate's tile. A tile shared across the grid, such
as a filled background (one Arc for all of it), therefore came out as
one fresh tile per coordinate: a white 2048×1024 background went from
1 to 32 tile allocations, a 24 MP one to 384.

Each distinct tile is now converted once and the result shared by
every coordinate that shared the original. Pixels are unchanged; tests
compare against a per-pixel conversion and check the sharing for
convert, try_convert, map_color and Image › Mode › CMYK.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:46:54 -04:00
Marco ArnulfoandBrandon Thomas 7610116123 Layers panel: clicking a row's lock icon unlocks the layer (fixes #4723) (#4734)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:46:43 -04:00
10b936f159 perf(ui): Duotone and live-adjust display documents are built once per snapshot (#4527) (#4736)
canvas::display_doc runs 3–4 times a frame (canvas, Navigator, beyond-
canvas and texture paths). For a Duotone document it cloned the whole
document on every call to add the ink display layer, before any cache
check; while an adjustment slider is dragged it cloned it again on every
call to swap in the previewed settings.

memo_display keeps the built document per document id, keyed by the
snapshot (a Weak) and the preview key, so each is built once per edit
or setting and shared after that. The Channels panel borrows the masked
layer instead of cloning it every frame, and the adjustment Properties
copy the two flags they read instead of the whole layer.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:46:32 -04:00
42b399881a Handle signed coordinate limits in the Offset filter (#4738)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:46:22 -04:00
95a9a6b29c Layers: Add vector mask makes the mask from the selected path and targets it (#4740)
* Layers: Add vector mask makes the mask from the selected path and targets it (fixes #4728)

* Pen menu test: the vector mask made from a path becomes the active path

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 02:45:53 -04:00
F4LCONandBrandon Thomas f4e3dc63b6 Properties: Align and Distribute with the Background in a multiple selection (#4608) (#4637)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:45:42 -04:00
015ad7083a Select › Sky: a white or grey backdrop that reaches the bottom is not sky (#4638)
* Select > Sky: a white or grey backdrop that reaches the bottom is not sky

The colour prior counts bright, nearly neutral colour as overcast sky,
so a seamless studio backdrop starting at the top edge flooded the
whole background (79 % of a product shot). A sky never reaches the
bottom of a photo; a backdrop does. A region that also covers most of
the bottom rows and is nearly neutral (mean saturation under 0.1) is
now taken for a backdrop and nothing is selected; blue sky reaching
the bottom, as in a photo taken upward, still is.

Fixes #4634

* Sky: read the bottom rows of the mask with get()

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 02:45:31 -04:00
2d190e7c57 perf(engine): Edit › Copy shares whole tiles instead of rebuilding them (#4516) (#4621)
lift read the whole copied area into one f32 buffer (16 bytes per RGBA
pixel, about 366 MiB at 24 MP), multiplied in the selection and wrote
everything back into new tiles, so the clip shared nothing with the
layer: 273 ms for a plain Copy of a 24 MP layer and 341 ms after Select
All.

It now goes tile by tile. A source tile the copy takes whole (inside the
canvas, no selection or a fully selected tile, already in the clip's
format) is shared copy-on-write; the other tiles go through the old
per-pixel path one at a time. Measured on the same layer: 17 ms and
42 ms. The clip is bit-identical: the old whole-area version is kept as
the test oracle across depths, with and without alpha, no selection,
Select All, a partial feathered selection and one off the tile grid,
and content past the canvas.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:45:20 -04:00
4062d40070 Color: Dot Gain 20%-compatible default working gray, as in Photoshop (#4610)
* feat(color): Dot Gain 20%-compatible default working gray, as in Photoshop

Photoshop's default working gray is Dot Gain 20%, and its RGB -> Grayscale
measured in Photoshop 27.11 (default Color Settings) is D50 luminance
encoded with gamma 1.732: sRGB 64 / 128 / 192 grey and pure red become
46 / 105 / 177 / 107. PhotoCraft defaulted to sGray, which kept the sRGB
value (64 / 128 / 192 / 130), up to 24 levels off.

Adds the built-in "Dot Gain 20%-compatible (gamma 1.732, Photocraft)"
gray profile (id dot-gain-20-compat; our own fitted curve, not Adobe's
profile, the same gamma PhotoCraft already uses for gray adjustments) and
makes it the default working gray and the fallback for untagged gray
documents. sGray and Gray Gamma 2.2 stay selectable.

This changes a default: untagged gray documents now convert and display
as Photoshop shows them (midtones lighter than under sGray); documents with
an embedded profile are unchanged.

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

* Status bar test: the default working gray is now Dot Gain 20%-compatible

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

* .atn test: #202020 in the Dot Gain 20%-compatible working gray

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:45:09 -04:00
f2c52a92c9 Pen: a close-path pointer over the first anchor (#4596) (#4612)
* Pen: a close-path pointer over the first anchor (#4596)

* Canvas: the close-path pointer arm before the pen tools' own cursors

The pen tools' icon cursors (#4650) matched first and made this arm unreachable.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 02:44:57 -04:00
f57db2b5bb feat(gpu): composite Blend If on the GPU (#4395) (#4541)
A layer with Blending Options › Blend If made the planner refuse the
whole document, so every refresh of it ran the CPU compositor: on the
#209 layered document an opacity edit took 140 ms instead of 6 ms.

Blend If is compose::apply_blend_if: the composite with the layer mixed
back towards the backdrop by a per-pixel weight from the layer's own
content with its masks ("This Layer"; an adjustment's result) and the
backdrop ("Underlying Layer"). The planner now keeps the backdrop,
composites the layer as before (channel restrictions included, as on
the CPU), renders its own content and emits one Lerp pass with the new
F_BLEND_IF flag and the ranges packed exactly in its parameters. Base
and clipped layers both go through it; Advanced Blending still falls
back to the CPU.

Hard points: compositing can leave an 8-bit level a few millionths
below itself (a level-20 backdrop at partial alpha reads 19.999998),
which put it on the wrong side of a hard point at that level on the
CPU, and the GPU's arithmetic differs there. Both now treat a value
within 1e-4 of a level as that level; 8/16-bit values off a level are
at least 1/257 away. On 16/32-bit documents the GPU reads layers as
RGBA16F, so a value within about a tenth of a level of a hard point can
still land on the other side, as with the GPU Threshold adjustment.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:11:23 -04:00
af21e1e21b fix(text): preserve hyphenated PSD font identity on reopen (#3878)
* fix(text): preserve hyphenated PSD font identity on reopen

* fix(io): preserve installed font families during PSD import

---------

Co-authored-by: zenjabba <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:11:19 -04:00
Brandon ThomasandClaude Opus 5.5 ebffaa6e42 i18n: drop duplicate Japanese rows; Serbian for the mask-to-selection items (#4793)
#4651 re-added Japanese rows that #3893 and earlier work already had, so
bundled_catalogs_are_consistent failed on duplicates. #4605's mask thumbnail
menu landed without its three Serbian strings.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 02:04:35 -04:00
mjohnson7cdandBrandon Thomas e0f6ce4302 fix(ui): floating panel groups get an opaque title strip in Studio themes (#4509) (#4543)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:04:10 -04:00
e18e3c066f OpenRaster: never overwrite an .ora with less than the document has (#3806) (#3881)
* OpenRaster: never overwrite an .ora with less than the document has (#3806)

File › Save wrote any document back to its .ora, though OpenRaster
drops adjustment layers, layer styles, vector masks and clipping,
applies masks and fill opacity to the pixels and rasterises type and
shape layers; the save then marked the document saved, so the edits
were lost without asking.

An OpenRaster file now counts as the document's own file only while
it holds everything in it. photocraft_io::ora_losses lists what a save
would leave out, from the same checks the writer warns with but
without encoding anything, and file_cmds::holds says whether a format
keeps the whole document (PSD, PSB and .pcraft always; OpenRaster when
nothing is lost). When it doesn't:

- File › Save opens Save As on a PSD of the same name, with a notice
  listing what OpenRaster would drop, instead of overwriting the .ora;
- a save to an .ora path writes a copy, as Photoshop does for formats
  that can't keep everything: the document keeps its file and stays
  unsaved;
- automation (app.save, the headless doc.save) refuses a pathless save
  with the reasons and treats an .ora save as a copy.

An sRGB profile is no longer reported as lost, as OpenRaster readers
assume sRGB. A plain document still saves back to its .ora.

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

* i18n(vi): translate the OpenRaster save notice

vi.tsv is now a complete catalog, so the two new strings need
Vietnamese rows. Terms follow the catalog's own: Không thể for
can't, như for such as, Lưu bản sao for Save a Copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(hu, tr): translate the OpenRaster save notice

main added complete Hungarian and Turkish catalogs after this branch
was cut, so every_tl_literal_is_translated needs their rows for the
two new strings. Wording follows each catalog: informal Hungarian
with "{name}" after a colon; formal Turkish with Biçim for format.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(sr): translate the OpenRaster save notice

Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated
needs the notice's two strings there too.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 02:04:02 -04:00
mjohnson7cdandBrandon Thomas d3e33d89a9 fix(ui): click and hold on a toolbar button selects its tool (#4453) (#4544)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:51:23 -04:00
becc27ac1c CMYK: Invert, Posterize and Threshold on the inks; 16-bit Lab Invert keeps greys neutral (#3930)
* Lab and CMYK: Invert, Posterize and Threshold work on the document's channels

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:38:52 -04:00
Scott C Hughesandskylar taylor-barrick 9ccd4aa557 fix(engine): preserve copied smart object source records
Carry only the linked-source records referenced by copied layers through Copy/Paste and save/reopen. Preserve raw records, avoid duplicate payloads on same-document paste, and reject ambiguous or incomplete selected identities before replacing the clipboard.

Co-authored-by: skylar taylor-barrick <[email protected]>
2026-10-12 23:38:45 -06:00
skylar taylor-barrick 728579aea5 Integrate current PhotoCraft UI while retaining native driver settings 2026-10-13 07:36:47 +02:00
cda46050f7 Background Eraser: sample and match against the pre-stroke pixels (#4552)
Dabs read the hotspot from pixels earlier dabs had already erased, so
Continuous skipped most dabs and Contiguous erased nothing from an
erased hotspot, leaving patches after one pass.

Fixes #1280

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:28:29 -04:00
skylar taylor-barrick f85eddf3cc Complete native printing translations and Serbian theme labels 2026-10-13 07:20:25 +02:00
Marco ArnulfoandBrandon Thomas 4eb8ca25bd Layers: right-clicking a layer-mask thumbnail opens the mask menu (fixes #4575) (#4605)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:15:13 -04:00
Marco ArnulfoandBrandon Thomas 93397b08c6 Fill keys: Alt/Cmd+Backspace recolour the text of a type layer (fixes #4580) (#4606)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:15:00 -04:00
F4LCONandBrandon Thomas ad5b11a120 Layers panel menus: highlight the item a press-drag is over (#4601) (#4607)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:14:49 -04:00
Marco ArnulfoandBrandon Thomas 9716b2b319 Gradient options bar: the swatch covers its checkerboard at half-pixel offsets (fixes #4577) (#4616)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:14:37 -04:00
cf804c4b27 feat(brush): Photoshop's measured round tip (#4617)
Photoshop 27.11's round tip, measured on single dabs (hardness 0-100 in
steps of 10 at 40 px, checked at 100, 9, 7, 5, 3, 2 and 1 px): a solid
core of radius r0*R, then a Gaussian shoulder exp(-((d - r0) / s)^2) of
width s*R, both from a table by hardness. A soft tip reaches well past its
nominal radius (hardness 0 is a Gaussian of sigma 0.47 R: 8 % at 1.05 R);
PhotoCraft's (1 - t^2)^4 ended at R and kept far less coverage (a 40 px
hardness-0 dab held 251 coverage units against Photoshop's 542). The edge
stays at least ~1 px wide (0.3 R on small tips, never under 1/4 px).
exp is (1 - u/64)^64 by squaring, so it is the same on every platform.

Dab rectangles, reaches and the retouch footprint grow to the tip's reach
(tip_reach); the brush cursor's 50 % ring (half_coverage_radius) follows the
new curve. Tests that hard-coded the old curve now check measured values;
tests that bounded a stroke by the nominal radius use the reach.

Measured against Photoshop (max levels / mean): 40 px dabs, hardness 0-70:
70-156 / 12-15 -> 2-6.5 / <0.7; 100 px dabs 70-177 -> 2-27; strokes
(probe 13) soft and medium 20-60 px 133-178 / up to 17 -> 4-26 / <=0.5;
spacing sweeps 134-221 -> 7-9; soft Flow build-up 85 / 7.5 -> 5 / 0.16;
small 1-7 px total error lower in 21 of 30 cases (medium hardness at
2-5 px a little higher: a follow-up).

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:14:26 -04:00
bf5d01b00d Layers panel: shape layers show a shape badge on their thumbnail (#4584) (#4619)
A shape layer's thumbnail shows its pixels like any other layer, so nothing
in the row said it was a vector shape. It now carries a badge in the
bottom-right corner, in the same frame as the smart-object badge: a path
with an anchor point on each corner.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:14:14 -04:00
Jason BrashearandBrandon Thomas 781871ff93 Codecs: FITS keeps long CONTINUE strings intact on open and save (#4633)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:13:52 -04:00
9c3f96649f fix(transform): Photoshop's Bicubic for Free Transform and Warp, with a reduction prefilter (#4635)
Free Transform and Warp sampled with Catmull-Rom (Keys a = -0.5).
Photoshop's Bicubic is the Keys cubic at a = -0.75 (Image Size already
uses it): a 200 % step edge matches within 0.5 levels (Catmull-Rom was 10
off). When a Bicubic transform reduces, the source is now resized first by
the whole local scale with Image Size's widened Bicubic, and the warp
samples it at about 1:1; the old pre-reduce only kicked in below 50 % and
by powers of two, so 50-99 % reductions aliased. Bilinear still samples
directly (it matched Photoshop exactly at 50 %).

Measured against Photoshop 27.11 Free Transform (max levels / mean):
Bicubic 200 % 10 / 0.36 -> 1 / 0.01; 50 % 48-59 / 1.0-1.2 -> 6-8 /
0.02-0.05; 150 % mean 0.59 -> 0.17. Bilinear unchanged. Bicubic
Smoother / Sharper / Automatic (not offered by Transform yet) and rotated
edges are follow-ups.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:13:40 -04:00
Marco ArnulfoandBrandon Thomas 8da02bf540 Hue/Saturation: colour ranges apply in full to dull colours (part of #4089) (#4645)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:13:26 -04:00
f8cfc1131a Cursors: the pen tools show their icon, and Caps Lock swaps it for the crosshair (#4639) (#4650)
* Cursors: the pen tools show their icon, and Caps Lock swaps it for the crosshair (#4639)

The pen tools fell through to the canvas's default crosshair whatever
Preferences > Cursors > Other Cursors said, and Caps Lock did nothing.
In Photoshop the Standard cursor is the tool's icon, Precise is the
crosshair, and Caps Lock swaps between them.

The Pen, Freeform Pen, Curvature Pen, Add/Delete Anchor Point and
Convert Point tools now draw their toolbar icon with the nib (Convert
Point: its anchor) on the point. precise_other_cursor (the preference,
flipped while Caps Lock is on) decides it, and the Eyedropper, Color
Sampler and Paint Bucket, which already followed the preference, now
swap with Caps Lock too.

Test: caps_lock_swaps_the_other_cursors_between_their_icon_and_the_crosshair.

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

* Canvas: keep pipette_cursor's doc comment on pipette_cursor

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:13:16 -04:00
a63964dd63 Update Japanese translations in ja.tsv (#4651)
* Update Japanese translations in ja.tsv

I have added items that appear to have been omitted from the registration.

* i18n(ja): keep the rows this update dropped

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 01:13:03 -04:00
Marco ArnulfoandBrandon Thomas bb15340af6 Filter dialogs: the preview keeps square pixels (fixes #4640) (#4655)
The in-dialog preview clamped the texture UV to the image but still drew
it over the whole preview region. Whenever the region overhung the image
on one axis (a 100% view of a small document in the wide preview), that
axis was stretched to fill it. The preview now draws only the part of the
source rectangle that lies on the image, at its own place and scale in the
region, with the checkerboard under the image only.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:12:50 -04:00
Marco ArnulfoandBrandon Thomas 481b939fdf PSD save: keep the Lr16/Lr32 padding outside the block length (part of #4654) (#4663)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:12:39 -04:00
Dan HiornsandClaude Opus 5.5 caabe4b836 Screen color picker: every way out ends it (#4291) (#4636)
The picker covers every screen and stays on top, and a report on #4291
describes one that ignored Esc, right-click and left-click, so the
computer could only be freed from another device. In the picker's frame:

- Esc only worked once Windows gave the picker keyboard focus, which
  nothing asked for. The picker now takes focus on its first shown frame.
- A right-click did nothing; it now cancels.
- A left click was dropped when no snapshot pixel mapped to the pointer,
  and a press that moved before its release never counted as a click.
  Any left release over the picker now ends it: it picks the pixel under
  the pointer, or cancels when there's none.

The frame moved into screen_picker::frame so it can be tested.

Test: every_way_out_of_the_picker_ends_it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 01:12:15 -04:00
f769c3735d perf(engine): Fill, Stroke and Clear keep tiles the selection doesn't reach (#4517) (#4677)
blend_into takes every tile in the selection's bounds and blended each
one into a fresh copy. History holds the previous document, so
Arc::try_unwrap always failed and a tile with nothing selected was
deep-copied too, unchanged: a 4 px Edit › Stroke on Select All of a
20 MP layer stored +82.5 MB per step for a ring of edge tiles.

blend_tile skips every pixel whose weight (selection × opacity) is
zero, so such a tile comes out byte-identical: keep the tile itself.
Mask::empty sits beside Mask::full. Measured on the same layer: the
stroke step now stores +17.5 MB and a 4000×3000 ellipse fill +14.2 MB
instead of +26.0 MB. The new test fails on the old code.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:11:53 -04:00
0de20b5523 perf(ui): Channels thumbnails skip selection-only steps and reuse their textures (#4518) (#4678)
channel_thumbs was keyed by the document snapshot, so every step,
including a marquee or Deselect, recomposited the whole document for the
56 px thumbnails (`compose::thumbnail`, about 0.7 s on the #209 layered
20 MP document with effects) and loaded new textures for each.

A new snapshot that differs from the previous one only in its selection
keeps the thumbnails (`same_but_selection`: the old document with the
new selection compared to the new one; tiles compare by pointer first).
A real rebuild updates the existing textures with `set` instead of
allocating new ones. The new test fails on the old code: a marquee
and Deselect no longer upload anything, and a fill rebuilds into the
same textures.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:11:42 -04:00
233b6c63f0 perf(text): applying preferences no longer waits for the system font scan (#4519) (#4679)
Session::apply_prefs set the shared text engine's missing-glyph
protection through photocraft_text::shared(), which blocks until the
system font scan that launch starts in the background is done. It runs
on the UI thread at launch and on every preferences change, so the first
frame waited for the rest of the scan: 248–257 ms on this machine
(release, launch order replayed), more with many fonts installed.

set_shared_missing_glyph_protection applies it at once when the engine
is built, and otherwise from one helper thread once the scan finishes.
Both read the newest setting under the engine's lock, so a late change
is never overwritten by an older one. Measured the same way: 0.1 ms.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:11:31 -04:00
Nigel Ihza FirdausyandBrandon Thomas 5cff9ebed2 fix(clipboard): prompt to save new documents containing pasted pixels (#4683)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:11:20 -04:00
bb4e55ebb4 Checkboxes: a pressed box shows it, as in Photoshop (#4476) (#4553)
* Checkboxes: a pressed box shows it, as in Photoshop (#4476)

Pressing a checkbox, its label or a Brush Settings section row gave no
feedback until the release. While pressed, an unchecked box now darkens
a little and a checked one turns a little less opaque.

widgets::check_box paints the box for both the shared checkbox and the
Brush Settings section list (which drew its own). The section boxes now
get the field fill like every other checkbox.

Tests: section_boxes_show_a_pressed_state,
checkboxes_show_a_pressed_state_from_their_label.

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

* Checkboxes: checkbox-only Brush Settings rows show the pressed state too

main made the rows without settings (Wet Edges, Build-up, ...) one
checkbox across the row; a press anywhere on such a row presses its box.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:11:18 -04:00
Marco ArnulfoandBrandon Thomas 45ce763ebb New Document: blank presets follow the picked orientation and physical unit (part of #4668) (#4686)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:11:09 -04:00
3a307a757e Play real Photoshop action sets: TrueGrit .atn mappings and fixes (#4356, #219) (#4698)
Third-party action sets that load since 3204231 now play. Four TrueGrit
Texture Supply sets (Debaser, Distress Press, Halftone Zine Machine,
Transparency) go from 223 to 3 unmapped steps of 533 and play end to end.

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

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 01:10:58 -04:00
a80fefce5c fix(ui): expose custom paper dimensions in Print Settings (#3893)
* fix(ui): expose custom paper dimensions in Print Settings

* fix(i18n): keep custom paper labels away from catalog tails

* Custom print paper: Serbian rows; drop the env-gated capture test

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

---------

Co-authored-by: zenjabba <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 01:04:53 -04:00
F4LCONandBrandon Thomas 8c135457b4 Brushes: a New Brush Preset dialog for every way of making a preset (#4490) (#4563)
* Brush presets: remember what the New Brush Preset dialog captured (#4490)

* Brushes: a New Brush Preset dialog for every way of making a preset (#4490)

* Brushes: the New Brush Preset strings in the Serbian catalog (#4490)

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:58:50 -04:00
a6e1beb1be Fix: Preview pixelation in Sharpen filters (#3936)
* Fix: Preview pixelation in Sharpen filters by using full resolution

Sharpen filters (Sharpen, Sharpen More, Sharpen Edges, Unsharp Mask, Smart Sharpen) now preview at full resolution instead of using a downsampled proxy. This fixes the pixelated appearance when enabling preview.

Sharpen algorithms work by detecting and enhancing edges. On a downsampled proxy image with fewer edges and details, the result appears pixelated or blocky. By previewing at full resolution (k=1), users now see an accurate preview that matches the final result.

Fixes #3929

* Fix: Preview issues in edge-detection filters (High Pass, Find Edges, Emboss)

High Pass, Find Edges, and Emboss filters now preview at full resolution instead of using a downsampled proxy. This fixes pixelated or inaccurate previews when enabling preview.

These filters work by detecting and enhancing edges:
- High Pass: extracts detail/edge information
- Find Edges: performs edge detection
- Emboss: creates 3D effect by detecting edges

On a downsampled proxy image with fewer edges and details, the result appears pixelated or blocky. By previewing at full resolution (k=1), users now see an accurate preview.

Similar to the Sharpen filter fix (#3929).

* Fix: Preview issues in morphological and motion blur filters

Median, Maximum, Minimum, and Motion Blur filters now preview at full resolution instead of using a downsampled proxy. This fixes pixelated or inaccurate previews.

**Median** - Similar to Dust & Scratches, removes noise by replacing pixels with median of neighborhood. Downsampling averages away the speckles it's designed to remove.

**Maximum/Minimum** - Morphological operations (dilate/erode) that expand or shrink features by one pixel. On a proxy, these features become fractional pixels, distorting the preview.

**Motion Blur** - Creates directional blur streaks. Pixel-sized distance parameter (1..2000 px) needs full resolution for accurate preview.

Like sharpen and edge-detection filters, these algorithms depend on pixel-level detail and need full resolution to preview accurately.

* Fix: Preview issues in despeckle and pixel-based blur filters

Despeckle, Box Blur, and Surface Blur now preview at full resolution instead of using a downsampled proxy. This fixes pixelated or inaccurate previews.

**Despeckle** - Removes one-pixel speckles/noise. Like Dust & Scratches, it works on per-pixel features that downsampling smooths away.

**Box Blur** - Creates rectangular blur artifacts with fixed pixel-sized radius (1..2000 px). On downsampled images, these rectangular patterns distort because the radius becomes a fraction of a pixel.

**Surface Blur** - Selective blur that preserves edges while smoothing flat areas. On downsampled proxy, edge preservation algorithm may not work correctly since edges have been blurred together.

All three algorithms depend on precise pixel-level operations and produce inaccurate results on downsampled images.

* Fix: Correct preview_factor for fixed-kernel sharpen and edge-detection filters

Remove parameterized filters (Unsharp Mask, Smart Sharpen, High Pass, Median, Maximum, Minimum, Motion Blur, Box Blur, Surface Blur) from FULL_RESOLUTION_PREVIEW.

These filters have pixel-sized parameters (radius, distance) that preview_factor already scales correctly. Forcing them to k=1 makes previews of large blurs on 24-36 MP documents unnecessarily slow without changing the result.

Keep only fixed-kernel filters that have no size parameter:
- Sharpen, Sharpen More, Sharpen Edges (one-pixel unsharp mask kernel)
- Find Edges (one-pixel edge detection kernel)
- Emboss (one-pixel edge-based emboss kernel)
- Despeckle (one-pixel speckle removal kernel)

These use fixed one-pixel kernels with no size parameter, so a downsampled proxy changes their look: edges average away, making sharpening and edge detection invisible. These must preview at full resolution (#3929).

Add tests to verify preview_factor returns 1 for these 6 filters.
Update doc comment to explain why each filter is in FULL_RESOLUTION_PREVIEW.

---------

Co-authored-by: AvrazAkraye <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:39:08 -04:00
Marco ArnulfoandBrandon Thomas adaebc084d Channels: clicking RGB hides the alpha channel overlay (fixes #4538) (#4564)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:37:42 -04:00
skylar taylor-barrick 06387a955c Apply CI formatter output for print guard 2026-10-13 06:36:33 +02:00
6b6a9234d1 feat(vector): add shape from selection with simplify tolerance (#214) (#3954)
* feat(vector): add shape from selection with simplify tolerance (#214)

- Add tolerance and simplifyTolerance parameter (clamped 0.5..10.0 px) to select.convertToShape.
- Wire select.convertToShape in filter_dialog::has_dialog to prompt for tolerance.
- Add tests for knot count tolerance scaling and selection requirement.

* rustfmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 00:28:50 -04:00
935aa35a4a Add source layer, Scale and Offset to the Apply Image dialog (#4574)
The engine already took a source layer and Add/Subtract scale and offset,
but the dialog only offered the merged image and no way to set Scale or
Offset, so the frequency separation recipe (Subtract, Scale 2, Offset 128)
couldn't be done from the menu.

Fixes #3965

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:19:21 -04:00
Vikram BhaskaranandBrandon Thomas cb2cf3ccbd Show a Cancel mnemonic on the unsaved-changes prompt. (#4622)
Windows and Linux already marked Yes and No. Cancel had no letter, so the prompt now shows (C)ancel and C cancels. macOS still hides the letters and keeps the shortcut. (#4582)

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:14:12 -04:00
Vikram BhaskaranandBrandon Thomas d825840bce Make the space between popup menu rows part of the rows. (#4624)
Context menus inherited a 6 pt item gap that belonged to neither button, so a drag across Merge Down or an adjustment list fell into dead space. Popup menus now stack their rows, and Pro rows use a square hover. (#4602)

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:14:01 -04:00
Vikram BhaskaranandBrandon Thomas 53db0f5c1f Indent layer-effect rows under their layer. (#4627)
Effect eyes sat in the same column as layer eyes, so a long list read as extra layers. The eye, its hit target and the label now sit to the right of that column. (#4572)

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:13:50 -04:00
Vikram BhaskaranandBrandon Thomas 24930b3188 Draw adjustment thumbnails as a half-filled circle. (#4628)
The contrast glyph put a smaller semicircle inside the ring, which read as concentric rings on a layer thumbnail. It is now one circle with a filled half. (#4561)

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:13:39 -04:00
Vikram BhaskaranandBrandon Thomas 2fc225c782 Translate Turkish Amount as Miktar. (#4629)
Tutar is a sum of money. Filter and adjustment Amount strings now use Miktar, the word for a quantity. (#4350)

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:13:28 -04:00
Vikram BhaskaranandBrandon Thomas 438490040b Keep the scrollbar thumb visible while the pointer is on it. (#4630)
The thin scrollbar faded its handle to the same opacity as the track, so scrolling a menu showed a bar with no position. The handle stays opaque and the resting bar is slightly wider. (#4611)

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:13:16 -04:00
Vikram BhaskaranandBrandon Thomas 339397aabb Reject negative PSD style-sheet indexes and a non-finite dpi. (#4631)
A negative Parent, font or normal-sheet index was cast to a huge usize, which skipped the name fallback and could hide the text. Those indexes now have to fit in a usize, and an infinite dpi uses 72. (#4429)

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:13:05 -04:00
rgoza1996andBrandon Thomas 9ade93b247 perf(select): reuse run-table buffer in Feather horizontal pass (#4632)
Feather r50 on 24MP was at ~81ms kernel / ~130ms scenario against the
100ms P27 budget. The cosine-series line() allocated a fresh
n-usize run table per row (thousands of ~48KB Vecs on a 24MP mask).
Reuse one buffer per worker thread via a thread-local; no arithmetic
touched, oracle tests (Photoshop-mapped + direct-Gaussian <=1/255)
prove identical output.

bench_24mp (24MP ellipse, best of 5, x86-64 release):
  feather r50: 80.8 -> 70.7ms (-12.5%)
  feather r250: 144.1 -> 133.6ms (-7.3%)
  feather r1000: 301.7 -> 287.7ms (-4.6%)
Full perf_scenarios P27 with patch: p50 119.3ms (was over by ~30ms,
now over by ~19ms); remaining gap is session/snapshot overhead,
follow-up.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:12:54 -04:00
F4LCONandBrandon Thomas 99fbd8e96b Move tool: the curved rotate arrow just outside its transform controls (#4658) (#4687)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:12:42 -04:00
Marco ArnulfoandBrandon Thomas 139f4cf01d Fit Image: the dialog is as narrow as its few fields (fixes #4673) (#4692)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:12:30 -04:00
f02ba1eadd Channels: drag a channel onto New, Delete or Load Selection (#4689) (#4693)
As in Photoshop, a Channels panel row can be dragged onto the footer's
buttons: New Channel duplicates it (the layer mask's row as
"<layer> Mask copy"), Delete deletes an alpha or spot channel, and Load
Channel as Selection loads it. The layer mask's row on Delete asks
"Apply mask to layer before removing?", like its Layers thumbnail.
Colour channels can't be deleted, so Delete ignores them. The dragged
name follows the pointer and the button under it is highlighted.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:12:17 -04:00
4fd08b5395 Free Transform: H and V skew fields in the options bar (#4699)
* Free Transform: H and V skew fields in the options bar

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:11:54 -04:00
1cfcc31518 Add timeline playback to Save for Web previews (#4703)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:11:40 -04:00
9cba6a66d8 Screen color picker: no DX12 crash, and the overlay can be left on Windows (#4443) (#4704)
* fix(picker): the screen color picker no longer crashes on DX12 or traps the overlay on Windows (#4443)

Two Windows-only faults in the Pick screen color window:

- Created always-on-top, the monitor-sized picker window makes the DX12
  backend fail swapchain creation (0x887A0001, DXGI_ERROR_INVALID_CALL);
  wgpu then panics in Surface::get_current_texture_view. The window is now
  created at the normal level and raised once its first frame is painted.
- As a deferred viewport the picker was painted on its own, and the main
  window stopped being redrawn while it was up on Windows (OpenGL). A click
  or Esc only stored an answer that the main window's tick() never took, so
  the overlay could not be left. It is now an immediate viewport, painted
  inside the main window's frame.

Verified on Windows 11 / Intel UHD 630: DX12 (default) opens, picks and
cancels; OpenGL picks and cancels; macOS still picks and cancels. Adds a
test that the picker viewport is not built always-on-top.

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

* Screen picker: raise the window level once per picker window, not every frame

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

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:11:29 -04:00
715f4fd397 Status bar: wrap fullscreen zoom scrubbing at horizontal edges (#3983)
* Status bar: wrap fullscreen zoom scrubbing at horizontal edges (#3344)

* Status bar: preserve keyboard editing and handle fullscreen edge crossings

* widgets: initialise the new FieldOpts field after merging main

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 00:09:46 -04:00
skylar taylor-barrick 3454620f89 Format shared print-disable error 2026-10-13 06:04:03 +02:00
39f3660def Open: warn about type in fonts that aren't installed (#4559) (#4576)
Opening a file whose type uses a font that isn't installed said nothing
in the desktop app, so the text silently rendered in a fallback face.
The CLI and automation already reported it; Photoshop warns on open.

The open notice now lists each missing family, with the engine's
existing warning text (type_extra_cmds::missing_font_warnings), for both
the synchronous open and the background open the desktop app uses.

Tests: opening_type_in_a_missing_font_warns,
background_open_warns_about_missing_fonts.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 00:02:50 -04:00
a7919b24e1 Edit › Transform › Rotate and Skew no longer scale (#3941) (#3986)
* Make Edit Transform Rotate and Skew use their own handle drags.

Rotate and Scale both started Free Transform, so a corner drag scaled the box. Skew only remapped edge handles, so corners still scaled. Give Rotate its own mode that turns the box from handles, and skew a corner along its adjacent edge (#3941).

* Rotate mode: fix the Skew doc comment and rustfmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 23:55:31 -04:00
skylar taylor-barrick 8f56cd5be5 Preserve explicit PDF export before disabled submission refusal 2026-10-13 05:54:57 +02:00
F4LCONandBrandon Thomas 321b7da12b Load Files into Stack: Photoshop's dialog with a Source Files list and the align option (#4671, #4696) (#4705)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:54:15 -04:00
skylar taylor-barrick f7a3262531 Merge current PhotoCraft and enforce print-disable guard on both transports 2026-10-13 05:52:52 +02:00
F4LCONandBrandon Thomas 25dd3b4794 Save for Web: no matte by default, as Photoshop's scripted export (#4115) (#4578)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:52:16 -04:00
Brandon ThomasandClaude Opus 5.5 68c624003a i18n(sr): translate the Soft Dark and Soft Light theme strings (#4727)
The Serbian catalog (#4436) and the Soft themes (#4378) landed together, so
sr.tsv lacked their four strings and the ui-egui i18n completeness tests failed.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 23:50:26 -04:00
Nigel Ihza FirdausyandBrandon Thomas c8de7870d5 perf: reuse sampled composite across channel visibility toggles (#4080)
* perf: reuse sampled composite across channel visibility toggles

* test: borrow full composite for sampled-grid oracle

* test: compare the entire channel view after invalidation

* style: keep channel cache construction readable

* fix: format channel test and share band renderer

* fix: invalidate sampled channel composite after edits and previews

* refactor: keep channel rendering optimization isolated from compositor

* test: cover both partial-damage and full channel redraw paths

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:44:50 -04:00
Marco ArnulfoandBrandon Thomas 71968f0806 Free Transform: the live preview feathers a linked layer mask (part of #4560) (#4581)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:38:14 -04:00
Vikram BhaskaranandBrandon Thomas 13a16a695b Resize the Liquify brush with Alt+right-drag instead of painting. (#4707)
The preview treated every drag, including Alt plus the right button, as a stroke. That gesture now sets the diameter and density, and a plain left drag still paints. Fixes #4702.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:35:35 -04:00
Vikram BhaskaranandBrandon Thomas 964f9fb9d0 Sample every visible layer in Content-Aware Fill by default. (#4133)
* Sample every visible layer in Content-Aware Fill by default.

An empty layer above the photo used to report a successful fill and stay empty, because only the active layer was sampled. The result is still written to the active layer. sampleAllLayers can be set to false to keep the old behaviour.

Fixes #4026

* Sample other layers in Content-Aware Fill only when asked or the layer is empty.

Reuse the Remove tool's composite, which writes the active layer's format, and leave Photoshop's Sample All Layers checkbox off unless the window has no pixels on this layer. An explicit false still reads only this layer.

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:35:21 -04:00
70537058eb Open Canon CR3 camera JPEG previews with container orientation (#4595)
Co-authored-by: optimalbytee <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:29:29 -04:00
adfeab1750 Composited CMYK documents now export as CMYK TIFF and JPEG with their profile (#2220) (#4199)
A CMYK document with several layers, masks or effects is flattened through the
RGB compositor. Its TIFF and JPEG exports were written as sRGB RGB, losing the
document's mode and ICC profile. The composite is now converted back through the
document's CMYK profile (relative colorimetric with black point compensation)
inside each parallel worker, written as four ink channels plus straight alpha at
the document depth, and tagged with that profile (the built-in coated CMYK
profile when the document is untagged). Invalid, non-CMYK or unusable profiles
are rejected. RGB-only formats keep the direct sRGB composite, and a single
plain raster layer keeps its native samples.

This fixes the output mode and tagging only: the RGB roundtrip does not
preserve original ink separations, black generation or CMYK-native blending.


Claude-Session: https://claude.ai/code/session_01BSB7fMQUccqFQekksDGWpo

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:29:26 -04:00
Gert Pellin - GPEandBrandon Thomas 09db9f1424 Content-Aware Fill: no tinted blotch in a smooth gradient (#4708)
inpaint::fit fits each channel as gain · source + bias, the gain the
ratio of the patches' deviations within the level's range and the bias
clamped after it. On nearly flat patches that ratio is noise, so the
gain often sat at its limit; scaling about zero, it moved a bright mean
further than Default's (or High's) bias could bring back, each channel
by a different amount: a tinted blotch in a sky gradient (up to 14
levels at Default, 27 at High).

For nearly flat patches (deviations under about 5 levels) whose means
are already within the bias of each other, the gain is now kept where
the bias can still match the means. Textured patches, and flat ones of
clearly different colours (Content-Aware Move's Color, measured against
Photoshop), are fitted as before.

Fixes #4706

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:27:49 -04:00
876bb8e281 Check peak RAW development memory and bound AHD scratch (#4597)
Co-authored-by: optimalbytee <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:25:42 -04:00
23daf16f82 Align tighter camera JPEG references for RAW colour fitting (#4598)
Co-authored-by: optimalbytee <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:24:18 -04:00
Brandon ThomasandClaude Opus 5.5 72b499a28f Tile grid overlay: gate rayon to native so the web build compiles (#4694)
#4217 used rayon's par_iter in ui-egui unconditionally, but rayon is a
native-only dependency there, so cargo xtask wasm failed on main.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 23:20:45 -04:00
Fabio Lissi 70e182b08f panic_hunt: don't fuzz the print commands into a real printer (#4467)
* panic_hunt: don't fuzz the print commands into a real printer

file.print and file.printOneCopy spool to CUPS, so every run of the adversarial-params test
printed a blank page on the default printer. The hunt now skips those two ids (their params
are validated like everyone else's; spooling is not what the test is for).

* Guard the print spooler itself and the CI environment

- print_cmds: disable_printing() (a process-wide flag the panic hunt sets) and the
  PHOTOCRAFT_NO_PRINT environment variable make spool_pdf refuse before running lp; dryRun
  still renders and reports the command. Test.
- CI: PHOTOCRAFT_NO_PRINT=1 and LPDEST=photocraft-no-such-printer, so a regression fails
  loudly instead of printing. Documented in docs/development.md › Environment variables.
2026-10-12 23:13:42 -04:00
15aac8d7b6 Type: type on a path, glyphs follow the curve (#4365) (#4377)
* Type: type on a path, glyphs follow the curve (#4365)

Add TextShape::Path (path, start offset, flip). The text is laid out as one
point-text line and each glyph is placed rigidly on the flattened path by arc
length, turned to the tangent at its centre; glyphs past the end are hidden and
a degenerate path falls back to straight text. New commands type.attachToPath
(work path or shape layer subpath) and type.detachFromPath. TextShape is no
longer Copy. .pcraft round-trips the new variant.

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

* Automation policy: type.attachToPath/detachFromPath take document vector paths, not host files (#4365)

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

* docs: regenerate scorecard after merging main

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:11:09 -04:00
e8af9d592f Themes: Soft Dark and Soft Light (#4368) (#4378)
* Themes: Soft Dark and Soft Light (#4368)

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

* docs: regenerate scorecard after merging main

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 23:06:37 -04:00
skylar taylor-barrick d6fbac1720 Pin qualification fixes integrated with current print progress 2026-10-13 05:04:13 +02:00
f5f0f73f25 feat(ui): Linux caption buttons follow the desktop layout, style and icon theme (#4439)
* feat: linux caption buttons follow the desktop: layout, style and icon theme

The title bar's minimize/maximize/close buttons now match the desktop:
- Layout and side: GNOME button-layout via the settings portal (live),
  KDE kdeglobals ButtonsOnLeft/Right, else gtk-3.0 settings.ini.
- Style family (GNOME round, Breeze chevrons, Windows) follows the
  desktop, then the GTK theme.
- Glyphs come from the icon theme's window-*-symbolic.svg (freedesktop
  lookup with Inherits and hicolor; unlisted theme directories such as
  symbolic/ui are searched lazily), rasterized with resvg at the real
  pixel size, recoloured and cached; missing icons fall back to the
  built-in vector glyphs. Override: PHOTOCRAFT_CAPTION_ICONS=<theme>|none.
- Theme and portal work runs on a worker thread (10-22 ms per theme).

* feat: linux caption buttons follow the desktop: layout, style and icon theme

The title bar's minimize/maximize/close buttons now match the desktop:
- Layout and side: GNOME button-layout via the settings portal (live),
  KDE kdeglobals ButtonsOnLeft/Right, else gtk-3.0 settings.ini.
- Style family (GNOME round, Breeze chevrons, Windows) follows the
  desktop, then the GTK theme.
- Glyphs come from the icon theme's window-*-symbolic.svg (freedesktop
  lookup with Inherits and hicolor; unlisted theme directories such as
  symbolic/ui are searched lazily), rasterized with resvg at the real
  pixel size, recoloured and cached; missing icons fall back to the
  built-in vector glyphs. Override: PHOTOCRAFT_CAPTION_ICONS=<theme>|none.
- Theme and portal work runs on a worker thread (10-22 ms per theme).

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 23:01:08 -04:00
skylar taylor-barrick 70059ac1b4 Pin driver option retention qualification fixes 2026-10-13 04:56:32 +02:00
20539b5dc3 i18n(sr): add a Serbian (Latin, Ekavian) UI catalog (#4436)
* i18n(sr): add a Serbian (Latin, Ekavian) UI catalog

Adds sr.tsv with 4,022 rows: every key the i18n-coverage tool measures
plus the union of the dynamic labels the other catalogs translate. The
catalog is written in Latin script, Ekavian; every Serbian locale
(sr-RS, sr-Latn-*, sr_RS@latin and Cyrillic sr-Cyrl-*) resolves to it.
Plurals use the one/few/many rule (plural_russian) with three forms per
@plural entry.

Original translations from the plain meaning of the English labels; no
proprietary localisation resources were used (ATTRIBUTION.md and
LICENSE-translations.txt updated). Tests cover locale resolution,
contextual and id overrides, placeholders, and plurals across teens and
compound counts.

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

* Info.plist: declare the Serbian localization

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

---------

Co-authored-by: Dejan Port <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 22:50:20 -04:00
0a32b3593d Add Krita (.kra) import (#4388)
Open .kra and .krz with paint layers (8/16-bit RGBA and gray, any offset), nested
and pass-through groups, names, visibility, opacity, locks, blend modes and
transparency masks, plus the image's ICC profile and resolution. Vector, filter,
fill, file and clone layers and the other mask kinds are left out with a warning;
float, CMYK and Lab documents open as Krita's merged image (preview). Import only:
Save suggests .ora. Checked against 35 Krita 5.2.9-authored files.

Co-authored-by: Scott Campbell <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:41:38 -04:00
F4LCON 05e98e7ade Smart Filters: select a filter, drag or ⌥-drag it onto another smart object (#4271) (#4371) 2026-10-12 22:35:54 -04:00
2842973dc0 Compose: no overflow or unbounded allocation on hostile coordinates (#4427) (#4431)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 22:35:39 -04:00
skylar taylor-barrick 4de93daa91 Pin pre-submission annotation visibility guard 2026-10-13 04:25:32 +02:00
F4LCON b4aea45e19 Actions: record File › Save a Copy and play it back as a copy (#4373) (#4390) 2026-10-12 22:24:18 -04:00
Valentin KleinandClaude Opus 5.5 f586e7133e Compose: key the vector-mask cache by mask content, not a temporary's address (#4426) (#4428)
RenderVectorMasks keyed compiled masks by the address of the VectorMask it
was given. hiding_mask/effects_source pass temporary clones, whose addresses
get reused, so a later layer received an earlier layer's compiled mask.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:24:15 -04:00
F4LCON 1f76d839e3 SVG: a group clipped by one path opens as editable shapes with a vector mask (#4266) (#4425) 2026-10-12 22:24:11 -04:00
FredApps 78c61e6df5 Swatches: an RGB group with the exact primaries and secondaries (#4302) (#4409)
The built-in "Pure" group's hues are vivid but not pure (its green is
#28C850), so picking it for text looked dull next to Photoshop's #00FF00.
Add a Photoshop-style "RGB" group (#FF0000, #FFFF00, #00FF00, #00FFFF,
#0000FF, #FF00FF) right after Grays. Pure is kept unchanged so existing
documents, actions and saved swatches that use it keep their colours.
2026-10-12 22:24:07 -04:00
Valentin KleinandClaude Opus 5.5 5aa6e33adf Content-Aware Move: live preview of the result (#4385) (#4393)
* Content-Aware Move: live preview of the result while the pointer rests (#4385)

A cancellable, debounced background job shows the move/extend result during the drag and in the Transform On Drop box.

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

* docs: regenerate scorecard after merging main

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:24:03 -04:00
Marco Arnulfo 88f84aab59 Raw: treat DNG samples piled up below the stated white level as clipped (fixes #4348) (#4389)
Some DNG writers state a white level the sensor never reaches (an iPhone
15 Pro Max Bayer DNG states 4095 and clips at 3567). Development clips
each channel at 1 after white balance, so red and blue reached 1 while
green stayed at 0.85 and blown highlights turned magenta. On decode, a
per-colour pile-up clearly below the white level is now raised to it, as
in storytold/lightcraft#586.
2026-10-12 22:23:59 -04:00
Valentin KleinandClaude Opus 5.5 20e8f949f9 Type: fall back to installed Indic fonts for document text (#4383) (#4386)
* Type: fall back to installed Indic fonts for document text (#4383)

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

* docs: regenerate scorecard after merging main

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:23:55 -04:00
jonathanwxh-cellandjonathnwxh 4543ef35b9 Preserve signed HDR colors in float text rasterization (#4379)
Co-authored-by: jonathnwxh <[email protected]>
2026-10-12 22:23:50 -04:00
Valentin KleinandClaude Opus 5.5 ee8aca562b Engine: reject unknown params for layer.hideLayers/showLayers via an opt-in registry check (#4336) (#4364)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:23:46 -04:00
f80e57fb8a Fix macOS system dialog localization (#4261)
* Fix macOS system dialog localization

* Info.plist: declare the Hungarian and Turkish localizations too

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:16:46 -04:00
575f8eb2bc Allow toolbar tool shortcuts to be remapped (#4194)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:16:06 -04:00
cee010ef5a Replace Color: a canvas eyedropper picks the From colour (#4143) (#4247)
The dialog had no colour control at all: the engine defaulted the From
colour to the foreground and the spec's `color` param rendered nothing.
It now shows a Color row (swatch opens the Color Picker, seeded from the
foreground) plus a pipette that arms a canvas eyedropper: the next click
on the image samples the merged composite into the field, like the
Curves picker and Photoshop's Replace Color.

Co-authored-by: highperfocused <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 22:03:15 -04:00
4d4103460f Toolbar: a press outside a tool flyout closes it (#4460) (#4480)
An open tool flyout closed only on a click outside it. Dragging on the
canvas (cropping, painting) isn't a click, so the flyout stayed open
over the work.

Any press that starts outside the flyout and its button now closes it,
as in Photoshop. A press on the flyout itself still keeps it open for
the row it picks on release, and Escape still closes it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 22:01:17 -04:00
1cb3eaa5d2 Free Transform: apply a large layer's warp in the background, with progress and Esc to cancel (#1015) (#4218)
Committing Free Transform on a 300-550 MP layer ran the bicubic warp on the UI
thread: several seconds of a frozen window with no feedback. On a 20000x16000
layer the warp alone took about 6.6 s.

edit.transform is now a background job (edit_job): the warp reports progress per
tile and stops when cancelled. The transform session stays open while it runs,
showing the moving pixels without the box, so the layer doesn't jump back to its
old place until the result lands. The modal progress dialog shows after the
usual delay, and Esc or Cancel cancels the job. A copy or placed layer the
session made is folded into the step, or taken back, in its own document.
Undo and Redo are refused while the commit runs.

Tests and scripts run inline as before.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:57:47 -04:00
b1b44595b7 Toolbar: no tooltips over an open tool flyout (#4450) (#4469)
Holding a tool button to open its flyout showed that button's tooltip on
top of the flyout, and other toolbar buttons kept showing theirs while it
was open. The flyout is our own Area, so egui's rule that hides tooltips
while a popup is open didn't apply.

The toolbar's buttons (the tools, Quick Mask, Change Screen Mode) now skip
their tooltips while a tool flyout is open or the mouse button is held.
icons::button_with_icon_size_tip keeps the tooltip text as the button's
label when the tooltip is hidden.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 21:56:29 -04:00
875e17ea33 Save As: on Windows, picking another file type changes the extension (#4394) (#4407)
With File name extensions shown in Explorer, the Windows save dialog keeps
a visible extension when the user picks another type: image.psd saved as
PhotoCraft stayed image.psd. Given a bare name, the dialog adds the chosen
type's own extension (rfd sets the default extension, which Windows
updates on every type change).

On Windows the dialog now starts with the stem, and the suggested type
leads the type list with the suggested extension first (so .psb, .fit and
.jpeg stay what they were). Names with a second dot or an upper-case
extension keep the whole name. macOS and Linux are unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 21:56:24 -04:00
c1e3ae9f16 Pen: Auto Add/Delete anchors on the active path (#4144) (#4540)
* Pen: Auto Add/Delete anchors on the active path (#4144)

With the Pen's new Auto Add/Delete option (options bar, on by default as
in Photoshop), a click on a segment of the active path adds an anchor
there and a click on one of its anchors deletes it, one History step
each, instead of starting a new path. The active path is the targeted
vector mask or the active shape layer's path, then the work path.

The end points of an open subpath, a path being drawn, and Shift, Alt
or Cmd/Ctrl held leave the click to the Pen. Hovering highlights the
segment or anchor and the cursor shows + or -, like the Add/Delete
Anchor Point tools, which share the same code (anchor_tools::apply).

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

* Pen: keep the close-drag test off Auto Add/Delete when it redraws over a finished path

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 21:56:19 -04:00
c96842f7d9 The wheel steps the options bar's Mode dropdown for Brush, Pencil and Gradient (#2061) (#4447)
The options bar's Mode dropdowns now use widgets::dropdown_wheel_hovered, the wheel stepping the
Layers panel's Blend Mode already has (#1747): down picks the next mode, up the previous one, one
per notch, stopping at the ends, over the button or the open list. The Brush and Pencil list keeps
Photoshop's Clear after Dissolve. Adds my contributors/people.toml entry.


Claude-Session: https://claude.ai/code/session_01RXND3Ywis6CuB5mv6fLYU7

Co-authored-by: megaDon101 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 21:56:14 -04:00
a9bb38c96e Clone Source: clip the overlay to a shaped brush tip (#4372) (#4376)
* Clone Source: clip the overlay to a shaped brush tip (#4372)

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

* docs: regenerate scorecard after merging main

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 21:56:09 -04:00
Marco Arnulfo 2cb3b70d97 Crop tool: the drawing crosshair, not the move cursor, inside the untouched default frame (fixes #4461) (#4481) 2026-10-12 21:47:50 -04:00
F4LCON 1bc7d7baad Puppet Warp: ⌘Z / ⌘⇧Z undo and redo pin edits (#4440) (#4479) 2026-10-12 21:47:45 -04:00
Dan HiornsandClaude Opus 5.5 40a93b7dc4 Toolbar: click and hold on Change Screen Mode opens its modes (#4444) (#4464)
The button draws the flyout triangle, but its list was an egui context
menu, which opens on right-click (or a touch long-press) only. Clicking
and holding with the mouse did nothing until release, which then cycled
the screen mode.

A hold of 0.35 s, the same as the tool flyouts, now opens the list, and
the release that ends the hold neither cycles the mode nor closes the
list. A plain click still cycles (F), and right-click still opens it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:47:41 -04:00
Dan HiornsandClaude Opus 5.5 98ec507375 Paths: curves stay smooth when zoomed in (#4084) (#4445)
The path overlays sampled every Bezier segment at a fixed 16 points (24
for the Curvature Pen's preview) in document space, so zoomed in a curve
showed as visible straight chords with corners.

curve_points maps a segment's four points to the screen (the view is
affine, so the curve maps exactly) and picks the number of chords from
how much it bends there: the chord error stays within 0.3 px (the bound
the vector crate's flattening uses), capped at 1024 chords. A straight
segment, with handles retracted or along the line, is one chord. Used by
path_lines (work path, shape and vector mask outlines, Direct Selection,
the shape preview, Paths thumbnails), the Pen's path in progress and the
Curvature Pen's preview.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:47:36 -04:00
F4LCON 6c2934ba79 Recent files: a saved document is listed, as an opened one is (#4400) (#4411) 2026-10-12 21:47:32 -04:00
F4LCON 851916e0d7 Save As: offer ICO, GIF and BMP in the file type list (#4357) (#4405) 2026-10-12 21:47:27 -04:00
Tanish Manku 3f2ac1601e macOS: big brushes keep a native outline too, and big cursor bitmaps rasterise 7x faster (#1343) (#4401)
On macOS the brush outline fell back to the app-drawn outline past 255 points, and that
outline trails the pointer by 26 ms on a 120 Hz Mac (syncroot's measurement in #1343): a
1000 px brush at fit, or 300 px at 200 %. AppKit sets no size limit on an NSCursor image, so
macOS now takes the same 2048 cap as Windows. Linux keeps 255 for X servers and hardware
cursor planes.

A circle's bitmap evaluated every pixel of its square, though only a thin ring is painted:
a 1000 px radius took 41.6 ms per bitmap, a hitch on each zoom step or [ / ] resize (on
Windows too). Only the ring's two arcs per row, and the centre mark, are evaluated now, on
a transparent buffer: 6.2 ms (radius 500: 10.6 -> 1.7 ms), identical pixels. App-drawn
tip outlines likewise only write the pixels their segments splat.
2026-10-12 21:47:23 -04:00
F4LCON f5d64a66bd Color panel: the spectrum field in every theme (#4367) (#4399) 2026-10-12 21:47:18 -04:00
10ba166a82 feat(codecs): open DDS textures, uncompressed and BC1-BC3 (#1327) (#4255)
A read-only DirectDraw Surface codec on the model of PCX: the top mip
level of a 2D texture, uncompressed (channel masks, RGB or luminance,
8 to 32 bits) or BC1/BC2/BC3 (DXT1/3/5), with the legacy or the DX10
header. Cubemaps, arrays, volumes and other formats are refused with
an Unsupported error naming what was found; every header field is
treated as hostile (checked sizes, limits before allocating, no
indexing on file offsets).

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:47:16 -04:00
FredApps b507e04f1f Menus keep clear of a panel that covers a maximized window (X11) (#3737) (#4380)
On X11 a window manager that doesn't reserve a bottom panel's space maximizes
PhotoCraft to the whole monitor, and the panel covers its bottom. The work-area
check (#315) trusted every maximized window to be inside the work area, so long
menus (Layer, View, Window, Edit at larger UI scales) ran under the panel with
their scroll arrow hidden. A maximized window whose position is known is now
checked like any other; only full screen, or maximized with no position
(Wayland, #3739), uses the whole window.
2026-10-12 21:47:14 -04:00
Valentin KleinandClaude Opus 5.5 f1855caf73 Type tool: spelling suggestions in the edit menu (#4370) (#4375)
* Type tool: spelling suggestions in the edit menu (#4370)

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

* Type spelling menu: cap suggestions at 3 so the menu stays short (#4370)

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

* docs: regenerate scorecard after merging main

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:47:09 -04:00
Valentin KleinandClaude Opus 5.5 72ef526d8e Pen: show a close cue when hovering the first anchor (#4193) (#4361)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:47:04 -04:00
0002d861ee fix(io): read CS2 placed layers that carry only a PlLd block (#2465) (#4268)
Photoshop CS2 predates SoLd: its smart objects have a PlLd (plcL) block
alone, with the embedded file in a global lnkD block. Import only read
SoLd/SoLE, so such layers got an empty source id and Edit Contents said
"the smart object's contents are unavailable".

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

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

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

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

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

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

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:44:49 -04:00
e90dfdcf10 feat(ui): Photoshop's type formatting keys while editing text (#4239)
* feat(engine): relative leading, baseline shift and tracking steps in type.setStyle

`leadingBy`, `baselineShiftBy` and `trackingBy` move every character in the
range from its own value, so mixed values keep their differences. Auto
leading starts from the value layout uses (the paragraph's auto-leading
percentage of the character's size), results clamp to Photoshop's ranges,
and non-finite or oversized steps are refused as bad params. `leadingBy`
follows `metricsAsShown` like `leading`.

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

* feat(ui): Photoshop's type formatting keys while editing text

While a Type tool edit is active, Shift+Cmd+K/U/B/I and Shift+Cmd+/ toggle
All Caps, Underline, Faux Bold, Faux Italic and Strikethrough on the
selection (off when every selected character has it, else on);
Alt+Up/Down step leading by 2 pt and Shift+Alt+Up/Down baseline shift by
2 pt (10 with Cmd); Alt+Left/Right over a selection track it by 20 (100
with Cmd), a collapsed caret still kerning; Cmd+Alt+Shift+H toggles
Hyphenate for the paragraphs touched. Each press is one type.setStyle
history step, and the keys no longer fall through to Color Settings,
Desaturate, Auto Color or Inverse while typing; outside an edit those
shortcuts work as before.

A collapsed caret in non-empty text changes nothing: Photoshop styles the
next characters typed, which the document model can't hold apart from the
text. In empty text the style becomes the typing style.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 21:41:23 -04:00
ccfc878a14 Camera Raw Filter: save and load settings as .xmp (#4279)
* Camera Raw Filter: save and load settings as .xmp (#4274) [WIP]

* Camera Raw Filter: Save Settings… and Load Settings… (.xmp) (#4274)

Adds Save Settings… and Load Settings… to the Camera Raw Filter dialog's
footer. They write and read Adobe Camera Raw settings files: the crs:
namespace on x:xmpmeta/rdf:RDF/rdf:Description, as attributes or elements.

photocraft_io::camera_raw_xmp maps every control the dialog has:
- Basic (Exposure2012 … Saturation, IncrementalTemperature/Tint)
- the parametric and point tone curves (ToneCurvePV2012[Red|Green|Blue])
- the HSL mixer
- Color Grading (including the SplitToning* names for the shadow and
  highlight wheels)
- sharpening and noise reduction
- grain
- post-crop vignetting

Loading applies only what the file holds over the current settings, as
Camera Raw does. Values are clamped to the slider ranges. Unsupported
settings (lens, calibration, geometry, defringe) are ignored, and the
status bar counts them when they aren't neutral. Malformed files fail
with a message and leave the settings unchanged. The control channel
takes {"ui":{"saveSettings"|"loadSettings":true}}.

Translations are added for all 17 catalogs.

Fixes #4274

* Add Turkish and Hungarian rows for the Camera Raw settings strings

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:34:54 -04:00
465a663540 feat(ui): developer tile grid overlay (ui.set tileGrid) (#4217)
Draw the active layer's storage tiles over the canvas: a faint
TILE_SIZE grid over the document, every stored tile filled and
labelled tx,ty, tiles with identical pixels in one colour per group
and unique tiles in blue. It shows how sparse a layer is, which tiles
copy-on-write shares, and which ones .pcraft saves once.

It has no menu item and isn't a View > Show item. The control channel
and MCP turn it on with ui.set {"tileGrid": true}; ui.inspect reports
it as view.tile_grid, and View > Extras doesn't hide it.

Grouping hashes each distinct tile allocation once, in parallel (or
serially while jobs hold the pool, #1554), cached per document
revision and active layer. A 36 MP layer of all-different tiles groups
in 3.9 ms (8-bit) and 7.5 ms (16-bit) on 24 cores in release; the
ignored tile_grouping_36mp test reproduces this.

Also documents tileGrid in docs/control-protocol.md and
docs/development.md, and adds my contributors/people.toml entry.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 21:31:32 -04:00
8157d1a851 Free Transform: refuse outputs over a pixel budget instead of building them (#4293)
A large scale (e.g. 200x on a thin column) made the warp fill every tile of
a result up to 2^21 px on a side, enough to exhaust memory. Free Transform
now prices each warp before building anything and refuses with an
allocation-style error ("...; the document was not changed") once a result
would exceed MAX_TRANSFORM_PIXELS (2^30, the same bound as Puppet Warp's
MAX_WARP_PIXELS). Only the result's size counts, so moving content far off
the canvas still works, and nothing is clipped.

The check covers the layer, a smart object's cache and filter mask, linked
masks, a lone mask/channel target and the selection outline, and runs over
the whole layer tree before any layer is changed. Transform Selection gets
the same check. The transform box now stays open when a commit is refused.

Part of #3773

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:27:12 -04:00
Valentin KleinandClaude Opus 5.5 dd258cccf9 Type: faux bold keeps antialiased edges at small sizes (#4130) (#4360)
* Type: faux bold keeps antialiased edges at small sizes (#4130)

Faux bold drew each glyph at 8 offsets into one signed-area coverage buffer, so edge pixels from the copies summed and saturated to full coverage (hard, pixelated stems, about 2.2x the ink at 12 px). Each offset now gets its own buffer, merged with max.

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

* Type: faux bold ring radius tuned so stems keep their weight (#4130)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:27:07 -04:00
Valentin KleinandClaude Opus 5.5 f6db7a8115 Smart Objects Warp: mesh knots span the contents, not the skewed bounding frame (#4113) (#4341)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:27:03 -04:00
FredApps 96f4256ced fix(type): the Type tool's canvas pick skips text in hidden groups (#4320) (#4332) 2026-10-12 21:26:59 -04:00
MrCloudy2andClaude Opus 5.5 b79d0a0bcf feat(gradients): Photoshop's interpolation method for Gradient Overlay, gradient Stroke and the Gradient tool (#4292)
layer.layerStyle.gradientOverlay, layer.layerStyle.stroke (gradient) and
paint.gradient take "method" (classic|perceptual|linear|smooth) and
"smoothness" (0..100, default 100). The stops are baked with the same
model PSD import uses (io::gradient_bake, now public as bake_gradient and
GradientMethod), so the compositors' linear interpolation reproduces
Photoshop's curve. Without a method the stops stay as they were.

Measured in Photoshop 27.11 (max level error vs main -> this change):
Gradient Overlay linear/reflected styles, any angle/scale/reverse 12 -> 0-1,
radial/diamond 16 -> 4-5; gradient Stroke 12 -> 2-4; Gradient tool
black -> white Classic 13-14 -> 2-3; red -> blue Classic 12 -> 1,
Perceptual 83 -> 2, Linear 64 -> 4, Smooth 83 -> 3. A 3-stop Classic
gradient improves only 14 -> 10 (the multi-stop smoothness spline is a
separate follow-up).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:26:53 -04:00
Nigel Ihza Firdausy ab5a6c5ed2 fix(layer-picker): include text bounds in right-click layer list (#4288) 2026-10-12 21:26:49 -04:00
1412ac26e2 Save for Web and Export As remember their settings across restarts (FILE-215-18) (#4184)
* engine: remember the last Save for Web settings in the preferences

* engine: test that a complete export remembers its settings for the next launch

* ui: seed the Save for Web dialog from the remembered preferences

* ui: Export As opens with the settings it was used with last

* scorecard: FILE-215-18 done, the export dialogs remember their settings

* docs: scorecard, File compatibility 7 done of 27

* Regenerate the scorecard

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:26:47 -04:00
Persi 37ee4f5a20 Simplify the raster stale-block filter for clippy's nonminimal_bool (#4314)
Clippy 1.96 flags the `retain` predicate that strips stale content blocks
from pixel layers on PSD export (`clippy::nonminimal_bool`), which breaks
`cargo clippy -- -D warnings` on current stable.

Rewrite it as a single negated disjunction. The four terms, their order and
the short-circuiting are unchanged, so the same blocks are kept and dropped.
2026-10-12 21:26:45 -04:00
97d06b240b feat(ui): add hamburger menu to the Brush Settings panel (#4272) (#4358)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:24:09 -04:00
AlonandClaude Opus 5.5 ab82871271 fix(compose): paint interior effects over the layer's blend mode (#3996) (#4231)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:17:57 -04:00
David MannandClaude Opus 5.5 95663451c0 Find Edges and Solarize: treat CMYK values as ink (#4222) (#4223)
CMYK samples are ink (1 = full ink), but both filters assumed light
polarity. Find Edges wrote 1 - edge, so flat CMYK areas became 100% ink
(black) instead of white; Solarize inverted channels above 50%, i.e. the
shadows. Both now use fxutil::subtractive(ctx), like Trace Contour: Find
Edges writes the edge strength as ink, Solarize inverts channels below
50% ink. Alpha stays untouched. RGB, Grayscale, Lab and other modes are
unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:17:54 -04:00
David MannandClaude Opus 5.5 468d1c622a Bitmap mode: validate the halftone screen angle (#4214) (#4215)
Image > Mode > Bitmap (halftone) passed its angle through unchecked: 1e39
became inf as f32, the screen rotation turned it into NaN and an almost
white bitmap was recorded in history; 500 was accepted although the
params doc (and Photoshop's Halftone Screen dialog) says -180..180.

The angle must now be a finite number in -180..=180 and the frequency a
finite number (still clamped to 1..999 lines/inch); otherwise the command
returns BadParams before touching the document.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:17:49 -04:00
David MannandClaude Opus 5.5 8029b061af EXR: an unnamed part can be opened by the name the part list gives it (#4210) (#4211)
file.exrParts lists a part without a name attribute as "part <index>", but
file.openExrParts only matched parts whose name attribute equalled the
requested name, so "part/part 0" failed with "no flat part named part 0".
One helper now gives the display name for the listing, the lookup and the
layer name; a named part still wins over an unnamed one with the same
fallback name.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:17:45 -04:00
David MannandClaude Opus 5.5 a0e9ee213d Cryptomatte: decode the .blue/.alpha rank of every channel set (#4208) (#4209)
Each Cryptomatte RGBA set stores two (ID, coverage) ranks, (red, green)
and (blue, alpha), but only red/green were read, so every second rank
was lost and mattes got holes at edges and overlaps. Suffixes were also
matched case-sensitively, missing Blender's .R/.G/.B/.A.

Stream channels now carry a rank (0 red/green, 1 blue/alpha), suffixes
match in any case, and decode stores each channel at slot*2+rank
(bounds-checked), still sorted by coverage. The allocation check counts
four channels per set.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:17:41 -04:00
David MannandClaude Opus 5.5 72c8f58e11 Shapes: a rectangle with a negative size extends from its origin like the other shapes (#4203) (#4204)
rounded_rect took the absolute width/height without moving the origin, so
shape.create {"kind":"rect","rect":[50,10,-20,30]} covered x 50..70 while an
ellipse or polygon with the same rect covered 30..50. Normalise (x, w) and
(y, h) for negative sizes instead. The Shape tool drag already normalises its
rect, so interactive drawing is unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:17:38 -04:00
David MannandClaude Opus 5.5 e4f15eecdf Vector masks: validate feather like pixel masks (#4201) (#4202)
layer.vectorMask.edit stored `feather` as f.max(0.0) as f32, so 1e39 became
inf (hard edge, null in info, .pcraft save refused) and any huge value was
accepted. It now takes the same finite 0..=1000 px range and error as
layer.layerMask.edit, checked before the edit so a bad value leaves the mask
and history untouched. The params doc says so.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:17:14 -04:00
JGFandClaude Opus 5.5 63a0e43e74 fix(type): key font menu caches on the shared engine's fonts (#4200)
The font menu's preview samples, family list and localized names were
cached on fonts::generation(), which any FontDb moves, and the previews
also on the served catalog's generation. Other engines registering fonts
(tests running in parallel) re-rendered every visible sample each pass,
and families covering Arabic asked for a repaint, so Harness::run never
settled: options_font_family_preserves_peer_faces_and_batches_layer_flags
and the font picker tests failed intermittently with "exceeded max_steps".

- photocraft-text: fonts::shared_generation(), moved only by the shared
  engine's FontDb (FontDb::mark_shared from new_shared).
- ui-egui: families(), localized_families() and the preview cache key on
  it; previews no longer key on the served catalog (a served family's
  sample appears when its fonts register, which moves the generation).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:17:10 -04:00
David MannandClaude Opus 5.5 56869d12e3 Layer Comps: the warning icon clears only its own comp (#4197) (#4198)
The warning triangle on a comp row sent layerComp.updateWarnings
{"clear":true}, which dropped the stale states of every comp. The
command now takes an optional "comp" (id or name) that limits both the
report and the clear to that comp; an unknown comp is an error and
changes nothing. Without "comp" it still acts on every comp (Clear All
Layer Comp Warnings). The row icon sends its own comp id.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:17:06 -04:00
David MannandClaude Opus 5.5 71827a614c Color Sampler: keep the CMYK readout in range on 32-bit documents (#4191) (#4192)
32-bit samples can lie outside 0..1. The RGB readout clamped them, but
device_cmyk did not, so a pixel at 1.5 read K:-50% and negative values
gave C/M/Y above 100%. Clamp each channel to 0..1 (NaN reads as 0) before
the conversion; this fixes both the Color Sampler and the Info panel.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:17:01 -04:00
David MannandClaude Opus 5.5 60c825513c Type: every text edit respects Lock All (#4188) (#4189)
#3132 refused type.edit on a Lock All layer, but type.insertText,
edit.checkSpelling "change", Paste Lorem Ipsum (and the other
type_extra_cmds::with_text edits) and Find and Replace Text "change"
on a layer locked after the find still changed its text and recorded
history. The check is now one pub(crate) helper,
type_cmds::check_text_unlocked, called by every one of these paths
with the same "...is locked" error as type.edit.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 21:16:56 -04:00
94ea94f889 feat(ui): add GRD import option to gradient map preset picker (#4337)
* feat(ui): add GRD import option to gradient preset picker

* Translate the GRD import strings into Vietnamese, Turkish and Hungarian

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:14:16 -04:00
858fbcf1ad Object Selection: hover preview of the object it would select (Object Finder) (#4087) (#4095)
* WIP: Object Selection hover preview (#4087)

* Object Selection: Object Finder hover preview and click-to-select (#4087)

Hovering with Object Selection highlights the object a click selects,
computed off the UI thread after a short rest and cached per document
pixels; a click commits it via select.object {point} honouring
add/subtract/intersect; dragging keeps the rectangle mode. Adds an
'Object Finder' options-bar toggle (default on, persisted) with
translations in every catalog.

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:06:58 -04:00
908c47021f Layers: Fill and the Paint Bucket edit an adjustment layer's mask when its icon is targeted (#4298) (#4308)
* Layers: Fill and the Paint Bucket edit an adjustment layer's mask when its icon is targeted (#4298)

An adjustment layer has no pixels, but once its icon thumbnail was clicked (or it was
reached with Alt+[ / Alt+]) the Layers panel no longer targeted its mask, so Edit > Fill
(Shift+F5) and the fill keys were greyed out, and the Paint Bucket and brushes failed with
"not a pixel layer". As in Photoshop, painting and filling an adjustment layer now always
edit its mask; the panel's target (brackets, trash, Delete) is unchanged.

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

* Keep the web read_dropped doc comment on its function

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 21:05:59 -04:00
1874214f79 fix(ui): keep brush settings section lock icons permanently visible (#4278) (#4300)
* fix(ui): keep brush settings section lock icons permanently visible (#4278)

* test(ui): verify brush settings locks stay visible and toggle on click (#4278)

* Format the lock-icon test

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 21:00:58 -04:00
Korg1ch 0d6a83e3ce fix(ui): add Brushes button at top of Brush Settings control list (#4265) (#4354)
* fix(ui): add Brushes button at top of Brush Settings control list (#4265)

* test(ui): verify Brushes button at top of Brush Settings opens Brushes panel (#4265)
2026-10-12 20:56:18 -04:00
skylartaylor d9bc5a8cd5 Use shared native print transport and keep page composition in PhotoCraft 2026-10-13 02:56:11 +02:00
Korg1ch 4122bc6918 fix(ui): use a linear gradient ramp glyph for the Gradient tool icon (#4172) (#4335)
* fix(ui): use a linear gradient ramp glyph for the Gradient tool icon (#4172)

* test(ui): verify Gradient tool icon rasterizes a left-to-right ramp (#4172)
2026-10-12 20:56:09 -04:00
Korg1ch ec61df6f06 fix(ui): make clickable tab, checkbox, and radio labels non-selectable (#3972) (#4331)
* fix(ui): make clickable tab and radio labels non-selectable with pointing hand cursor (#3972)

* test(ui): verify New Document preset tabs show pointing hand cursor instead of text caret (#3972)
2026-10-12 20:55:47 -04:00
Dan HiornsandClaude Opus 5.5 68a31b5a12 Free Transform: unlinking W and H frees the handles too (#4174) (#4322)
The options bar's Maintain Aspect Ratio link only affected typed W/H values; dragging a
corner or edge handle still scaled proportionally, so unlinking seemed to do nothing. As
in Photoshop CC 2019 and later, the link now also sets the drag default: unlinked, corner
and edge drags stretch freely and Shift keeps the proportions. Linked (the default) and
the legacy preference are unchanged. The link state moves from egui temp data into
UiState (transform_unlinked), next to the transform session.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:55:38 -04:00
Lessa 315c9cc0bc Give numeric type controls translated accessible names (#4310)
* Give numeric type controls translated accessible names

* Find the size and leading row by accessible names in layout tests
2026-10-12 20:55:28 -04:00
Dan HiornsandClaude Opus 5.5 e777690ac6 Properties: the Studio-layout themes show it for every layer (#4246) (#4299)
In the Studio-layout themes (Studio, Studio Light, Breeze, Adwaita,
Solarized) Properties is a floating card over the canvas. The card
returned early unless the active layer was an adjustment or fill layer
or a mask was targeted, so for a pixel layer, the Background, type or a
group nothing was drawn, although Properties was on (the rail button
showed it). The Pro theme's docked panel shows all of those.

The card now renders the docked panel's body (properties_body), so both
show the same content: the Document properties for the Background or no
selection, layer, adjustment and mask properties. Its title bar, hide
button, position and drag are unchanged.

Tests: floating_properties_card_shows_document_properties_for_the_background
and floating_properties_card_shows_pixel_layer_properties (Studio and
Breeze Dark) failed before the fix.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:54:20 -04:00
Dan HiornsandClaude Opus 5.5 21b018c8a9 Brush Settings: Brush Tip Shape lines up with the other section names (#4282) (#4294)
The section list indented every row without an enable box by the box's
width, except the first, so Brush Tip Shape started in the checkbox
column while Smoothing, the other box-less row, lined up with the names.
Photoshop indents Brush Tip Shape too. Every name now starts in the
same column.

Test: section_names_line_up_whether_or_not_they_have_a_box checks the
painted x of every section name (Brush Tip Shape was 20 px left before).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:54:11 -04:00
Nigel Ihza Firdausy 94b6042289 fix(linux): follow GNOME's light default when the portal has no preference (#4289) 2026-10-12 20:53:58 -04:00
6f41b324a6 Canvas: a tool drag past the edge auto-scrolls the view (#4183) (#4257)
* Canvas: a tool drag past the edge auto-scrolls the view (#4183)

Dragging a marquee, lasso, stroke or crop off the visible part of a zoomed
image now scrolls toward the pointer, as in Photoshop, and stops at the
scroll limits. An axis where the image fits does not scroll, and an
overscrolled view is not pulled back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VFAjTZQw6MTyhfpQqjAfoW

* Fix rotated canvas auto-scroll limits

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: jonathnwxh <[email protected]>
2026-10-12 20:53:49 -04:00
Marco Arnulfo 8145bea70f Control: a transport that gives up knows whether the command was dispatched (#3662) (#4256)
* Control: a timeout says whether the command ran (#3662)

A request still queued when its deadline passes is dropped before
dispatch, but it answered with the same text as the transport giving up
on a command that may still apply. The two now read differently: "timeout:
not run ... safe to re-send" and "timeout: no reply in 60s ... may still
apply", both keeping the `timeout` prefix machines match on.

* Control: a transport that gives up knows whether the command was dispatched (#3662)

The transport and the UI thread share the reply deadline, and the
transport's wait runs out first, so the UI thread's "not run" reply
almost never reached a client. A request now carries a dispatch flag
settled once between them: the UI thread claims it before running, the
transport abandons it when it stops waiting. The transport then replies
"not run" for a request that will never run, and "may still apply"
only for one that was dispatched; a request given up on is never run.
2026-10-12 20:53:33 -04:00
.CooCoo 3e3a948c0b Tests: GPU startup marker tests wait while another test's child process holds the lock (#4251)
returned_non_graphics_errors_do_not_trigger_backend_recovery failed at
random in full `cargo test` runs: "gpu-error: ...", left: false,
right: true. Its gpu-error case drops the sentinel, which leaves the
marker file in place, and calls `Sentinel::begin` again for the same
directory. Tests in monitor_profile.rs spawn processes in the same
binary. A child process started while the marker is open gets a copy
of its file descriptor, and the marker stays locked through that copy
until the child's exec closes it. When `begin` runs in that window, it
returns `Previous::Busy` instead of the crashed marker, and the
assertion fails.

sentinel_detects_a_crash_and_clears_on_success has retried `begin` for
this since 3b7870be (#289). The retry moves into a test helper,
`begin_when_unlocked`, which also runs where
returned_non_graphics_errors_do_not_trigger_backend_recovery and
a_failed_cpu_start_does_not_pin_later_starts start again after
dropping or finishing their sentinel. In
a_failed_cpu_start_does_not_pin_later_starts the CPU plan finishes the
sentinel, which removes the marker, so the helper's first `begin`
finds the lock free; the helper covers that test's branch for a plan
that keeps the marker.

A new test, begin_when_unlocked_waits_for_a_briefly_held_lock, takes
the marker's lock through a second handle after the sentinel is
dropped, releases it from a thread after 50 ms, and checks that the
helper returns the crashed marker. It fails when the helper calls
`begin` only once.
2026-10-12 20:53:09 -04:00
e627b01169 feat: resize Save for Web previews and custom palette editing for Gif export (#4129)
* feat: resize Save for Web previews and added palette editing for Gif/PNG8

* Save for Web: add Turkish and Hungarian rows for the preview navigation hint

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:50:00 -04:00
Fabio Lissi 137d7c2df1 Tests: set the Move tool's Auto-Select target to Layer in the layout_perf clicks (#4056) (#4548)
#4093 changed the Auto-Select default to Group, as in Photoshop. The two
layout_perf tests that click layers inside groups with Auto-Select on
expected the layer under the pointer, so they now select the group and
fail on main. They set the target to Layer explicitly, which is what
they test.
2026-10-12 20:44:01 -04:00
David MannandClaude Opus 5.5 2f0979f61f Global Light: reject angles beyond f32 range instead of storing NaN (#4181) (#4182)
layer.layerStyle.globalLight cast its params straight to f32, so an angle
of 1e39 became inf and rem_euclid turned it into NaN, which was stored,
recorded as a history step and fed to every effect using global light.
Angle and altitude are now read as f64 and rejected with BadParams when
non-finite or beyond f32 range, before the edit. In-range wrapping and the
altitude clamp are unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:38:05 -04:00
David MannandClaude Opus 5.5 0fe109f7b2 Contours (.shc): keep long names valid and readable (#4177) (#4179)
The writer capped names with encode_utf16().take(1024), which could end
on a lone high surrogate; it now uses io::utf16_units, like .aco/.ase,
so the cap falls on a character boundary.

The reader's "count left out the null" check looked at the last unit
kept, not the last unit read. For a name of 1024+ units the counted null
is read but not kept, so a following 0x0000 (an empty contour's point
count) was swallowed and the stream shifted. It now checks the last unit
actually read.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:38:01 -04:00
David MannandClaude Opus 5.5 43c7584d5d Warp: move only the selected part of a linked mask (#4175) (#4176)
With a selection, Edit > Transform > Warp warped only the selected pixels
but the whole linked layer mask, so the unselected mask no longer lined up
with the unselected pixels. Warp now limits the linked mask to the selection
through transform_cmds::move_gray_selected, as Free Transform and
Perspective Warp already do (#3083); layers that move whole still take their
whole mask along. Adds a regression test driven by edit.transform.warp.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:56 -04:00
David MannandClaude Opus 5.5 e4bfa7907f Video layers: edits keep the timeline work area (#4170) (#4171)
Every video-layer edit ran `work_end = work_end.max(duration)`, so
Show Altered Video, Restore Frame, Interpret Footage and the other frame
edits reset a narrowed work area to the full duration. A new
`grow_duration` helper, used by every video edit and by New Video Layer
from File, grows the timeline and moves the work-area end only when it
reached the old end; a narrowed work area stays where the user put it.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:52 -04:00
David MannandClaude Opus 5.5 a256f1c579 Paint Bucket and Gradient: validate opacity like the Magic Eraser (#4167) (#4168)
paint.bucket and paint.gradient read `opacity` as f(p,"opacity",100)/100
with no range check, so 200 became a coverage of 2 and overshot the blend
(#808080 on white painted near-black). Both now use the Magic Eraser's
param reader: the value is clamped to 1..100, and a non-numeric opacity is
BadParams before any edit or history step.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:47 -04:00
David MannandClaude Opus 5.5 f4b73f42ed Automation: reject an invalid document index instead of using the active document (#4160) (#4161)
Root cause: the JSON-lines server's `index_of` read `index` with `as_u64`, so a
string, negative, fractional or boolean index became None, which means "the
active document". `doc.close {"index":"0"}` therefore closed the active
(possibly unsaved) document instead of failing. The MCP server types the param
as Option<usize> and already rejects these values.

Fix: `index_of` returns Result<Option<usize>, AutomationError>. A missing or
null index still means the active document; a non-negative integer is used;
anything else is a bad request ("`index` must be a non-negative integer").
All doc.* call sites propagate the error with `?`.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:40 -04:00
David MannandClaude Opus 5.5 a8a30aed33 Layers panel: show the lock icon for every lock (#4158) (#4159)
Root cause: the layer row's reserved width and indicators, and the Studio
themes' lock button, treated a layer as locked only for transparency,
position or all. A "Lock image pixels" or artboard lock left the row
without its lock icon, and the Studio button drew "unlocked" although
clicking it unlocked the layer (simple_lock_toggle checked all five).

Fix: add Locks::any() in photocraft-doc and use it in all four places,
so every lock shows the icon, as in Photoshop. Tests cover each lock on
the row (reserved width and Indicator::Lock) and the Studio button state.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:34 -04:00
David MannandClaude Opus 5.5 cb3aef5753 Count tool: Marker Size and Label Size fields take effect (#4156) (#4124)
Root cause: the options bar sent markerSize/labelSize as f32 JSON floats
(8.0) and count.setGroup read them with as_u64, which is None for floats,
so the command returned Ok and changed nothing.

Fix: count.setGroup accepts whole numbers sent as floats (UIs, agents and
actions send them), rejects fractions, negatives and non-numbers with
BadParams, and clamps in u64/f64 so huge values no longer wrap through
`as u32`. The options bar now sends integers.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:29 -04:00
David MannandClaude Opus 5.5 991ebe16d5 Liquify, Puppet Warp and Perspective Warp: refuse locked smart objects (#4155) (#4123)
Root cause: run_on_layer in distort_cmds.rs added the smart filter and
returned for a smart object before it called check_locks, so a smart
object with Lock All or Lock Pixels (its own or a parent group's) took
the distortion and a history step.

Fix: check the pixel and full locks (not position) before recording the
smart filter, as regular filters do, and use the filters' error wording.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:24 -04:00
David MannandClaude Opus 5.5 1014c37eaa Find and Replace Text: backward Change/Find moves past the replaced text (#4154) (#4122)
After Replace Text the find cursor collapsed to the end of the replacement.
A backward search takes the last match ending at or before the cursor, so
when the replacement still matched (e.g. "cat" -> "Cat", case-insensitive)
it found the replaced text again and never reached earlier matches.

Collapse the cursor at the start of the replacement when searching backward
(the end when forward), mirroring how forward search skips it.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:20 -04:00
David MannandClaude Opus 5.5 426747d7d2 Auto-Select: honour layer mask density when picking layers (#4153) (#4121)
layer.pickAt (Auto-Select, the canvas right-click layer list and the Move
tool's rollover highlight) read the raw layer-mask pixel, while the
compositor uses LayerMask::value, which applies density (1 - d*(1 - v)).
A hide-all mask at density 0 shows the layer in full, yet the click went
through to the layer below.

Read the mask through LayerMask::value, as the compositor does (it also
returns 1 for a disabled mask). Regression test covers the click and the
list form.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:15 -04:00
David MannandClaude Opus 5.5 b0451df33b Compose: the effect cache follows Transparency Shapes Layer and Blend If (#4119)
Root cause: layer_identity, the effect-map cache key, hashed pixels, masks,
fill opacity and effects but not layer.advanced or layer.blend_if. Turning
Transparency Shapes Layer off (which makes the whole layer the effect shape)
or changing a styled group child's Blend If (which changes the composite the
group's shape comes from) left the key and region unchanged, so stale maps
were reused: a stroke kept rimming a hole until the cache was purged.

Fix: hash layer.advanced and layer.blend_if in layer_identity (recursing into
group children). Regression tests compare a warm render after each edit with
a cold render after purge_effect_cache.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:11 -04:00
David MannandClaude Opus 5.5 b87180e14e Swatches: reject colour values beyond f32 range so presets survive a restart (#4150) (#4118)
Root cause: SwatchColor::from_param checked that each value was finite as
an f64, then cast it to f32. A value like 1e39 became inf, and an HSB hue
then wrapped (rem_euclid) to NaN. swatches.add stored Hsb([NaN, ..]), the
prefs file wrote it as null, and on the next launch load_presets_json
failed to parse the whole presets blob, so every user gradient, style,
shape, tool/document preset and swatch fell back to the defaults.

Fix: reject values whose magnitude exceeds f32::MAX with BadParams, for
every colour model.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:07 -04:00
David MannandClaude Opus 5.5 b1b3dff8f1 PSD: a zero-length annotation entry no longer hangs the import (#4149) (#4117)
Root cause: parse_entries sets the cursor to start + len after each Anno
entry. An entry length below 8 (its own u32 plus the type) left the cursor
on the same entry, so a count of 0xFFFFFFFF re-read it about 4.3e9 times,
pushing an entry each time until allocation failed and the process
aborted (not a panic, so the import guard could not catch it).

Fix: reject an entry length below 8, so the block is reported unreadable
and kept verbatim (the #944 behaviour); every entry now advances at least
8 bytes. Regression test covers lengths 0/4/7 for sndA and txtA entries
plus the hostile 16-byte block.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 20:37:02 -04:00
0fbba911ba Quick Selection: cut window sizes in parallel, cancel the larger ones once one is kept (#4062)
Releasing a Quick Selection stroke on a 24 MP photo froze the canvas for
0.2-0.5 s: the growing-window loop cut a window, found the selection
touching its side, doubled the margin and started over, and on real photos
every stroke needed 2-4 sizes.

The window sizes are independent, so the next 4 are now cut at once (rayon)
and the first in growing order that stays clear of its sides is kept: the
same result as one after another. Once one is kept, larger windows of the
batch stop through the cancel hook grid_cut_impl / maxflow_with already
have, now also polled before the graph is built, before the max-flow and
between the coarse and fine levels of cut_levels. wasm32 keeps the
sequential loop.

36 strokes on a 6000x4000 photo: masks byte-identical to main, 21.9 s ->
13.9 s (-37%); small 30 px clicks +8 ms (setup of larger windows).

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

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

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

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 20:23:56 -04:00
7716d609e3 Affinity: rectangles keep per-corner radii and straight corners (#1606) (#4055)
Unlocked rectangle corners now each use their own radius and type (top left,
top right, bottom right, bottom left) instead of all following the first one,
and straight corners are cut across instead of rounded. Same fix as
VectorCraft's copy of the reader (storytold/vectorcraft#1362).

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 20:20:44 -04:00
003f9b4a18 Affinity import: pie shapes with huge angles no longer hang the reader (#4054)
* Affinity import: pie shapes with huge angles no longer hang the reader

The reader raised the end angle of a pie shape (`ShPi`) by `TAU` in a
loop until the end angle reached the start angle. Both angles come from
the file. The loop ran about `(AngS - AngE) / TAU` times, and it never
ended once adding `TAU` no longer changed the end angle: with
`AngS` = 1e308 and `AngE` = -1e308, reading the document did not return.

The reader now computes the pie's angles in constant time. The start
angle is `AngS` modulo `TAU`. The sweep is `AngE - AngS` capped at one
turn, or its remainder modulo `TAU` when `AngE` is below `AngS`. When
`AngE` is below `AngS` and that difference overflows to negative
infinity, the sweep comes from the remainders of the two angles.

The change and its two tests come from Riccardo Balloch's commit
79c5d673 in storytold/vectorcraft, which fixed the same loop in
VectorCraft's copy of this reader. Here the tests read the document on
a thread of their own and fail when reading takes more than 20 seconds.

Co-authored-by: Riccardo Balloch <[email protected]>

* Contributors: add dotCooCoo (Robert Lee)

---------

Co-authored-by: Riccardo Balloch <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:16:56 -04:00
d3d735c6c7 feat(ui): the shaped brush cursor turns and flips with the view (#4233)
#3467 traces a shaped tip's outline but scales it by the screen radius
alone, so on a rotated or flipped canvas (R, View › Flip Horizontal) the
cursor kept the document's orientation while the stroke did not.

The outline now goes on screen through the view's own axes:
ViewXform::map_vec maps a document vector (pixel aspect, rotation, flip,
zoom), and brush_cursor::tip_axes gives one tip radius along the
document's x and y. A plain view gives the same points as before.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 20:16:49 -04:00
9712f5e7d5 fix(selection): reject incomplete magic wand pixel buffers (#4046)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:14:41 -04:00
0761234c2f fix(algo): reject oversized Magic Eraser masks without overflow (#4043)
* fix(algo): reject oversized Magic Eraser masks before multiplying dimensions

* Format the oversized Magic Eraser mask test

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:14:33 -04:00
2338492b12 Type: Space inserts a space while editing type (#4224)
* Type: Space inserts a space while editing type

Space was grabbing the Hand tool during canvas type edit even though
Photoshop keeps Space typing a space. Skip temporary-tool holds when
ui.text_edit is active.

* Credit @Hevarh1 with sync_github_name in About

* Format

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:11:42 -04:00
Nigel Ihza Firdausy 799f28167f fix(selection): avoid panics when sampling incomplete region masks
Use checked mask indexing so incomplete or extreme regions return zero coverage instead of panicking or wrapping.

Validated with the full algo suite, independent native and Wasm regressions, and green PR checks.
2026-10-12 18:10:27 -06:00
ef937adf98 Refresh Liquify's zoomed preview when a stroke is undone. (#4135)
* Refresh Liquify's zoomed preview when a stroke is undone.

Above 100% zoom the dialog shows a source-resolution crop. Undo rebuilt the proxy and left that crop untouched, so the stroke stayed on screen until the next zoom.

Refs #4100

* Format

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:06:24 -04:00
dbadd21a3e Stylus: a pen held still is not a press-and-hold right click (#4106)
* Stylus: a pen held still is not a press-and-hold right click

On Windows a pen reaches egui as touch events, and egui takes any touch
contact held still past max_click_duration (0.8 s) for a touch screen's
press-and-hold: a secondary click (context menu) that also drops the drag.
Holding the pen still for a second opened a context menu.

PenContacts tells a pen contact from a finger in raw_input_hook (only a
pen hovers, and a pen reports pressure) and turns the press-and-hold
timeout off while one is down, through the frame it lifts in, so a long
still press releases as a tap. Fingers keep press-and-hold.

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

* Format

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 20:06:11 -04:00
xsvm 776b9dddd0 Clone Stamp and History Brush: releasing a stroke commits the live pixels (#4019)
Releasing a Clone Stamp or History Brush stroke rendered the whole
stroke again, although the live stroke had already drawn exactly those
pixels. The canvas now offers the live stroke to its commit as the Brush
does (#2131), through the same LiveCommit: a commit with the same
command, document revision, params and points takes the live surface
and reports fromLive; anything else renders as before.

LiveRetouch renders only the position and pressure of its points, which
is all the commands parse, and the offer is refused when the brush, layer
or clone source changed during the stroke (the revision doesn't cover
them). The Healing Brush, which blends on release, is left out.
2026-10-12 20:05:09 -04:00
13657f35f2 Docs: refresh target-app-parity and file-format-parity against main (#4417) (#4418)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 20:04:19 -04:00
af9316b97e Docs: refresh gaps.md against main (#4412) (#4413)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 20:03:29 -04:00
skylartaylor 3702555aa9 Make native RGB color policy explicit and preserve embedded profiles 2026-10-13 02:02:13 +02:00
4a53fad835 feat(codecs): add XPM2 plain-text image import support (#4264)
* feat(codecs): import XPM3 images with transparency (#3692)

* Correct XPM test expectation for green palette name

* feat(codecs): decode XPM2 plain-text image files

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 20:01:35 -04:00
5981a02e64 feat(codecs): support full X11 rgb.txt color names in XPM (#4263)
* feat(codecs): import XPM3 images with transparency (#3692)

* Correct XPM test expectation for green palette name

* feat(codecs): resolve full X11 rgb.txt color names in XPM

* XPM: fix Format::ALL length after DPX, reject '+' in hex colours, fix non-ASCII byte-string test, fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:59:36 -04:00
David MannandClaude Opus 5.5 9970cf7214 Brush resize HUD: horizontal is size and vertical is hardness in a rotated view (#4228) (#4229)
The Control+Alt / Alt+right-drag HUD measured the drag in document axes and
only corrected for View > Flip Horizontal, so at 90 degrees a horizontal drag
changed the hardness and at 180 degrees dragging right shrank the brush.
Map the anchor and pointer to screen with the active ViewXform and divide by
its zoom: identical numbers at rotation 0 (flipped or not), screen-relative
when rotated, as in Photoshop. Without a view the old behaviour remains.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:59:11 -04:00
David MannandClaude Opus 5.5 f52549571f Rotate View: the image follows the pointer in a flipped view (#4226) (#4227)
ViewXform::to_screen mirrors x after rotating, so with View > Flip
Horizontal on a larger rotation turns the image counter-clockwise on
screen. The Rotate View drag and the trackpad rotate added their screen
delta unchanged, so the image spun against the pointer. A small helper,
rotate_view::screen_delta, negates the delta in a flipped view and serves
both paths; Shift still snaps the final angle.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:58:57 -04:00
David MannandClaude Opus 5.5 4566f5f55f Shape tools: the click-to-open-dialog tolerance is the same on HiDPI displays (#4205) (#4207)
finish_gesture passed the device-pixel zoom (current_zoom) to
shape_dialog::is_click, whose 3-point slop is in egui points, so at
pixels_per_point 2 the tolerance halved and a slightly jittery click drew
a tiny shape instead of opening Create Rectangle/Ellipse/... Pass
point_zoom(), as the marquee already does.

Regression test: a 2 pt jitter at ppp 2 and 100 % opens the dialog and
adds no layer.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:58:43 -04:00
David MannandClaude Opus 5.5 f60202879d Exposure: the slider keeps fractional stops (#4164) (#4165)
The shared adjustment slider rows rounded to 0.01 for ranges of 10 or
less and to whole numbers otherwise, so Exposure (-20..20) snapped to
whole stops: 0.5 became 1. Each row can now carry its own decimals:
Exposure keeps 2 and Offset 4, as in Photoshop, and their fields show
that many places. All other rows (Gamma, Posterize, Threshold,
Brightness/Contrast, Vibrance, Hue/Saturation ranges, Photo Filter
density) keep the old rule.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:58:30 -04:00
David MannandClaude Opus 5.5 5ce608e5f5 Image Size and Canvas Size: no silent resize of large documents in physical units (#4162) (#4163)
The Width/Height fields capped non-pixel units at 30000. egui's DragValue
clamps the shown value to its range when drawn, and number_edit reports that
as an edit, so a 40000 px document at 72 ppi shown in points opened as
30000 pt and OK resized (Image Size) or cropped (Canvas Size) it with no
edit. The bound is now the engine's 300000 px limit converted into the
current unit, never below 30000.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:58:16 -04:00
Vikram Bhaskaran bfa63f77b4 Snap 1 px pencil clicks to the pixel the aliased tip paints. (#4140)
A shift-line between two clicks inside the same pixels changed with the sub-pixel position. Pencil and pencil-mode Eraser clicks now use the same grid centre as the dab.

Fixes #4071
2026-10-12 19:58:03 -04:00
Vikram Bhaskaran 4709af046b Draw the Free Transform rotate arrow instead of hiding the pointer. (#4139)
The rotate zone requested CursorIcon::None, which removes the system cursor. Crop already draws a turn arrow for that case. Free Transform now uses the same arrow.

Fixes #4050
2026-10-12 19:57:49 -04:00
Vikram Bhaskaran 9fd20de408 Keep Liquify undo and redo working while a numeric field is focused. (#4134)
Ctrl+Z was ignored whenever a slider's text edit had focus, so a stroke could not be undone from the keyboard. Other Liquify keys still stay out of that field. The bracket keys also step the brush by at least one pixel, so a 2 px brush can reach 1 px.

Fixes #4027
2026-10-12 19:57:34 -04:00
David MannandClaude Opus 5.5 d323d872b2 Layers panel: double-clicking an effect row opens that layer's Layer Style (#4157) (#4131)
Root cause: the effect rows' double-click handler called
layer_style::open, which always reads the active layer. Double-clicking
an effect row of a layer that wasn't active opened (and on OK edited)
the active layer's style instead.

Fix: new layer_style::open_for_layer selects the row's layer with
layer.select first, as the row's right-click menu does, then opens the
dialog. Regression test covers two layers with only the inactive one
carrying an effect.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:56:50 -04:00
David MannandClaude Opus 5.5 b26bab69ee Layer Style: OK keeps an Inner Glow's Center source (#4152) (#4120)
The dialog's values_of() started each Inner Glow from the factory
defaults ("source": "edge") and never loaded the effect's own source,
so a Center-source glow (from a command or a PSD) showed Edge, previewed
as Edge and became Edge on OK, since the engine's overlay applies the
"source" key it receives.

values_of() now loads the source for Inner Glow. Regression test:
inner_glow_center_source_survives_ok.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:56:47 -04:00
SckraTandClaude 1e9f1ae486 perf(engine): read selection coverage per row when mixing edits back (#4424)
Destructive adjustments (pixels::adjusted) and the RGBA filter helper (adjust_cmds
process_surface) each had the same loop mixing the edited pixels back over the original by
selection coverage, looking the selection's tile up for every pixel (sample_channel, a
BTreeMap lookup per pixel). They now share pixels::mix_through_selection, which reads the
coverage one row at a time with sample_row_strided: one tile lookup per tile per row, and a
row-sized buffer.

Results are unchanged: a new test compares the helper with the per-pixel sample_channel mix,
bit for bit, for 8-bit, 8-bit-with-alpha and 32-bit selections over 8- and 16-bit layers,
across tile edges and untouched (default) selection tiles.


Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf

Co-authored-by: Claude <[email protected]>
2026-10-12 19:55:21 -04:00
SckraTandClaude e683108613 perf(compose): read PHOTOCRAFT_FX_NOCACHE once (#4423)
effect_maps looked the environment variable up (taking the process environment lock) for
every effect-map lookup, i.e. per styled layer per render tile. It is read once, like
PHOTOCRAFT_FX_TRACE in the GPU compositor.


Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf

Co-authored-by: Claude <[email protected]>
2026-10-12 19:55:13 -04:00
SckraTandClaude 3c469bfd05 perf(engine): move layers without a second document clone (#4422)
move_layers cloned the document on every layer.translate, Align and Distribute only to read
the pre-move state while type and shapes re-render. Session::edit already clones the
document for the edit, and the callers hold the starting state (an Arc), so they pass it in.

The clone copies the layer tree and every surface's tile map (pixel tiles themselves are
shared), so its cost grows with the layer and tile count: a nudge in a document with many
layers or very large layers goes from two such clones per command to one.


Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf

Co-authored-by: Claude <[email protected]>
2026-10-12 19:55:05 -04:00
SckraTandClaude be43d58f1a perf(compose): stop sweeping the bounds cache on every call once it is large (#4415)
compose::bounds caches per-tile content bounds and drops entries of dead tiles when it holds
more than 65,536 of them. Once more than 65,536 tiles were alive (a large document plus its
history), every call (every render tile, every GPU plan) walked the whole map again and kept
it all. The sweep now runs past a high-water mark: twice the entries that survived the last
sweep, and at least 65,536.

The per-tile scan moves to raster as Tile::content_bounds, used by both
Surface::content_bounds and the cache instead of two copies of the loop; an empty pixel
yields no content instead of a chunks_exact(0) panic.


Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf

Co-authored-by: Claude <[email protected]>
2026-10-12 19:54:57 -04:00
SckraTandClaude 97448b27f3 perf(raster): moved fills keep sharing one copy-on-write tile (#4414)
Surface::translated copied every target tile of a move that isn't a whole number of tiles,
so a solid fill (one shared tile) became one unique tile per coordinate after its first
nudge: each later move, undo and refresh then rescanned and re-uploaded all of them, and
history kept them all.

A target tile whose window reads only tiles holding one single pixel value (each source
tile is scanned once per call; one tile is kept per value) now shares that tile. Fully
covered targets also skip the default-pixel pre-fill they are about to overwrite.

12 MP RGBA fill nudged by +-(10, 7) twenty times (release, 4-core container): unique tiles
192 -> 76; RGBA16 move p50 55 -> 21 ms; RGBA8 unchanged within noise. Pixels are unchanged
(the test compares every depth with the copied pixels, and checks that a shared but
non-uniform tile is still copied).


Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf

Co-authored-by: Claude <[email protected]>
2026-10-12 19:54:50 -04:00
Valentin KleinandClaude Opus 5.5 dc8a3f0b35 Paint Bucket and Edit > Stroke: a CMYK foreground writes the picked inks (#4351) (#4352)
* Color Picker: CMYK foreground fills a CMYK document with the picked inks (#4196)

The tool colours are stored as sRGB, so a colour picked as CMYK 100/0/0/0
(outside sRGB) came back as 100/28/0/17 when a CMYK document was filled.
tools.setColors now takes foregroundCmyk/backgroundCmyk, the picker sends
the entered inks, and Edit > Fill (and the fill keys) write them as they
are into a CMYK target. Fill also enters the document's CMYK profile.

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

* Brush and Pencil paint a CMYK foreground with the picked inks in a CMYK document (#4344)

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

* Paint Bucket and Edit > Stroke: a CMYK foreground writes the picked inks (#4351)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:54:16 -04:00
skylartaylor 4a730912c0 Preserve PDF text and CMYK paths and reject native layout mismatches 2026-10-13 01:53:16 +02:00
skylartaylor 9f7f8b7a7b Check that driver copy fields remain active after validation 2026-10-13 01:51:12 +02:00
skylartaylor 3c4e1536a0 Merge current PhotoCraft and preserve asynchronous native printing 2026-10-13 01:48:54 +02:00
Valentin KleinandClaude Opus 5.5 46740ad4bc Brush: CMYK foreground paints the picked inks into a CMYK document (#4344) (#4347)
* Color Picker: CMYK foreground fills a CMYK document with the picked inks (#4196)

The tool colours are stored as sRGB, so a colour picked as CMYK 100/0/0/0
(outside sRGB) came back as 100/28/0/17 when a CMYK document was filled.
tools.setColors now takes foregroundCmyk/backgroundCmyk, the picker sends
the entered inks, and Edit > Fill (and the fill keys) write them as they
are into a CMYK target. Fill also enters the document's CMYK profile.

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

* Brush and Pencil paint a CMYK foreground with the picked inks in a CMYK document (#4344)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:47:14 -04:00
f48a72cbe3 Codecs: open and save DPX frames (#1049) (#4104)
Read SMPTE 268M DPX in both byte orders (SDPX, XPDS): the first image
element, descriptor 6 (luma), 50 (RGB) or 51 (RGBA), at 8, 10, 12 or 16
bits, with 10-bit packing 0, 1 (method A) or 2 (method B) and 12-bit
packing 0 or 1. 10- and 12-bit code values open as U16
round(c * 65535 / (2^b - 1)) and come back exactly when written at their
depth. No colour transform: the transfer characteristic and colorimetric
specification are reported as import notes; extra image elements are
reported; the orientation is applied on import.

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

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

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:44:04 -04:00
Valentin KleinandClaude Opus 5.5 3ba0e9419c Color Picker: CMYK foreground fills a CMYK document with the picked inks (#4196) (#4339)
The tool colours are stored as sRGB, so a colour picked as CMYK 100/0/0/0
(outside sRGB) came back as 100/28/0/17 when a CMYK document was filled.
tools.setColors now takes foregroundCmyk/backgroundCmyk, the picker sends
the entered inks, and Edit > Fill (and the fill keys) write them as they
are into a CMYK target. Fill also enters the document's CMYK profile.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:39:55 -04:00
7366a0b429 Italian: native-speaker review of the UI catalog (#4290)
* Italian: native-speaker review of the UI catalog

Fix mistranslations: Trap (Trapping, was overprint), HDR Toning (Mappatura tonale, was chemical toning), Display Text (Mostra testo), Vine Color, Override Action "Save" Commands. Improve phrasing: Bevel & Emboss, East Asian / Middle Eastern Features, linked smart objects, Conditional Mode Change (metodo, as in Image > Mode), bit depth labels. Record the review and new glossary terms in the localization docs. Credit lucAmbr0 in people.toml.

* Tests: allow Italian's compact 8/16 bit labels, which match the source

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:38:55 -04:00
Bihacsy LászlóandBrandon Thomas 992d945da7 i18n(hu): informal address in the rows added on merge (#4334)
* i18n(hu): informal address in the rows added on merge

Seven of the 23 rows translated when #3831 was merged addressed the
user with the formal verb forms; the catalog uses the informal second
person throughout. One of them also glued a hyphen to the {key}
placeholder; it now reads "{key}+kattintás: …" like the other hints.

* i18n(hu): translate the Enable animations preference

The Interface › Enable animations preference (#3273) reached main after
#3831; preference_labels_are_translated needs a Hungarian row for it.

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 19:32:00 -04:00
Marco Arnulfo a1fbfd9a6c Filters on a type layer: ask to rasterize or convert to a Smart Object instead of greying out (#4333)
* Filters on a type layer: ask to rasterize or convert to a Smart Object instead of greying out (fixes #4303)

* Filters on a type layer: Liquify, Filter Gallery, Camera Raw, Adaptive Wide Angle, Vanishing Point and Last Filter ask too (fixes #4303)
2026-10-12 19:30:58 -04:00
9f98df582a Stylus: a light Windows pen tap is pressure 0, not a full-pressure click (#4072)
winit's Windows pointer path reports pen pressure 0 as no force, so a tap
lighter than the pen's first pressure level arrived as a force-less touch.
Stylus::update ignored those, saw no pen, and the tap painted at 1 like a
mouse click.

Track touch contacts: a touch move outside a contact (hovering) or any
reported force marks a pen, and a pen contact with no force yet is pressure
0. A finger on a screen without force sensing still paints at 1. A contact
that starts in the frame another lifted now keeps its force too.

Also credit @Furash in contributors/people.toml.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 19:27:14 -04:00
David MannandClaude Opus 5.5 6c0dcf428b Color Table: keep the transparent index inside the palette (#4186) (#4187)
Replacing the colours could shrink the table while the transparent index
still pointed past its end, so the palette PNG export refused the table.
A stale index is now dropped (pixels end up as with "transparent": null),
and an explicit invalid index (out of range, negative, non-integer) is a
BadParams error before any edit instead of silently keeping the old one.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:24:17 -04:00
61c52c08db Brush Settings: toggle switch rows without losing smoothing settings (#4463)
Noise, Wet Edges, Build-up, Smoothing and Protect Texture toggle from
anywhere on their row while keeping the selected controls open. Keep
lock clicks independent and expose each switch as one accessible checkbox.

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

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

Fixes #4283

Co-authored-by: augrclk <[email protected]>
Co-authored-by: GPT-6 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 19:23:17 -04:00
d303bf9b83 Brush cursor: the tip's centre mark follows Show Crosshair in Brush Tip (#4105)
* Brush cursor: the tip's centre mark follows Show Crosshair in Brush Tip

Brush tips wider than 6 screen pixels always drew the centre crosshair, so
Preferences > Cursors > Show Crosshair in Brush Tip could add it but never
remove it. Photoshop shows it only when that preference is on. The
Background Eraser keeps its sampling hotspot, and Clone Stamp / Healing
Brush still show it while Option is held.

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

* rustfmt canvas.rs

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 19:22:05 -04:00
ae72084a9c Resolve every pattern name before a batch delete removes any of them. (#4132)
* Resolve every pattern name before a batch delete removes any of them.

A missing name used to delete the earlier presets and then return an error. The pattern library is not on the document undo stack, so that partial delete could not be undone.

Fixes #4103

* Pattern batch delete: reject non-string entries and resolve against the library only

A document-only pattern resolved through pattern_cmds::resolve, so the batch
deleted earlier entries before pattern.delete refused it. Non-string array
members were silently dropped (ported from #4127).

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:19:23 -04:00
AlonandClaude Opus 5.5 9d37e1518b fix(move): no trail when dragging a shape past the canvas edge (#3946) (#4069)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 19:17:36 -04:00
Nigel Ihza Firdausy 5c4ad593de fix(ui): reject rotation angles outside f32 range (#4044) 2026-10-12 19:17:31 -04:00
Nigel Ihza Firdausy fd4ba52f79 fix(ui): cancel unfinished polygon on lasso tool switch (#4021) 2026-10-12 19:17:25 -04:00
67c90e307f Quantize the colour field when Only Web Colors is on. (#4138)
* Quantize the colour field when Only Web Colors is on.

The picked colour already snapped to the web-safe ramp, but the field and slider stayed continuous, so the restriction was invisible.

Fixes #4107

* Only Web Colors: draw the field and slider in flat web-safe bands

The coarse interpolated mesh blended between snapped corners, so it showed a
smooth low-resolution gradient rather than bands. Draw flat cells sampled at
their centres instead, and test that every cell is a single web-safe colour.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:12:59 -04:00
Fabio Lissi 90c42f5555 Map Place, Open, Image Size in percent, the Paint Bucket and Insert Conditional (#219) (#4470)
- image.imageSize takes percent (both sides; an explicit side wins).
- actions.conditional {if, then, else}: Photoshop's Insert Conditional as a step, 33 conditions
  on the document (shape, mode, depth, selection, layers, alpha channels, unsaved) and the
  active layer (kind, locks, visibility, masks, effects); a failing branch stops the caller.
- .atn events: placeEvent → file.placeEmbedded/placeLinked (path, scale), open → file.openAs,
  imageSize in percent, fill from a point → paint.bucket (pixels), conditional. 105 → 108.
- Panel labels conditional steps as 'If cond → then / else'.
2026-10-12 19:12:35 -04:00
3f0a633056 Make channel filter previews match the committed edit (#3979)
* Fix selected-channel filter previews and reduced Quick Mask proxies

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

* Keep the new dialog preview aligned with the selected channel

* Bound full-resolution dialog preview textures to the device limit

---------

Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:05:49 -04:00
e2a35b04fd Windows: keep full screen and maximized apart; never restore full screen (#4396)
* Windows: keep full screen and maximized apart; never restore full screen

winit on Windows keeps the maximized style on a window that goes full
screen from maximized. The shell then draws the taskbar black instead of
hiding it, and on the way back the window stays maximized at the
monitor's full size, over the taskbar (#3550, #4018, #4230). Un-maximize
before entering full screen and maximize again after leaving it.

eframe also restores full screen from ui.ron, as full screen and
maximized at once; drop that flag in the window builder hook, so
PhotoCraft starts in Standard Screen Mode as its own state says.

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

* contributors: add aweussom

* view_cmds: mark the winit#4756 workaround and how to retire it

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

* rustfmt view_cmds.rs

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 19:05:37 -04:00
Fabio Lissi 13f5c104f6 Map the remaining frequent Photoshop action events (#219) (#4465)
- edit.transform gains the relative form (scaleX/scaleY %, rotate, dx/dy about the frame
  centre), as a Photoshop action records a Free Transform; image.applyImage takes a source
  layer by name.
- .atn events: transform (layer → translate or relative transform; selection →
  transformSelection), applyImageEvent, export (Save for Web, with the txtu path record
  decoded and written back; kept verbatim without a destination), newPlacedLayer /
  placedLayerEditContents / updatePlacedLayer, close with the Save answer → save + close,
  delete of the target channel → layer mask delete, Quick Mask on/off, polygon selections →
  select.lasso, Gradient Map (direct and as an adjustment layer). 92 → 105 events.
- Export tries every event listing a command id (Quick Mask on → set, off → clearEvent).
- Docs: event counts in the parity docs.
2026-10-12 19:02:15 -04:00
FredApps 7015f2916d View: Esc leaves full screen modes (part of #4116) (#4145)
Esc returned to Standard only from Full Screen Mode, and was consumed in
the UI pass before tools, dialogs and popups could see it. Handle it in
the shortcut chain instead, after everything that owns Esc, for both
full screen modes, via view.screenMode.standard.
2026-10-12 19:01:24 -04:00
bbfaefd5f0 File Handling: wire Maximize PSD and PSB File Compatibility (#204) (#3955)
The preference was stored but read nowhere, so Edit › Preferences hid it.
Never now writes Photoshop's white placeholder instead of the merged image
and records has_real_merged_data = false in resource 1057, without
rendering the composite: smaller files and faster saves. Always and Ask
When Saving keep the real merged image, as Image Previews already does
with Ask. A file without layer records always keeps its real merged image,
and layered TIFF still renders the composite it is made of.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 19:00:03 -04:00
4ff2dc33ca Fix multi-layer Delete and Move drags on a locked Background (#4109)
* Fix multi-layer Delete and Move drags on a locked Background.

Delete was removing only the active layer's targeted mask when several layers were selected. A Move drag that starts on the locked Background now draws the layer marquee instead of showing the lock dialog.

* Drop the multi-layer Delete hunk: #4078 fixes #4025

This PR keeps only the Move-on-locked-Background marquee fix (#4058).

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

* Move: a click on a locked layer still selects it before the marquee

Keeps hidden_layer_tests::auto_select_skips_hidden_layers green: Auto-Select
picks the visible Background under the pointer, and a drag's marquee replaces
that selection on release.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 18:56:58 -04:00
Marco Arnulfo cd36d1ea0d Delete with several layers selected deletes them, not the active layer's targeted mask (#4025) (#4078) 2026-10-12 18:52:43 -04:00
Fabio Lissi 9862dfbb09 Actions panel: set folders, dialog toggle, Insert Stop (#219) (#4456)
* Actions panel: set folders, dialog toggle column, Insert Stop (#219)

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

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

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

A filter confirmed from a dialog runs as a background job in the desktop app; the resumed
steps met a locked document and failed. Wait for the job first (test covers background jobs).
2026-10-12 18:51:53 -04:00
Marco Arnulfo 40427f25dc Properties: a new adjustment or fill layer brings Properties to the front (#4028) (#4079) 2026-10-12 18:47:59 -04:00
zenjabbaandzenjabba 95dbb01ad9 fix(gpu): preflight compositor uniform buffer limits (#3931)
Co-authored-by: zenjabba <[email protected]>
2026-10-12 18:44:09 -04:00
mroxsoandhighperfocused 23abc4302b fix(ui): default the Move tool Auto-Select target to Group (#4056) (#4093)
Photoshop ships with Auto-Select on and its target set to Group;
PhotoCraft defaulted to Layer. ToolOptions::default and the serde
default now return "group"; saved preferences keep their stored value.

Co-authored-by: highperfocused <[email protected]>
2026-10-12 18:43:21 -04:00
049a92c288 feat(engine): recolor vector shape layer fill with Alt+Backspace and Cmd+Backspace (#3925)
* feat(shape): recolor vector shape fill via Alt/Cmd+Backspace fill keys

Photoshop fill keys (Alt+Backspace for foreground and Cmd+Backspace for background, as well as their Shift variants) now recolor the active vector shape layer's solid fill when targeting a shape layer, matching Photoshop 1:1 instead of erroring out with 'active layer is a Shape layer, not a pixel layer'. Channel and mask targets continue to be routed to edit.fill. Includes undo support, locked layer checks, and regression tests.

* Fill keys: keep Fill from History's own enablement after merging main; rustfmt

edit.fillHistory joined the shared fill-key IDS on main and shares their
enabled check, which now also accepts shape layers. Give Fill from History
edit.fill's check so it isn't enabled where it can only fail, and limit the
locked-shape test to the four colour keys.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 18:40:05 -04:00
Fabio Lissi 3204231d7d Load and save Photoshop Actions (.atn) (#4356, #219) (#4434)
* Load and save Photoshop Actions (.atn) (#219)

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

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

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

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

The legacy dock renders its own group menu and only knew the Layers and Swatches entries, so
Load Actions… and Save Actions… were missing there.
2026-10-12 18:39:57 -04:00
zenjabbaandzenjabba dd992c7e57 fix(color): validate RGB working profiles atomically (#3924)
Co-authored-by: zenjabba <[email protected]>
2026-10-12 18:34:59 -04:00
7f8b5816ca Use whole group bounds for transforms with active selections (#3970)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 18:30:20 -04:00
xsvm 63fd5ec7b6 Selection: lasso polygons scan only the edges that cross each row (#3959)
* Selection: lasso polygons scan only the edges that cross each row

The polygon fill behind Lasso, Polygonal Lasso and Magnetic Lasso tested
every edge on every sub-row of the whole document, O(height x 4 x points),
on one thread. It now keeps an active edge list over the edges sorted by
their top and fills bands of 64 rows in parallel. Crossings use the same
formula and spans accumulate in the same sorted order, so the mask is bit
identical; a test compares it with the previous fill.

* Selection: the lasso fill bands go through par_rows; edges are a named struct
2026-10-12 18:26:39 -04:00
Gert Pellin - GPE d32d3f41bb Save As: a saved .pcraft manifest reports the file it was saved under (#3312) (#3571)
photocraft_io::named_for_file(doc, name) serialises the document under the
file it is being written to, so a .pcraft manifest carries the identity the
save is about to adopt rather than the previous one — the same rule every
other format follows on import, where document.name comes from the file it
was read from. A bare extension names no file and changes nothing.

Applied once, in the .pcraft branch of export_stages, which export and
export_with both funnel through: the desktop Save (services.rs:242),
Export (services.rs:384), the web build (web.rs:355) and the engine file
command (file_cmds.rs:295) are covered by one site, and the previews
rendered before the bytes describe the same document as the manifest.

Scope per review: the automation funnels (files::save / save_bytes) already
landed in #3319 with their own named_for_output, so this carries no
crates/automation change; its scenarios are covered by
crates/automation/tests/native_save_name.rs on main.
2026-10-12 18:19:22 -04:00
50b97605fb Convert grayscale profiles before untagged exports (#3978)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 18:13:21 -04:00
ddf6696dce Pencil: no 8-bit dither on aliased strokes, one flat colour at partial opacity (#3990)
The 8-bit stroke dither (render.rs `dither8`) ran on every stroke, including
aliased ones. An aliased stroke paints each pixel by the same amount, so it
has no gradient to band; dithering only scattered it over neighbouring
levels. A 1 px Pencil line of 219/41/61 at 30% opacity over white came out
as four colours (244/191/197, 245/191/197, 244/190/196, ...), and a
foreground between 8-bit levels did the same at 100%. Photoshop's Pencil
paints one value, and pixel art relies on it: a tolerance-0 Magic Wand or
Paint Bucket and Indexed Color palettes trip on the grain.

Composite now skips the dither when the brush is aliased (Pencil, Eraser
Pencil and Block modes). Soft brushes keep it.

Test: an_aliased_stroke_at_partial_opacity_is_one_flat_colour (fails
before the fix at pixel 5). photocraft-paint 96/96, photocraft-engine
1529/1529, ui-egui pencil 13/13, clippy -D warnings and fmt clean.

Co-authored-by: cloudalister <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 18:10:29 -04:00
buivanvu94 e753a71851 feat(automation): allow Crop and Straighten Photos through automation (#1052) (#3917) 2026-10-12 18:03:32 -04:00
5dac5c6d61 Tests: allow-list language-invariant bit-depth labels, add missing tr/hu 'Enable animations' (#4359) (#4362)
* Tests: language-invariant bit-depth labels are allow-listed; add missing tr/hu 'Enable animations' (#4359)

Turkish writes '8 bit'/'16 bit' exactly like the English source, so the
translated-differs assertion needs an explicit allow-list (the entry must
still exist). hu and tr had no 'Enable animations' preference label.

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

* Merge main; drop the Enable animations rows main already has

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 17:33:30 -04:00
skylar taylor-barrick b3222ee8e7 Merge pull request #3872 from skylartaylor/codex/photocraft-craft-print
Use craft-print for system printing
2026-10-12 15:00:09 -06:00
skylar taylor-barrick 91b08488c8 Validate paired purge availability in MCP resource tests 2026-10-12 20:55:44 +02:00
skylar taylor-barrick 7b0317ff41 Merge PhotoCraft 0.8.2 and stabilize background-job tests 2026-10-12 20:21:10 +02:00
skylar taylor-barrick a301e31864 Merge current main fixes and validate craft-print rollout 2026-10-12 19:39:05 +02:00
Claude d43db3e153 Release 0.8.2: web build back under the wasm size gate
0.8.1's Web job failed: photocraft-web's wasm was 25,587,059 bytes, over
packaging/web/package.sh's 24 MiB gate. #4253 merged with #4147 brings
it to 24,161,781.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 12:57:00 -04:00
Claude 7f9c4394c1 Merge #4253: wasm back under the 24 MiB gate
Combined with #4147's opt-level "z" list. Conflict in Cargo.toml's
wasm-release profile: kept #4147's photocraft-psd = "z" and dropped
#4253's "s" entry for it (one table per package). Measured with trunk
--release: main 184c226a is 25,235,857 bytes (over the 25,165,824 gate);
with this merge, 24,161,781.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 12:56:54 -04:00
Brandon ThomasandClaude Opus 5.5 184c226a76 Fix main: wasm size cap, Windows Export As test, filter preview window (scope and a crash), CLI duplicate font warnings (#4147)
* test(ui-egui): Export As artboards test accepts the Windows path separator

The test added in #3909 compared the second file's path with a literal
'/', but the exporter joins the sibling name with the platform separator,
so Windows CI saw '/out\boards-2-Capsule.jpg' and failed.

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

* fix(ui): only Filter menu dialogs get the in-dialog preview window

#3877 drew the preview window in every schema dialog with a Preview
checkbox, including Image > Rotation > Arbitrary. Photoshop's Rotate
Canvas and adjustment dialogs have no preview window, and the taller
dialog covered the canvas, which broke
arbitrary_rotation_preview::size_changing_rotation_preview_is_visible_and_preserves_the_panned_center
on main. The window and its reserved height now apply to filter.*
commands only.

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

* build(web): opt-level z for large cold crates in the wasm release profile

The browser wasm sat at the 24 MiB gate. The document model, file
parsers (psd, format, affinity), serde_json, usvg and naga are off the
per-frame and per-pixel paths; opt-level "z" for them takes the
release wasm from 25,186,233 to 24,829,840 bytes (-356 KB) with no
behaviour change.

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

* fix(cli): run prints each missing-font warning once

Since #3630 the headless open and save also report missing fonts, so
`run <file> --out` and `run --cmd ... --out` printed the same warning
two or three times and missing_typeface_is_reported_by_run_info_and_convert
failed on main. Warnings from the open and the save now go through the
same already-reported set as the per-command ones.

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

* fix(ui): the filter preview window never uploads a texture past the GPU limit

With low-resolution previews off, the preview window from #3877 uploaded
the whole document as one texture. egui panics on a texture bigger than
max_texture_side, so opening a filter dialog on a large image crashed
(full_res_preview::filter_dialog_previews_at_full_resolution_when_low_res_is_off).
The texture is now reduced to fit; the view maps through UV fractions,
so it still lines up.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:48:28 -04:00
Brandon ThomasandClaude Opus 5.5 2db6b7d8f9 Release 0.8.1: rebuild with the fixed shared release workflow
The previous release build failed in craft-common-actions, not in this
repo: riscv64 packaging got --formats deb,rpm,tar (fixed in
craft-common-actions#3), Linux jobs lacked the ALSA headers
(craft-common-actions#5), and Windows signing needed the AZURE_* secrets
listed in the caller.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 11:11:02 -04:00
Brandon ThomasandClaude Opus 5.5 3d6050b931 Release workflow: pass the Azure signing secrets so Windows builds are signed (#3794)
Since the release workflow moved to storytold/craft-common-actions, it has
passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads
values from the release environment, but only for names the caller lists, so
the AZURE_* names that Trusted Signing uses came through empty and Windows
builds would ship unsigned. Same fix as storytold/craft-launcher#174.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:08:34 -04:00
9e623e7a45 i18n: add Hungarian (hu) UI translation (#3831)
* i18n(cs): drop duplicate catalog entries

The Czech catalog listed Tool, Clone Stamp, History Brush, Dodge, Burn,
Sponge and Stroke Path… twice, which fails `cargo xtask i18n-coverage`
and the bundled_catalogs_are_consistent test. Keep one row each; for
Stroke Path… keep the later row, the one the loader already used.

* test(crop): pin the UI language to English in crop_default_mode

The test queries widgets by their English labels ("Ratio", "Swaps height
and width") but its harness followed the system locale, so it failed on a
machine whose locale has a registered catalog. Set interface.language to
"en" in the harness, as paint_bucket_all_layers and export_as_scaling do.

* i18n: add Hungarian (hu) UI translation

A complete Hungarian catalog (hu.tsv, 3908 entries: 3829 plain, 50
contextual, 21 plural, 8 command-id overrides) registered with the
complete-menu languages; hu-HU locales resolve to it.

Plural rule: plural_none. A Hungarian count takes the singular noun
("3 réteg"), so every @plural entry has one form.

Clean room: written from the plain meaning of the English labels with
ordinary Hungarian image-editing vocabulary; no proprietary localisation
resource, string table or product UI was used.

Also listed in the coverage test, LICENSE-translations.txt,
ATTRIBUTION.md and docs/localization.md, with tag, system-locale and
plural tests.

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:07:17 -04:00
b3309e27d7 i18n(tr): add Turkish UI translation catalog (#3809)
Add Turkish (tr) translation catalog covering 100% of measured UI strings (1,727/1,727 keys, 3,731 catalog entries).

- Register Turkish language in crates/ui-egui/src/i18n/mod.rs with complete_menus: true and plural_none
- Add tr.tsv translation catalog
- Add negotiation and coverage tests in system.rs, mod.rs and i18n_coverage.rs
- Add MIT OR Apache-2.0 notice in LICENSE-translations.txt
- Update docs/localization-parity.md

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 11:01:46 -04:00
+3 67e4b8a499 feat: Alt+Cancel → Reset for opt-in command dialogs (#3871)
* Update .gitignore and add UI Dialogs documentation

- Added .specstory, .private, and .vscode to .gitignore to exclude additional files from version control.
- Introduced a new documentation file, ui-dialogs.md, detailing the architecture, rules, and usage of modal and modeless dialogs in the egui shell, mirroring Photoshop's functionality.

* Enhance UI Dialogs documentation in ui-dialogs.md

- Expanded the section on command routing and dialog types, clarifying the roles of various dialog kinds and their associated modules.
- Improved descriptions of routing markers and private keys, ensuring better understanding of the dialog architecture and usage.
- Updated the menu command entry details to reflect changes in module organization and functionality.

* Implement Alt+Cancel (Reset) functionality for various UI dialogs

- Introduced a new module `dialog_alt_reset` to handle the Alt+Cancel behavior, allowing users to reset dialog fields to their original values while keeping the dialog open.
- Updated multiple dialog modules to support this feature, including adjustments for color pickers, sizing, and guide layouts.
- Added tests to ensure that the reset functionality correctly restores original values and maintains dialog state.
- Enhanced documentation to reflect the new Alt+Reset behavior and its implementation details across different dialog types.

* Update manual UI testing command to include build timing summary

- Modified the `manual-ui-testing` command in `.cargo/config.toml` to include the `--timings` flag, enabling the generation of a build timing summary after compilation and linking.
- Refactored the `reset_fields` function in `filter_dialog.rs` for improved readability by consolidating the retrieval of the `spec` variable into a single line, enhancing code clarity.

* Layers panel: dragging the vector mask to the Trash asks to delete just the mask (#3119) (#3125)

* Layers panel: Shift+Plus/Minus cycle blend modes; effects dropped on the Trash are deleted (#3102)

- blend_keys.rs: Shift+Plus / Shift+Minus step to the next / previous blend mode, wrapping:
  the brush's mode with the Brush or Pencil (tools.setBrush), the Gradient's with the Gradient
  tool, otherwise the active layer's (layer.setProps; groups include Pass Through, the
  Background is left alone). One undo step per press.
- panels.rs: an effect row dragged onto the footer's Delete button deletes that effect, the
  "Effects" row clears the whole style (layer.layerStyle.clear).
- engine: layer.layerStyle.clear takes an optional "effect" index ("Delete Effect" step);
  bad indices and layers return Err.
- scorecard UI-217-14 -> done (its wheel, footer drop and thumbnail checker parts were already
  on main via #1961, #965).

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

* Layers panel: Shift+Plus/Minus step the blend mode on a German layout too (#3102)

On layouts with a key of its own for `+` (German, Italian, Spanish) Shift+`+`
types `*`, which egui has no key for, so egui-winit reports the physical key:
`]` on German. That matched Shift+] (Increase Brush Hardness) before the blend
keys ever saw it. Shift+`-` types `_` and arrives as the physical `/`.

shortcuts::layout_symbols now turns such a fallback press (Shift, the key equal
to its physical key, not a digit, next to the text `*` or `_`) into `+` / `-`
before any shortcut is dispatched. A US Shift+] still arrives as `}` and keeps
raising the hardness; US Shift+8 (`*`) stays a digit for the opacity keys.

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

* Shortcuts: Ö / # are the brush keys on a German layout (#3109)

Photoshop's German shortcuts step the brush size with Ö / # and the hardness
with Shift+Ö / Shift+#, since [ and ] need AltGr there (and that press carries
Alt on the Mac and Windows, which the shortcut match rejects). egui can't name
ö or #, so they arrived as the physical ; and \ and matched nothing; Shift+#
types ' and arrived as Quote.

shortcuts::layout_symbols (from #3102) now reads a table of typed symbol and
Shift: ö / Ö -> [, # / ' -> ], next to * -> + and _ -> -. It runs at the top of
shortcuts::handle (unless a text field has focus), so Liquify's and the
Magnetic Lasso's [ ] get the German keys too. Only presses whose key egui took
from the physical key or from that very text change, and never digits.

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

* Layers panel: a mask dragged onto the Trash asks to apply or delete it (#3113)

A drag that started on a layer's mask thumbnail dragged the whole row, and
dropping it on the Trash deleted the layer. Now a drag from the pixel mask
thumbnail (where it was drawn last frame) drags the mask: it shows a "Layer
Mask" ghost and starts neither a layer reorder nor a cross-document transfer.
Dropped on the Trash it opens Photoshop's prompt "Apply mask to layer before
removing?" with Apply (layer.layerMask.apply, greyed out where it can't run),
Delete (layer.layerMask.delete) and Cancel. Clicking the Trash still deletes
the targeted mask at once (#2197).

The prompt (mask_trash_prompt.rs) is an ordinary dialog: ui.dialog.confirm
applies, ui.dialog.set {"choice": "delete"} + confirm deletes, cancel keeps.
Two new strings in all 16 catalogs.

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

* Layers panel: dragging the vector mask to the Trash asks to delete just the mask (#3119)

A drag that started on a row's vector mask thumbnail picked up the whole
layer row, so dropping it on the Trash deleted the layer. The mask drag
from #3113 now covers both thumbnails: a vector mask dropped on the Trash
opens Photoshop's "Delete the vector mask?" (OK / Cancel), and OK runs
layer.vectorMask.delete. Clicks on the thumbnail are unchanged.

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

* Brush keys: layout table plus bracket-position fallback for every layout (#3109)

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

* layout_keys: drop unused field, fmt

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]>

* Interface: an Enable Animations preference (#3273) (#3276)

Preferences › Interface › Enable animations (interface.enableAnimations, on by
default). Off, prefs_ui::sync_animations sets egui's animation_time to 0 and
scroll_animation to none in both the dark and light styles, so menus, popups
and collapsing sections change at once and scroll areas jump. It is re-applied
each frame, so a theme change keeps it. Translated in all 16 catalogs; the
scorecard row UI-217-10 goes to partial.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* Edit > Copy takes a targeted layer mask, so masks travel between layers and documents (#2928) (#3267)

* automation: export layers, data sets and CSVs through the granted roots (#3216) (#3230)

* automation: doc.place reads a file through the read root (#2810)

file.placeEmbedded takes an ambient path, so automation refuses it and an
agent could not place a file into the open document at all. doc.place
resolves the path beneath the read root the way doc.open does and hands the
bytes it already read to the public file_cmds::place_bytes, so the existing
cap-std rules stay the only path rules: no new scope logic, and the ambient
command keeps its refusal. Trusted-local callers still run the command.

* automation: export layers, data sets and CSVs through the roots (#3216)

file.export.layersToFiles and file.export.dataSetsAsFiles take an ambient
dir, file.import.variableDataSets an ambient path, so all three are refused
in automation even for files inside the granted roots, and an agent could
not export a single file or a data set at all.

Both halves already existed: the engine writes bytes it has just encoded
(save_doc is encode + write_file, and the data-set exporter encodes with
photocraft_io::export before writing), and the automation side already
holds a write capability — AuthorizedWorkspace::export_sequence creates the
directory, refuses to overwrite, accepts only single-component names and
removes every file it created when a step fails, which is what Render Video
uses. So each command body moves behind the same sink callback
render_video_with takes: the command keeps writing the bytes it is handed
to its dir, the new methods write the very same bytes through the root, and
the two implementations cannot drift.

---------

Co-authored-by: switch87 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>

* Type: fall back to installed Khmer, Lao and Burmese fonts (#218) (#3259)

Text in these scripts drew .notdef boxes although the OS had a font for
them. Detection was never the problem: the system font scan loads them,
and asking for one by name shapes correctly. The fallback stack is a
list of family names intersected with the installed fonts, and it had no
entry for any of the three, so an installed Khmer, Lao or Burmese font
could not be reached from a Latin-only face.

- photocraft-text: KHMER_FAMILIES, LAO_FAMILIES and MYANMAR_FAMILIES join
  fallback_candidates next to THAI_FAMILIES; the web build's served-font
  scripts gain Khmr, Laoo and Mymr (with their ranges and languages).
- photocraft-ui-egui: the UI's lazy loader had one non-CJK slot (Thai).
  It becomes a LazyScript slot table, so the three scripts are data
  rather than a third and fourth copy of the same state machine.
- Tests assert detection and selection separately, so a future failure
  names its layer, and skip when a machine lacks the script's font.

Co-authored-by: Raccoon <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>

* Curves: pencil mode, Smooth and Edit points (#3088) (#3108)

* Curves: pencil mode, Smooth and Edit points (#3088)

The Curves editor (Image > Adjustments dialog and the adjustment layer's
Properties panel) gains Photoshop's "Edit points to modify the curve" and
"Draw to modify the curve" buttons plus Smooth, with tooltips. A drawn
curve is a 256-level map per channel, evaluated piecewise-linearly.

- doc: Adjustment::Curves gains `pencil` (serde default), with
  curve_map / curve_map_outputs helpers.
- compose: curve_map_lut / curve_lut_mode for drawn curves.
- engine: curves params take `pencil` and `smooth`; channels accept
  256-level maps; switching modes draws the spline into maps or fits up to
  16 points to the drawing. Eyedroppers fit points first.
- io: the PSD `curv` map flag (first byte, psd-tools `is_map`) was read
  as padding, so pencil curves from Photoshop were misparsed; they are now
  read and written as 256-byte maps, and smart-filter Curves use `Mpng`.
- scorecard UI-217-15 note updated.

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

* Merge upstream/main: Curves in new compose/gpu HDR tests gets the pencil field (#3088)

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]>

* fix(io): smooth PSD gradients no longer overshoot their stops (#3028) (#3285)

The Catmull-Rom tangents used for Smoothness are derived from the
neighbouring stops, so a segment between two equal-coloured stops (a
flat plateau) picked up a non-zero tangent and dipped below its own
colour. In the Gradient Map from #3028 the 27%-53% plateau (#1c1c1c)
rendered about 19/255 too dark.

Bound each interpolated channel to the range of the segment's two end
colours. Smoothness still bends the ramp, but never invents a colour
outside the segment, so plateaus stay exactly flat.

The regression test uses the four stop positions and grey values from
the issue (synthetic, no artwork) and fails without the clamp.

* Codecs: decode PNM straight into the budgeted output buffer (#2938) (#3291)

Limits::max_alloc budgets the decoded pixel buffer, but the PNM decoder
collected one u32 per sample first (4x an 8-bit image, plus a u16 copy
for 16-bit) and PFM built a Vec<f32> beside the output bytes. Samples are
now rescaled and written straight into the native-endian output buffer
(reserved fallibly), so the only header-sized allocation is the one
Limits::check already charged.

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

* Properties: Curves adjustment layers get the black/gray/white eyedroppers (#3110) (#3115)

* Curves: pencil mode, Smooth and Edit points (#3088)

The Curves editor (Image > Adjustments dialog and the adjustment layer's
Properties panel) gains Photoshop's "Edit points to modify the curve" and
"Draw to modify the curve" buttons plus Smooth, with tooltips. A drawn
curve is a 256-level map per channel, evaluated piecewise-linearly.

- doc: Adjustment::Curves gains `pencil` (serde default), with
  curve_map / curve_map_outputs helpers.
- compose: curve_map_lut / curve_lut_mode for drawn curves.
- engine: curves params take `pencil` and `smooth`; channels accept
  256-level maps; switching modes draws the spline into maps or fits up to
  16 points to the drawing. Eyedroppers fit points first.
- io: the PSD `curv` map flag (first byte, psd-tools `is_map`) was read
  as padding, so pencil curves from Photoshop were misparsed; they are now
  read and written as 256-byte maps, and smart-filter Curves use `Mpng`.
- scorecard UI-217-15 note updated.

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

* Properties: Curves adjustment layers get the black/gray/white eyedroppers (#3110)

The Set Black/Neutral Gray/White Point pickers existed only in the
Image > Adjustments > Curves dialog (#873). The Properties editor of a
Curves adjustment layer now has them too (layer_picker.rs): with one
armed, a click on the image runs one layer.setAdjustment with the new
`eyedropper` param, which samples the image below the layer (the layer
hidden, like its Properties histogram) at the Eyedropper's Sample Size,
so a repeated click keeps the curve and the edit is one undoable,
recordable step agents can send directly. Esc, another tool or another
layer disarms it; ui.pointer and ui.inspect (`layerPicker`) cover it.

Scorecard UI-217-15 is done.

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

* Merge upstream/main: Curves in new compose/gpu HDR tests gets the pencil field (#3088)

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]>

* Format: admit the declared .pcraft canvas before loading it (#2935) (#3295)

* Format: admit the declared .pcraft canvas before loading it (#2935)

A .pcraft manifest's canvas size was copied into the Document unchecked, so
a few bytes of JSON could declare a 300000x300000 (or u32::MAX) canvas that
the CPU reduction, thumbnails and display textures then work against in
proportion to its area.

The loader now refuses an empty canvas, a side above 300000 (Photoshop's PSB
limit, as for PSD and file.new) and an area above 4 gigapixels, right after
reading the manifest and before any tile or blob is decoded. Both limits are
LoadOptions fields so trusted callers can change them.

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

* Format: admit every canvas the app can create on .pcraft load (#2935)

The default pixel budget was 2^32 (65536 x 65536), but file.new, Canvas
Size, Image Size with Resample off and PSB import all reach 300000 x
300000, so a document a user could create and save would not reopen.
The default is now the full 300000^2 PSB canvas; the side limit still
refuses anything larger, and callers handling untrusted files can lower
either budget. Loading stays proportional to the file: layers keep only
the tiles the bundle holds, so a huge declared canvas allocates nothing
for its area.

A compile-time assert in document_preset_cmds ties file.new's
MAX_DIMENSION to the loader's defaults.

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

---------

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

* fix(format): save the target file name in native bundle manifests (#3312) (#3319)

* Levels: black, gray and white point eyedroppers (#3116) (#3128)

* Curves: pencil mode, Smooth and Edit points (#3088)

The Curves editor (Image > Adjustments dialog and the adjustment layer's
Properties panel) gains Photoshop's "Edit points to modify the curve" and
"Draw to modify the curve" buttons plus Smooth, with tooltips. A drawn
curve is a 256-level map per channel, evaluated piecewise-linearly.

- doc: Adjustment::Curves gains `pencil` (serde default), with
  curve_map / curve_map_outputs helpers.
- compose: curve_map_lut / curve_lut_mode for drawn curves.
- engine: curves params take `pencil` and `smooth`; channels accept
  256-level maps; switching modes draws the spline into maps or fits up to
  16 points to the drawing. Eyedroppers fit points first.
- io: the PSD `curv` map flag (first byte, psd-tools `is_map`) was read
  as padding, so pencil curves from Photoshop were misparsed; they are now
  read and written as 256-byte maps, and smart-filter Curves use `Mpng`.
- scorecard UI-217-15 note updated.

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

* Properties: Curves adjustment layers get the black/gray/white eyedroppers (#3110)

The Set Black/Neutral Gray/White Point pickers existed only in the
Image > Adjustments > Curves dialog (#873). The Properties editor of a
Curves adjustment layer now has them too (layer_picker.rs): with one
armed, a click on the image runs one layer.setAdjustment with the new
`eyedropper` param, which samples the image below the layer (the layer
hidden, like its Properties histogram) at the Eyedropper's Sample Size,
so a repeated click keeps the curve and the edit is one undoable,
recordable step agents can send directly. Esc, another tool or another
layer disarms it; ui.pointer and ui.inspect (`layerPicker`) cover it.

Scorecard UI-217-15 is done.

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

* Levels: black, gray and white point eyedroppers (#3116)

Image > Adjustments > Levels and a Levels adjustment layer's Properties
panel get Photoshop's Set Black/Neutral Gray/White Point eyedroppers.
Black/white move each channel's input black/white to the sample, gray
sets each channel's midtone gamma so the sample turns neutral at its
luma (Grayscale: composite input, no gray). The engine takes the same
`eyedropper` param as Curves on image.adjustments.levels,
layer.newAdjustmentLayer.levels and layer.setAdjustment.

On a smart object, image.adjustments.levels/curves with `eyedropper`
now record the resolved adjustment as the smart filter instead of the
raw sample, which re-rendered as an identity (#3127).

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

* Merge upstream/main: Curves in new compose/gpu HDR tests gets the pencil field (#3088)

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]>

* Puppet Warp: the mesh follows the object outline (#3105) (#3117)

The mesh was every square grid cell touching the layer's opaque pixels:
a staircase up to a cell outside the shape, and shapes closer than about
a cell shared cells, so pinning one dragged the other along.

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

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

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* Clone Source: W/H link and overlay blend modes (#3298) (#3310)

The Clone Source panel gets Photoshop's W/H chain (Maintain aspect
ratio): with it on, a new W or H scales the other by the same factor.
cloneSource.set takes "linked" and cloneSource.list reports it.

The overlay's Darken, Lighten and Difference modes were stored but
never drawn. draw_clone_preview now blends the source with the
composite under the brush (clone_source::overlay_blend), so Difference
goes black where source and image line up.

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

* Vanishing Point: on-canvas plane editor with Create/Edit Plane, grid and paste (#3122) (#3133)

Filter > Vanishing Point now opens a dialog mode on the canvas instead of
the generic parameter dialog:

- Create Plane (C): four clicks, in any order, make a plane (corners are
  normalised clockwise from the top-left, the order paste expects).
- Edit Plane (V): drag corner nodes, or edge nodes to slide an edge along
  the plane's own perspective; click a plane to select it, Delete removes it.
- The plane grid is drawn at the Grid Size, in the accent colour when the
  plane is valid, warning yellow when its perspective is doubtful and red
  (outline only) when the quad isn't convex (VpPlane::check).
- With an image on the clipboard the selected plane previews it pasted in
  perspective; drag to move it, Width sets its size, New Layer renders it
  on a new layer. OK / Enter runs filter.vanishingPoint with the planes and
  the paste as one history step; the control channel drives the same
  session through {"ui": {...}}.

The engine now rejects a drawn plane whose corners don't form a convex
quad (bow-tie, dented) instead of warping along it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>

* Let `[` shrink the brush to 1 px (#3229) (#3332)

`[` steps the brush size down by 25 %, rounded: from 2 px that is 1.6,
which rounds back to 2, so the key never reached 1 px and pixel artists
had to type the size. `]` already grows by at least a pixel; `[` now
shrinks by at least a pixel the same way, still never below 1 px.

* MCP: command_batch says it is not atomic, and the docs explain recovery (#3338)

Agents read the tool description, not docs/mcp.md, and the command_batch
description only said it stops at the first error. It now also says the
steps run in order and that earlier successful steps stay applied: there
is no rollback. docs/mcp.md gives the stop_on_error default and a recovery
recipe: inspect with doc_inspect, undo with edit.undo once per history
entry (undo does not reverse files written to disk), or experiment on a
copy made with image.duplicate or history.newDocument. A test checks the
listed description.


Claude-Session: https://claude.ai/code/session_01BSB7fMQUccqFQekksDGWpo

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

---------

Co-authored-by: Valentin Klein <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: Gert Pellin - GPE <[email protected]>
Co-authored-by: switch87 <[email protected]>
Co-authored-by: RaccoonPie-314 <[email protected]>
Co-authored-by: Raccoon <[email protected]>
Co-authored-by: Sir Starch <[email protected]>
Co-authored-by: Nigel Ihza Firdausy <[email protected]>
Co-authored-by: myangaa1 <[email protected]>
Co-authored-by: KnottyDyes <[email protected]>
2026-10-12 10:52:19 -04:00
zenjabbaandzenjabba 9163afbdcf Verify PngSuite archives and cached corpus files (#3973)
Co-authored-by: zenjabba <[email protected]>
2026-10-12 10:45:05 -04:00
f4fa53140c ops: count selection masks in history pixel budget (#3614)
* ops: count selection masks in history pixel budget

* test: expect unclipped HDR levels in float documents

* web: optimize engine code for the wasm size budget

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:43:07 -04:00
Korg1ch dc35ce7630 fix(linux): avoid duplicate AppImage desktop launcher (#3623) (#3641) 2026-10-12 10:35:47 -04:00
Valentin KleinandClaude Opus 5.5 a8b52f1dbf Codecs: enable JPEG and CCITT fax compression for TIFF (#3712) (#3727)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:34:45 -04:00
Valentin KleinandClaude Opus 5.5 019a391f40 Codecs: decode only the requested EXR part; Cryptomatte uses the cumulative cap (#3721) (#3722)
read_layer decoded every part and picked one; use a first-valid-layer reader
that declines all other parts. Cryptomatte now goes through read_meta and
read_layer. Enable exr/rayon on native targets.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 10:33:21 -04:00
skylar taylor-barrick 27fecdab44 Translate print queue guidance in new Vietnamese catalog 2026-10-12 16:22:43 +02:00
null 00000111 0b935da882 HEIF: move the heic-rs pin from 0.1.1 to 0.1.4 (#3637)
heic-rs 0.1.2 to 0.1.4 fix six panics on malformed files, found by fuzzing:
four in release builds (a uuid box too small for its extended type, a
block starting right of the picture, a conformance window larger than the
picture, and a reference row below the picture) and two with overflow
checks on (a PCM bit depth above the sample bit depth, a slice QP out of
range). The API, MSRV and output are unchanged.

Both pins move together: crates/heif and the codecs fuzz target, which
calls heic-rs directly. guarded() stays as the backstop it was meant to be.
2026-10-12 10:22:11 -04:00
null 00000111 423421a879 Fuzz: ignore the same build leftovers in every fuzz crate (#3585)
crates/raw/fuzz had no .gitignore, crates/psd/fuzz didn't ignore
Cargo.lock and crates/format/fuzz didn't ignore coverage/, so running
those fuzz targets left untracked files in the checkout. All five fuzz
crates now ignore target/, corpus/, artifacts/, coverage/ and Cargo.lock,
as codecs and affinity already did.
2026-10-12 10:19:06 -04:00
skylar taylor-barrick 5fe4d09478 Merge current main while preserving craft-print transport contracts 2026-10-12 16:17:51 +02:00
73a56d7603 Format: overflow-safe blob decode and bounds-checked ZIP reads (#3486) (#3501)
- store: `max_blob_bytes as u64 + 1` overflowed for usize::MAX (debug
  panic; release take(0), so every blob failed). The blob read and the
  tile decode now share decompress_bounded(), which saturates.
- zip: ZipReader::read sliced `data_offset..data_offset + compressed`
  unchecked although Entry is pub + Clone; a foreign entry is now a
  Corrupt error.
- store: Collect::tile reuses tile_le(); Object::content() gives the
  stored bytes once (existing_object_is_valid no longer builds them twice).

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 10:08:20 -04:00
fd21e73411 Format: check pattern sizes on load (#3478) (#3484)
* Format: check pattern sizes on load (#3478)

A .pcraft pattern's width/height came straight from the manifest, so a
1 KB file could declare a 65535x65535 pattern without tiles; painting it
allocated width*height*16 bytes (abort) and the engine's u32 size math
overflowed.

- format: reject patterns 0 px on a side (Corrupt) or above the PSD
  reader's 30 000 px edge (LimitExceeded).
- doc: Pattern::pixel_count / is_paintable with MAX_PAINT_PIXELS (2^26,
  a 1 GiB f32 RGBA working copy).
- compose/gpu: patterns too large to paint paint nothing instead of
  allocating (Tile::new_in, pattern_rgba, PreparedPatterns, GPU plan).
- engine: brush.texturePattern refuses them; average_color uses u64 math
  and a bounded corner.

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

* gpu: skip unpaintable patterns in the Bevel texture too

The Bevel & Emboss texture element uploaded the pattern through
pattern_rgba, which now returns no pixels for a pattern too large to
paint; filter it like the other GPU pattern lookups.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 10:06:16 -04:00
JGFandClaude Opus 5.5 5a0e2e2658 Web: wasm back under the 24 MiB gate with ~1 MiB headroom (25.38 -> 24.17 MB)
The "web build and wasm size gate" job failed on main and every PR: the
release `_bg.wasm` measured 25,379,677 bytes at 8d49ecf5, over the
25,165,824-byte limit in packaging/web/package.sh.

- egui's default fonts (Ubuntu Light, Hack, Noto Emoji, emoji-icon-font)
  were embedded uncompressed (1.4 MB) by egui's `default_fonts` feature.
  The app now builds egui without it; ui-egui's build.rs deflates the
  same files from the `epaint_default_fonts` crate (a build-dependency,
  so no font files are committed) and `egui_fonts.rs` rebuilds egui's
  exact default stack from them on first use. Test builds keep the
  feature as the oracle: the rebuilt definitions equal egui's byte for
  byte.
- Translation catalogs: every catalog repeated the same English sources.
  build.rs now stores the `context<TAB>source` keys once and each catalog
  as references plus translations, losslessly (`text()` still returns
  each .tsv byte for byte; malformed lines are kept as they are).
- wasm-release profile: opt-level "z" for PhotoCraft's own crates.
  Dependencies stay at "s" (at "z" the wasm grew 0.4 MB, measured), the
  file-decoding crates (codecs, psd, raw, heif, vector) stay at "s" and
  the pixel crates at 3, so decoding and filters keep their speed.

Measured with `trunk build --release` (trunk 0.21.14, wasm-opt v123 -Oz):
  upstream main 8d49ecf5      25,379,677 bytes
  this change                 24,167,186 bytes (-1,212,491)
  gate                        25,165,824 bytes (998,638 headroom)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 09:59:25 -04:00
HEOJUNFOandClaude Opus 5.5 bb17dd555c Color: convert input-only embedded profiles to the working space on open (#3876) (#3900)
An iPhone photo embeds Apple's Wide Color Sharing Profile, an input-class
profile with only AToB tables. Preserve kept it as the document's space, so
every composite into the document failed with "RGB profile without
matrix/TRC or BToA tags". Such a profile can't be a destination, so open
converts from it to the working space whatever the policy (Off still
discards it) and the report says inputOnly.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:57:11 -04:00
a58b6f3d9e Mix filtered selection edges with premultiplied alpha (#3895)
* Mix filtered selection edges with premultiplied alpha

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

* Update stylize kernel comparison for alpha-aware selection mixing

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

---------

Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 09:52:59 -04:00
8072636e0f Count video footage in history memory budgets (#3894)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 09:46:59 -04:00
FredApps 094c4f30e4 CMS tests: validate profile-specific CMYK black endpoints (#3890)
Retain the relative paper-white requirement and round-trip checks while validating finite, bounded endpoints and darker full-K ink.

Add an encoded and reparsed AToB1 regression with an independently calculated 100/87/74/61/48 lightness ramp, plus invalid endpoint cases.

Signed-off-by: FredApps <[email protected]>
2026-10-12 09:43:34 -04:00
7958dc63fb Layers: Merge Group for a selected group (#3832) (#3888)
* Layers: Merge Group for a selected group (#3832)

With a lone group selected, Layer > Merge Layers (Cmd+E) reads Merge Group and the
Layers panel's right-click menu offers Merge Group instead of Merge Down, as in
Photoshop. The new layer.mergeGroup command flattens the group's visible contents
into one pixel layer in its place, named after the group, keeping its opacity, blend
mode (Pass Through becomes Normal), clipping and locks. Before, Cmd+E on a group ran
Merge Down and merged the whole group into the layer below it.

* i18n: Vietnamese row for Merge Group

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 09:42:58 -04:00
4010630c98 Preserve surviving channel targets and visibility on deletion (#3887)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 09:32:37 -04:00
76fa06dbd5 Merged and stamped layers keep their content bounds in CMYK and Lab (#3886)
Merging or stamping converts the straight-RGBA composite into the
document's colour model. Transparent black converts to K = 100% in CMYK
and neutral a/b in Lab, so those pixels no longer equal the surface's
default pixel, prune() keeps them, and the layer's content bounds span
everything that was rendered: the whole canvas for Stamp Visible, Stamp
Down and Copy Merged, and for Merge Layers, Merge Visible and Merge Down
whenever an input may draw anywhere (a fill layer, even a masked one).

Fully transparent pixels are now written as the default pixel in both
conversion paths: photocraft_compose::flatten_to_surface (stamps, Copy
Merged) and engine pixels::composite_layers (merges). Only formats with
alpha are affected; over a Background everything stays opaque.

Tests: engine merge_transparent_bounds (Merge Layers, Merge Visible over
a transparent document, with a masked fill layer and onto a Background,
Stamp Visible, Stamp Down, undo) in RGB/Gray at 8/16/32 bit and CMYK/Lab
at 8/16 bit, and a compose unit test for flatten_to_surface. The masked
fill layer test fails on main (merged layer = full canvas in CMYK).

Co-authored-by: dgliwa <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 09:28:37 -04:00
73ed08ddbe PSD type: read styles from the run's sheets and place text at its Txt2 frame (#3795, #1317) (#3819)
* PSD type: resolve paragraph and character styles from the run's own sheets (#3795)

apply_engine_data filled a run's missing attributes only from the Normal
sheets (TheNormalParagraphSheet / TheNormalStyleSheet). A run that takes
its alignment or size from a named sheet (by Parent index or Name), or
from its paragraph's DefaultStyleSheet, was read with Normal's values, so
a centred paragraph style came back Left and starting an edit re-laid
the text half a line to the side.

Resolve run -> named sheet -> paragraph's default character style ->
Normal, ignoring missing or out-of-range references. On export, runs
whose sheet reference is rewritten also get the attributes they
inherited, so they keep their look whatever they now point to.

* PSD type: place text at its Txt2 frame and keep box lines whose descent overhangs (#1317)

Recent Photoshop keeps part of a type layer's position in its Txt2 frame: a
frame-to-text-space matrix and, for point text, the anchor, with EngineData's
PointBase left at the origin and BoxBounds in the frame's space. Import ignored
them, so box text landed far off the canvas once an edit started (the text
'disappeared') and point text moved up a line and off its alignment anchor.
Apply the frame when its origin lands in the TySh bounds (and not already
without it, so a TySh PhotoCraft rewrote is not moved twice).

Photoshop also keeps a box line whose baseline is inside the box while its
descenders hang below it; layout dropped such lines (and some single-line boxes
rendered nothing).

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 09:28:31 -04:00
FinnNkandClaude Opus 5.5 8d49ecf50d feat: Photoshop's tool keys (arrows turn the brush, Shift-click new type, Space+F canvas colour) (#3725)
* feat: Left and Right arrows turn the brush tip

With a painting tool, Photoshop's Left and Right arrow keys turn the brush
tip 1 degree (15 with Shift); Right turns it clockwise. tools.rotateBrush
turns the session brush's angle by "degrees", wrapped into -180..180, and
move_mods::arrow_keys gives it the plain and Shift arrows only for the
painting tools, when no Move-tool, transform or selection nudge applies
and no type is being edited. Alt-arrows still kern type; Ctrl-arrows still
nudge the layer.

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

* feat(ui): Shift-click with the Type tool starts new text over existing type

In Photoshop a click on existing type with the Type tool edits it, and a
Shift-click there starts a new type layer instead, so text can be placed
over other text. pointer_down now leaves a Shift-click unconsumed when no
type is being edited, so it starts new text; inside the text being edited,
Shift-click still extends the selection.

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

* feat(ui): Space+F cycles the canvas colour

Photoshop's Space+F steps the pasteboard (canvas) colour through Default,
Black, Dark Gray, Medium Gray, Light Gray and Custom, and Shift+Space+F
steps back, round and round. While the Hand key is held, F does this
instead of Cycle Screen Mode; the choice is saved like picking it in the
pasteboard menu (interface.canvasColor). docs/ui-parity.md lists the new
tool keys.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 09:17:43 -04:00
aa4d4859b4 fix(io): preserve indexed BMP color tables (#3802) (#3869)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 09:13:38 -04:00
3f72158f0a fix(psd): write kept 8B64 blocks as 8BIM in a PSD (#3743)
A PSD re-saved from a PSB kept the source's global FMsk and cinf blocks with
their 8B64 signature, and Photoshop 27.11 refuses such a file as "not
compatible with this version". 8B64 exists only in PSB, so a kept block is
written as 8BIM when the output is a PSD; a PSB keeps 8B64. Measured by
bisecting the 3 refused corpus re-exports in Photoshop: each opened once the
8B64 blocks were gone.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 09:05:22 -04:00
81f9745033 fix(smart-object): keep pixels when rasterizing smart object layer (#3873)
Resolve an issue where rasterizing a smart object layer resulted in an empty raster surface if the cache was not yet populated. Now refreshes the smart layer snapshot if needed, extracts the rendered surface, converts to the document pixel format, and cleans up PSD placed-layer blocks (SoLd, SoLE, PlLd). Includes a regression test suite covering solid fills, new layers, shape layers, and transformed smart objects.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 09:03:40 -04:00
cbcdeb2549 Layer styles: Photoshop's measured contour presets; Satin's contour lookup (#3803)
* fix(effects): Photoshop's measured contour presets; Satin reads contours from the far end

The built-in contour presets were hand-drawn guesses and none matched
Photoshop. They are now Photoshop 27.11's own curves, measured by running
a Satin with each preset against a Linear one (38 levels per curve,
thinned to within 1 %, straight segments).

The measurement also showed how Satin applies a contour: |c(1 - v) - c(1)|
(Cone and Cone - Inverted Satins are identical; a Gaussian Satin is 0.36
at 0.5 while the Gaussian on a glow is 0.64). New effects::satin_lut, used
by the CPU and GPU satin paths.

Probe results vs Photoshop (max level error): Satin with every preset
<= 3 (Cove - Shallow 9), was up to 128; Outer Glow Gaussian 38 -> 2,
Ring - Double 121 -> 15, Cone 48-52 -> 4-7; bevel Contour Cone at 100 %
range 27 -> 12. Corpus oracles unchanged (their stored curves are
symmetric). Gloss Contour is still off: that is the bevel's gloss model,
not the presets.

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

* Layer style UI test: the Contour Editor on the measured Cone

The measured Cone preset has 18 points, all inner ones corners, so the
editor test moves point 1 between its neighbours and checks the corner
flags by position instead of the old three-point shape.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 08:52:57 -04:00
bbe77354e0 Camera Raw on raw opens: Clarity, Texture and Dehaze in linear light, measured (#3815)
* Camera Raw: Whites, Blacks, Shadows and Highlights closer to Photoshop's

Measured as a black box on Photoshop 25.4's Camera Raw Filter.

Whites and Blacks are global curves of the grey level: the measured rows
per slider value, Whites first, after Shadows and Highlights. The
clipping directions adapt to the image's black and white points
(Whites up to 2.7 times stronger on a dark image, Blacks at least 1.2).
On ten public-domain photos the mean error against Photoshop drops from
4.2-36.8 to 0.9-8.9 levels of 255.

Shadows and Highlights are a local Laplacian filter (Paris et al. 2011,
fast form of Aubry et al. 2014) on the log luminance, the method Adobe
credits for process version 2012. Shadows raises pixels darker than
their surroundings by an amount set by their own and their
surroundings' tone, so big edges keep no halo, and keeps the 99th
percentile; Highlights pulls pixels brighter than their surroundings
toward them and keeps the 1st. The strengths are fitted to the photos
(mean error 2.8-9.4 levels, was 3.7-14.3); Photoshop's exact operator
was not recovered.

The tone stages decode light with the document's curve: filter.cameraRaw
stores encodingGamma (1.8 for ProPhoto, where raws open).

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

* Camera Raw Shadows/Highlights: no contour lines on smooth gradients

Twelve reference values over a raw's whole range left more than a stop
between them, and the remapping changes within half a stop: coefficients
interpolated across that gap drew contour lines in out-of-focus areas
(Shadows +100 on a Nikon D4 raw). References are now an eighth of a stop
apart over the image's tones (the deepest 0.1 % share the lowest), on a
copy at most 1600 px across whose change comes back to full size by a
guided upsampling, so a 16-megapixel image takes about 2 s per slider and
the preview matches the result.

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

* Camera Raw on raw opens: Shadows/Highlights in linear light, D4 look and black point as Photoshop

Measured black-box on 29 Nikon D4 NEFs opened through Camera Raw in Photoshop 25.4 (sliders at
+-100 and +50, exposure +-1) against our own linear development of the same files.

- Shadows and Highlights of the open-time dialog now act while developing, in linear light before
  the tone curve, as Camera Raw does for raw files (exposure -1 gives the same gains). The log2
  gain is slider/100 * C(B - anchor): B is a guided-filter base of the log2 luminance on a copy
  1232 px on its long side, the anchor is image-adaptive (Shadows: log2 power mean p=1.5 of the
  linear luminance; Highlights: mean log2 luminance). New module photocraft_raw::tone; the
  preview undoes the camera curve to show the same (photocraft_raw::retone).
- The D4 profile copies Camera Raw's default rendering: look matrix folded into the
  ForwardMatrix, per-channel curve, and an adaptive black point (0.33 x the 0.1th percentile of
  the luminance, Sensor::black_point).

Against Photoshop on the 29 frames (1/4 size, log2 Y): default rendering median difference
0.29 -> 0.04 stop; correlation of the slider's effect Shadows +100 0.96, -100 0.91, Highlights
-100 0.87, +100 0.85 (the previous filter model: 0.68 and 0.55 on DSG_3464).

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

* docs: the open-time Camera Raw re-develop covers Shadows and Highlights

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

* Camera Raw raw-stage Shadows: keep shadow contrast as Photoshop does

The user saw Photoshop's shadows sink deeper with more contrast. Measured on the 29 D4 NEFs:
with Shadows +100 our shadow detail had 0.79 of Photoshop's local contrast and shadows sat 0.38
stop lighter. Two causes:

- The black point now comes before the Shadows / Highlights gain, as in Camera Raw (lifting
  first let the lift wash out the black point's deep-shadow contrast).
- The guided upsampling's epsilon goes from 0.01 to 1 stop^2: the change follows strong edges
  but stays smooth inside texture (with 0.01 it followed every detail and flattened it). Near
  strong edges the effect still matches Photoshop as well as elsewhere.

Shadows +100: shadow detail 0.79 -> 0.91, shadow bias +0.38 -> +0.16 stop; effect correlation
Shadows -100 0.906 -> 0.946, Highlights -100 0.870 -> 0.886, others unchanged.

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

* Camera Raw on raw opens: Clarity, Texture and Dehaze in linear light, as Photoshop

Measured black-box from Photoshop 25.4 opening 29 Nikon D4 NEFs through Camera Raw (descriptor
keys Cl12, CrTx, Dhze; each at ±100 and +50, and +100 at exposure −1, 348 opens). All three act
before the tone curve: the change at exposure −1 equals the one at 0 (correlation 0.98–0.99).

- Clarity: log2 luminance split into bands between guided-filter bases (radii 2, 8, 32, 128 on
  the 1232 px work copy), each band scaled by a measured gain that follows its base's tone, plus
  a tone term; carried to full size with the Shadows / Highlights change.
- Texture: Gaussian bands σ 0.5…16 on a half-size copy, each through its measured response
  (small detail raised more than strong edges, flat areas unchanged), guided-upsampled.
- Dehaze: He, Sun and Tang's dark channel prior on linear ProPhoto (dark channel in Display P3,
  airlight from the brightest 0.1 % of it, guided-smoothed transmission), w 0.8 at +100;
  −100 a uniform veil. The black point stays the default rendering's.

Against Photoshop, PhotoCraft's develop vs Photoshop's open on all 29 frames (median effect
correlation, log2 luminance at 1/4 size, Texture at 1/2): Clarity +100 0.81, −100 0.85;
Texture +100 0.85, −100 0.88; Dehaze +100 0.61 (0.78 in gamma 2.2: the crushed shadows
differ), −100 0.96. Shadows / Highlights unchanged (0.964 / 0.886).

The open-time Camera Raw dialog re-develops for them and its preview shows them (retone).

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 08:52:45 -04:00
6c7328b709 Workspace: wire Auto-Collapse Iconic Panels (#204) (#3880)
* ui: a panel expanded from its collapsed strip auto-collapses on an outside click

* ui: test the Auto-Collapse Iconic Panels peek open and outside close

* engine: show Auto-Collapse Iconic Panels in Preferences, it is wired

* docs: scorecard, settings that do nothing 30 -> 29 of 154

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 08:52:32 -04:00
Diego Chagas de22bca760 Remove tool: Create a new layer, and Remove after each stroke off (#1750) (#3808)
paint.remove takes several strokes at once (`strokes`) and fills them as one job and one undo
step, so the fill sees every hole together; and `newLayer` writes the fill to a new layer above
the target, only where the hole is, leaving the target as it was (with Sample All Layers it
fills from the composite). The options bar shows Photoshop's three options; with Remove after
each stroke off, strokes collect as a tint on the canvas until Enter or the ✓ applies them and
Esc or ⊘ drops them. Strokes are dropped when the tool or document changes under them.
2026-10-12 08:51:56 -04:00
Jakub 286ff73533 perf: edits, undo and redo recomposite only where added or removed layers draw (#3847)
Session::edit reported damage only for edits that changed no pixels (same_pixels) and left
every other edit to recomposite the whole document unless the command reported its own
damage. Duplicate and paste do (#2883, #2901), but Fill, Invert, filters, Delete Layer and
the undo/redo of a duplicate, a paste or a delete all recomposited everything.

- Session::edit now reports step_damage(before, after), the same bound undo and redo use.
- step_damage bounds layers only one side has in both directions, so a removed layer counts
  where it drew like an added one; the bounded_additions flag and insert_damage go, and
  added_bounds becomes one_sided_bounds. A layer that is (or was) the clipping base of
  clipped layers above it, an adjustment, a video layer or one excluding channels still
  recomposites everything (#3090).
- The subject-mask cache is dropped only when the step can have changed pixels.

perf_scenarios on the layered 20 MP document, edits on a 1 MP layer (p50, RTX 3090, Vulkan):
delete layer 9.1 -> 1.4 ms, undo a duplicate 9.3 -> 1.6 ms, fill a 500 px square
17.4 -> 5.5 ms, Invert 16.7 -> 9.2 ms. Duplicate and paste are unchanged (already bounded).

Tests: duplicate_paste_and_delete_report_where_the_layer_draws checks that each step, its
undo and its redo report the same bounded damage and that no pixel outside it changes.
new_layers_that_change_clipping_recomposite_everything (renamed) now expects bounded damage
for a painted or empty new layer and None for a new clipping base or an adjustment; deleting
and restoring a layer reports its bounds.
2026-10-12 08:47:05 -04:00
Jakub 05c4cd685e perf: the Navigator redraws only the area an edit changed (#3845)
With the Navigator open, every edit re-rendered the whole document into a new thumbnail on the
UI thread: a full-resolution CPU composite, 303 ms after an opacity change and 486 ms after a
brush stroke on a 20 MP layered document.

The navigator cache now records the document revision of its image. When the document is
exactly one edit ahead, with the same preview key, a bounded last_damage and the same thumbnail
size, only the damage (grown by the layers' effect reach) is rendered with
render_reduced_damage and written into the texture with set_partial; an edit with empty damage
(a rename, a selection, a new empty layer) costs nothing. Anything else renders the whole
thumbnail as before. compose gains thumbnail_size, which thumbnail_buffer now uses, so both
paths agree on the size.

On the 20 MP document: 303 -> 36 ms after an opacity change, 486 -> 171 ms after a stroke
(RTX 3090 machine; the navigator renders on the CPU).

Test: navigator_damage_updates_match_a_full_thumbnail patches a thumbnail after a translate,
an opacity change, a stroke, a blend mode change and a new layer on a document with a drop
shadow, and compares it with a full thumbnail; it fails without the effect-reach inflation.
2026-10-12 08:46:53 -04:00
Jakub 3e428e7302 perf(gpu): a duplicated layer's texture starts as a GPU copy of its source's (#3848)
A duplicate shares its source's tiles (Arc), but its resident texture was new, so the
compositor converted and uploaded every tile again: duplicating a 20 MP photo layer uploaded
330 tiles. When a new resident's surface shares tiles with another resident of the same role,
region, texel kind, format and default pixel, its texture now starts as a copy of that one
(Stats.tiles_shared counts the tiles taken) and the usual per-tile pass fixes up whatever
differs. Layer textures gain COPY_SRC.

The copy is recorded in the frame's encoder, after whatever brought the donor up to date this
frame. Queued writes land before the encoder runs, so both textures are marked `ordered` and
their writes go through the encoder from then on; otherwise a write to either in the same
refresh would be overwritten by the copy or leak into it. A resident whose region only moved
still keeps its own texels (keep_overlap) first.

Duplicating a 20 MP photo layer in perf_scenarios: 25.1 -> 6.3 ms, 330 tile uploads -> 0
(RTX 3090, Vulkan). Paste is unchanged: the pasted layer has new tiles.

Test: duplicates_copy_their_source_texture covers a plain duplicate (no uploads), a
duplicate and its source each painted with one tile in the same refresh, and a duplicate
missing a tile, all against the CPU composite. Without the `ordered` routing the second case
is off by 167.6/255.
2026-10-12 08:46:28 -04:00
Jakub 9ff6c57e03 GPU composite: submit every 1000 copies and render passes, not only between page cells (#3844)
The compositor submitted only between 2048x2048 page cells, so one cell of a deep stack
recorded every pass into a single command buffer: 700 full-size layers make 1403 render
passes, and wgpu-core opens two hal command buffers per pass. Metal allows 4096 command
buffers per queue, so a few thousand passes in one cell can lose the device.

run_cell now counts copies and render passes and, when a refresh may submit in between,
submits the batch after FLUSH_OPS (1000) of them, waiting for the previous batch as the
page-cell flush does. Chunk textures, uniforms and bind groups outlive the submit and the
queue keeps the order, so a chunk continues in the next command buffer. A device lost in
such a submit ends the refresh with an error instead of recording more work.

Not measurably slower on Vulkan (RTX 3090): perf_scenarios rows are within noise.

Test: many_layers_submit_inside_one_chunk (700 noise layers on a 64x48 document) needs
flushes > 0 inside one chunk and matches the CPU composite; main records all 1403 passes
without a flush.
2026-10-12 08:46:16 -04:00
Scott C Hughes 3cc0d48410 fix(engine): Flatten All Layer Effects keeps layer and vector masks (#3804) (#3835)
Flatten All Layer Effects baked the layer (including its masks) into the pixels and then cleared mask and vector_mask, destroying unrelated mask pixels and their enabled/disabled state permanently after save. Bake the effect visuals with the masks left off the render and keep the original mask and vector_mask objects, so each mask still applies once to the baked raster and the command stays a visual no-op.

Tests: cargo test -p photocraft-engine --lib file_cmds; cargo test -p photocraft-engine --lib; cargo clippy -p photocraft-engine --all-targets (with the two pre-existing lints allowed); cargo test -p photocraft-engine --test panic_hunt -- --ignored
2026-10-12 08:46:04 -04:00
MrCloudy2andClaude Opus 5.5 04d2874e38 fix(image): 32 Bits/Channel holds linear light, as in Photoshop (#3792)
PhotoCraft treats 32-bit documents as linear light (Transfer::for_document,
Exposure, Photo Filter, PSD import), but Image > Mode > 32 Bits/Channel only
rescaled the samples, so a converted 8-bit grey 128 stayed 0.502. Photoshop
stores 0.2159 (measured in Photoshop 27.11: 32-bit float TIFF of an sRGB
chart, every sample = the sRGB-to-linear value). The conversion now decodes
colour samples through the document's tone curve after widening to 32 bits,
and encodes them before narrowing (so no linear value is quantised to 8 or
16 bits). Alpha, masks and alpha channels keep their values. RGB and
Grayscale only (the 32-bit modes).

With it, Photoshop's 32-bit results match exactly for the conversion itself,
Levels, Black & White (default), Brightness/Contrast and Colorize (were 40-80
levels off in sRGB terms).

Adds Surface::map_color (colour samples only, including the default pixel)
and makes Transfer::decode/encode public.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 08:45:48 -04:00
Desayuno64 8af15cf72a Shift Edge moves by a fraction of a pixel so the slider does not snap. (#3800)
100% is still max(radius, 4) pixels. Amounts between whole pixels blend the two neighbouring morphs.
2026-10-12 08:45:25 -04:00
MrCloudy2andClaude Opus 5.5 7890b277a0 fix(adjust): absolute Selective Color scales by the room the ink has (#3793)
Photoshop's absolute mode scales each range's ink change by how much the
ink can still move: adding ink by min(1, 2 x (1 - ink)), removing it by
min(1, 2 x ink). PhotoCraft added the full percentage and clipped, so
Neutrals Cyan +50 darkened a grey 64 to 0 instead of 32. Measured in
Photoshop 27.11 over every range x ink at +-50 (72 cases): all within 1
level now (8 of 72 within 3 before). CPU and GPU paths.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 08:45:09 -04:00
Reynold Hu dcc9f86aa5 Workspace: wire Enable Narrow Options Bar (#204) (#3870)
* engine: expose workspace.enableNarrowOptionsBar through a Workspace helper

* ui: the options bar honors the narrow options bar preference

* docs: scorecard, settings that do nothing 30 -> 29 of 154
2026-10-12 08:43:48 -04:00
fc02c91ffb io: read version 3 Photo Filter colour as XYZ, not Lab (#3790)
Photoshop CS2 writes `phfl` version 3 with "4 bytes each for XYZ color".
The parser read those values as Lab x100, so real files (L = 241.91)
fell back to Unsupported. Read them as D50 XYZ with 1.0 = 32768 (the
ICC 16-bit PCSXYZ encoding; the scale is inferred from a real file, the
spec does not give it). Writing stays version 2 RGB.

Fixes #3777

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 08:43:41 -04:00
Reynold Hu b0554b8178 Type: wire missingGlyphProtection (script fallback toggle) (#3863)
* text: missing glyph protection can turn the script fallback stack off (#1909)

* engine: apply type.missingGlyphProtection to the live text engine

* docs: scorecard, settings that do nothing 30 -> 29 of 154
2026-10-12 08:34:22 -04:00
25c0ec78d5 Keep CMYK ink values when flattening and merging (#3855)
Flatten Image, Merge Visible and Merge Down now composite a CMYK stack of
plain Normal raster layers in its ink values, so a lone Background flattens
bit-exact and 16-bit depth is kept. Stacks that still need the RGB
compositor separate back through the document's own CMYK profile instead of
the built-in one, and Merge Down's scratch document keeps the profile.

Fixes #3778

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 08:33:50 -04:00
David MannandClaude Opus 5.5 6231325aea Brushes: drag a brush folder into another folder (#2014) (#3851)
Photoshop's Brushes panel nests folders by dragging one onto another; ours
could only reorder a folder among its siblings.

- brush.presets.moveGroup takes `into: {group?, folder?}`: the group or
  nested folder (with everything in it, presets in order) moves inside that
  folder of that group (`folder` [] = directly in the group), or with no
  `group` out to the top level as a group of its own. `before` names a
  sibling folder (or top-level group) there to land before. Moving a folder
  into itself or one of its own folders, a name clash at the destination,
  a result deeper than MAX_FOLDER_DEPTH, the ungrouped presets, `index`
  with `into`, and bad shapes are errors that leave the library unchanged.
  The preset store follows the move (a group moved into another one loses
  its group file; a folder moved out gets one).
- Brushes panel and Brush Preset picker: a group or folder header dropped
  on another header goes inside it; on the header's upper third it lands
  before that folder, reordering siblings or moving a nested folder next to
  it (out to the top level before a group). Every drop is one moveGroup
  command (`folder_drop` maps the drop).

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 08:33:28 -04:00
bacc886a6d Preserve signed HDR colors during surface transforms (#3857)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 08:32:45 -04:00
6037ade5ed Fix straight-path anchor insertion at the hit position (#3856)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 08:32:15 -04:00
Scott C Hughes 2aa1f8d2c6 feat(engine): add history.undo and history.redo aliases (#3675) (#3786)
A `history.*` namespace exists (history.deleteState, history.newDocument) but not history.undo, so a client or agent guessing `history.undo` gets a hard `unknown command`. Register history.undo and history.redo as aliases of edit.undo and edit.redo, with no menu entry and no shortcut; the Edit menu items keep Cmd+Z / Cmd+Shift+Z.

Tests: cargo test -p photocraft-engine --lib history_cmds; cargo test -p photocraft-engine --test panic_hunt -- --ignored; cargo xtask parity.
2026-10-12 07:49:26 -04:00
MrCloudy2andClaude Opus 5.5 c5da1a0d5f Hue/Saturation: Photoshop's saturation curve, lightness order and Colorize lightness (#3781)
* fix(adjust): Hue/Saturation saturation, lightness order and Colorize as in Photoshop

Measured in Photoshop 27.11:
- Raising Saturation divides it by (1 - s): +50 doubles it. PhotoCraft
  multiplied by (1 + s), up to 26 levels off.
- Master Lightness applies first; hue and saturation then work on the
  result. Fixes the corpus file adjustments/rgb8/hue-saturation (Hue
  +40, Saturation +25, Lightness -10): 17 levels / 11.8 % bad -> 2 / 0 %.
- Colorize builds the colour at the lightened lightness, so black with
  Lightness +30 is a 30 % lightness colour, not grey (was 78 off).
- A colour range's positive Lightness lightens towards the pixel's
  brightest channel, not towards white (Blues +50 was 71 off).

All 18 Hue/Saturation probe cases are within 4 levels. Corpus: the
Colorize files go to 1-3 levels (psd-tools huesaturation_colorize_rgb
120 -> 3). CPU and GPU paths.

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

* fix(adjust): a range's negative Lightness darkens towards the darkest channel

Measured in Photoshop 27.11 (Blues -50 / -100, Reds -50): (102, 128, 204)
at Blues -50 becomes (102, 115, 154), towards the pixel's darkest channel,
not towards black. Mirrors the positive rule (towards the brightest).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:49:16 -04:00
MrCloudy2andClaude Opus 5.5 836fd52089 fix(adjust): Black & White tints like the Color blend mode, as Photoshop does (#3780)
Photoshop's Black & White tint keeps the grey's luminosity and takes the
tint's hue and saturation (SetLum + ClipColor, luma 0.30/0.59/0.11), the
same as the Color blend mode. PhotoCraft mixed half the grey with twice
the grey times the tint, up to 141 levels off. Measured in Photoshop
27.11 on 9 cases (4 tint colours over a grey ramp and hue sweeps): all
within 3 levels now. Corpus: photoshop adjustments/rgb8 and rgb16
black-white-tint go from 69 levels max / 77 % bad pixels to 1-3 / <=0.5 %.
CPU and GPU paths.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:49:05 -04:00
UnemarsandClaude Opus 5.5 e35671ffa9 Shadows/Highlights: Photoshop's, measured (#3765)
Image › Adjustments › Shadows/Highlights follows Photoshop 25.4, measured as a
black box: per-channel screen curves (1 - (1 - v)^q blended over q = 1, 2, 4,
8), the Tone mask as a Beta distribution function of an 8-bit neighbourhood
brightness, an edge-aware (guided bilateral) neighbourhood, the luma of the
result with the colour differences scaled by Color, and the Midtone S-curve.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 07:48:55 -04:00
DennisandClaude Opus 5.5 e4e3f00f48 Camera raw colour fit: read a Sony Adobe RGB preview as Adobe RGB, not sRGB (#3760)
The per-file colour fit (`fit_preview_look`) converted the embedded JPEG to sRGB only when the JPEG
carried an ICC profile, and otherwise read it as sRGB. A Sony body set to Adobe RGB writes no profile
into its previews; it states the colour space only in the raw's EXIF (ColorSpace Uncalibrated with
InteropIndex "R03", the DCF option file for Adobe RGB). The fit then targeted a desaturated,
hue-shifted reference, and the developed raw came out that way.

`photocraft_raw::embedded_preview_color_space` reads that EXIF (sRGB, Adobe RGB or unstated; never for a
DNG, whose previews are the converter's), and `preview_profile` uses it when the preview has no ICC
profile of its own. A preview's own profile still wins. LightCraft reads the same tags for the same
reason (storytold/lightcraft#778).

Measured on 19 private Sony ILCE-1M2 ARWs, all shot in Adobe RGB, against the camera's JPEG read as
Adobe RGB (median CIE76 ΔE at 240 px): full-frame 400 mm shots against blue sky 5.6-7.0 → 1.5-2.4,
their mean a* offset +4.3…+4.9 → +0.7…+1.1; every file improved, mean of the medians 4.9 → 2.2.

Tests: `exif_colour_space_of_the_previews` (sRGB, Adobe RGB; R98, a missing interop IFD, other values,
a DNG and junk are unstated) and `a_preview_without_a_profile_takes_the_raws_exif_colour_space` (fails
without the EXIF fallback; the preview's own ICC profile wins). The hostile-input harness calls the new
function.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:48:45 -04:00
David MannandClaude Opus 5.5 81bff2aaa4 Brushes: Photoshop's texture Height/Subtract modes, Brightness and Contrast (#3340) (#3749)
Imported brushes whose Texture uses Height or Linear Height at a low depth
(common in Photoshop brush packs) painted nothing: the modes were modelled
backwards (paint only on the brightest texels, more of it with more depth).
Measured against Photoshop with single dabs over a 0..255 ramp pattern:

- Height: paint reaches 12 x depth x coverage into the pattern and fills
  what lies below it (dark valleys first; a brighter pattern takes paint
  away, which is why the reporter had to flip Brightness's sign).
  Linear Height grades the shallow end by the pattern instead.
- Subtract takes depth x pattern away (white removes the most paint);
  Color Dodge/Burn scale the pattern toward their neutral with depth.
  These now live in texture_combine; the Dual Brush keeps mask_combine.
- Brightness shifts the pattern by value/255 (not value/150); positive
  Contrast steepens by 1/(1-c/100) after the shift, negative flattens by
  1+c/100 before it. Texture::level is shared by the renderer and the
  Brush Settings swatch.
- ABR import mapped textureContrast with /50 for both signs; positive
  values are now /100, matching the model and the Brush Settings field.

Built-in Subtract presets get Invert so they look as before.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 07:48:35 -04:00
David MannandClaude Opus 5.5 b071929ecb Type: editing text keeps its ligatures and OpenType alternates (#3617) (#3747)
Photoshop keeps Stylistic Sets, Contextual Alternates, Swash, Fractions, Ordinals, Titling and
Stylistic Alternates and Ornaments only in a PSD's Txt2, not in EngineData. Import now reads them
into the runs' font features, so the layout drawn while the type is edited keeps Photoshop's
glyphs instead of falling back to the default ones.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 07:48:24 -04:00
MrCloudy2andClaude Opus 5.5 56c5fd5797 fix(effects): Satin at distance 0 draws as 1 px, as Photoshop does (#3744)
Photoshop 27.11 draws Satin Distance 0 as a 1 px offset (an edge band of
2/size), not as two copies that cancel out. PhotoCraft drew nothing. Measured
on a #808080 square with a Linear contour at sizes 5, 10 and 20; the other 12
cases already matched within 1 level. Applied to the CPU and GPU paths and
to the effect reach.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:48:14 -04:00
224cebbd8a fix(automation): warn when text falls back from a missing font (#3391) (#3630)
* fix(automation): warn when text falls back from a missing font (#3391)

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:48:03 -04:00
JGFandClaude Opus 5.5 edc1b1e774 View: remember Extras, Show and Snap To across restarts (#3762) (#3787)
* View: remember Extras, Show and Snap To across restarts (#3762)

View › Extras, the View › Show items (Pixel Grid, Grid, Guides, …) and
the View › Snap To targets reset to their defaults on every launch. They
are now saved in Preferences (`viewExtras`) as they change and restored at
startup, as Photoshop keeps them between sessions.

Only items that differ from their defaults are stored, so a default
changed in a later release still reaches users who never touched it. A
hand-edited file can't break them: unknown keys and non-boolean values
keep their defaults.

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

* View: rustfmt the view-extras restart test (#3762)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:47:33 -04:00
53d814f643 feat: Photoshop's layer and selection keys (Ctrl+, toggle, Ctrl+\, /, Ctrl+Alt+2, Ctrl+Alt+Backspace, Tab rename, Alt-click thumbnail) (#3710)
* feat(engine): Ctrl+, toggles a layer's visibility and / its transparency lock

Photoshop's Ctrl+, (Cmd+, on macOS) hides the selected layers and, once
they are all hidden, shows them again: its Layer menu item then reads
Show Layers. layer.hideLayers now does the same, so the key toggles: it
shows them only when every targeted layer is hidden.

The / key toggles Lock Transparent Pixels, as the Layers panel's first
lock button does (layer.toggleLockTransparency). With several layers
selected, any unlocked one means lock them all; the Background, which
can't take this lock, is left out. Each press is one undoable step.

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

* feat(engine): Ctrl+\ targets the layer mask and Ctrl+Alt+2 loads luminosity

Photoshop's Ctrl+\ (Cmd+\) targets the active layer's mask, as clicking
its thumbnail does, beside Ctrl+2 for the composite
(channel.target.layerMask; greyed when the layer has no mask). Its
Ctrl+Alt+2 (Cmd+Option+2) loads the image's luminosity as a selection,
the Ctrl-click on the composite channel (select.loadLuminosity).

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

* feat(engine): Ctrl+Alt+Backspace fills from history

Photoshop's Ctrl+Alt+Backspace (Cmd+Option+Delete) fills the selection, or
the layer, from the History Brush's source state. edit.fillHistory is
edit.fill with contents "history": the oldest state still held, unless
"state" names another. It joins the other fill keys, so it honours the
Channels panel target and appears with them in Keyboard Shortcuts.

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

* feat(ui): Tab renames the next layer; Alt-click a thumbnail fits the layer

While a layer name is being edited, Tab commits it and starts renaming the
layer below, and Shift+Tab the layer above, as in Photoshop; the
Background, which isn't renamed in place, ends it. Alt-clicking a layer's
thumbnail selects the layer and fits its content on screen (View › Fit
Layer(s) on Screen).

Key tests cover Ctrl+,, /, Ctrl+\, Ctrl+Alt+2 and Ctrl+Alt+Backspace
through the shortcut dispatcher. docs/ui-parity.md lists the new keys.

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

* fix(ui): an explicit channel target drives the Layers panel's mask target

After Ctrl+\ targeted the layer mask, Ctrl+2 moved the Channels target back
to the composite, but the Layers panel kept the mask targeted (its own
flag, which only ever followed the engine towards the mask), so painting
still went to the mask. The same happened after clicking the mask
thumbnail. Any channel.target command (Ctrl+2, Ctrl+\, Ctrl+3…) now sets
the panel's mask and vector-mask targets from the engine's target.

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

* Use Document::is_background after #4036 removed extra_cmds::is_background

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 07:32:23 -04:00
3dd5fc1cdc automation: say why a command is disabled in engine.commands (#3740)
engine.commands, command.list and the MCP command_list tool now give each
row a disabledReason (null when enabled, else the failed precondition) and
needsDocument (true when the command is off because no document is open),
so an agent can tell "open a document first" from "not runnable". enabled
keeps its meaning. Both listings share one row builder in the engine.

Fixes #3664

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:13:57 -04:00
Valentin KleinandClaude Opus 5.5 6751a46003 Codecs: integer fast paths for Image::convert (#3717) (#3729)
U8/U16 depth changes and pure channel-copy layout changes (alpha add/drop, gray to RGB) skip the two intermediate f32 buffers. Output is bit-identical to the f32 route.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:13:45 -04:00
89312de597 Answer a repeat auth with "already authenticated" (#3723)
A second `auth` frame on an authenticated TCP connection fell through to
the method dispatcher and came back as an unknown method, in both the
headless serve server and the desktop control server. A shared
`security::repeat_auth_reply` now answers it with `already authenticated`
(keeping the request id) and the connection keeps serving.

Fixes #3668

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:13:32 -04:00
Nicolas Falesy 0c2ca488ae fix(text): scale faux bold dilation with glyph scaling (#3705) (#3719)
When drawing faux-bold glyphs, the offset dilation was composed as
`Xform(ox * r, oy * r).mul(&glyph)`. Because `Xform::mul(o)` applies `o`
first (`self ∘ o`), the translation offset was added after the glyph's
scaling transform, leaving faux bold dilation unscaled by horizontal
and vertical font scaling.

Applying the glyph transform to the offset transform (`glyph.mul(&offset)`)
ensures dilation is performed in font-local coordinates and scaled
proportionally with anisotropic glyph scaling, matching Photoshop.
2026-10-12 07:13:06 -04:00
e54adc250c feat(codecs): decode legacy PCX images and palettes (#3692) (#3701)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:12:55 -04:00
Streetblock 38236f90c2 perf(compose): allow empty vector masks in preview proxies (#3716) 2026-10-12 07:12:47 -04:00
Streetblock e156a9ed85 perf(compose): select Normal RGB kernel once per buffer (#3715) 2026-10-12 07:12:34 -04:00
Valentin KleinandClaude Opus 5.5 476f983876 Codecs: bound the TIFF IFD walk by a file-size entry budget (#3683) (#3711)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:12:13 -04:00
Valentin KleinandClaude Opus 5.5 7893a6e04d PSD: avoid cloning layer data when padding and presize the output buffer (#3687) (#3707)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:12:00 -04:00
Valentin KleinandClaude Opus 5.5 21f1012ce2 Codecs: refuse implausible PNG/WebP headers before allocating (#3691) (#3706)
A tiny file declaring a huge image reserved the full output buffer before any
data was checked. Add Limits::check_plausible (compression-ratio guard) and a
fallible alloc_zeroed, use them in PNG and WebP, and lower the 32-bit default
max_alloc to 1 GiB.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:11:41 -04:00
Brandon ThomasandClaude Opus 5.5 b564f2ca9c fix(layers): identify the Background by structure, never by its English name (#4036)
Photoshop's Background is the bottom layer of a file without transparency: a pixel layer that is
transparency- and position-locked, whatever the file's language calls it ("Hintergrund", "背景",
"Arrière-plan"). We matched the name "Background" in compose (deep / shallow knockout), the
engine (cut/clear, merge visible, transforms, warps, distort, erasers, styles, masks, crops,
straighten, smart-object unpack) and the UI (Properties, layer row, tab title), so a localized
file's deep knockout revealed transparency instead of the Background.

- `Document::background` / `background_mut` / `is_background` and `Layer::has_background_traits`
  in the doc model replace every name check; `extra_cmds::background_to_layer` is the one
  "Background → Layer 0" conversion.
- PSD import marks the bottom record position-locked when it is transparency-protected and has
  no transparency channel, with or without an `lspf` block. Affinity import and HDR Toning now
  lock the Background's position like every other place that creates one.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:04:33 -04:00
Brandon ThomasandClaude Opus 5.5 6547ca0b84 fix(psd): Multichannel alpha channels stay alpha; a save keeps resources and global blocks (#4031)
- DisplayInfo kinds 0/1 (selected / masked areas) now import as alpha channels instead of black
  spot inks; a Multichannel composite still prints them as black ink, so the look is unchanged.
- Saving a Multichannel PSD writes global light, grid and guides, saved / work / clipping paths,
  EXIF and every other imported resource (print settings, IPTC, layer-id seed, alpha ids while
  they match the channels), except the ones that only describe layers, plus the document-level
  tagged blocks (4-byte padded). Path writing and global-block padding are shared with the
  layered writer.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:02:10 -04:00
4705fc5980 feat(io): open Krita .kra composites as editable raster documents (#3692) (#3700)
* feat(io): open Krita .kra composites as editable raster documents (#3692)

* cargo fmt

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 07:01:20 -04:00
0dbc288667 fix(algo): mesh warps filter their source when shrinking, so thin lines don't alias (#3672) (#3678)
* fix(algo): mesh warps filter their source when shrinking, so thin lines don't alias

warp_triangles (Edit > Transform > Warp, smart objects placed through a warp, Puppet, Lens and
Wide Angle corrections) took one Catmull-Rom sample per output pixel with no minification
filter. A large smart object placed small through a warp (a 4200x4800 design at ~1/3-1/4 size
in a shirt mockup) came out with crinkled, doubled or missing grid lines where Photoshop's render
is smooth. The affine path (transform::warp_surface) already pre-reduces below 50% for exactly
this reason ("bicubic alone aliases below ~50%"); the mesh path had no equivalent.

Each triangle now carries its inverse-map Jacobian, computed analytically from its vertices.
Where it shrinks the source, an output pixel averages nx x ny bilinear subsamples over its
source footprint, with about one subsample per source pixel per axis (anisotropic, capped at 8).
Averaging happens in premultiplied space, before alpha is divided out. Triangles that keep or
enlarge the source still take the exact single sample, so identity and unscaled warps are
bit-for-bit unchanged. The per-tile source read grows by the subsamples' actual spread instead
of a fixed pad. Per-pixel filtering is used rather than a global pre-reduce because a mesh
warp's scale varies across the surface: one reduction factor over-blurs the parts that stay
large or under-filters the parts that shrink most.

Measured with an earlier cut of this approach (square bicubic subsampling) against Photoshop
exports of real mockups (RMSE of the placed design): 1.78% -> 0.81%
(White shirt), 0.49% -> 0.29% (Pepper). A mockup without a warp is unchanged.

Test: a_shrinking_mesh_warp_is_prefiltered (8/16/32-bit). A 1 px grid every 4 px placed at
1/5 through an Arc must stay within 0.25 of its 9/16 area average. Before: 0.5625 off (solid
black pixels). After: 0.20 off, which is the limit of a ~5 px box over a 4 px period.

Perf (30 MP RGBA8 source, place_source with Arc 10, release, M-series, best of 7):
  1/3 size: 55 ms -> 87 ms
  1/1 size: 240 ms -> 239 ms (no subsampling: identical path)

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

* Use as_chunks for the opaque-pixel scan (clippy 1.99)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 06:49:25 -04:00
e4298f554b Free Transform: a layer's style shows while the box is open, drawn from the transformed layer (#3653) (#3696)
* Free Transform: layer styles render with the transformed layer (#3653), work in progress

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

* Free Transform: a layer's style shows while the box is open, drawn from the transformed layer (#3653)

The live preview drew the moving pixels as a bare texture on a mesh, so Stroke,
Drop Shadow and every other effect vanished while the box was open (and for
type, shapes, groups and smart objects the style was baked into the texture,
cut off at the layer's bounds and stretched with the box).

A styled layer is now composited for the preview: the moving texels are
resampled through the box and the style is laid over them by the CPU
compositor's composite_with_effects, the code that draws the layer once the
transform is applied. A stroke keeps its width when the box is enlarged and a
shadow keeps its direction when it is turned, as after the commit.

The composite is made at screen resolution (never finer than the document),
with the style's sizes scaled to match, within a pixel budget from a measured
cost per effect: about 30 ms per rebuild while dragging and 120 ms at rest.
It is rebuilt only when the box, the zoom or the layer changes.

Still plain: the selected part of a styled layer, and Warp mode.

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

* Merge fix: Warp on type reads the texels as the session did (bare when the box draws the style)

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

* Format the type warp texel read

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

* Free Transform: Content-Aware Scale's preview has no styled composite

Main added a TransformPreview initializer for Content-Aware Scale (#3103) after this
branch started; give it the new field so the merge builds.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 06:37:15 -04:00
304720b1a0 Gradient Map: edit color stops on the ramp (#3982)
* Gradient Map: edit color stops on the ramp (#2958)

* Gradient Map: keep stop removal scoped to the editor

* Gradient Map: fit selected stop controls in narrow Properties panels

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:30:14 -04:00
Scott MacdonaldandClaude Opus 5.5 590bec7998 feat(io): import Photoshop Displace smart filters with their embedded map (#3680)
A Displace smart filter (Fltr class `Dspl`, filterID 1148416108) was not in
the modelled filter list, so it became psd.unsupportedFilter and rendered
as a pass-through. Mockup PSDs that bend a replaceable design over fabric
(a design smart object with Displace then Gaussian Blur) rendered flat.

Photoshop stores the displacement map inside the filter: `DspD` (raw data)
holds u32 LE height, u32 LE width, u16 LE mode 3, then two 8-bit planes
(horizontal, vertical) and padding, next to HrzS/VrtS (scale %), DspM
(StrF stretch / Tile), UndA (RptE repeat / WrpA wrap), EmbF and DspF (an
alias to the author's map file, usually unavailable).

The filter now maps to filter.distort.displace. As for Camera Raw, the
whole item (with the map) is kept as hex in `__displacePsd`, so it
re-renders, survives .pcraft and is written back to PSD with only the
editable settings and blending replaced. params_for resolves the embedded
map, so the smart filter re-render applies it, stretched over the document
canvas (the bounds smart filters already pass). Another mode, a missing
or truncated map, or unknown enum values keep the item opaque as before.
The map parser uses checked sizes and caps maps at 2^26 pixels.

Tests: displace_map unit tests (layout, every truncation, hostile and
corrupted headers, round trip, edited settings, opaque fallbacks, bad
exports) and smart_psd::displace_psd_filter_renders_with_its_embedded_map
(8/16/32-bit: renders displaced, PSD export/import and .pcraft keep it).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:28:55 -04:00
Scott MacdonaldandClaude Opus 5.5 5ed758763d PSD: smart object Trnf frames the custom warp's control points, not the source rect (#3677)
A placed layer with a custom warp (warpCustom, a 4 x 4 customEnvelopeWarp
mesh) can have control points outside its source rect 0..w x 0..h. Photoshop
maps the bounding box of those 16 control points onto the Trnf quad. PhotoCraft
mapped the source rect onto it, so a mesh that spills past the source counted
the overhang twice and the re-rendered smart object came out stretched.

Measured against Photoshop's own export of a real shirt mockup (4200 x 4800
source, mesh spilling to x = -9, y = -88): before, a ~1.6% vertical stretch,
the tile offset growing from 0 to -16 px top to bottom; after, alignment is
exact everywhere and the RMSE against Photoshop is 0.49%. The bounding box of
the evaluated warp outline (rather than the control points) fixed that file
but put another at 5.4% RMSE, so it is the control-point box.

- blocks::placed_frame: the shared rule. The control-point box of a
  single-patch custom warp, else the source rect; a degenerate or non-finite
  box falls back to the source rect.
- blocks::parse_smart (and so smart_map::parse_sold): the affine maps that
  frame onto Trnf.
- smart_map::quad_points: export writes Trnf (and PlLd's quad) as the image of
  the same frame, so a spilled mesh round-trips and quad_moved keeps the
  imported Trnf as stored. Distort / Perspective placements keep the source
  rect, matching parse_smart_perspective.

Refs #579.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:28:38 -04:00
Scott MacdonaldandClaude Opus 5.5 2cb396d154 fix(engine): Displace smart filter ignores its mapPath displacement map (#3676)
A Displace applied to a smart object is recorded as a smart filter and
rendered by apply_smart_filters -> filters::apply_filter_to_surface, both
when it is added and on every later re-render (replace contents, toggling
a filter, transforms). That path built FilterParams with params_for alone,
which leaves Displace's map as None; only run_filter resolved `mapPath`
(through filters_ext::resolve, which needs the session). With no map,
algo's displace returns the source unchanged, so the smart filter was a
silent no-op.

apply_filter_to_surface now resolves `mapPath` through the same file read
and import the direct command uses (filters_ext::map_from_path, shared by
resolve and the new session-free resolve_map_path). A map that can no
longer be read on re-render leaves the pixels alone, as for an unknown
filter; the command still refuses an unreadable map when the filter is
added. `mapDocument` needs the session and stays unresolved on re-render.

Regression test: smart_cmds::tests::
displace_smart_filter_reads_its_map_path_on_re_render (8/16/32-bit).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:28:33 -04:00
Valentin KleinandClaude Opus 5.5 64a02107ba PSD: cap descriptor list/object preallocation from untrusted counts (#3636) (#3699)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:28:18 -04:00
Valentin KleinandClaude Opus 5.5 16341720c0 PSD: bound the zero padding of undersized pattern channels (#3652) (#3686)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:28:14 -04:00
Valentin KleinandClaude Opus 5.5 7c77143b97 PSD: don't preallocate knots from untrusted path length counts (#3635) (#3654)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:28:09 -04:00
Valentin KleinandClaude Opus 5.5 04396dd85f PSD: composite_rgba8 checks the output budget and converts planes fallibly (#3639) (#3651)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:27:57 -04:00
Brandon ThomasandClaude Opus 5.5 27f56eb762 feat(compose): Lab documents evaluate Invert, Posterize, Threshold, Brightness/Contrast and Gradient Map on L, a, b (#4038)
In Lab documents Photoshop evaluates these adjustments on the Lab channels,
not on the screen RGB colour:

- Invert and Posterize act on L, a and b alike (normalised L/100,
  (a+128)/255, (b+128)/255; Invert is n -> 1 - n).
- Brightness/Contrast (modern and legacy) applies its tone curve to L only;
  a and b are unchanged.
- Threshold splits on L alone and outputs neutral black or white.
- Gradient Map is driven by L (Reverse uses 1 - L).

Adjustment layers (CPU compositor) and destructive Image > Adjustments both
go through the new `adjust::apply_in`; the GPU compositor defers these
adjustments to the CPU in Lab documents. Levels and Curves keep their Lab
records; other adjustments stay on the screen colour.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:20:44 -04:00
zenjabbaandzenjabba ce16e100b0 Expose WebP and optional Batch export quality (#3956)
Co-authored-by: zenjabba <[email protected]>
2026-10-12 06:20:16 -04:00
McClabe be877c27d0 fix(ui): keep filter jobs polling on idle Linux (#3961) 2026-10-12 06:20:00 -04:00
Nigel Ihza Firdausy 6ca7e60cb0 fix(ui): make checkbox labels nonselectable with pointer cursors (#4002) 2026-10-12 06:19:44 -04:00
Nigel Ihza Firdausy 73520f8681 fix(ui): Open layer color choices from the visibility icon (#4001)
* fix(ui): offer label colors from the layer eye icon

* fix(ui): offer label colors from the layer eye icon

* test(ui): cover layer eye color context menu

* style(ui): keep extracted color menu indentation consistent
2026-10-12 06:19:28 -04:00
MarekSpodBiedry db509dc4d9 fix(ui): show a paint-bucket cursor for the Paint Bucket tool (#3968) (#3989) 2026-10-12 06:19:13 -04:00
Vikram Bhaskaran ad3417dc05 Dismiss Home when the document count changes. (#3984)
Chrome stored the document count when Home opened so File New could leave the overlay. Closing a tab that restored that count re-opened Home and hid remaining tabs (#3981). Clear the stored count as soon as it no longer matches, then keep Home dismissed until the user opens it again.
2026-10-12 06:18:58 -04:00
Brandon ThomasandClaude Opus 5.5 0d6e658e73 PSD: rasterized fill and smart layers stay rasterized after save and reopen (#4034)
* fix(psd): rasterized fill and smart layers stay rasterized after save and reopen

Layer > Rasterize > Fill Content / Smart Object swapped the layer's content
to pixels but kept the fill (SoCo/GdFl/PtFl) or placed-layer (SoLd/PlLd/SoLE)
blocks read from the PSD. Written back, they made the reopened file a fill
layer or smart object again and discarded the pixels. Photoshop writes none
of them for a pixel layer.

- Rasterize drops those blocks from the layer.
- The PSD exporter never writes fill, placed, type (TySh), shape-content
  (vscg/vstk), adjustment or CgEd blocks for a raster layer, whichever
  command made it one; a vector mask (vmsk/vsms with its vogk) stays.

Tests: synthetic fill + smart object rasterize/save/reopen, the exporter
backstop with stale blocks, and Photoshop-authored corpus files.

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

* fix(psd): keep a pixel layer's vstk when it has no vector mask, as Photoshop does

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:11:33 -04:00
Brandon ThomasandClaude Opus 5.5 6f1c4481bf fix(psd): type layer bytes match Photoshop (TySh bounds, AntiAlias, version info) (#4041)
- TySh: the four values after the warp descriptor are big-endian f32, not
  i32. Written as Photoshop writes them: zeros for unwarped type, the
  text-space bounds for warped type (corpus: warp-arc-50.psd, unwarped
  text oracles).
- EngineData AntiAlias numbers as Photoshop writes them, on write and on
  read (when there is no AntA descriptor): 0 None, 1 Crisp, 2 Strong,
  3 Smooth, 4 Sharp (corpus text/antialias-*.psd). Platform modes, which
  no corpus file confirms, write Smooth and keep their exact mode in AntA.
- Resource 1057: writer "Adobe Photoshop", reader "Adobe Photoshop 2026",
  no NUL terminator, byte for byte what all 258 Photoshop 2026 oracles
  write.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:06:46 -04:00
5bc66094f8 fix(ui): allow selecting shapes and editing color with path selection (#3915) (#3932)
* fix(ui): allow selecting shapes and editing color with path selection (#3915)

- In vector_ui.rs, implement pick_shape_at to hit-test visible shape layers by path, surface pixels, or live shape geometry.
- In path_selection_finish and canvas ToolEvent::Down, select the clicked shape layer under the pointer when using Path Selection.
- In direct_select.rs, include visible shape layers in candidate hit tests and sync layer.select on press.
- In options_bar, display Fill swatch/checkbox, Stroke swatch/width/style, and corner radius when a shape is selected with Path Selection or Direct Selection, and dispatch shape.edit.
- Add tests in path_selection_tests.rs and vector_ui.rs verifying shape selection on click and options bar editing.

Fixes storytold/photocraft#3915

* Collapse nested ifs for clippy (collapsible_if)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:05:15 -04:00
Scott C Hughes 24bd7533fc feat(ui): the generic filter dialogs get a Photoshop-style preview window (#3769) (#3877)
Photoshop shows a preview of the filtered result inside its filter dialogs; ours only had the parameter widgets, with the live result on the canvas behind the dialog. The generic schema dialog now draws a preview window from the same downsampled proxy the canvas already computes, with - / + zoom steps and a percentage readout, drag to pan, a click to centre on a document point, and press-and-hold to compare with the unfiltered image. It sits under the parameters (reserving its height so a small window still fits OK/Cancel); the Preview checkbox still toggles the on-canvas result, unchanged.

Tests: cargo fmt --all -- --check; cargo test -p photocraft-ui-egui --lib (only the pre-existing Czech cs.tsv duplicate-key test fails); cargo clippy -p photocraft-ui-egui --all-targets -- -A clippy::nonminimal_bool -A clippy::collapsible_match -D warnings; cargo xtask wasm; a snapshot of the Emboss dialog shows the window and its zoom controls.
2026-10-12 06:05:02 -04:00
Fabian GhenofandClaude Opus 5.5 83f4452bf2 Layer › Export As exports every selected layer or artboard (#3629) (#3909)
With several layers selected, Export As was greyed: its dialog exported only
the active layer. Someone with 20+ artboards (ads, Steam store capsules) had
to export them one at a time.

Export As now opens the same dialog for the selection ("Export As: N
layers", the preview showing the first) and, on Export, asks for one
destination and writes one file per layer with the dialog's format, quality,
transparency and scale, each at its own size. Files are named as Quick
Export as PNG names a selection's: the chosen path, then numbered siblings
named after each layer, now in the chosen format. The layers are captured
when Export is pressed.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 06:04:35 -04:00
Scott C Hughes 19e51cf0ad refactor(ui): unify plain text inputs on one text_field widget (#3789) (#3896)
Plain text fields (Preferences search and hex, the Color Picker hex, and the brush search fields) each drew egui's default TextEdit frame, so they differed in padding and border and showed egui's white focus ring (its selection.stroke). Add widgets::text_field, which draws the same box as a value_field (field fill, field_border, radius_sm) with frame NONE, a consistent inner padding, no focus outline, and a scoped, non-blue text-selection colour (grey in the monochrome Pro themes, the theme accent elsewhere). Migrate those fields to it; numeric fields already use value_field.

Tests: cargo fmt --all -- --check; cargo test -p photocraft-ui-egui --lib; cargo clippy -p photocraft-ui-egui --all-targets -- -A clippy::nonminimal_bool -A clippy::collapsible_match -D warnings; cargo xtask wasm
2026-10-12 06:04:23 -04:00
coolvitto c0fa2269d2 Update Japanese translations (#3892)
Improved readability and several translation corrections.
2026-10-12 05:57:45 -04:00
Brandon ThomasandClaude Opus 5.5 32552a61a1 fix(liquify): dab spacing runs on along the whole stroke, so pointer speed no longer changes the warp (#2606) (#4047)
Liquify dabs were spaced a fifth of the brush radius apart and restarted
at every pointer event, so a fast mouse (few far-apart events) and a slow
one (many close ones) warped the same path differently, and a slow drag
piled up one dab per event. Dabs now fall every twentieth of the radius
by distance travelled along the whole stroke, carried from one segment
to the next, as in Photoshop: any sampling of a path gives the same
field. The stationary tools' per-dab strength is scaled to the new
spacing so their effect per distance travelled is unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:56:09 -04:00
Brandon ThomasandClaude Opus 5.5 f86517a029 fix(psd): write descriptor ids warp, list and time as length-4 strings (#4039)
A descriptor class or key id is a u32 length and bytes, where length 0
means a 4-byte code follows. Photoshop stores `warp`, `list` and `time`
as length-4 strings and treats the code form as a different key, so the
warps of new smart objects and type layers and regenerated layer comp
lists were not recognised. In the corpus, `warp` appears 741 times as a
string and never as a code, `list` 5/0, `time` 7/0.

`Id::new` now makes these three names string ids, as keys and class ids.
Reading is byte-based and accepts both forms; a code read from a file is
written back as a code.

Tests: exact bytes for each name as class and key, both forms read,
fresh placed-layer warp (SoLd/PlLd) and layer comp list bytes.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:50:36 -04:00
Brandon ThomasandClaude Opus 5.5 62346f7fb6 feat(save): File › Save keeps a JPEG's quality and a WebP's lossy/lossless encoding (#2399) (#4032)
Opening a JPEG now estimates the quality it was saved at from its quantisation
tables (the Annex K tables of ITU-T T.81 scaled by the IJG quality curve; exact
for such tables, the closest fit otherwise). Opening a WebP records whether it
is lossless (VP8L) or lossy (VP8), and a lossy one's quality from its base
quantiser index. The settings live on the document (`Document::file_encoding`,
saved in .pcraft) and a save that asks for no quality writes the file back the
same way, as Photoshop's Save does: File › Save, Save As and Save a Copy start
there instead of the fixed codec defaults (quality 90, WebP always lossless).
An explicit quality still wins; new documents and other formats keep the
defaults. Hostile or truncated DQT/VP8 headers give no estimate, never a panic.

Refs #2399

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:45:50 -04:00
Brandon ThomasandClaude Opus 5.5 3d0c819d46 Release prep: 0.8.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 05:44:37 -04:00
Brandon ThomasandClaude Opus 5.5 dd708f4ce4 feat(psd): Gradient Map stops in CMYK, Lab, Gray and HSB import and round-trip (#4033)
Photoshop writes a Gradient Map's colour stops in the document's own colour
space (CMYK, Lab, Grayscale) or HSB. PhotoCraft only read RGB stops, so such
layers imported as unsupported and did nothing.

- Decode every plain PSD colour space (RGB, HSB, CMYK, Lab, Grayscale) in
  `grdm` stops; CMYK converts through the document's own CMYK profile
  (photocraft-cms), Lab through D50 Lab. Book colours and unknown ids stay
  unsupported (kept verbatim).
- Saving keeps the file's own `grdm` block, so stops keep their colour space;
  toggling Reverse or Dither patches just those flags in place.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:43:27 -04:00
Reynold Hu e79d6571ba Actions: replay tests for crop, filters, selections and styled text on a second document (AUTO-219-4) (#3566)
* test(engine): a recorded edit run replays on a second document

* docs(scorecard): mark AUTO-219-4 done; the replay tests cover all four families
2026-10-12 05:36:39 -04:00
a1a7b17ee0 fix(engine): a PSD type layer names the installed font, not an invented one (#3556) (#3564)
* fix(engine): a PSD type layer names the installed font, not an invented one (#3556)

A type layer created with a font by family name stored no PostScript name, so PSD export
invented one ("<Family>-Regular", spaces removed). Photoshop looks type up by PostScript name,
so it showed the font as missing and substituted Myriad Pro as soon as the text was edited.

Resolve the family's installed face (matching weight/italic) from the font database when the
family, style, weight or italic is set, and store its real PostScript name; an explicit
postscriptName still wins. A family PhotoCraft does not have keeps no name, so the export falls
back as before.

Test: a_family_font_gets_the_installed_postscript_name; the existing named-face test now expects
the resolved name.

* Type tool test: a family change names the installed face (#3556)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:32:03 -04:00
79cfa2b65a Guides: Shift-drag out of a ruler lands on the nearest ruler tick (#3770) (#3796)
As in Photoshop, holding Shift while dragging a new guide out of a ruler
snaps it to the tick marks the ruler draws (tenths of the labelled step),
in the current ruler unit and zoom. Without Shift the guide snaps to the
document, layers, selection and grid as before.

The tick spacing draw_rulers computed inline moves to ruler_scale, shared
by the renderer and the new snap_to_tick, so the snap always matches the
ticks on screen.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 05:22:58 -04:00
bade115202 Fail CLI run when recorded action playback fails (#3861)
Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Codex <[email protected]>
2026-10-12 05:14:54 -04:00
Jakub 6d03075ed7 perf: draw marching ants as one mesh instead of a line shape per dash (#3846)
Every ants segment was a white line shape plus a black line shape per 4 px dash, and egui
tessellates each. A selection with many edges seen whole (a speckled selection at 30% zoom:
94,666 segments) cost 45.6 ms per idle frame, ten frames a second while the selection shows.

Horizontal and vertical segments now go into one egui::Mesh of 1 px colored quads, with the
same pixel-centre rounding, dash length and phase; the white base runs half a pixel past both
ends so corners join as the line caps did. Slanted segments (Content-Aware Move's transformed
outline) stay line shapes, which egui antialiases. A textured, repeating dash isn't possible:
egui-wgpu clamps texture coordinates.

Speckled selection, idle frame: 45.6 -> 12.0 ms at 30% zoom, 6.6 -> 4.5 ms at 125%
(RTX 3090, Vulkan; an idle frame with no selection is about 3 ms). Screenshots at 100% and
800% zoom are pixel-identical to the line shapes.
2026-10-12 05:14:41 -04:00
Sir Starch 5bdb12ec51 fix(canvas): keep transform preview visible while panning (#3610) (#3843) 2026-10-12 05:14:27 -04:00
Scott C Hughes ae0e5eb731 feat(ui): drag to reorder swatch groups and preset folders (#3658) (#3828)
Swatch groups and the shared preset-browser folders could only be clicked, so there was no way to put them in the order the user wants. Dragging a folder header now mirrors the dock's per-group tab reorder (#2272): an accent insertion line shows where it will land, and the order is stored by name in PresetUi (UI state, groupOrder) so it saves and reloads with the rest of the state, reading as leniently as tabOrder. Because the folders share one browser, Gradients, Patterns, Styles and Shapes get it too.

Tests: unit tests for the reorder and lenient/persisted order, plus an egui_kittest drag of a group header in the Swatches panel.
2026-10-12 05:14:17 -04:00
zenjabbaandzenjabba ad5fb53f68 Web: avoid shifting canvas during preset saves (#3805)
Co-authored-by: zenjabba <[email protected]>
2026-10-12 05:14:07 -04:00
JGFandClaude Opus 5.5 a2cdc6c2d7 fix(ui): drag a document tab off the strip to float it in its own window (#3733) (#3785)
Dragging a document tab more than 24 pt above or below the tab strip now
tears it off into a separate OS window placed where it was dropped, so it
can be moved anywhere, other screens included (as in Photoshop). Sideways
drags still reorder. Both tab strips share the new tab_drag helper.

window.arrange.floatInWindow takes optional `document` and `position`
params (validated: bad values are errors, not panics); DocWindow gains a
serde-default `position`.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:13:48 -04:00
Valentin KleinandClaude Opus 5.5 9f49cae093 Move: with an unlinked layer mask targeted, the Move tool moves the mask alone (#3604) (#3611)
layer.translate takes target=mask: a layer whose mask is unlinked then shifts that mask only
(the shell adds the target when the Layers panel targets the mask), live preview included.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:04:13 -04:00
xsvm 6d21a69968 Brush: symmetric live strokes update only what changed (#3593) (#3605)
With painting symmetry on, every pointer event cloned and finished the stroke and each mirrored
pass, merged them again, composited the whole stroke and restored each pass's full bounds, so an
event cost grew with the area painted so far. StrokeRenderer::composite_union_live merges and
composites only the coverage tiles that changed (new dabs, the smoothing tails now and before),
leaving exactly the pixels the full merge gave; a mirror joining redraws every tile once.
2026-10-12 04:53:36 -04:00
4736a4bcbe Character panel: OpenType feature buttons (TYPE-218-5, #3752) (#3757)
The Character panel (and the Properties Character section) gains
Photoshop's row of eight OpenType buttons: Standard Ligatures, Contextual
Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling
Alternates, Ordinals and Fractions. Each shows pressed while its feature is
on at the selection (else the layer) and toggles it through the existing
type.openType.* command, on the same scope as the other Character controls
(the selected characters while typing, else every selected type layer).

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

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 04:52:23 -04:00
MrCloudy2andClaude Opus 5.5 8b21621671 Chisel bevels: Photoshop's facet slope and ramp ends (#3595)
Measured on Photoshop 27.11 (scripted: a 128 px square with one Bevel &
Emboss, light from the top at 30°, sampled pixel by pixel across its
edges; Chisel Hard and Soft, inner and outer, size 10 and 20, depth 100
and 300):

- A chisel facet's slope is depth × size / (2 (size + 1)): it rises to
  the same depth × size / 2 as a Smooth bevel, over size + 1 px.
  PhotoCraft gave it the full depth, about twice as steep (facet 217
  where Photoshop shows 188).
- Inner Hard's ramp runs from half a pixel beyond the edge to size + ½,
  so the facet is flat right up to the edge pixel; Inner Soft starts a
  pixel further out (a steeper edge pixel) and is not blurred; Outer
  Hard ends a pixel further out; Outer Soft is a pixel shorter with a
  steeper edge pixel. Outer chisel bevels also shade the pixel where
  their ramp reaches zero (it still slopes), as emboss styles already
  did.

All six inner and outer chisel cases are now within 1 level (they were
27 to 154 off); Smooth bevels are unchanged. Emboss and Pillow chisel
are not modelled yet (they keep the blur).

On the GPU: the same ramps in fs_mbevelh (Chisel Soft flag in p0.z), the
emboss-only blur, and the outer reach-past flag in p3.x; the 44 GPU
parity tests pass. Corpus: every Photoshop chisel oracle improves
(bevel-inner-chisel-hard 20.1 -> 6.5 % differing pixels, outer-chisel-
hard 13.6 -> 5.2 %, inner-chisel-soft 22.3 -> 9.3 %, emboss and pillow
chisel a few points); pass counts unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 04:43:01 -04:00
fabiodalez-dev dbf9d0d998 i18n(it): translate the dynamic labels the Italian catalog was missing (#3742)
Adds the 1,287 rows the other catalogs have and Italian lacked (filter and adjustment parameters and choices, Camera Raw, Lighting Effects, History and Layers labels, metadata, dialogs), fixes mistranslations in existing rows (Facet, Gradient stops, Save As Preset, Canvas, Spot Color) and uses one term per concept (Predefinito, Traccia, clic, scorciatoia, filmato).

Factors the Korean generated-label check into missing_generated_filter_labels and runs it for Italian too. Adds docs/localization-it.md and updates localization-parity.
2026-10-12 04:38:03 -04:00
FredApps 62f4b9d453 Menus: keep clear of the taskbar when Wayland hides the window position (#3737) (#3739)
* test(ui): scaffold reported menu viewport coverage (#3737)

* chore: opt FredApps into public profile credit

* Menus keep clear of the taskbar when the window position is unknown (Wayland) (#3737)

On Wayland winit can't report a window's position, so egui has no
inner_rect/outer_rect, only the monitor size. work_area::visible_part
then returned the whole window, and fit_window never maximized an
oversized window, so long menus (Layer, View, Window...) were sized to
the full window and ran under a bottom panel. Without a position, cut
the window to the monitor size less the taskbar (the most that can be
visible wherever the window is), and use the content size as the
outer size for fit_window.

Replaces the scaffold test with one that fails on main.

---------

Signed-off-by: FredApps <[email protected]>
2026-10-12 04:33:54 -04:00
Giuseppe 986f977a01 Fix: video frames to layers working using folder dialog request (#3513) (#3718) 2026-10-12 04:33:48 -04:00
UnemarsandClaude Opus 5.5 986f68f969 Toolbar: the colour chips sit right under the tools again (#3745)
Since the shared docking migration (#3097) the tool column's scroll area
filled the toolbar's whole height, so a window taller than the tools
pushed the colour chips, Quick Mask and Screen Mode to the bottom.
Photoshop keeps them right under the last tool. The scroll area now
shrinks to its tools and only a column taller than the window scrolls,
with the chips kept at the bottom as before.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 04:33:36 -04:00
403e321034 Select: open the Save Selection and Load Selection dialogs (#3738)
The menu items ran the engine commands with no params: Save Selection
silently saved into a new alpha channel and Load Selection failed with
"missing `channel`". The form dialogs already existed in view_cmds but
the two ids were missing from wraps(), so they were never shown. Load
Selection now only offers the layer mask when the active layer has one.

Fixes #3713

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 04:33:25 -04:00
FredApps 4cf8a5fd05 Canvas: wheel and pinch zoom over the pasteboard keep the image in view (#3720) (#3731)
With the pointer on the gray pasteboard, wheel/Alt-wheel/pinch zoom anchored on the pointer,
so each notch slid the image away from it and out of the window. The anchor is now the
nearest point of the image (clamped in document space, so rotated and flipped views work);
over the image nothing changes.
2026-10-12 04:33:12 -04:00
MrCloudy2andClaude Opus 5.5 97c6769d6b Glows: Photoshop's precise ramp, and centre-source glows inverted after the contour (#3578)
Measured on Photoshop 27.11 (scripted: a 128 px square with one glow,
sampled pixel by pixel across its edges; outer and inner, both
techniques, sizes 10 and 20, spread 0/40 %, choke 0/30 %, Range 50 %):

- A Precise glow is 1 - D / (size + 1) beyond the solid spread/choke
  part, D being the centre-to-centre distance to the shape's edge.
  PhotoCraft's ramp ended at `size` and the default Range then doubled
  it, so it stayed solid for half the size and fell off steeply: up to
  122 levels off. The raw coverage is now 0.5 at the edge falling to 0
  at size + 1, so the shared Range step (v / range) gives Photoshop's
  ramp at the default 50 %; other Range values are unmeasured.
- A centre-source inner glow is exactly 1 - the edge glow, taken after
  the contour and Range. PhotoCraft inverted before them: the softer
  centre glow was 115 levels off, the precise one 64.

All 16 glow cases are now within 2 levels (10 were 58 to 122 off);
softer edge and outer glows are unchanged. On the GPU: the same ramp in
fs_mglow, and an "invert after the contour" mode (p0.y = 2) in
fs_mfinish for glows only (Satin keeps its own invert). The 44 GPU
parity tests pass. Corpus: the two Photoshop precise-glow oracles go
from 0.31 / 0.37 to 0.05 worst error (curved anti-aliased shapes,
still not under 2/255); nothing else changes.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 04:32:33 -04:00
MrCloudy2andClaude Opus 5.5 78c611007b PSD: alpha channel resources count the merged transparency, 13-byte DisplayInfo (#3577)
Photoshop 27.11 refused two of PhotoCraft's re-exported corpus files
(ag-psd layer-mask/src.psd, psd-tools effects/stroke-without-vector-mask)
with "Could not import the clipboard because an unexpected end-of-file
was encountered". Both carry transparency in the merged image plus an
alpha channel: PhotoCraft wrote two extra channels but named and
described only one in resources 1006, 1045 and 1077. In every corpus
file that has them (177, Photoshop's included), those resources hold one
entry per extra channel, the merged transparency first.

- Export: a "Transparency" entry leads the names and DisplayInfo when
  the merged image carries transparency and there are alpha channels.
- DisplayInfo (1077) records are 13 bytes, as in all 166 corpus files
  that have one; PhotoCraft wrote and read 14 (the older 1007 layout),
  which shifts every channel after the first.
- Import skips the transparency's entry, so an alpha channel keeps its
  own name and options ("Alpha 1", not "Transparency").

Corpus oracles and round trips are unchanged (io 148/170, psd-tools
237/309, Photoshop 137/258; every round trip renders the same).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 04:22:10 -04:00
dac995ee6c Edit › Transform › Skew: corners and sides move along one axis, like Photoshop (#3437) (#3590)
* Edit › Transform › Skew: corners and sides move along one axis, like Photoshop (#3437)

In Skew mode a corner drag scaled the box and the side handles moved
freely. Checked in Photoshop: a corner moves alone, horizontally or
vertically (whichever way the drag mostly goes); the top and bottom
handles slide only horizontally and the left and right ones only
vertically; Option moves the opposite corner or edge the other way. On
editable type a corner slides its whole edge instead, so the text stays a
parallelogram and remains live type. Free Transform is unchanged.

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

* Skew mode: a drag outside the box doesn't rotate it; ⇧ changes nothing (#3437)

Checked in Photoshop. A click outside still commits.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 04:18:33 -04:00
agent-346022andphotocraft-agent UNCONFIGURED 8c6e4a3abc fix(engine): image.canvasSize rejects unlisted anchors instead of landing between two (#2946) (#3572)
anchor_factors resolved the x and y axes independently and with different matching
rules (x looked for a capitalised "Left"/"Right" or the exact word, y for a lowercase
"top"/"bottom" prefix). A misspelling matched one rule and missed the other, so
"topleft" anchored top-centre and "TopLeft" left-centre -- anchors the command does not
offer -- with no error.

Match the anchor exactly against the nine documented values via one ANCHORS table;
anything else is a BadParams error naming `anchor` and listing them, as #507 settled
for other command params. The nine valid values keep the exact factors they had, and an
omitted anchor still means center.

Test covers the nine offsets, the omitted default, and the misspellings from the issue
("topleft", "TopLeft", "TOPLEFT", "bottomleft", "bottomright", "middle", "", "NOPE").

Fixes #2946.

Co-authored-by: photocraft-agent UNCONFIGURED <[email protected]>
2026-10-12 04:13:20 -04:00
Nigel Ihza Firdausy 84aa8df05a fix(transform): keep nearest-neighbor previews on original texels (#3688) 2026-10-12 04:10:55 -04:00
David MannandClaude Opus 5.5 b623db1ed7 Crop tool: a resized frame snaps its handle, not the pointer (#3638) (#3640)
With Auto Center Preview off, or in Classic Mode, dragging a crop edge or
corner snapped the pointer, which grabs the handle up to the 8 px grab
radius away from it. The snap line showed on the target while the frame
edge stopped as far from it as the grab offset, so recropping toward the
pixels a Delete Cropped Pixels-off crop kept left a transparent strip
along the top and sides (or cut a few pixels off).

The crop handle snap (snap_ui::crop_handle) now covers every resize: it
snaps the handle of the frame crop_ui would make and moves the pointer by
the difference, 1:1 without Auto Center Preview and holding the ratio on
a corner as before.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 04:10:50 -04:00
agent-346022andphotocraft-agent UNCONFIGURED 55b3301384 test(ui): marquee shift+alt before the drag intersects the selection (#208) (#3624)
The marquee already picks the selection mode from the modifier held at the press
(shift adds, alt subtracts, shift+alt intersects) rather than constraining the shape.
marquee_tests.rs covered add and subtract but not intersect; this adds that case,
the one gap BUG-208-4 still names.

The test fixes an existing selection, holds shift+alt at the press (then releases so
the drag stays a plain rectangle, isolating the mode from the square/centre shape
constraints) and drags a second rectangle. It samples three points -- one in the
overlap, one only in the first rectangle, one only in the dragged rectangle -- so it
passes for intersect and fails for replace, add or subtract.

Test only; no production change.

Co-authored-by: photocraft-agent UNCONFIGURED <[email protected]>
2026-10-12 04:10:45 -04:00
Scott C Hughes 6ca04e5afa fix(ui): one eliding label for fixed-width chrome, so translations can't overrun (#3580) (#3616)
A translated label beside a fixed-width control could run over its neighbours: the Layers
Lock/Fill row guarded only the redundant "Lock:" caption, so "Fill:" ("Заливка:" in Russian,
longer still as pt-br "Preenchimento:" / fr "Remplissage :") overlapped the lock icons.

Rather than a per-row fit check, add the one shared primitive for fixed chrome:
`widgets::elided_label(ui, text, max_w)` lays the text out no wider than its room, cuts it with
'…' and puts the full string on hover (built on the existing `tab_strip::elided`). The
Opacity/Fill scrub labels now elide to the room beside their field, so any translation shrinks
instead of overlapping, in every locale.

Documented the rule in docs/ui-design.md: wrap by default; beside a fixed control, elide; a
redundant caption may drop on a narrow dock (the lock icons carry the meaning).

Test: widgets::tests::elided_label_never_exceeds_its_room. Full photocraft-ui-egui suite: 2077
pass; fmt and clippy clean.
2026-10-12 04:10:40 -04:00
Valentin KleinandClaude Opus 5.5 c7c6031254 Options bar: the Gradient tool's controls show in every theme (#3601) (#3607)
Studio, Studio Light and Classic showed only the hint 'Drag to draw a
gradient'. The Gradient arm was gated by t.pro; it now runs in every theme.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 04:10:35 -04:00
Reynold Hu d2b5b144aa test(automation): six rounding-out acceptance tasks (31-36) (#3568) 2026-10-12 04:03:30 -04:00
Nigel Ihza Firdausy c5a9e7f9b2 fix(ui): don't reopen floating Brush Settings on restart (#3570) 2026-10-12 04:03:07 -04:00
Reynold Hu 857a4f7d8a M11: ten more MCP acceptance tasks (11-20) for FILE-216-6 (#3562)
* test(automation): ten more MCP acceptance tasks (11-20)

* docs(scorecard): FILE-216-6 note counts 20 tasks
2026-10-12 04:00:30 -04:00
Atos LinsandBrandon Thomas da3da0942c Linux: StartupWMClass is the window's app id (#3481)
* Linux: StartupWMClass is the window's app id

The desktop entry said StartupWMClass=photocraft, but the window carries
the app id ai.storyteller.photocraft (APP_ID) on Wayland and under X11
alike, so nothing that matches a window to its launcher by that key ever
matched it. VectorCraft, GridCraft and WordCraft already use the app id.

A test reads the entry and compares it with APP_ID.

* Contributors: add atoslins (Atos Lins)

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 03:28:09 -04:00
dbca08cd20 Add Vietnamese localization (#2636)
Adds the Vietnamese (vi) UI translation and turns on its coverage gate. A maintainer pass merged main, added 1,029 rows from @tonguyenducmanh's catalog (#3453) and 33 new ones, and set complete_menus.

Co-authored-by: tonguyenducmanh <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 03:05:18 -04:00
agent-346022andphotocraft-agent UNCONFIGURED 649b1d5d92 psd: harden the standalone binary parsers (fuzz + property tests, gradient/swatch correctness) (#3542)
* psd: harden the standalone binary parsers (gradients, swatches, patterns, filter effects, metadata, resources, tagged blocks)

Correctness and robustness work on the psd crate's auxiliary parsers, with
fuzz targets and property tests so the invariants are enforced, not just asserted.

grd (gradients):
- Split num()/finite() into a single guard with a saturating f64->f32 cast: only
  NaN has no value, everything else (including +/-inf and 1e300) saturates to the
  representable range so the mapping stays monotonic instead of snapping to a field
  default and inverting a hostile value.
- Hue now wraps with rem_euclid(360) instead of clamping (370 -> 10, not 370 -> 360,
  which would be a different colour at the boundary).
- Every colour model is bounded: RGB/CMYK/Gray to 0..=1, L* to 0..=100 and a*/b* to
  the CIELAB gamut (-128..=127), so a downstream converter never sees out-of-gamut input.

aco/ase (swatches):
- Share one io::utf16_units() helper that caps a name by UTF-16 code units without
  ever splitting a surrogate pair; keeping it in one place stops the fix from drifting
  between the two formats.

Coverage:
- New fuzz targets for grd, aco, ase, patterns, filter_effects, metadata, tagged and
  resources (wired into the fuzz CI matrix).
- Property tests mirroring each fuzz invariant (round-trip stability, no panic on
  arbitrary bytes, documented colour/number ranges) so normal CI covers them; the
  fuzz jobs run on demand.

369 psd tests pass; clippy -D warnings and rustfmt clean; layering and wasm check pass;
all psd fuzz targets build and run clean.

* psd: address Gemini/Claude review of the parser hardening

Follow-up to the previous commit, applying the substantive points from two
independent code reviews (Gemini 3.8: ship; Claude: ship after small fixes).

Production:
- grd hue: an in-range value (0..=360, including exactly 360) is now kept verbatim;
  only an out-of-range hue wraps with rem_euclid. Wrapping unconditionally altered a
  legal 360 to 0 (and f32 round-off on a tiny negative could itself land on 360),
  changing the colour at the boundary for anything that interpolates in HSB.
- ase: compile-time assert that MAX_NAME_UNITS < u16::MAX, since put_name writes the
  length as `units.len() as u16 + 1`; guards the invariant if the cap is ever raised.

Stronger test/fuzz invariants (byte stability alone would pass even if the writer
silently dropped a field; these assert semantic equality):
- metadata fuzz: assert the parsed-then-written block re-parses to the same items
  (the weakened byte-only check was the fix the first fuzz run surfaced).
- patterns fuzz: assert parse(write(parse(x))) == parse(x); channel offsets/padding
  are wire artifacts, not fields, so the round trip is lossless (confirmed, 2M execs).
- filter_effects fuzz: assert back == fx as well as byte stability; also decode every
  cached slot, not just the mask.
- resources fuzz: round-trip ResolutionInfo through to_bytes/from_bytes.

Coverage:
- tagged fuzz: select the block key from a table of every typed/strict key via the
  first byte, so the fuzzer reaches each branch instead of guessing a 4-byte key.
- new property tests: damage-a-valid-block for metadata/patterns/filter_effects (pure
  random bytes rarely pass a header), and check_structure over every structural key.
- grd_every_colour_model_is_clamped_and_finite now pins exact saturation values
  (a*/b* to +/-128, L* to 0/100, units to 0/1), so a weaker clamp is caught, not just
  an out-of-range value; grd_hue_wraps now pins the exact-360 and 0 boundaries.

Verified: 371 psd tests pass; clippy -D warnings, rustfmt, layers and wasm clean; all
13 fuzz targets build and run clean (metadata 11.5M, patterns 2M, filter_effects 5.9M,
resources 3.5M, tagged 5.4M execs); the a*/b* clamp-weakening and hue-guard-removal
mutants are both killed by the strengthened tests.

---------

Co-authored-by: photocraft-agent UNCONFIGURED <[email protected]>
2026-10-12 02:49:08 -04:00
Valentin KleinandClaude Opus 5.5 35b5b8ceb0 Format: index ZIP entries by name and use crc32fast (#3474) (#3488)
ZipReader::find scanned every entry per lookup, and a bundle load looks
up one entry per tile reference, so loads were O(refs x entries). Build
a name -> index map once in ZipReader::new (first entry wins for
duplicate names, as the scan did) and look names up through it.

crc32 now delegates to crc32fast (already in Cargo.lock via flate2)
instead of a byte-at-a-time table loop.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 02:42:40 -04:00
Dan HiornsandClaude Opus 5.5 20cb95bc27 Windows installer: refuse Windows 7 and 8 with a clear message (#2310) (#3261)
The Rust standard library has needed Windows 10 since 1.78, so on
Windows 7 and 8 PhotoCraft fails at launch with "The procedure entry
point ProcessPrng could not be located in the dynamic link library
bcryptprimitives.dll" (#2310). The MSI installed anyway.

It now has a launch condition: a registry search sets WINDOWS10ORLATER
from HKLM\...\Windows NT\CurrentVersion\CurrentMajorVersionNumber,
which exists only from Windows 10 on (in the 32- and 64-bit views), and
without it the installer stops with "PhotoCraft needs Windows 10 or
later." Repair and uninstall still run (Installed). MSI's VersionNT
isn't used: it has reported both 603 and 1000 on Windows 10.

The README's Windows downloads now say Windows 10 or later is required;
the portable zip can't check, as Windows rejects the exe before any of
its code runs.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 02:26:36 -04:00
skylar taylor-barrick 83fcbe7d9b Merge current main while preserving hardened print contracts through craft-print 2026-10-12 07:36:25 +02:00
skylar taylor-barrick e3d118e0f3 Separate print queue status from generic cancellable progress controls 2026-10-12 05:53:56 +02:00
skylar taylor-barrick 0467f715c6 Reuse upstream counted catalogs to prevent duplicate keys in PR merge 2026-10-12 05:48:59 +02:00
skylar taylor-barrick 8c557fcb7d Place identical browser engine profile beside upstream override 2026-10-12 05:47:22 +02:00
skylar taylor-barrick 67ca74fe18 Align overlapping CI repairs with upstream while retaining regression coverage 2026-10-12 05:44:48 +02:00
skylar taylor-barrick 82d2b7e0f1 Fill missing plural statuses in coverage-enforced catalogs 2026-10-12 04:54:47 +02:00
skylar taylor-barrick a538567a0d Read plural keys from form arguments and report all catalog gaps 2026-10-12 04:49:59 +02:00
skylar taylor-barrick 6651724db8 Align CLI save test with native destination filename semantics 2026-10-12 04:35:18 +02:00
skylar taylor-barrick 3a06871d69 Keep print queue translations alongside background job controls 2026-10-12 04:11:22 +02:00
skylar taylor-barrick 055e4fee1b Merge remote-tracking branch 'upstream/main' into codex/photocraft-craft-print-sync
# Conflicts:
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-12 04:10:55 +02:00
skylar taylor-barrick ce48721c6b Translate the system print queue guidance in every UI language 2026-10-12 04:04:07 +02:00
skylar taylor-barrick cf98b91f7b Remove duplicate Czech catalog rows while retaining active translations 2026-10-12 04:03:54 +02:00
skylar taylor-barrick 9db0b30910 Keep browser command orchestration within the existing size budget 2026-10-12 03:53:52 +02:00
skylar taylor-barrick f65f7cc9eb Correct stale HDR Levels test endpoints 2026-10-12 03:24:40 +02:00
skylar taylor-barrick 4bb6ffc46f Record background print hooks as one command 2026-10-12 02:58:32 +02:00
skylar taylor-barrick cc902c681b Report refused print cancellation accurately to automation 2026-10-12 02:56:10 +02:00
skylar taylor-barrick b8ea8206cc Refresh document views after completed print hooks 2026-10-12 02:51:42 +02:00
skylar taylor-barrick 223e9499c3 Keep keyboard shortcut parser available in browser builds 2026-10-12 02:46:08 +02:00
skylar taylor-barrick c6407cac07 Keep scorecard test reference current 2026-10-12 02:34:03 +02:00
skylar taylor-barrick bb46f846d9 Keep print completion tied to its document and hold quit 2026-10-12 02:32:26 +02:00
skylar taylor-barrick 698c0f554d Format printing completion regression 2026-10-12 02:29:59 +02:00
skylar taylor-barrick ccee4894bd Use craft-print for PhotoCraft system printing 2026-10-12 02:28:40 +02:00
moiztermindandClaude Sonnet 5 f9b3c6bd85 winprint: draw crop and registration marks on GDI prints
Printer marks (corner/centre crop, registration) were silently dropped on
Windows GDI prints -- only the PDF export path drew them, as content-stream
vector operators. A workshop test print surfaced the gap.

Adds a Marks type to photocraft-winprint and draws the same geometry GDI's
way (MoveToEx/LineTo for lines, Ellipse for registration circles) once per
page between StretchDIBits and EndPage. The JSON field marksSkipped becomes
textSkipped: description and label text are still PDF-only (no font layout
in the GDI path yet).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-10-11 23:19:28 +01:00
MoiztermindandClaude Opus 5.5 ce8d8d950b winprint: let the driver validate settings; never print on defaults silently
The settings check compared the printer name stored in the DEVMODE with
the queue name. Windows keeps only 31 characters of it, so a long queue
name ("Sublimation - EPSON ET-18100 Series") failed the check and the
chosen paper type and quality were silently dropped: the job printed on
the driver defaults (plain paper).

Settings now go through DocumentProperties(DM_IN_BUFFER | DM_OUT_BUFFER)
before CreateDC, so the driver validates them itself, and a refusal is
an error instead of a silent fallback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 23:19:07 +01:00
MoiztermindandClaude Opus 5.5 628a54b564 Print on Windows: printer list, driver Print Settings, GDI printing
- photocraft-winprint: an isolated unsafe crate (like photocraft-tablet)
  for Windows printing: printer list, the driver's Print Settings dialog,
  page geometry, and GDI printing with ICM off.
- file.print prints on Windows (explicit "send": true only); new
  file.print.printers and file.print.page commands; printerSettings param.
- Print dialog on Windows: Printer menu, Print Settings…, paper from the
  driver.
- panic_hunt fuzzes print commands as dry runs.

Closes #2697.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 23:18:58 +01:00
717 changed files with 95978 additions and 5660 deletions

No files matched your search

+12
View File
@@ -17,6 +17,10 @@ on:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# No test or fuzz run may print a page: the engine refuses to spool, and if something ever
# bypasses that, lp fails loudly on a printer that does not exist.
PHOTOCRAFT_NO_PRINT: "1"
LPDEST: photocraft-no-such-printer
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
# debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough
# for the Linux job's second, craft-fonts build (it ran out of disk with full debug info).
@@ -257,6 +261,14 @@ jobs:
- { crate: psd, name: parse }
- { crate: psd, name: descriptor }
- { crate: psd, name: image_source_data }
- { crate: psd, name: grd }
- { crate: psd, name: ase }
- { crate: psd, name: aco }
- { crate: psd, name: patterns }
- { crate: psd, name: filter_effects }
- { crate: psd, name: metadata }
- { crate: psd, name: tagged }
- { crate: psd, name: resources }
- { crate: codecs, name: decode_any }
- { crate: codecs, name: decode_png }
- { crate: codecs, name: decode_jpeg }
+6
View File
@@ -35,3 +35,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
-1
View File
@@ -131,7 +131,6 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
- `../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics.
- `plan/` (local, gitignored): research, parity plan, execution plan, estimates.
- `log/` (local, gitignored): the dev log.
-
## 8. Keeping the native format complete
+8 -1
View File
@@ -10,6 +10,7 @@ next to them.
| Path | Title | Author | Source | License |
|---|---|---|---|---|
| `crates/codecs/src/codecs/xpm/x11_colors.rs` | X11 `rgb.txt` color-name lookup table (normalized X.Org RGB values) | X.Org Foundation and X11 contributors | <https://cgit.freedesktop.org/xorg/app/rgb/tree/rgb.txt> | MIT (X.Org app/rgb) |
| `crates/ui-egui/src/i18n/el.tsv` | Greek UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/de.tsv` | German UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/it.tsv` | Italian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
@@ -19,9 +20,13 @@ next to them.
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/cs.tsv` | Czech UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
@@ -33,6 +38,7 @@ next to them.
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
@@ -48,6 +54,7 @@ next to them.
| `packaging/macos/dmg/` (all files) | PhotoCraft DMG window background and layout | @XusBadia, from the PhotoCraft app icon | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md) | MIT OR Apache-2.0, like the code |
| `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | <https://ethanschoonover.com/solarized/> | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) |
| `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, <https://invent.kde.org/plasma/breeze> (its colour schemes are LGPL-2.0-or-later) | Original work |
| `crates/ui-egui/src/theme.rs` (Soft Dark and Soft Light themes) | Original colour values and radii for two low-contrast themes on PhotoCraft's Studio layout; no third-party palette, file or code is used | PhotoCraft contributors | Original | Original work |
### Optional build input: craft-fonts (not files in this repo)
Generated
+1270 -47
View File
File diff suppressed because it is too large. Load diff
+36 -16
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.7.0"
version = "0.9.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -38,6 +38,7 @@ photocraft-testkit = { path = "crates/testkit" }
photocraft-tablet = { path = "crates/tablet" }
# external
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" }
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
@@ -50,7 +51,9 @@ zune-core = "0.5"
zune-jpeg = "0.5"
proptest = "1"
eframe = { version = "0.36", default-features = false }
egui = "0.36"
# Without `default_fonts`, which embeds egui's fonts uncompressed: ui-egui ships them deflated
# (egui_fonts.rs; the web build's size gate).
egui = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
log = "0.4"
@@ -81,13 +84,14 @@ codegen-units = 1
# The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the
# size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for the UI, command, and I/O
# shells, and "s" for other crates except pixel processing, which keeps opt-level 3. panic
# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for
# the native app shell). packaging/web/package.sh fails the build above 24 MiB.
# visit, so this profile optimizes for size: fat LTO, opt-level "z" for PhotoCraft's own crates,
# "s" for dependencies and for the crates that decode files, and 3 for pixel processing (filters
# and compositing). Dependencies stay at "s": at "z" the whole wasm grows (by 0.4 MB, measured).
# panic stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"`
# is for the native app shell). packaging/web/package.sh fails the build above 24 MiB.
[profile.wasm-release]
inherits = "release"
opt-level = "s"
opt-level = "z"
lto = "fat"
codegen-units = 1
strip = "debuginfo"
@@ -95,20 +99,35 @@ strip = "debuginfo"
[profile.wasm-release.package."*"]
opt-level = "s"
# Keep the browser shell compact as shared panel controls grow. Pixel-processing crates below
# retain their speed-oriented optimization level.
[profile.wasm-release.package.photocraft-ui-egui]
opt-level = "z"
# Decoding and rasterizing keep their speed.
[profile.wasm-release.package.photocraft-codecs]
opt-level = "s"
[profile.wasm-release.package.photocraft-raw]
opt-level = "s"
[profile.wasm-release.package.photocraft-heif]
opt-level = "s"
[profile.wasm-release.package.photocraft-vector]
opt-level = "s"
# Keep browser command dispatch, import/export glue, and the entry point compact. Native
# builds and the speed-oriented pixel-processing overrides below retain their settings.
[profile.wasm-release.package.photocraft-engine]
# Large crates off the per-frame and per-pixel paths: the document model, file parsers run once
# per open/save, JSON (settings, presets, control channel), SVG parsing, and shader translation
# (runs when a pipeline is created). "z" keeps the wasm well under the size gate.
[profile.wasm-release.package.photocraft-doc]
opt-level = "z"
[profile.wasm-release.package.photocraft-psd]
opt-level = "z"
[profile.wasm-release.package.photocraft-format]
opt-level = "z"
[profile.wasm-release.package.photocraft-affinity]
opt-level = "z"
[profile.wasm-release.package.serde_json]
opt-level = "z"
[profile.wasm-release.package.photocraft-io]
[profile.wasm-release.package.usvg]
opt-level = "z"
[profile.wasm-release.package.photocraft-web]
[profile.wasm-release.package.naga]
opt-level = "z"
# Pixel processing keeps its speed-oriented optimization level.
[profile.wasm-release.package.photocraft-raster]
opt-level = 3
[profile.wasm-release.package.photocraft-color]
@@ -121,3 +140,4 @@ opt-level = 3
opt-level = 3
[profile.wasm-release.package.photocraft-paint]
opt-level = 3
+2 -2
View File
@@ -187,7 +187,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗃️ Layers, done properly</h4>
Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 Photoshop blend modes plus six Paint.NET variants, opacity and fill, locks, colour labels, layer filters, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into.
Groups, clipping masks, pixel and vector masks, fill layers (solid, gradient and pattern), adjustment layers, live smart objects with smart filters and lossless transforms and warps, multi-layer selection with align, distribute and link, alpha channels and Quick Mask, 27 Photoshop blend modes plus six Paint.NET variants, opacity and fill, locks, colour labels, a layer kind filter, merge, flatten, rasterize, Layer via Copy/Cut, Paste Into.
</td>
</tr>
<tr>
@@ -199,7 +199,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗂️ Formats</h4>
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
</td>
<td width="33%" valign="top">
<h4>🪄 The everyday essentials</h4>
+4 -3
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (oracle floor 145 of 258 and 953 open issues, as in gaps.md and on GitHub; toolbox count 56 of 68, 12 missing) · **Target:** Adobe Photoshop 2026
PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from
the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md)
@@ -40,7 +40,7 @@ the one above. About 80% parallelizes (independent crates and command modules);
architecture and the PSD writer are the serial parts.
**Why alpha.** Core workflows (open, layer, select, paint, adjust, filter, type, save) work end to
end, and breadth is high, but users of 0.5/0.6 have 579 open issues, 22 of 25 performance budgets
end, and breadth is high, but 953 issues are open (2026-10-13), 22 of 25 performance budgets
are missed, and PSDs we write are not yet reliably accepted by Photoshop (#1281, #2469). Beta
needs ~75% ready and no blocking PSD gap. The [alpha gate](docs/roadmap.md#alpha-gate) passes: all six core workflows
(retouch, composite, cut-out, design, paint, PSD exchange) complete end to end and save and reopen;
@@ -110,7 +110,7 @@ Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dut
| Rank | Milestone | Estimate |
|---|---|---:|
| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 133 → 200+ of 256 | 150–250 h |
| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 145 → 200+ of 258 | 150–250 h |
| 2 | Performance budgets: the 22 over-budget scenarios and the 150-layer GPU crash | 200–350 h |
| 3 | User bug backlog to under 100 open | 250–400 h |
| 4 | Brush and transform feel (brush-parity, ui-parity rows) | 160–290 h |
@@ -135,6 +135,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Focus item 1 quoted the oracle floor as 133 of 256; it is 145 of 258 (`crates/io/tests/corpus.rs`, gaps.md G1). Open issues 579 → 953 (2026-10-13) |
| 2026-10-11 | minor | Toolbox count 56 of 68 (12 missing) with the Convert Point tool (#3204) |
| 2026-10-10 | minor | Readiness by audience with hours per tier |
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
+65 -12
View File
@@ -15,19 +15,21 @@ pub const USAGE: &str = "\
photocraft-cli: headless Photocraft
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
--quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless.
--tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as
its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24.
--dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16.
photocraft-cli info <file> [--compact]
Print the document as JSON (size, mode, depth, layer tree).
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
Open a file, run engine commands in order, save the result. Each --params
applies to the preceding --cmd. Prints each command's JSON result.
photocraft-cli batch --actions <actions.json> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>]
Apply an action list to every image in a directory. Steps are [id, params] pairs,
photocraft-cli batch --actions <actions.json|set.atn> [--action <name>] --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a
set: its action --action names plays, as below. JSON steps are [id, params] pairs,
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
(a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An Actions set (the Actions
panel's actions.json, {\"actions\": [{\"name\", \"steps\"}…]}) plays the action --action names (needed
@@ -67,17 +69,17 @@ struct Subcommand {
}
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits"], bare: &["--tiff-layers"], run: convert },
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert },
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
Subcommand {
name: "run",
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits"],
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
bare: &["--tiff-layers"],
run: run_cmds,
},
Subcommand {
name: "batch",
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits"],
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"],
bare: &["--in-place", "--tiff-layers"],
run: batch,
},
@@ -213,6 +215,10 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
if let Some(b) = a.get("--tga-bits") {
o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?);
}
if let Some(b) = a.get("--dpx-bits") {
o.encode.dpx_bits =
Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?);
}
Ok(o)
}
@@ -240,7 +246,7 @@ fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
let opts = export_opts(a)?;
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
warn_all(err, &o.warnings);
warn_all(err, &missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&o.document)));
warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document));
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
warn_all(err, &ws);
Ok(())
@@ -292,11 +298,16 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let opts = export_opts(a)?;
let mut h = Headless::trusted_local();
// Opening, running commands and exporting can all notice the same missing font:
// one `run` invocation reports each warning line at most once.
let mut reported_fonts = std::collections::HashSet::<String>::new();
match (a.positional.as_slice(), a.get("--new")) {
([file], None) => {
let o = h.open(Path::new(file)).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(o["warnings"].clone()).unwrap_or_default();
reported_fonts.extend(ws.iter().cloned());
warn_all(err, &ws);
reported_fonts.extend(ws);
}
([], Some(new)) => {
let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?;
@@ -308,9 +319,11 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
if cmds.is_empty() && a.get("--out").is_none() {
return Err("run needs at least one --cmd (or --out)".into());
}
let mut reported_fonts = std::collections::HashSet::<String>::new();
for (id, params) in cmds {
let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?;
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
return Err(format!("`{id}`: {e}"));
}
let new_warnings: Vec<String> = h
.session
.active()
@@ -339,6 +352,7 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
if let Some(o) = a.get("--out") {
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
let ws: Vec<String> = ws.into_iter().filter(|w| reported_fonts.insert(w.clone())).collect();
warn_all(err, &ws);
}
Ok(())
@@ -384,6 +398,38 @@ pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec<Action
Ok((play, set))
}
/// [`load_actions`] for a file on disk: a Photoshop Actions file (`.atn`, by extension or by
/// its version word) is converted first; the played action's steps with no PhotoCraft
/// equivalent come back as warnings (they fail at playback, as in Photoshop when a step can't run).
pub fn load_actions_file(path: &str, name: Option<&str>) -> Result<(Steps, Vec<Action>, Vec<String>), String> {
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let is_atn = Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]);
if !is_atn {
let text = String::from_utf8(bytes).map_err(|e| format!("{path}: {e}"))?;
let (steps, set) = load_actions(&text, name)?;
return Ok((steps, set, Vec::new()));
}
let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|e| format!("{path}: {e}"))?;
let names = || imp.actions.iter().map(|a| format!("`{}`", a.name)).collect::<Vec<_>>().join(", ");
let action = match (name, imp.actions.as_slice()) {
(Some(n), _) => {
imp.actions.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set `{}` (it holds {})", imp.set, names()))?
}
(None, [only]) => only,
(None, []) => return Err(format!("{path}: the set holds no actions")),
(None, _) => return Err(format!("the set `{}` holds {} actions: pick one with --action <name> ({})", imp.set, imp.actions.len(), names())),
};
let warnings = imp
.unsupported
.iter()
.filter(|u| u.action == action.name)
.map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event))
.chain(imp.warnings.iter().cloned())
.collect();
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
Ok((play, imp.actions, warnings))
}
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
/// trailing separator, through a symbolic link).
fn same_dir(a: &Path, b: &Path) -> bool {
@@ -392,7 +438,7 @@ fn same_dir(a: &Path, b: &Path) -> bool {
fn is_input(p: &Path) -> bool {
let known = |e: &str| {
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "af" | "afdesign" | "afphoto" | "afpub")
matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "af" | "afdesign" | "afphoto" | "afpub")
|| photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read)
};
p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase()))
@@ -402,8 +448,10 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let actions_path = a.get("--actions").ok_or("batch needs --actions <file>")?;
let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in <dir>")?);
let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out <dir>")?);
let text = std::fs::read_to_string(actions_path).map_err(|e| format!("{actions_path}: {e}"))?;
let (actions, set) = load_actions(&text, a.get("--action"))?;
let (actions, set, warnings) = load_actions_file(actions_path, a.get("--action"))?;
for w in &warnings {
let _ = writeln!(err, "warning: {w}");
}
let opts = export_opts(a)?;
// Results are saved as `<out>/<stem>.<ext>`, so an `--out` that is the `--in` folder would
// replace the originals (#492).
@@ -626,6 +674,11 @@ mod missing_font_warning_tests {
let (code, _, stderr) = invoke(&["convert", &ora, &png]);
assert_eq!(code, 0, "{stderr}");
assert!(is_input(std::path::Path::new(&ora)));
// Krita documents are batch inputs too (import-only).
for name in ["sketch.kra", "old.KRZ"] {
std::fs::write(folder.join(name), b"").unwrap();
assert!(is_input(&folder.join(name)), "{name}");
}
let _ = std::fs::remove_dir_all(&folder);
}
+108
View File
@@ -203,6 +203,50 @@ fn run_commands_and_save() {
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_stops_after_failed_action_without_exporting() {
let d = tmp("run-failed-action");
let out = d.join("result.png");
let result = bin()
.args(["run", "--new", r#"{"width":8,"height":8}"#])
.args(["--cmd", "layer.new.layer"])
.args(["--cmd", "actions.record", "--params", r#"{"name":"Delete"}"#])
.args(["--cmd", "layer.delete", "--cmd", "actions.stop"])
// The recorded deletion succeeded with two layers; replay cannot delete the last one.
.args(["--cmd", "actions.play", "--params", r#"{"action":"Delete"}"#])
.args(["--cmd", "document.inspect", "--out"])
.arg(&out)
.output()
.unwrap();
let error = String::from_utf8_lossy(&result.stderr);
assert_eq!(result.status.code(), Some(1), "stdout: {}\nstderr: {error}", String::from_utf8_lossy(&result.stdout));
assert!(error.contains("actions.play") && error.contains("Delete") && error.contains("step 1"), "{error}");
let rows: Vec<Value> = String::from_utf8_lossy(&result.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert!(rows.iter().all(|row| row["command"] != "document.inspect"), "later commands must not run");
assert!(!out.exists(), "a failed action must not export a partial result");
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_continues_after_successful_action_and_exports() {
let d = tmp("run-successful-action");
let out = d.join("result.png");
let (stdout, _) = ok(bin()
.args(["run", "--new", r#"{"width":8,"height":8}"#])
.args(["--cmd", "actions.record", "--params", r#"{"name":"Add"}"#])
.args(["--cmd", "layer.new.layer", "--cmd", "actions.stop"])
.args(["--cmd", "actions.play", "--params", r#"{"action":"Add"}"#])
.args(["--cmd", "document.inspect", "--out"])
.arg(&out));
let rows: Vec<Value> = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect();
let playback = rows.iter().find(|row| row["command"] == "actions.play").unwrap();
assert_eq!(playback["result"]["ran"], 1);
assert!(playback["result"].get("failed").is_none());
assert!(rows.iter().any(|row| row["command"] == "document.inspect"));
assert!(out.is_file());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_errors() {
let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap();
@@ -411,6 +455,40 @@ fn quality_outside_1_to_100_is_refused() {
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
}
/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the
/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049).
#[test]
fn dpx_converts_both_ways_with_the_bits_asked_for() {
let d = tmp("dpx");
let a = d.join("a.png");
write_png(&a, 8, 4, 3);
for (bits, want) in [(None, 8), (Some("10"), 10), (Some("12"), 12), (Some("16"), 16)] {
let out = d.join("a.dpx");
let mut convert = bin();
convert.arg("convert").arg(&a).arg(&out);
if let Some(b) = bits {
convert.args(["--dpx-bits", b]);
}
ok(&mut convert);
let bytes = std::fs::read(&out).unwrap();
assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}");
}
let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact"));
let v: Value = serde_json::from_str(&info).unwrap();
assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}");
assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}");
let back = d.join("back.png");
ok(bin().arg("convert").arg(d.join("a.dpx")).arg(&back));
assert!(back.exists());
for bad in ["24", "0", "abc"] {
let out = d.join("bad.dpx");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}");
assert!(!out.exists(), "--dpx-bits {bad} wrote a file");
}
}
/// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is
/// refused and nothing is written.
#[test]
@@ -640,3 +718,33 @@ fn a_non_unicode_argument_is_a_usage_error_not_a_panic() {
assert!(!err.contains("panicked"), "{err}");
}
}
/// `batch --actions set.atn`: a Photoshop Actions file plays like an Actions set, and its steps
/// PhotoCraft can't play are warned about up front.
#[test]
fn batch_plays_a_photoshop_actions_file() {
use photocraft_engine::actions_cmds::Action;
let d = tmp("atn");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
let invert = Action::new("Negative", vec![("image.adjustments.invert".into(), json!({})), ("select.all".into(), json!({}))]);
let odd = Action::new("Odd", vec![(photocraft_engine::atn_cmds::PHOTOSHOP_STEP.into(), json!({"event": "syntheticFill", "name": "Generative Fill"}))]);
let (bytes, skipped) = photocraft_engine::atn_cmds::export("Set", &[&invert, &odd], &[], false).unwrap();
assert!(skipped.is_empty());
let atn = d.join("Set.atn");
std::fs::write(&atn, bytes).unwrap();
let out_dir = d.join("out");
// Two actions: the set needs --action.
let o = bin().args(["batch", "--actions"]).arg(&atn).arg("--in").arg(&input).arg("--out").arg(&out_dir).output().unwrap();
assert!(!o.status.success());
assert!(String::from_utf8_lossy(&o.stderr).contains("--action"), "{}", String::from_utf8_lossy(&o.stderr));
let (_, err) = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir));
assert!(out_dir.join("a.png").exists());
assert!(!err.contains("warning"), "{err}");
// The unsupported step is warned about, and fails the file as Photoshop would stop on it.
let o = bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Odd", "--in"]).arg(&input).arg("--out").arg(d.join("out2")).output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert!(err.contains("Generative Fill"), "{err}");
assert!(!o.status.success());
}
+1 -1
View File
@@ -29,7 +29,7 @@ log = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
eframe = { workspace = true, features = ["wgpu", "accesskit"] }
rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>PhotoCraft</title>
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-wasm-opt-params="--converge" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
+1
View File
@@ -337,6 +337,7 @@ mod tests {
let b = Bridge::default();
b.unavailable("SecurityError".into());
let (mut s, _) = session(b.clone());
assert!(!b.unsaved(), "nothing is lost before the first change, so the web shell shows no banner");
import(&mut s);
assert!(s.tools.presets.iter().any(|p| p.name == "Browser seam brush"));
assert!(b.unsaved());
+27 -23
View File
@@ -14,12 +14,12 @@ use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
"ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
"svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf",
"rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
@@ -305,27 +305,27 @@ impl eframe::App for WebShell {
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let error = self.presets.error();
if error.is_some() || self.presets.unsaved() {
// Only while a preset change is unsaved. Storage the browser denies from the start (a
// sandboxed iframe, blocked site data) loses nothing until the first change, and that
// change brings the banner with the reason.
if self.presets.unsaved()
&& let Some(error) = self.presets.error()
{
egui::Panel::top("browser_preset_storage").show(ui, |ui| {
if let Some(error) = error {
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
// Put retry first so floating brush windows cannot cover the control.
ui.horizontal_wrapped(|ui| {
if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() {
self.presets.retry();
ui.ctx().request_repaint();
}
ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)]));
});
ui.label(if self.database.is_some() {
i18n::t("Keep this tab open to retain your brushes.")
} else {
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
});
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
// Put retry first so floating brush windows cannot cover the control.
ui.horizontal_wrapped(|ui| {
if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() {
self.presets.retry();
ui.ctx().request_repaint();
}
ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)]));
});
ui.label(if self.database.is_some() {
i18n::t("Keep this tab open to retain your brushes.")
} else {
ui.label(i18n::t("Saving brush presets… Keep this tab open until saving finishes."));
}
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
});
});
}
self.app.ui(ui, frame);
@@ -352,6 +352,7 @@ fn services(inbox: Inbox) -> Services {
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
opts.tga_bits = settings.tga_bits;
opts.image_previews = settings.image_previews;
opts.psd_maximize_compatibility = settings.psd_maximize_compatibility;
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
})),
file_dialog: Some(Box::new(|request, _parent, reply| match request {
@@ -382,6 +383,8 @@ fn services(inbox: Inbox) -> Services {
};
reply.send(answer);
}),
// Browser folder handles cannot be represented as filesystem paths for engine commands.
FileDialogRequest::Folder { .. } => reply.send(None),
// No save dialog on the web: the suggested name becomes the download name.
FileDialogRequest::Save { suggested } => {
let name = std::path::Path::new(&suggested).file_name().map_or_else(|| suggested.clone(), |n| n.to_string_lossy().to_string());
@@ -443,6 +446,7 @@ fn mime_for(name: &str) -> &'static str {
Some("gif") => "image/gif",
Some("psd" | "psb") => "image/vnd.adobe.photoshop",
Some("ora") => "image/openraster",
Some("kra" | "krz") => "application/x-krita",
_ => "application/octet-stream",
}
}
+7 -1
View File
@@ -29,7 +29,7 @@ photocraft-automation = { workspace = true }
photocraft-ui-egui = { workspace = true }
# Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate).
photocraft-tablet = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence"] }
eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@@ -66,8 +66,14 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
[target.'cfg(target_os = "linux")'.dependencies]
photocraft-cms = { workspace = true }
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] }
# Clipboard images in types other than PNG on Wayland (`services.rs`, #2546): arboard asks for
# image/png only. Already in Cargo.lock (arboard's wayland-data-control); pinned exactly.
wl-clipboard-rs = { version = "=0.9.4", default-features = false }
# Wayland desktop color selection through the existing XDG portal transport.
zbus = "=5.19.0"
# Caption button glyphs from the desktop's icon theme (`caption_style.rs`): SVG paths only, no
# text or images. Already in Cargo.lock (photocraft-io); pinned exactly.
resvg = { version = "=0.45.1", default-features = false }
# Already used by the portal stack; bounded waits let cancelling close the OS request.
futures-lite = "=2.6.1"
async-io = "=2.6.0"
+10 -7
View File
@@ -6,7 +6,7 @@ const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
const PORTAL_KEY: &str = "color-scheme";
#[cfg(target_os = "linux")]
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
pub(crate) fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
}
@@ -14,7 +14,7 @@ fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
#[cfg(target_os = "linux")]
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
decode(portal_code(value)?)
portal_code(value).and_then(decode).or_else(gtk_theme)
}
#[cfg(target_os = "linux")]
@@ -39,8 +39,8 @@ fn decode(code: u32) -> Option<egui::Theme> {
}
}
/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by
/// absolute path, and is never repeated.
/// GNOME fallback when the portal has no preference. Read only on the appearance worker,
/// using an absolute path rather than the shell's PATH.
#[cfg(target_os = "linux")]
fn gtk_theme() -> Option<egui::Theme> {
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
@@ -55,7 +55,7 @@ fn gtk_theme() -> Option<egui::Theme> {
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
match s.trim().trim_matches('\'') {
"prefer-dark" => Some(egui::Theme::Dark),
"prefer-light" => Some(egui::Theme::Light),
"prefer-light" | "default" => Some(egui::Theme::Light),
_ => None,
}
}
@@ -97,7 +97,7 @@ pub fn service() -> Option<photocraft_ui_egui::SystemThemeFn> {
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
continue;
}
publisher.publish(portal_code(changed).and_then(decode));
publisher.publish(portal_code(changed).and_then(decode).or_else(gtk_theme));
}
})
}
@@ -184,7 +184,10 @@ mod tests {
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
// GNOME's "default" appearance is light, even though the portal reports no preference.
assert_eq!(super::parse_gtk_scheme("'default'\n"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("default"), Some(egui::Theme::Light));
assert_eq!(super::parse_gtk_scheme("'not-a-scheme'"), None);
}
/// #2798: a first read that misses the start-up wait still wakes the idle UI.
+399
View File
@@ -0,0 +1,399 @@
//! Linux: which desktop the custom title bar's caption buttons look like, and which buttons go on
//! which side (`photocraft_ui_egui::CaptionStyle`). GNOME gets libadwaita's buttons in the order of
//! its `button-layout` setting (read through the settings portal, and followed as it changes); KDE
//! gets Breeze's in KWin's order (`kwinrc`); other desktops get plain round buttons, or Adwaita's
//! or Breeze's when their GTK theme is one of those, in GTK's `gtk-decoration-layout` order.
//!
//! `PHOTOCRAFT_CAPTION_STYLE` (`windows`, `adwaita`, `breeze`, `generic`) and
//! `PHOTOCRAFT_CAPTION_LAYOUT` (a GTK layout such as `close,minimize:`) override what is found.
#[cfg(target_os = "linux")]
use photocraft_ui_egui::{CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle};
#[cfg(target_os = "linux")]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Desktop {
Gnome,
Kde,
Other,
}
/// The desktop, from the first of its variables that is set: `ubuntu:GNOME`, `Budgie:GNOME` and
/// the like count as GNOME.
#[cfg(target_os = "linux")]
fn desktop(var: &dyn Fn(&str) -> Option<String>) -> Desktop {
for key in ["XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"] {
let Some(v) = var(key).map(|v| v.to_ascii_lowercase()).filter(|v| !v.trim().is_empty()) else { continue };
return if v.contains("gnome") {
Desktop::Gnome
} else if v.contains("kde") || v.contains("plasma") {
Desktop::Kde
} else {
Desktop::Other
};
}
if var("KDE_FULL_SESSION").is_some() {
Desktop::Kde
} else if var("GNOME_DESKTOP_SESSION_ID").is_some() {
Desktop::Gnome
} else {
Desktop::Other
}
}
/// GNOME's apps are libadwaita's whatever the GTK theme, and KWin draws Breeze; elsewhere the GTK
/// theme says (Adwaita, adw-gtk3, Yaru, Breeze), and any other theme gets the plain buttons.
#[cfg(target_os = "linux")]
fn family(desktop: Desktop, gtk_theme: Option<&str>) -> CaptionFamily {
match desktop {
Desktop::Gnome => CaptionFamily::Adwaita,
Desktop::Kde => CaptionFamily::Breeze,
Desktop::Other => {
let theme = gtk_theme.unwrap_or_default().trim().to_ascii_lowercase();
if ["adwaita", "adw-gtk3", "yaru"].iter().any(|p| theme.starts_with(p)) {
CaptionFamily::Adwaita
} else if theme.starts_with("breeze") {
CaptionFamily::Breeze
} else {
CaptionFamily::Generic
}
}
}
}
/// The value of `key` in `[section]` of an INI-style file (GTK's `settings.ini`, KDE's `kwinrc`).
#[cfg(target_os = "linux")]
fn ini_value(text: &str, section: &str, key: &str) -> Option<String> {
let mut inside = false;
for line in text.lines().map(str::trim) {
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
inside = name == section;
} else if inside
&& let Some((k, v)) = line.split_once('=')
&& k.trim() == key
{
return Some(v.trim().to_string());
}
}
None
}
/// KWin's button order (`[org.kde.kdecoration2]` `ButtonsOnLeft` / `ButtonsOnRight`, one letter a
/// button: `I` minimize, `A` maximize, `X` close), as a GTK layout. Missing keys keep KWin's
/// defaults: the window menu on the left, minimize, maximize and close on the right.
#[cfg(target_os = "linux")]
fn kwin_layout(kwinrc: &str) -> String {
let side = |key: &str, default: &str| {
let letters = ini_value(kwinrc, "org.kde.kdecoration2", key).unwrap_or_else(|| default.to_string());
letters
.chars()
.filter_map(|c| match c {
'I' => Some("minimize"),
'A' => Some("maximize"),
'X' => Some("close"),
_ => None,
})
.collect::<Vec<_>>()
.join(",")
};
format!("{}:{}", side("ButtonsOnLeft", "MS"), side("ButtonsOnRight", "HIAX"))
}
/// What the style is worked out from.
#[cfg(target_os = "linux")]
#[derive(Clone, Default)]
struct Found {
gtk_theme: Option<String>,
/// A GTK decoration layout.
layout: Option<String>,
icon_theme: Option<String>,
}
/// The icon theme the glyphs come from: `PHOTOCRAFT_CAPTION_ICONS` (`none` draws PhotoCraft's own),
/// the desktop's setting, or its default theme.
#[cfg(target_os = "linux")]
fn icon_theme_name(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option<String>) -> Option<String> {
match var("PHOTOCRAFT_CAPTION_ICONS") {
Some(name) if name.trim().eq_ignore_ascii_case("none") => None,
Some(name) => Some(name.trim().to_string()),
None => Some(found.icon_theme.clone().unwrap_or_else(|| if desktop == Desktop::Kde { "breeze" } else { "Adwaita" }.into())),
}
}
/// The style from what was found; `icons` loads a theme's glyphs.
#[cfg(target_os = "linux")]
fn resolve(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option<String>, icons: &dyn Fn(&str) -> Option<CaptionIcons>) -> CaptionStyle {
let family = var("PHOTOCRAFT_CAPTION_STYLE").and_then(|s| CaptionFamily::parse(&s)).unwrap_or_else(|| family(desktop, found.gtk_theme.as_deref()));
// GNOME's own default shows Close only; GTK's shows all three.
let default = if desktop == Desktop::Gnome { "appmenu:close" } else { "menu:minimize,maximize,close" };
let layout = var("PHOTOCRAFT_CAPTION_LAYOUT").or_else(|| found.layout.clone()).unwrap_or_else(|| default.to_string());
let mut style = CaptionStyle::from_gtk_layout(family, &layout);
style.icons = icon_theme_name(desktop, found, var).and_then(|name| icons(&name));
style
}
#[cfg(target_os = "linux")]
fn read_text(path: &std::path::Path) -> Option<String> {
crate::icon_theme::read_small(path).map(|b| String::from_utf8_lossy(&b).into_owned())
}
/// The themes and layout from the desktop's files: `kwinrc` and `kdeglobals` on KDE, GTK 3's
/// `settings.ini` elsewhere (where GNOME's portal answers, read later, replace them).
#[cfg(target_os = "linux")]
fn from_files(desktop: Desktop, var: &dyn Fn(&str) -> Option<String>) -> Found {
let config = var("XDG_CONFIG_HOME")
.filter(|d| std::path::Path::new(d).is_absolute())
.map(std::path::PathBuf::from)
.or_else(|| var("HOME").map(|h| std::path::Path::new(&h).join(".config")));
let Some(config) = config else { return Found::default() };
let gtk = read_text(&config.join("gtk-3.0").join("settings.ini")).unwrap_or_default();
let gtk_theme = ini_value(&gtk, "Settings", "gtk-theme-name");
if desktop == Desktop::Kde {
let layout = Some(kwin_layout(&read_text(&config.join("kwinrc")).unwrap_or_default()));
let icon_theme = ini_value(&read_text(&config.join("kdeglobals")).unwrap_or_default(), "Icons", "Theme");
Found { gtk_theme, layout, icon_theme }
} else {
let layout = ini_value(&gtk, "Settings", "gtk-decoration-layout");
Found { gtk_theme, layout, icon_theme: ini_value(&gtk, "Settings", "gtk-icon-theme-name") }
}
}
/// The caption glyphs of icon theme `theme` (`window-*-symbolic.svg`), read now and rendered at
/// whatever size the bar asks for. `None` when the theme (with its parents) has none of them.
#[cfg(target_os = "linux")]
fn load_icons(theme: &str) -> Option<CaptionIcons> {
use crate::icon_theme::{IconTheme, base_dirs, rasterize_mask, read_small};
let found = IconTheme::load(theme, &base_dirs(&|k: &str| std::env::var(k).ok()))?;
let svg = |name: &str| found.find_svg(name).and_then(|p| read_small(&p)).map(std::sync::Arc::<[u8]>::from);
let files = [svg("window-minimize-symbolic"), svg("window-maximize-symbolic"), svg("window-restore-symbolic"), svg("window-close-symbolic")];
if files.iter().all(Option::is_none) {
return None;
}
let render = move |icon: CaptionIcon, px: u32| {
let i = match icon {
CaptionIcon::Minimize => 0,
CaptionIcon::Maximize => 1,
CaptionIcon::Restore => 2,
CaptionIcon::Close => 3,
};
files.get(i)?.as_deref().and_then(|svg| rasterize_mask(svg, px))
};
Some(CaptionIcons { theme: theme.to_string(), render: std::sync::Arc::new(render) })
}
/// [`load_icons`] for the last theme asked for, kept, so a layout change doesn't reread it.
#[cfg(target_os = "linux")]
#[derive(Default)]
struct IconCache(std::sync::Mutex<Option<(String, Option<CaptionIcons>)>>);
#[cfg(target_os = "linux")]
impl IconCache {
fn get(&self, theme: &str) -> Option<CaptionIcons> {
let mut last = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((name, icons)) = last.as_ref()
&& name == theme
{
return icons.clone();
}
let icons = load_icons(theme);
*last = Some((theme.to_string(), icons.clone()));
icons
}
}
#[cfg(target_os = "linux")]
const WM_NAMESPACE: &str = "org.gnome.desktop.wm.preferences";
#[cfg(target_os = "linux")]
const LAYOUT_KEY: &str = "button-layout";
#[cfg(target_os = "linux")]
const INTERFACE_NAMESPACE: &str = "org.gnome.desktop.interface";
#[cfg(target_os = "linux")]
const ICONS_KEY: &str = "icon-theme";
/// A string setting from the portal, unwrapping the variants some portals nest it in.
#[cfg(target_os = "linux")]
fn portal_string(value: zbus::zvariant::OwnedValue) -> Option<String> {
let mut value: zbus::zvariant::Value<'_> = value.into();
for _ in 0..4 {
match value {
zbus::zvariant::Value::Value(inner) => value = *inner,
zbus::zvariant::Value::Str(s) => return Some(s.to_string()),
_ => return None,
}
}
None
}
/// The caption style now, then with the icon theme's glyphs and (GNOME) the portal's settings,
/// following the button layout and icon theme as they change. A worker does the file and D-Bus
/// work and wakes the UI only when the style changes; the first answer usually lands before the
/// first frame.
pub fn service() -> Option<photocraft_ui_egui::CaptionStyleFn> {
#[cfg(target_os = "linux")]
{
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
let var = |k: &str| std::env::var(k).ok();
let desktop = desktop(&var);
let mut found = from_files(desktop, &var);
// Icons need the theme read, which takes tens of milliseconds: the worker brings them.
let current = Arc::new(Mutex::new(resolve(desktop, &found, &var, &|_: &str| None)));
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
let (shared, waker) = (Arc::clone(&current), Arc::clone(&wake));
let cache = IconCache::default();
let publish = move |found: &Found| {
let style = resolve(desktop, found, &|k: &str| std::env::var(k).ok(), &|t: &str| cache.get(t));
let mut cur = shared.lock().unwrap_or_else(PoisonError::into_inner);
if *cur != style {
*cur = style;
if let Some(ctx) = waker.get() {
ctx.request_repaint();
}
}
};
let (ready, wait) = std::sync::mpsc::channel();
let spawned = std::thread::Builder::new().name("caption-style".into()).spawn(move || {
let proxy = if desktop == Desktop::Gnome { crate::appearance::portal_proxy() } else { None };
let Some(proxy) = proxy else {
publish(&found);
let _ = ready.send(());
return;
};
// Subscribe before the first read, so a change during start-up isn't lost.
let signals = proxy.receive_signal("SettingChanged").ok();
let read = |namespace: &str, key: &str| {
let value: Option<zbus::zvariant::OwnedValue> = proxy.call("Read", &(namespace, key)).ok();
value.and_then(portal_string)
};
found.layout = read(WM_NAMESPACE, LAYOUT_KEY).or(found.layout.take());
found.icon_theme = read(INTERFACE_NAMESPACE, ICONS_KEY).or(found.icon_theme.take());
publish(&found);
let _ = ready.send(());
for message in signals.into_iter().flatten() {
let Ok((namespace, key, value)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { continue };
match (namespace.as_str(), key.as_str()) {
(WM_NAMESPACE, LAYOUT_KEY) => found.layout = portal_string(value),
(INTERFACE_NAMESPACE, ICONS_KEY) => found.icon_theme = portal_string(value),
_ => continue,
}
publish(&found);
}
});
// A fast desktop answers before the first frame; a slow one updates the bar later.
if spawned.is_ok() {
let _ = wait.recv_timeout(std::time::Duration::from_millis(150));
}
Some(Box::new(move |ctx| {
let _ = wake.set(ctx.clone());
current.lock().unwrap_or_else(PoisonError::into_inner).clone()
}))
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
use photocraft_ui_egui::Caption::{Close, Maximize, Minimize};
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
let pairs: Vec<(String, String)> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
move |k| pairs.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone())
}
#[test]
fn the_desktop_comes_from_the_first_variable_set() {
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "ubuntu:GNOME")])), Desktop::Gnome);
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "KDE")])), Desktop::Kde);
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "X-Cinnamon"), ("KDE_FULL_SESSION", "true")])), Desktop::Other);
assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", " "), ("DESKTOP_SESSION", "plasma")])), Desktop::Kde);
assert_eq!(desktop(&env(&[("KDE_FULL_SESSION", "true")])), Desktop::Kde);
assert_eq!(desktop(&env(&[("GNOME_DESKTOP_SESSION_ID", "this-is-deprecated")])), Desktop::Gnome);
assert_eq!(desktop(&env(&[])), Desktop::Other);
}
#[test]
fn the_family_follows_the_desktop_then_the_gtk_theme() {
assert_eq!(family(Desktop::Gnome, Some("Arc")), CaptionFamily::Adwaita);
assert_eq!(family(Desktop::Kde, None), CaptionFamily::Breeze);
assert_eq!(family(Desktop::Other, Some("adw-gtk3-dark")), CaptionFamily::Adwaita);
assert_eq!(family(Desktop::Other, Some("Yaru-blue")), CaptionFamily::Adwaita);
assert_eq!(family(Desktop::Other, Some("Breeze-Dark")), CaptionFamily::Breeze);
assert_eq!(family(Desktop::Other, Some("Mint-Y")), CaptionFamily::Generic);
assert_eq!(family(Desktop::Other, None), CaptionFamily::Generic);
}
#[test]
fn kwin_button_letters_become_a_gtk_layout() {
assert_eq!(kwin_layout(""), ":minimize,maximize,close", "KWin's defaults");
let rc = "[Desktops]\nButtonsOnLeft=X\n[org.kde.kdecoration2]\nButtonsOnLeft=XIA\nButtonsOnRight=\nlibrary=org.kde.breeze\n";
assert_eq!(kwin_layout(rc), "close,minimize,maximize:");
assert_eq!(ini_value(rc, "org.kde.kdecoration2", "library").as_deref(), Some("org.kde.breeze"));
assert_eq!(ini_value(rc, "nope", "library"), None);
// Garbage is just no buttons on that side, never a panic.
assert_eq!(kwin_layout("[org.kde.kdecoration2]\nButtonsOnRight=ÿ€=X\n"), ":close");
}
fn no_icons(_: &str) -> Option<CaptionIcons> {
None
}
#[test]
fn the_icon_theme_comes_from_the_override_the_setting_or_the_desktop() {
let found = Found { icon_theme: Some("Papirus".into()), ..Found::default() };
assert_eq!(icon_theme_name(Desktop::Gnome, &found, &env(&[])).as_deref(), Some("Papirus"));
assert_eq!(icon_theme_name(Desktop::Gnome, &Found::default(), &env(&[])).as_deref(), Some("Adwaita"));
assert_eq!(icon_theme_name(Desktop::Kde, &Found::default(), &env(&[])).as_deref(), Some("breeze"));
assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", " None ")])), None);
assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", "Tela")])).as_deref(), Some("Tela"));
// The style carries what the loader returns for that name.
let s = resolve(Desktop::Gnome, &found, &env(&[]), &|t| {
(t == "Papirus").then(|| CaptionIcons { theme: t.into(), render: std::sync::Arc::new(|_, _| None) })
});
assert_eq!(s.icons.map(|i| i.theme).as_deref(), Some("Papirus"));
}
#[test]
fn layouts_and_overrides_resolve_to_a_style() {
let none = env(&[]);
let gnome = resolve(Desktop::Gnome, &Found::default(), &none, &no_icons);
assert_eq!((gnome.family, gnome.left, gnome.right), (CaptionFamily::Adwaita, vec![], vec![Close]), "GNOME's default layout");
let kde = resolve(Desktop::Kde, &Found { layout: Some(kwin_layout("")), ..Found::default() }, &none, &no_icons);
assert_eq!((kde.family, kde.right), (CaptionFamily::Breeze, vec![Minimize, Maximize, Close]));
let found = Found { gtk_theme: Some("Mint-Y".into()), layout: Some("close:".into()), icon_theme: None };
let other = resolve(Desktop::Other, &found, &none, &no_icons);
assert_eq!((other.family, other.left, other.right), (CaptionFamily::Generic, vec![Close], vec![]));
let forced = env(&[("PHOTOCRAFT_CAPTION_STYLE", "Breeze"), ("PHOTOCRAFT_CAPTION_LAYOUT", "minimize,close:maximize")]);
let s = resolve(Desktop::Gnome, &found, &forced, &no_icons);
assert_eq!((s.family, s.left, s.right), (CaptionFamily::Breeze, vec![Minimize, Close], vec![Maximize]));
// An unknown override name falls back to what was found.
assert_eq!(resolve(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_STYLE", "aqua")]), &no_icons).family, CaptionFamily::Breeze);
}
#[test]
fn portal_strings_unwrap_nested_variants() {
use zbus::zvariant::{OwnedValue, Value};
let nested = Value::Value(Box::new(Value::Value(Box::new(Value::from("appmenu:close")))));
assert_eq!(portal_string(OwnedValue::try_from(nested).unwrap()).as_deref(), Some("appmenu:close"));
assert_eq!(portal_string(OwnedValue::try_from(Value::U32(7)).unwrap()), None);
}
#[test]
fn settings_files_are_read_from_the_config_dir() {
let dir = std::env::temp_dir().join(format!("photocraft-caption-{}", std::process::id()));
std::fs::create_dir_all(dir.join("gtk-3.0")).unwrap();
std::fs::write(dir.join("gtk-3.0/settings.ini"), "[Settings]\ngtk-theme-name=Yaru\ngtk-decoration-layout=close,maximize:\n").unwrap();
std::fs::write(dir.join("kwinrc"), "[org.kde.kdecoration2]\nButtonsOnRight=AX\n").unwrap();
std::fs::write(dir.join("kdeglobals"), "[Icons]\nTheme=breeze-dark\n").unwrap();
let var = env(&[("XDG_CONFIG_HOME", dir.to_str().unwrap())]);
let other = from_files(Desktop::Other, &var);
assert_eq!((other.gtk_theme.as_deref(), other.layout.as_deref()), (Some("Yaru"), Some("close,maximize:")));
let kde = from_files(Desktop::Kde, &var);
assert_eq!((kde.layout.as_deref(), kde.icon_theme.as_deref()), (Some(":maximize,close"), Some("breeze-dark")));
// A relative XDG_CONFIG_HOME is ignored, as the XDG spec says; no HOME: nothing found.
assert_eq!(from_files(Desktop::Other, &env(&[("XDG_CONFIG_HOME", "rel")])).layout, None);
let _ = std::fs::remove_dir_all(dir);
}
}
+50 -3
View File
@@ -9,10 +9,10 @@ use std::time::Instant;
use photocraft_automation::budgets::write_reply;
use photocraft_automation::security::{
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line,
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply,
};
use photocraft_ui_egui::ControlRequest;
use photocraft_ui_egui::control::{REPLY_DEADLINE, timeout_error};
use photocraft_ui_egui::control::REPLY_DEADLINE;
use serde_json::{Value, json};
pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
@@ -79,6 +79,12 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
}
continue;
}
if let Some(reply) = repeat_auth_reply(&line) {
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() {
break;
}
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
@@ -87,12 +93,13 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
let deadline = Instant::now() + REPLY_DEADLINE;
let (mut req, rrx) = ControlRequest::new(method, params);
req.deadline = Some(deadline);
let dispatch = req.dispatch.clone();
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let mut r =
rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": timeout_error()}));
rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": dispatch.give_up()}));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
@@ -202,4 +209,44 @@ mod tests {
handler.join().unwrap();
server.join().unwrap();
}
#[test]
fn repeat_auth_reports_already_authenticated_without_dispatching() {
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let server = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
serve(stream, TOKEN, tx, egui::Context::default());
});
let handler = std::thread::spawn(move || {
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(req.method, "test.after", "a repeat auth must not reach the UI dispatcher");
req.reply.send(json!({"ok": true, "result": "still serving"})).unwrap();
});
let mut stream = TcpStream::connect(addr).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut line = String::new();
for id in ["auth", "again"] {
writeln!(stream, "{}", json!({"id": id, "method": "auth", "params": {"token": TOKEN}})).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
}
let again: Value = serde_json::from_str(&line).unwrap();
assert_eq!(again["id"], "again");
assert_eq!(again["ok"], false);
assert_eq!(again["error"], "already authenticated");
writeln!(stream, "{}", json!({"id": 9, "method": "test.after"})).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
let after: Value = serde_json::from_str(&line).unwrap();
assert_eq!(after["id"], 9);
assert_eq!(after["result"], "still serving");
drop(reader);
drop(stream);
handler.join().unwrap();
server.join().unwrap();
}
}
+69 -12
View File
@@ -177,6 +177,12 @@ pub fn plan_with_mode(pref: GpuBackend, mode: RenderingMode, crashed: Option<&Ma
plan(backend, crashed, env, safe_gpu, os)
}
/// Persist successful hardware fallbacks, but never silently pin CPU mode.
/// The recovery dialog explicitly stores CPU mode when the user chooses it.
pub fn backend_to_remember(plan: &Plan) -> Option<GpuBackend> {
(plan.remember && plan.backend != GpuBackend::Cpu).then_some(plan.backend)
}
/// Instance backends for `plan` (`None`: egui's default, which honours `WGPU_BACKEND`).
pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
if plan.env.is_some() {
@@ -541,6 +547,27 @@ mod tests {
assert_eq!(plan(Auto, None, Some("dx12"), true, Os::Windows).backend, Cpu);
}
/// #3483: A temporary CPU recovery must not change the next launch.
#[test]
fn cpu_recovery_requires_explicit_confirmation_to_persist() {
let gl_crash = crashed("gl", "gl");
let cpu = plan_with_mode(Auto, RenderingMode::Auto, Some(&gl_crash), None, false, Os::Other);
assert_eq!((cpu.backend, cpu.remember), (Cpu, true));
assert_eq!(backend_to_remember(&cpu), None);
let safe = plan_with_mode(Auto, RenderingMode::Auto, None, None, true, Os::Other);
assert_eq!(safe.backend, Cpu);
assert_eq!(backend_to_remember(&safe), None);
// An unconfirmed fallback leaves the original automatic selection intact.
assert_eq!(plan_with_mode(Auto, RenderingMode::Auto, None, None, false, Os::Other).backend, Auto);
// A working hardware fallback can still be remembered.
let vk_crash = crashed("auto", "vulkan");
let gl = plan_with_mode(Auto, RenderingMode::Auto, Some(&vk_crash), None, false, Os::Other);
assert_eq!(backend_to_remember(&gl), Some(Gl));
// CPU explicitly selected in preferences still works.
assert_eq!(plan_with_mode(Auto, RenderingMode::Cpu, None, None, false, Os::Other).backend, Cpu);
}
#[test]
fn safe_gpu_forces_the_cpu_for_one_launch() {
let p = plan(Vulkan, Some(&crashed("vulkan", "vulkan")), None, true, Os::Other);
@@ -672,7 +699,7 @@ mod tests {
} else {
s.finish();
}
let (prev, _s) = Sentinel::begin(&dir);
let (prev, _s) = begin_when_unlocked(&dir);
assert_eq!(prev, Previous::Clean);
assert_eq!(plan(Auto, prev.crashed(), None, false, Os::Other).backend, Auto);
let _ = std::fs::remove_dir_all(&dir);
@@ -684,6 +711,22 @@ mod tests {
d
}
/// `Sentinel::begin`, called again up to 50 times, 10 ms apart, while it returns
/// `Previous::Busy`. Other tests in this binary spawn processes: between fork and exec a child
/// briefly shares the marker's flock, so `begin` can see the lock as busy just after a test
/// drops its sentinel.
fn begin_when_unlocked(dir: &Path) -> (Previous, Option<Sentinel>) {
let (mut prev, mut s) = Sentinel::begin(dir);
for _ in 0..50 {
if prev != Previous::Busy {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
(prev, s) = Sentinel::begin(dir);
}
(prev, s)
}
#[test]
fn sentinel_detects_a_crash_and_clears_on_success() {
let dir = temp_dir("cycle");
@@ -699,16 +742,7 @@ mod tests {
assert!(none.is_none());
// The process dies in the driver: the lock goes, the marker stays.
drop(s);
// Another test in this binary spawns a process: between its fork and exec the child briefly
// shares this file's flock, so `begin` can see the lock as busy. Retry for a moment.
let (mut prev, mut s) = Sentinel::begin(&dir);
for _ in 0..50 {
if prev != Previous::Busy {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
(prev, s) = Sentinel::begin(&dir);
}
let (prev, s) = begin_when_unlocked(&dir);
let m = prev.crashed().cloned().expect("crash detected");
assert_eq!((m.adapter.as_str(), m.tried()), ("GPU", Some(Vulkan)));
// This start succeeds: the next one finds nothing.
@@ -721,6 +755,29 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn begin_when_unlocked_waits_for_a_briefly_held_lock() {
let dir = temp_dir("shared-lock");
let (_, s) = Sentinel::begin(&dir);
let mut s = s.expect("sentinel");
s.write(Marker { backend: "vulkan".into(), ..Default::default() }).unwrap();
// After the start dies, a second handle on the marker holds the lock for 50 ms, as a
// spawned child's copy of the descriptor does until the child calls exec.
let held = File::open(dir.join(MARKER_FILE)).unwrap();
drop(s);
held.lock().unwrap();
assert_eq!(Sentinel::begin(&dir).0, Previous::Busy);
let release = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(50));
held.unlock().unwrap();
});
let (prev, s) = begin_when_unlocked(&dir);
release.join().unwrap();
assert_eq!(prev.crashed().map(|m| m.backend.as_str()), Some("vulkan"));
s.expect("sentinel").finish();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn returned_non_graphics_errors_do_not_trigger_backend_recovery() {
let cases = [
@@ -738,7 +795,7 @@ mod tests {
} else {
s.finish();
}
let (previous, s) = Sentinel::begin(&dir);
let (previous, s) = begin_when_unlocked(&dir);
assert_eq!(previous.crashed().is_some(), keep, "{name}: {result:?}");
if !keep {
assert_eq!(plan(Vulkan, previous.crashed(), None, false, Os::Other).backend, Vulkan);
+329
View File
@@ -0,0 +1,329 @@
//! Linux: icons from the desktop's freedesktop icon theme (the Icon Theme Specification), for the
//! caption buttons' glyphs (`caption_style.rs`). A theme is searched, then the themes it inherits,
//! then `hicolor`; within each, its directories from the best fit for a 16 px icon down. Only SVG
//! files are used: symbolic icons are drawn as a coverage mask in the button's ink colour.
use std::path::{Path, PathBuf};
/// Bounds on what a theme's files can make us do: themes in an inheritance chain, directories a
/// theme lists, and the bytes read from one file.
const MAX_THEMES: usize = 32;
const MAX_DIRS: usize = 4096;
/// How deep an index's unlisted directories are looked for (`symbolic/ui`, `16x16@2/actions`).
const SCAN_DEPTH: usize = 3;
const MAX_FILE: u64 = 1 << 20;
/// The size the caption glyphs are drawn at, in the spec's units.
const ICON_SIZE: i64 = 16;
/// A theme or icon name that stays inside its directory.
pub fn valid_name(name: &str) -> bool {
!name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
}
/// At most [`MAX_FILE`] bytes of a file (settings, theme indexes and icons are small).
pub fn read_small(path: &Path) -> Option<Vec<u8>> {
use std::io::Read;
let mut bytes = vec![];
std::fs::File::open(path).ok()?.take(MAX_FILE).read_to_end(&mut bytes).ok()?;
Some(bytes)
}
/// Where themes live, in the spec's order: `~/.icons`, `$XDG_DATA_HOME/icons`, then each
/// `$XDG_DATA_DIRS/icons`. Relative paths in the variables are ignored, as the XDG spec says.
pub fn base_dirs(var: &dyn Fn(&str) -> Option<String>) -> Vec<PathBuf> {
let absolute = |p: &String| Path::new(p).is_absolute();
let home = var("HOME").filter(absolute).map(PathBuf::from);
let mut dirs = vec![];
if let Some(home) = &home {
dirs.push(home.join(".icons"));
}
if let Some(data) = var("XDG_DATA_HOME").filter(absolute).map(PathBuf::from).or_else(|| home.map(|h| h.join(".local").join("share"))) {
dirs.push(data.join("icons"));
}
let data_dirs = var("XDG_DATA_DIRS").filter(|d| !d.trim().is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
dirs.extend(data_dirs.split(':').filter(|d| Path::new(d).is_absolute()).map(|d| Path::new(d).join("icons")));
dirs
}
/// One `[section]` of an `index.theme`, as (key, value) pairs.
fn section<'a>(index: &'a str, name: &str) -> Vec<(&'a str, &'a str)> {
let mut inside = false;
let mut pairs = vec![];
for line in index.lines().map(str::trim) {
if let Some(s) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
if inside {
break;
}
inside = s == name;
} else if inside && let Some((k, v)) = line.split_once('=') {
pairs.push((k.trim(), v.trim()));
}
}
pairs
}
fn list(value: Option<&str>) -> impl Iterator<Item = &str> {
value.unwrap_or_default().split(',').map(str::trim).filter(|s| !s.is_empty())
}
/// How far a theme directory is from a 16 px icon at scale 1 (the spec's
/// `DirectorySizeDistance`), then its scale: 0 is an exact fit.
fn distance(keys: &[(&str, &str)]) -> (i64, i64) {
let get = |k: &str| keys.iter().find(|(key, _)| *key == k).map(|(_, v)| *v);
let num = |k: &str, default: i64| get(k).and_then(|v| v.parse::<i64>().ok()).unwrap_or(default).clamp(0, 1 << 16);
let size = num("Size", 0);
let scale = num("Scale", 1).max(1);
let d = match get("Type").unwrap_or("Threshold") {
"Fixed" => (size * scale - ICON_SIZE).abs(),
"Scalable" => {
let (min, max) = (num("MinSize", size) * scale, num("MaxSize", size) * scale);
(min - ICON_SIZE).max(ICON_SIZE - max).max(0)
}
_ => ((size * scale - ICON_SIZE).abs() - num("Threshold", 2) * scale).max(0),
};
(d, scale)
}
/// Every directory under `root`, at most [`SCAN_DEPTH`] levels down and [`MAX_DIRS`] in all, in
/// path order: scalable and symbolic ones first, as icons there fit any size.
fn subdirectories(root: &Path) -> Vec<PathBuf> {
let mut found = vec![];
let mut level = vec![root.to_path_buf()];
for _ in 0..SCAN_DEPTH {
let mut next = vec![];
for dir in &level {
let Ok(entries) = std::fs::read_dir(dir) else { continue };
for entry in entries.flatten().take(MAX_DIRS) {
let path = entry.path();
if found.len() + next.len() < MAX_DIRS && path.is_dir() {
next.push(path);
}
}
}
found.extend(next.iter().cloned());
level = next;
}
let vector = |p: &PathBuf| !p.components().any(|c| matches!(c.as_os_str().to_str(), Some("scalable" | "symbolic")));
found.sort_by_key(|p| (vector(p), p.clone()));
found
}
/// One theme of a chain: its listed directories, best fit first, in every base directory that has
/// the theme, and (looked for only when an icon isn't in those) the directories its index forgot.
struct Theme {
roots: Vec<PathBuf>,
listed: Vec<PathBuf>,
unlisted: std::cell::OnceCell<Vec<PathBuf>>,
}
impl Theme {
/// The directories the index forgot to list: GTK still finds icons there, and custom themes
/// often add `symbolic/ui` without listing it.
fn unlisted(&self) -> &[PathBuf] {
self.unlisted.get_or_init(|| {
let listed: std::collections::HashSet<&PathBuf> = self.listed.iter().collect();
self.roots.iter().flat_map(|r| subdirectories(r)).filter(|d| !listed.contains(d)).collect()
})
}
}
/// A theme and the themes it inherits, in search order.
pub struct IconTheme {
themes: Vec<Theme>,
}
impl IconTheme {
/// `name` and the themes it inherits, then `hicolor`. `None` when no theme in the chain has an
/// `index.theme`.
pub fn load(name: &str, bases: &[PathBuf]) -> Option<Self> {
let mut chain: Vec<String> = vec![name.to_string()];
let mut themes = vec![];
let mut i = 0;
loop {
let Some(theme) = chain.get(i).cloned() else {
// Every chain ends in hicolor, whether or not the themes before it exist.
if chain.iter().any(|t| t == "hicolor") {
break;
}
chain.push("hicolor".into());
continue;
};
i += 1;
if !valid_name(&theme) {
continue;
}
let roots: Vec<PathBuf> = bases.iter().map(|b| b.join(&theme)).filter(|r| r.is_dir()).collect();
// The first index wins; a theme can still be spread over several base directories.
let Some(index) = roots.iter().find_map(|r| read_small(&r.join("index.theme"))) else { continue };
let index = String::from_utf8_lossy(&index);
let head = section(&index, "Icon Theme");
let get = |k: &str| head.iter().find(|(key, _)| *key == k).map(|(_, v)| *v);
let mut subdirs: Vec<((i64, i64), &str)> = list(get("Directories"))
.chain(list(get("ScaledDirectories")))
.filter(|d| !d.split('/').any(|part| part == ".." || part.is_empty()))
.take(MAX_DIRS)
.map(|d| (distance(&section(&index, d)), d))
.collect();
subdirs.sort_by_key(|(key, _)| *key);
// Not checked for existence: a theme lists hundreds, and the search stops at a hit.
let listed = subdirs.iter().flat_map(|(_, sub)| roots.iter().map(move |r| r.join(sub))).collect();
for parent in list(get("Inherits")) {
if chain.len() < MAX_THEMES && !chain.iter().any(|t| t == parent) {
chain.push(parent.to_string());
}
}
themes.push(Theme { roots, listed, unlisted: std::cell::OnceCell::new() });
}
(!themes.is_empty()).then_some(Self { themes })
}
/// The first `<icon>.svg` in search order: each theme's listed directories, then its unlisted
/// ones, before its parent.
pub fn find_svg(&self, icon: &str) -> Option<PathBuf> {
if !valid_name(icon) {
return None;
}
let file = format!("{icon}.svg");
let hit = |dirs: &[PathBuf]| dirs.iter().map(|d| d.join(&file)).find(|p| p.is_file());
self.themes.iter().find_map(|t| hit(&t.listed).or_else(|| hit(t.unlisted())))
}
}
/// An SVG's coverage (alpha) as a `px` × `px` mask, the drawing fitted and centred. `None` for an
/// unreadable SVG or an absurd size.
pub fn rasterize_mask(svg: &[u8], px: u32) -> Option<Vec<u8>> {
use resvg::{tiny_skia, usvg};
if !(1..=256).contains(&px) {
return None;
}
// Glyphs are paths: no `<image>` is loaded, so an icon can't make us read another file
// (`/dev/zero`, a huge picture) or nest an SVG.
let opts = usvg::Options {
image_href_resolver: usvg::ImageHrefResolver { resolve_data: Box::new(|_, _, _| None), resolve_string: Box::new(|_, _| None) },
..usvg::Options::default()
};
let tree = usvg::Tree::from_data(svg, &opts).ok()?;
let size = tree.size();
let side = size.width().max(size.height());
if !side.is_finite() || side <= 0.0 {
return None;
}
let s = px as f32 / side;
let (dx, dy) = ((px as f32 - size.width() * s) / 2.0, (px as f32 - size.height() * s) / 2.0);
let mut pixmap = tiny_skia::Pixmap::new(px, px)?;
resvg::render(&tree, tiny_skia::Transform::from_scale(s, s).post_translate(dx, dy), &mut pixmap.as_mut());
Some(pixmap.data().as_chunks::<4>().0.iter().map(|[_, _, _, a]| *a).collect())
}
#[cfg(test)]
mod tests {
use super::*;
/// A throwaway icon tree: `files` are (path under the base, contents).
fn tree(tag: &str, files: &[(&str, &str)]) -> PathBuf {
let base = std::env::temp_dir().join(format!("photocraft-icons-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for (path, text) in files {
let p = base.join(path);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, text).unwrap();
}
base
}
const SQUARE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M4 4h8v8H4z" fill="#2e3436"/></svg>"##;
#[test]
fn icons_come_from_the_theme_then_its_parents_then_hicolor_best_size_first() {
let base = tree(
"chain",
&[
("Child/index.theme", "[Icon Theme]\nInherits=Parent, Child\nDirectories=48,16\n[48]\nSize=48\nType=Fixed\n[16]\nSize=16\nType=Fixed\n"),
("Child/48/a-symbolic.svg", "big"),
// Not in the index: still found, and before the parent's copy.
("Child/symbolic/ui/d-symbolic.svg", SQUARE),
("Parent/ui/d-symbolic.svg", SQUARE),
("Child/16/a-symbolic.svg", SQUARE),
("Parent/index.theme", "[Icon Theme]\nDirectories=ui\n[ui]\nSize=16\nMinSize=8\nMaxSize=512\nType=Scalable\n"),
("Parent/ui/b-symbolic.svg", SQUARE),
("hicolor/index.theme", "[Icon Theme]\nDirectories=s\n[s]\nSize=16\nType=Scalable\n"),
("hicolor/s/c-symbolic.svg", SQUARE),
],
);
let theme = IconTheme::load("Child", std::slice::from_ref(&base)).expect("the theme loads");
assert_eq!(theme.find_svg("a-symbolic"), Some(base.join("Child/16/a-symbolic.svg")), "16 px before 48 px");
assert_eq!(theme.find_svg("b-symbolic"), Some(base.join("Parent/ui/b-symbolic.svg")));
assert_eq!(theme.find_svg("c-symbolic"), Some(base.join("hicolor/s/c-symbolic.svg")));
assert_eq!(theme.find_svg("d-symbolic"), Some(base.join("Child/symbolic/ui/d-symbolic.svg")), "unlisted, but the theme's own");
assert_eq!(theme.find_svg("missing"), None);
assert_eq!(theme.find_svg("../Parent/ui/b-symbolic"), None);
assert!(IconTheme::load("Nowhere", std::slice::from_ref(&base)).is_some(), "hicolor still answers");
assert!(IconTheme::load("Nowhere", &[base.join("empty")]).is_none());
let _ = std::fs::remove_dir_all(base);
}
/// Hostile theme files: cycles, escapes and junk sizes end the search, never hang or panic.
#[test]
fn cyclic_or_hostile_indexes_are_harmless() {
let base = tree(
"hostile",
&[
(
"A/index.theme",
"[Icon Theme]\nInherits=B,../etc,\nDirectories=../../x,,/abs,ok\n[ok]\nSize=99999999999999999999\nScale=-3\nType=Threshold\n",
),
("A/ok/x.svg", SQUARE),
("B/index.theme", "[Icon Theme]\nInherits=A\n"),
],
);
let theme = IconTheme::load("A", std::slice::from_ref(&base)).expect("loads");
assert_eq!(theme.find_svg("x"), Some(base.join("A/ok/x.svg")));
assert!(IconTheme::load("..", std::slice::from_ref(&base)).is_none());
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn distance_follows_the_spec() {
assert_eq!(distance(&[("Size", "16"), ("Type", "Fixed")]), (0, 1));
assert_eq!(distance(&[("Size", "22"), ("Type", "Fixed")]), (6, 1));
assert_eq!(distance(&[("Size", "16"), ("Scale", "2"), ("Type", "Fixed")]), (16, 2));
assert_eq!(distance(&[("Size", "32"), ("MinSize", "8"), ("MaxSize", "512"), ("Type", "Scalable")]), (0, 1));
assert_eq!(distance(&[("Size", "48"), ("Type", "Scalable")]), (32, 1));
assert_eq!(distance(&[("Size", "18")]), (0, 1), "Threshold 2 by default");
}
#[test]
fn masks_are_square_coverage() {
let mask = rasterize_mask(SQUARE.as_bytes(), 32).expect("renders");
assert_eq!(mask.len(), 32 * 32);
assert_eq!(mask[16 * 32 + 16], 255, "inside the square");
assert_eq!(mask[0], 0, "outside it");
assert!(rasterize_mask(b"not svg", 16).is_none());
assert!(rasterize_mask(SQUARE.as_bytes(), 0).is_none());
assert!(rasterize_mask(SQUARE.as_bytes(), 100_000).is_none());
}
/// An icon's `<image>` is never loaded: not a device file, not an embedded picture.
#[test]
fn masks_load_no_images() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16">
<image href="/dev/zero" width="16" height="16"/>
<image xlink:href="data:image/svg+xml;utf8,&lt;svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'&gt;&lt;rect width='16' height='16'/&gt;&lt;/svg&gt;" width="16" height="16"/>
</svg>"##;
let mask = rasterize_mask(svg.as_bytes(), 16).expect("renders");
assert!(mask.iter().all(|a| *a == 0), "nothing drawn");
}
#[test]
fn base_dirs_follow_xdg() {
let var = |k: &str| match k {
"HOME" => Some("/home/u".to_string()),
"XDG_DATA_DIRS" => Some("/a:rel:/b".to_string()),
_ => None,
};
let dirs = base_dirs(&var);
let want: Vec<PathBuf> = ["/home/u/.icons", "/home/u/.local/share/icons", "/a/icons", "/b/icons"].iter().map(PathBuf::from).collect();
assert_eq!(dirs, want);
assert_eq!(base_dirs(&|_| None), vec![PathBuf::from("/usr/local/share/icons"), PathBuf::from("/usr/share/icons")]);
}
}
+51 -16
View File
@@ -26,11 +26,14 @@ mod appearance;
#[cfg(target_os = "macos")]
mod apple_events;
mod caps_lock;
mod caption_style;
mod control_server;
mod crash_guard;
mod cursor;
mod file_dialog_probe;
mod gpu_startup;
#[cfg(target_os = "linux")]
mod icon_theme;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
@@ -72,7 +75,8 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
/// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame
/// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without
/// `centered`, Windows cascades each new window from the top-left corner, so it opened at a
/// different offset every launch (#419). Wayland compositors place windows themselves.
/// different offset every launch (#419). A later launch restores the saved position instead
/// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves.
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
@@ -90,11 +94,21 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
// eframe saves native window geometry and egui panel/window sizes on exit.
// Keep that state beside preferences, including config overrides and portable mode.
persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")),
window_builder: Some(Box::new(at_least_min_size)),
window_builder: Some(Box::new(restored_window)),
..Default::default()
}
}
/// The window eframe restores from `ui.ron`, corrected before it is created: at least the
/// minimum size, and never full screen. PhotoCraft always starts in Standard Screen Mode, but
/// eframe also saves and restores full screen, and a window closed in a full-screen mode is
/// saved full screen and maximized at once: the overlap `view_cmds::screen_mode_commands` keeps
/// apart at run time. [OBSERVED 2026-10-12, Windows 11] Restored that way, View › Screen Mode ›
/// Full Screen did nothing until `F` had cycled through Standard.
fn restored_window(builder: egui::ViewportBuilder) -> egui::ViewportBuilder {
at_least_min_size(builder).with_fullscreen(false)
}
/// Grow a restored window that is smaller than the minimum size, keeping its centre. On
/// Windows, eframe saves a minimized window's size (160 pt wide, 64 pt tall once eframe has
/// clamped it), and the minimum size isn't applied to a restored one: PhotoCraft then reopened
@@ -317,6 +331,9 @@ fn main() -> eframe::Result {
}
// eframe restores the saved window layout before our code runs; drop values that would crash it.
ui_state::sanitize(options.persistence_path.as_deref());
// Centre only the first window: eframe centres after restoring the saved position, so a
// window closed on another monitor reopened in the middle of the main one (#4826).
options.centered = !ui_state::has_saved_position(options.persistence_path.as_deref());
// Crash-safe GPU startup (#4): pick the backend (a marker left by a start that died in the
// driver moves to a safer one), and lock this start's marker until the first frames render.
let t_sentinel = std::time::Instant::now();
@@ -436,24 +453,13 @@ fn main() -> eframe::Result {
photocraft_ui_egui::gpu_status::queue_fallback_notice(&mut app, &reason);
}
app.perf.span("gpuSentinel", sentinel_ms);
// Once the first frames rendered: clear the marker, and keep a crash fallback.
let remember_cpu =
std::env::var_os("PHOTOCRAFT_GPU_STARTUP_FAILURE").is_some() || (plan.remember && plan.backend == photocraft_engine::prefs::GpuBackend::Cpu);
let remember = plan.remember.then_some(plan.backend);
// CPU rendering after a failed GPU start is temporary. Only the
// recovery-dialog choice may persist it (#3483).
let remember = gpu_startup::backend_to_remember(&plan);
app.on_started(move |app| {
if let Some(s) = started_sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
s.finish();
}
if remember_cpu
&& let Err(error) = app.run(
"prefs.set",
serde_json::json!({"values": {
"performance.renderingMode": "cpu", "performance.useGpu": false
}}),
)
{
log::warn!("couldn't remember CPU recovery: {error}");
}
if let Some(b) = remember
&& app.session.prefs().performance.gpu_backend != b
&& let Err(e) = app.run("prefs.set", serde_json::json!({"path": "performance.gpuBackend", "value": b.name()}))
@@ -482,6 +488,10 @@ fn main() -> eframe::Result {
// The custom title bar's undecorated window must stay borderless, or its caption and
// border offset the content and the pointer on some Windows systems (#2246).
app.services.window_frame = window_frame::service(cc, custom_titlebar);
// Linux: the desktop's caption buttons (GNOME's, KDE's) on the sides its layout puts them.
if custom_titlebar {
app.services.caption_style = caption_style::service();
}
// Caps Lock state (X11/Windows/macOS; `None` on native Wayland): the canvas shows the
// precise crosshair for painting tools while it is toggled on (#1758).
app.services.caps_lock = caps_lock::service(cc);
@@ -624,6 +634,22 @@ mod tests {
assert_eq!(super::at_least_min_size(builder).inner_size, None);
}
#[test]
fn a_window_saved_in_full_screen_reopens_maximized_not_full_screen() {
let hook = super::native_options(super::CUSTOM_TITLEBAR).window_builder.expect("a window builder hook");
// What eframe restores from a ui.ron saved in a full-screen mode on Windows.
let saved = super::native_options(super::CUSTOM_TITLEBAR)
.viewport
.with_inner_size([2560.0, 1392.0])
.with_position([0.0, 0.0])
.with_fullscreen(true)
.with_maximized(true);
let opened = hook(saved);
assert_eq!(opened.fullscreen, Some(false), "Standard Screen Mode is where PhotoCraft starts");
assert_eq!(opened.maximized, Some(true), "the rest of the saved geometry is kept");
assert_eq!(opened.inner_size, Some(egui::vec2(2560.0, 1392.0)));
}
#[test]
fn the_system_title_bar_preference_keeps_the_window_decorations() {
// #1271, #1316: Preferences › Interface › System Title Bar gives the window back its
@@ -644,4 +670,13 @@ mod tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
#[test]
fn the_desktop_entry_names_the_window_class() {
let entry = include_str!("../../../packaging/linux/ai.storyteller.photocraft.desktop");
let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass="));
assert_eq!(class, Some(super::APP_ID));
}
}
+191 -12
View File
@@ -16,13 +16,13 @@ use std::sync::Arc;
#[cfg(test)]
mod recovery_tests;
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and
/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and
/// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm",
"ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
"svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
"pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw",
"pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -43,6 +43,7 @@ fn open_filters() -> Vec<(&'static str, Vec<String>)> {
("All Formats", open_filter_extensions(OPEN_EXTS)),
("PhotoCraft", open_filter_extensions(&["pcraft"])),
("OpenRaster", open_filter_extensions(&["ora"])),
("Krita", open_filter_extensions(&["kra", "krz"])),
("SVG", open_filter_extensions(SVG_EXTS)),
]
}
@@ -59,8 +60,13 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("WebP", &["webp"]),
("TIFF", &["tif"]),
("Targa", &["tga"]),
// Also in Photoshop's Save As list; ICO is PhotoCraft's own (#4357): up to 256×256, with alpha.
("BMP", &["bmp"]),
("GIF", &["gif"]),
("ICO", &["ico"]),
("OpenEXR", &["exr"]),
("FITS", &["fits", "fit"]),
("DPX", &["dpx"]),
];
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
@@ -76,8 +82,11 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
let mut v: Vec<(String, Vec<String>)> = SAVE_FILTERS.iter().map(|(name, exts)| (name.to_string(), exts.iter().map(|e| e.to_string()).collect())).collect();
match v.iter().position(|(_, exts)| exts.contains(&ext)) {
Some(i) => {
let f = v.remove(i);
v.insert(0, f);
let (name, mut exts) = v.remove(i);
// Windows adds the first type's first extension to a bare name: `.psb`, not `.psd`.
exts.retain(|e| *e != ext);
exts.insert(0, ext);
v.insert(0, (name, exts));
}
None if !ext.is_empty() => v.insert(0, (ext.to_ascii_uppercase(), vec![ext])),
None => {}
@@ -85,6 +94,21 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
v
}
/// The name the save dialog starts with. On Windows a visible extension stays put when the user
/// picks another type (with File name extensions shown, `image.psd` saved as PhotoCraft stayed
/// `image.psd`, #4394), while a bare name gets the chosen type's extension, so there the dialog
/// gets the stem and the suggested type (listed first by [`save_filters`]) supplies `.psd`. A
/// stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case
/// extension (the user's spelling) keeps the whole name. Elsewhere the whole name is kept: the
/// macOS panel has no type list, and on Linux rfd doesn't report which type was picked.
fn dialog_file_name(suggested: &str, windows: bool) -> Option<String> {
let path = Path::new(suggested);
let name = path.file_name()?.to_string_lossy().into_owned();
let (Some(stem), Some(ext)) = (path.file_stem(), path.extension().and_then(|e| e.to_str())) else { return Some(name) };
let bare = windows && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase();
Some(if bare { stem.to_string_lossy().into_owned() } else { name })
}
/// Shows an Open or Save dialog without blocking the event loop (#673, #574): rfd's async dialog
/// is created here, on the main thread, with the window as its parent (a sheet on macOS, an owned
/// modal window elsewhere), and a helper thread waits for it and hands the answer to the app.
@@ -115,12 +139,19 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) })
}
}
FileDialogRequest::Folder { initial_dir } => {
if let Some(dir) = initial_dir {
dialog = dialog.set_directory(dir);
}
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
}
FileDialogRequest::Save { suggested } => {
for (name, exts) in save_filters(&suggested) {
dialog = dialog.add_filter(name, &exts);
}
if let Some(name) = Path::new(&suggested).file_name() {
dialog = dialog.set_file_name(name.to_string_lossy());
if let Some(name) = dialog_file_name(&suggested, cfg!(windows)) {
dialog = dialog.set_file_name(name);
}
if let Some(dir) = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()) {
dialog = dialog.set_directory(dir);
@@ -209,6 +240,7 @@ fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_i
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
opts.tga_bits = settings.tga_bits;
opts.image_previews = settings.image_previews;
opts.psd_maximize_compatibility = settings.psd_maximize_compatibility;
opts
}
@@ -316,6 +348,43 @@ fn image_from_files(paths: &[PathBuf]) -> Option<(u32, u32, Vec<u8>)> {
})
}
/// Largest clipboard image payload read on Wayland (1 GiB, a 16K × 16K RGBA BMP), so no real
/// screenshot is turned away, while a broken or hostile source can't make us allocate without end.
#[cfg(target_os = "linux")]
const MAX_CLIPBOARD_IMAGE_BYTES: u64 = 4 * 16384 * 16384;
/// The clipboard image arboard can't read on Wayland (#2546): arboard 3.6 asks for `image/png`
/// only, so a screenshot tool or clipboard manager that offers just `image/jpeg`, `image/bmp` or
/// `image/webp` pasted nothing. Reads the other `image/*` types through the same data-control
/// protocol arboard uses, and decodes them with our own codecs. `None` off Wayland.
#[cfg(target_os = "linux")]
fn wayland_clipboard_image() -> Option<(u32, u32, Vec<u8>)> {
use std::io::Read;
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
let offers = get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).ok()?;
image_from_offers(&offers, |mime| {
let (pipe, _) = get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Specific(mime)).ok()?;
let mut bytes = Vec::new();
pipe.take(MAX_CLIPBOARD_IMAGE_BYTES + 1).read_to_end(&mut bytes).ok()?;
(bytes.len() as u64 <= MAX_CLIPBOARD_IMAGE_BYTES).then_some(bytes)
})
}
/// The first clipboard offer, in the source's order, that `fetch` returns as a decodable image,
/// upright as RGBA8. Only `image/*` types are fetched, so text offers are never transferred.
#[cfg(any(target_os = "linux", test))]
fn image_from_offers(offers: &[String], mut fetch: impl FnMut(&str) -> Option<Vec<u8>>) -> Option<(u32, u32, Vec<u8>)> {
offers.iter().filter(|mime| mime.starts_with("image/")).find_map(|mime| {
let bytes = fetch(mime)?;
// TGA has no magic number (see `image_from_files`), so trust it only when the type says so.
if photocraft_codecs::detect(&bytes)? == photocraft_codecs::Format::Tga && !mime.contains("tga") {
return None;
}
let img = photocraft_codecs::decode(&bytes).ok()?;
Some((img.width(), img.height(), img.to_rgba8()))
})
}
/// The shell command that starts this install under XWayland, where winit delivers file drops
/// (winit 0.30 has none on Wayland, #386). A Flatpak gets the X11 socket only without the Wayland
/// one, an AppImage relaunches by its own path, and anything else is `photocraft` on the PATH.
@@ -416,8 +485,13 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
return Some(img);
}
let img = cb.get_image().ok()?;
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
if let Ok(img) = cb.get_image() {
return Some((img.width as u32, img.height as u32, img.bytes.into_owned()));
}
#[cfg(target_os = "linux")]
return wayland_clipboard_image();
#[cfg(not(target_os = "linux"))]
None
})
}),
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
@@ -655,11 +729,66 @@ mod tests {
.join(" ");
assert_eq!(exts, want.split(' ').collect::<Vec<_>>());
for (name, ext) in asked.iter().zip(exts) {
assert!(save_filters(name)[0].1.iter().any(|e| e == ext), "{name}: {:?}", save_filters(name)[0]);
// The first type's first extension is the one Windows adds to the bare name.
assert_eq!(save_filters(name)[0].1.first().map(String::as_str), Some(ext), "{name}: {:?}", save_filters(name)[0]);
}
assert_eq!(save_filters("Untitled")[0].0, "PSD Document", "no extension keeps the default");
}
/// Every flat format Save As offers is one PhotoCraft writes, and ICO is among them (#4357).
#[test]
fn save_as_offers_only_writable_formats_including_ico() {
let layered = ["psd", "psb", "pcraft", "ora"];
for (name, exts) in SAVE_FILTERS {
for ext in exts.iter().filter(|e| !layered.contains(e)) {
let format = photocraft_codecs::from_extension(ext).unwrap_or_else(|| panic!("{name}: .{ext} is no known format"));
assert!(format.caps().write, "{name}: .{ext} can't be written");
}
}
for ext in ["ico", "gif", "bmp"] {
assert!(save_filters("Untitled-1.psd").iter().any(|(_, exts)| exts.iter().any(|e| e == ext)), ".{ext} is offered");
}
assert_eq!(save_filters("icon.ico")[0].0, "ICO", "an .ico name leads with its own type");
// What Save As writes there: an icon up to 256×256, and a clear error past that.
let doc = |side: u32| {
let mut s = Session::new();
s.execute("file.new", json!({"width": side, "height": side})).unwrap();
s.active().unwrap().doc.clone()
};
let opts = export_options(&photocraft_ui_egui::ExportSettings::default());
let icon = photocraft_io::export(&doc(256), "icon.ico", &opts).unwrap();
assert_eq!(icon.bytes[..4], [0, 0, 1, 0], "an ICO header");
let err = photocraft_io::export(&doc(300), "icon.ico", &opts).map(|_| ()).unwrap_err().to_string();
assert!(err.contains("256"), "{err}");
}
/// #4394: with File name extensions shown, Windows kept `image.psd` when the type was changed to
/// PhotoCraft. Given the bare `image`, it adds the extension of whichever type is picked
/// (checked by hand with rfd 0.17.2 on Windows 11), defaulting to the first type's.
#[test]
fn the_windows_save_dialog_gets_a_bare_name_so_the_type_picks_the_extension() {
for (suggested, name, first) in [
("/pics/image.psd", "image", "psd"),
("/pics/image.psb", "image", "psb"),
("/pics/image.pcraft", "image", "pcraft"),
("/pics/sky.fit", "sky", "fit"),
("cat.jpeg", "cat", "jpeg"),
("swatches.aco", "swatches", "aco"),
] {
assert_eq!(dialog_file_name(suggested, true).as_deref(), Some(name), "{suggested}");
assert_eq!(save_filters(suggested)[0].1[0], first, "{suggested}");
// Elsewhere the dialogs keep the whole name (see `dialog_file_name`).
let whole = Path::new(suggested).file_name().unwrap().to_string_lossy().into_owned();
assert_eq!(dialog_file_name(suggested, false), Some(whole), "{suggested}");
}
// Kept whole: a second dot (Windows could keep `scan.jpg`), the user's upper case, no
// extension, and a dotfile.
for name in ["scan.jpg.psd", "my.photo.psd", "transparent.PNG", "Untitled", ".psd"] {
assert_eq!(dialog_file_name(name, true).as_deref(), Some(name));
}
assert_eq!(dialog_file_name("", true), None);
}
#[test]
fn block_on_waits_for_a_wake_from_another_thread() {
let (tx, rx) = std::sync::mpsc::channel::<std::task::Waker>();
@@ -778,6 +907,7 @@ mod tests {
let mut app = launch(&recovery);
new_doc(&mut app, "#ff0000");
let revision = app.session.active().unwrap().revision;
assert_ne!(revision, 0, "a sidecar-less bundle lists as revision 0");
autosave(&mut app, &ctx);
let mut failed = false;
@@ -798,12 +928,16 @@ mod tests {
std::fs::remove_file(&recovery).unwrap();
std::fs::create_dir(&recovery).unwrap();
autosave(&mut app, &ctx);
// The worker commits the bundle's manifest before it publishes the sidecar, and
// `list_recovery` lists a committed sidecar-less bundle (revision 0) on its own, so
// the first non-empty listing can come from the middle of the write. Wait for the write
// to finish: the entry then carries the sidecar's revision.
let mut recovered = Vec::new();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while std::time::Instant::now() < deadline {
prefs_ui::tick(&mut app, &ctx);
recovered = list_recovery(&recovery);
if !recovered.is_empty() {
if recovered.iter().any(|e| e.info.revision == revision) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
@@ -878,6 +1012,51 @@ mod tests {
assert!(image_from_files(&[bad]).is_none());
}
fn encoded(format: photocraft_codecs::Format, w: u32, h: u32) -> Vec<u8> {
let img = Image::from_u8(w, h, ChannelLayout::Rgba, [255u8, 0, 0, 255].repeat((w * h) as usize)).unwrap();
photocraft_codecs::encode(&img, format, &EncodeOptions::default()).unwrap()
}
#[test]
fn non_png_clipboard_images_paste() {
// #2546: a screenshot tool that offers only image/bmp or image/jpeg pasted nothing.
let bmp = encoded(photocraft_codecs::Format::Bmp, 3, 2);
let offers = vec!["text/plain".to_string(), "image/bmp".to_string()];
let mut asked = Vec::new();
let got = image_from_offers(&offers, |mime| {
asked.push(mime.to_string());
Some(bmp.clone())
});
assert_eq!(got, Some((3, 2, [255u8, 0, 0, 255].repeat(6))));
assert_eq!(asked, ["image/bmp"], "text offers are never fetched");
let jpeg = encoded(photocraft_codecs::Format::Jpeg, 16, 8);
let offers = vec!["image/jpeg".to_string()];
assert_eq!(image_from_offers(&offers, |_| Some(jpeg.clone())).map(|(w, h, _)| (w, h)), Some((16, 8)));
}
#[test]
fn undecodable_clipboard_offers_fall_through_to_the_next() {
let bmp = encoded(photocraft_codecs::Format::Bmp, 2, 2);
let cut = encoded(photocraft_codecs::Format::Png, 8, 8)[..20].to_vec();
let offers: Vec<String> = ["image/webp", "image/png", "image/x-tga", "image/bmp"].map(String::from).to_vec();
let got = image_from_offers(&offers, |mime| match mime {
"image/webp" => None,
"image/png" => Some(cut.clone()),
"image/x-tga" => Some(b"not an image".to_vec()),
_ => Some(bmp.clone()),
});
assert_eq!(got.map(|(w, h, _)| (w, h)), Some((2, 2)));
assert!(image_from_offers(&offers[..3], |_| Some(cut.clone())).is_none());
assert!(image_from_offers(&[], |_| Some(bmp.clone())).is_none());
}
#[test]
fn clipboard_tga_is_trusted_only_by_its_type() {
assert_eq!(image_from_offers(&["image/x-tga".to_string()], |_| Some(tga_1x1())), Some((1, 1, vec![255, 0, 0, 255])));
assert!(image_from_offers(&["image/x-unknown".to_string()], |_| Some(tga_1x1())).is_none());
}
#[test]
fn tga_is_trusted_only_with_its_extension() {
let dir = temp("tga");
+33 -1
View File
@@ -27,6 +27,23 @@ pub fn sanitize(path: Option<&Path>) {
}
}
/// Whether `path` holds a window position from an earlier session, which eframe restores. Then
/// `NativeOptions::centered` must be off: eframe applies it after restoring the saved geometry,
/// so it moved the window back to the centre of the main monitor on every launch (#4826).
pub fn has_saved_position(path: Option<&Path>) -> bool {
let Some(text) = path.and_then(|p| std::fs::read_to_string(p).ok()) else { return false };
saved_position(&text)
}
fn saved_position(text: &str) -> bool {
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return false };
let Some(window) = kv.get("window").and_then(|w| ron::from_str::<ron::Value>(w).ok()) else { return false };
// eframe restores the inner position on macOS and the outer position elsewhere
// (`egui_winit::WindowSettings::initialize_viewport_builder`).
let key = if cfg!(target_os = "macos") { "inner_position_pixels" } else { "outer_position_pixels" };
matches!(field(&window, key), Some(ron::Value::Option(Some(_))))
}
/// `Err` names the first value that would crash startup.
fn check(text: &str) -> Result<(), String> {
// eframe stores a map of key → RON string; a file that doesn't parse is ignored by eframe.
@@ -78,7 +95,7 @@ fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> {
#[cfg(test)]
mod tests {
use super::check;
use super::{check, saved_position};
fn file(window: &str, zoom: &str) -> String {
let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))");
@@ -105,6 +122,21 @@ mod tests {
}
}
#[test]
fn a_saved_window_position_is_detected() {
// #4826: the position from the report (Linux Mint, window moved to the third monitor).
let window = "(inner_position_pixels:Some((x:2048.0,y:1265.0)),outer_position_pixels:Some((x:2048.0,y:1228.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
assert!(saved_position(&file(window, "1.0")));
let no_position = "(inner_position_pixels:None,outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:880.0,y:480.0)))";
assert!(!saved_position(&file(no_position, "1.0")));
assert!(!saved_position(&format!("{{\"egui\":{:?}}}", "()")), "no window entry");
for text in ["", "not ron {{{", "{\"window\":\"(((\"}"] {
assert!(!saved_position(text), "{text:?}");
}
assert!(!super::has_saved_position(None));
assert!(!super::has_saved_position(Some(&std::env::temp_dir().join("photocraft-missing-ui.ron"))));
}
#[test]
fn unreadable_files_are_left_to_eframe() {
// eframe ignores files and entries it can't parse, so they can't crash it.
+1 -1
View File
@@ -10,5 +10,5 @@
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 18a6 6 0 0 0 0-12v12z" />
<path d="M12 2a10 10 0 0 0 0 20z" fill="currentColor" stroke="none" />
</svg>

Before

Width:  |  Height:  |  Size: 285 B

After

Width:  |  Height:  |  Size: 317 B

+19
View File
@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<defs>
<linearGradient id="g" x1="3" y1="0" x2="20" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="currentColor" stop-opacity="0.9" />
<stop offset="100%" stop-color="currentColor" stop-opacity="0" />
</linearGradient>
</defs>
<rect width="18" height="16" x="3" y="4" rx="2" fill="url(#g)" />
</svg>

After

Width:  |  Height:  |  Size: 549 B

+9
View File
@@ -20,6 +20,15 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened.
- TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit.
Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through
the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed
that exact profile. Untagged composites use and embed PhotoCraft's synthetic coated CMYK profile;
invalid, non-CMYK or unusable embedded profiles are rejected before compositing. TIFF retains
8/16/32f depth and straight alpha; JPEG reduces to 8-bit with a warning and mattes transparency
over zero-ink white. A plain single raster layer retains its native samples. RGB-only formats use
the RGB composite directly. This RGB composite roundtrip does **not** preserve original ink
separations, black generation or native CMYK blending, and is not a claim of Photoshop parity.
## Implemented decode limits
`DecodeOptions::default()` applies `Limits` before decoded pixel allocation:
+49
View File
@@ -17,6 +17,10 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.jonathanwxh-cell]
display_name = "jonathnwxh"
sync_github_name = false
[people.Skyfuller1303]
display_name = "Skyfuller"
sync_github_name = true
@@ -104,8 +108,53 @@ sync_github_name = true
[people.timothydaly1]
display_name = "SyntaxError"
[people.atoslins]
real_name = "Atos Lins"
display_name = "Atos Lins"
sync_github_name = false
[people.axlecoffee]
sync_github_name = true
[people.Losminek]
sync_github_name = true
[people.FredApps]
sync_github_name = true
[people.arthurdeka]
sync_github_name = true
[people.aweussom]
real_name = "Tommy Leonhardsen"
[people.Furash]
sync_github_name = true
[people.lucAmbr0]
sync_github_name = true
[people.Hevarh1]
sync_github_name = true
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.deadmeu]
sync_github_name = true
[people.megaDon101]
display_name = "megadon"
sync_github_name = false
[people.sssehnsuchttt]
sync_github_name = true
[people.VanTan666]
display_name = "VanTan"
sync_github_name = false
[people.yeniufuklar-com]
real_name = "Ufuk Efe"
sync_github_name = true
+2 -2
View File
@@ -16,7 +16,7 @@ Affinity files.
| Artboards (legacy `ABEn`, or current `.af` `phrp`/`aprp` properties, including converted curves), with name, solid background, any transform | artboards (nested ones as groups with a vector mask; rotated/curved boards use their bounding rectangle) |
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | groups (pass-through or Normal) |
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | shape layers (multi-subpath curves fill even-odd) |
| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers |
| Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers |
| Compound shapes (`Comp`, add and subtract) | one even-odd shape |
| A shape or curve with children clips them | group with the outline as vector mask, the shape at its bottom |
| Vector masks (`AdCh` curves and shapes) | vector masks |
@@ -34,7 +34,7 @@ Affinity files.
Everything else is reported in the import warnings, one line per kind with a count, never
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
transparency gradients, fill opacity, bitmap fills, master pages, unrecognized
parametric shapes (imported as their bounding ellipses), corner types other than round, stars with
parametric shapes (imported as their bounding ellipses), corner types other than round or straight, stars with
rounded points, several fills or strokes on one object (one of each is used), strokes behind the
fill, outlined or scaled text, text fields such as page numbers, frames with columns or a curved
outline, grey/Lab/32-bit pixels, CMYK and Lab colour (converted to RGB without the document's
+74 -41
View File
@@ -51,28 +51,44 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
let class = r.s.class(shape)?;
let sub = match &class.0.to_be_bytes() {
b"ShNR" | b"ShRR" => {
let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty());
let radius = match last(r, shape, b"ShCR") {
Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0),
_ => None,
let types = match last(r, shape, b"CTyp") {
Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(),
_ => Vec::new(),
};
if let (true, Some(f)) = (rounded, radius) {
if let Some(Value::Array(types)) = last(r, shape, b"CTyp")
&& types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. }))
{
r.warn("rectangles with straight, concave or other corner types (imported with round corners)");
}
let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) {
// An absolute radius is in document pixels, whatever the node's scale.
let [a, b2, c, d, ..] = world.0;
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) }
} else {
(f * w.min(h), f * w.min(h))
};
rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0))
} else {
let radii = match last(r, shape, b"ShCR") {
Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(),
_ => Vec::new(),
};
// Corners follow the first one (top left) unless they were unlocked; then each has
// its own radius and type, clockwise from the top left.
let locked = !matches!(last(r, shape, b"Lock"), Some(Value::Bool(false)));
let corners: [(f64, Option<u16>); 4] = std::array::from_fn(|i| {
let i = if locked { 0 } else { i };
(radii.get(i).copied().unwrap_or(0.0), types.get(i).copied().flatten())
});
if types.is_empty() || corners.iter().all(|(f, _)| *f == 0.0) {
polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])?
} else {
if corners.iter().any(|(f, t)| *f > 0.0 && !matches!(t, Some(0 | 1 | 4))) {
r.warn("rectangles with concave or other corner types (imported with round corners)");
}
let absolute = r.s.bool(shape, b"AbSz") == Some(true);
let [a, b2, c, d, ..] = world.0;
let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt());
cornered_rect(
b,
corners.map(|(f, t)| {
let (crx, cry) = if !absolute {
(f * w.min(h), f * w.min(h))
} else if sx > 0.0 && sy > 0.0 {
// An absolute radius is in document pixels, whatever the node's scale.
(f / sx, f / sy)
} else {
(0.0, 0.0)
};
(crx, cry, t == Some(1))
}),
)
}
}
b"ShpE" => ellipse(cx, cy, rx, ry),
@@ -115,13 +131,21 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
}))?
}
b"ShPi" => {
let start = num(r, shape, b"AngS").unwrap_or(0.0);
let mut end = num(r, shape, b"AngE").unwrap_or(TAU);
while end < start {
end += TAU;
}
let raw_start = num(r, shape, b"AngS").unwrap_or(0.0);
let raw_end = num(r, shape, b"AngE").unwrap_or(TAU);
// Normalize in constant time. Repeatedly adding TAU can hang on finite values
// large enough that floating-point addition no longer changes the endpoint.
let start = raw_start.rem_euclid(TAU);
let delta = raw_end - raw_start;
let sweep = if raw_end >= raw_start {
delta.min(TAU)
} else if delta.is_finite() {
delta.rem_euclid(TAU)
} else {
(raw_end.rem_euclid(TAU) - start).rem_euclid(TAU)
};
let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0);
pie(cx, cy, rx, ry, start, end.min(start + TAU), inner)
pie(cx, cy, rx, ry, start, start + sweep, inner)
}
b"ShpT" => {
let p = num(r, shape, b"Pos ").unwrap_or(0.5);
@@ -346,22 +370,31 @@ fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath {
}
}
fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath {
let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry);
SubPath {
start: pt(x0 + rx, y0),
segments: vec![
[pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)],
[pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)],
[pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)],
[pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)],
[pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)],
[pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)],
[pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)],
[pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)],
],
closed: true,
/// A rectangle whose corners (top left, top right, bottom right, bottom left) are each rounded,
/// or cut straight across, by their (horizontal, vertical) radii. Two radii that don't fit along
/// one side shrink in proportion until they do.
fn cornered_rect([x0, y0, x1, y1]: [f64; 4], corners: [(f64, f64, bool); 4]) -> SubPath {
let fit = |r: f64, pair: f64, side: f64| if pair > side { side * (r / pair) } else { r };
let [tl, tr, br, bl] = corners;
let (w, h) = (x1 - x0, y1 - y0);
let rx = [fit(tl.0, tl.0 + tr.0, w), fit(tr.0, tl.0 + tr.0, w), fit(br.0, bl.0 + br.0, w), fit(bl.0, bl.0 + br.0, w)];
let ry = [fit(tl.1, tl.1 + bl.1, h), fit(tr.1, tr.1 + br.1, h), fit(br.1, tr.1 + br.1, h), fit(bl.1, tl.1 + bl.1, h)];
let (kx, ky) = (rx.map(|r| (1.0 - KAPPA) * r), ry.map(|r| (1.0 - KAPPA) * r));
// (where the corner leaves the previous side, its two handles, where it joins the next side)
let arcs = [
[pt(x1 - rx[1], y0), pt(x1 - kx[1], y0), pt(x1, y0 + ky[1]), pt(x1, y0 + ry[1])],
[pt(x1, y1 - ry[2]), pt(x1, y1 - ky[2]), pt(x1 - kx[2], y1), pt(x1 - rx[2], y1)],
[pt(x0 + rx[3], y1), pt(x0 + kx[3], y1), pt(x0, y1 - ky[3]), pt(x0, y1 - ry[3])],
[pt(x0, y0 + ry[0]), pt(x0, y0 + ky[0]), pt(x0 + kx[0], y0), pt(x0 + rx[0], y0)],
];
let mut segments = Vec::with_capacity(8);
for ([s, c1, c2, e], straight) in arcs.into_iter().zip([tr.2, br.2, bl.2, tl.2]) {
segments.push([s, s, s]);
if s != e {
segments.push(if straight { [e, e, e] } else { [c1, c2, e] });
}
}
SubPath { start: pt(x0 + rx[0], y0), segments, closed: true }
}
/// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are),
+110
View File
@@ -349,3 +349,113 @@ mod archive {
}
}
}
mod shapes {
use crate::container::Limits;
use crate::model::{Document, Kind, Path, Point, SubPath};
use crate::stream::Tag;
use crate::synth::{self, F, Method, tag};
fn shape(id: u32, class: &[u8; 4], parameters: Vec<(Tag, F)>) -> F {
F::Def(
id,
vec![tag(b"ShpN")],
vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(id + 10_000, vec![tag(class)], parameters))],
)
}
/// Reads a document whose one page holds the object `top` from `definitions`, on a thread of
/// its own, and fails the test when reading takes more than 20 s.
fn read(definitions: Vec<F>, top: u32) -> Document {
let data = synth::stream(&[
(tag(b"Defs"), F::Shared(definitions)),
(
tag(b"DocR"),
F::Def(
1,
vec![tag(b"DocN")],
vec![(
tag(b"Chld"),
F::Shared(vec![F::Def(
2,
vec![tag(b"Sprd")],
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![F::Ref(top)]))],
)]),
)],
),
),
]);
let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || tx.send(crate::model::read(&bytes, Limits::default())).ok());
rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading the document did not finish")).unwrap()
}
fn path(document: &Document) -> &Path {
match &document.spreads[0].nodes[0].kind {
Kind::Shape { path, .. } => path,
other => panic!("expected shape, got {other:?}"),
}
}
#[test]
fn pie_angles_with_huge_finite_values_do_not_loop_or_emit_nonfinite_points() {
let document = read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(1.0e308)), (tag(b"AngE"), F::F64(-1.0e308))])], 100);
for subpath in &path(&document).subpaths {
assert!(subpath.start.x.is_finite() && subpath.start.y.is_finite());
assert!(subpath.segments.len() <= 20);
assert!(subpath.segments.iter().flatten().all(|point| point.x.is_finite() && point.y.is_finite()));
}
}
#[test]
fn wrapped_pie_angles_keep_the_same_geometry_as_the_positive_span() {
let make = |end| read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(0.0)), (tag(b"AngE"), F::F64(end))])], 100);
assert_eq!(path(&make(-std::f64::consts::FRAC_PI_2)), path(&make(3.0 * std::f64::consts::FRAC_PI_2)));
}
/// A 10×20 rectangle (`ShNR`) with the given parameters, read back as its outline.
fn rectangle(parameters: Vec<(Tag, F)>) -> (SubPath, Vec<String>) {
let node =
F::Def(3, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(4, vec![tag(b"ShNR")], parameters))]);
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![node]))]);
let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
let doc = crate::read(&synth::container(&[("doc.dat", &data, Method::Zlib)], None), Limits::default()).unwrap();
let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected a shape") };
(path.subpaths[0].clone(), doc.warnings)
}
/// `CTyp`: an array of corner-type enums (top left, top right, bottom right, bottom left).
fn corner_types(ids: [u16; 4]) -> F {
let mut raw = vec![0xaa];
raw.extend(tag(b"CTyp").0.to_le_bytes());
raw.extend(4u32.to_le_bytes());
raw.extend(0u16.to_le_bytes());
raw.extend(ids.iter().flat_map(|id| id.to_le_bytes()));
F::Raw(raw)
}
#[test]
fn unlocked_rectangle_corners_keep_their_own_radius_and_type() {
let p = |x: f64, y: f64| Point { x, y };
// Relative radii scale by 10. Round 5, round 1, straight 3, none.
let corners = |lock: Option<bool>| {
let mut fields = vec![(tag(b"ShCR"), F::F64s(vec![0.5, 0.1, 0.3, 0.0])), (tag(b"CTyp"), corner_types([0, 0, 1, 4]))];
fields.extend(lock.map(|l| (tag(b"Lock"), F::Bool(l))));
rectangle(fields)
};
let (sub, warnings) = corners(Some(false));
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(sub.start, p(5.0, 0.0));
let ends: Vec<Point> = sub.segments.iter().map(|s| s[2]).collect();
assert_eq!(ends, [p(9.0, 0.0), p(10.0, 1.0), p(10.0, 17.0), p(7.0, 20.0), p(0.0, 20.0), p(0.0, 5.0), p(5.0, 0.0)]);
assert_eq!(sub.segments[3], [p(7.0, 20.0); 3], "the straight corner is a line");
assert_ne!(sub.segments[1][0], p(9.0, 0.0), "the round corner is a curve");
// Locked (the default): every corner follows the top left one.
let (sub, _) = corners(None);
assert_eq!(sub.segments.len(), 8);
assert_eq!(sub.segments[3][2], p(5.0, 20.0));
}
}
+16
View File
@@ -59,6 +59,22 @@ fn main() {
prev = p;
}
println!("forward-warp segment (600 px brush, 25 px moves) + proxy update: mean {:.2} ms, worst {:.2} ms", total / n as f64, worst);
// One 3000 px drag with a 200 px brush (a straight leg, then a diagonal one), as a slow
// (every 2 px) and a fast (every 60 px) pointer reports it: the field cost of the whole
// stroke, and how far the two results differ.
let mut drag = LiquifyStroke::new(LiquifyTool::ForwardWarp, 200.0);
let mut fields = Vec::new();
let along = |d: f64| if d <= 1500.0 { vec![1500.0 + d, 2000.0] } else { vec![3000.0 + (d - 1500.0) * 0.6, 2000.0 + (d - 1500.0) * 0.8] };
for step in [2.0, 60.0] {
drag.points = (0..=(3000.0 / step) as usize).map(|k| along(k as f64 * step)).collect();
let t = Instant::now();
let mut f = LiquifyField::new(b, cell);
f.apply_stroke(&drag);
println!("forward-warp 3000 px drag (200 px brush), an event every {step} px: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
fields.push(f);
}
let diff = fields[0].d.iter().zip(&fields[1].d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
println!(" slow vs fast pointer: fields differ by at most {diff:.3} px (max displacement {:.1} px)", fields[0].max_displacement());
st.tool = LiquifyTool::Bloat;
let t = Instant::now();
let mut d = Rect::EMPTY;
+21 -6
View File
@@ -427,7 +427,7 @@ pub(crate) fn effect_reach(e: &GalleryEffect) -> i32 {
F::Crosshatch => (g("strokeLength") / 3.0) as i32 + gs(1.0) + 2,
F::DarkStrokes => 9,
F::InkOutlines => (g("strokeLength") / 2.0) as i32 + 3,
F::Spatter => g("sprayRadius").ceil() as i32 + 2,
F::Spatter => (g("sprayRadius") * 1.5).ceil() as i32 + 2,
F::SprayedStrokes => g("sprayRadius").ceil() as i32 + (g("strokeLength") / 2.0) as i32 + 3,
F::SumiE => gs(g("strokeWidth") * 0.2) + (g("strokeWidth") / 2.0) as i32 + 2,
F::Glass => (g("distortion") * 2.0).ceil() as i32 + 2,
@@ -768,9 +768,26 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
win.c[i] = map(win.c[i], |v| (v + (1.0 - v) * lt) * (1.0 - dk) * (1.0 - clamp01(ink)));
}
}
F::Spatter | F::SprayedStrokes => {
F::Spatter => {
// Smoothness smooths the random displacement: a larger feature size and, as with any
// averaged noise, a smaller reach. Low smoothness sprays specks out to the full
// radius; high smoothness leaves a torn but attached, hard edge (what Photoshop
// shows at Smoothness 15).
let rad = g("sprayRadius");
let sc = if e.filter == F::Spatter { 0.6 + g("smoothness") * 0.25 } else { 1.2 };
let feature = 1.0 + g("smoothness") * 0.35;
let reach = (rad * 1.2 / feature).min(rad);
let src = Win { r: win.r, w, h, c: win.c.clone() };
for i in 0..win.c.len() {
let (x, y) = win.xy(i);
let (fx, fy) = (x as f32 / feature, y as f32 / feature);
let dx = (fbm(fx, fy, 91) - 0.5) * 3.0 * reach;
let dy = (fbm(fx, fy, 92) - 0.5) * 3.0 * reach;
win.c[i] = src.sample(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy);
}
}
F::SprayedStrokes => {
let rad = g("sprayRadius");
let sc = 1.2;
let src = Win { r: win.r, w, h, c: win.c.clone() };
for i in 0..win.c.len() {
let (x, y) = win.xy(i);
@@ -779,9 +796,7 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
let dy = (vnoise(fx, fy, 92) - 0.5) * 2.0 * rad;
win.c[i] = src.sample(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy);
}
if e.filter == F::SprayedStrokes {
line_blur3(&mut win.c, w, h, direction(e.choice("strokeDirection")), (g("strokeLength") / 2.0) as usize);
}
line_blur3(&mut win.c, w, h, direction(e.choice("strokeDirection")), (g("strokeLength") / 2.0) as usize);
}
F::SumiE => {
let sw = g("strokeWidth");
+3 -2
View File
@@ -992,8 +992,9 @@ mod tests {
let mixed = crate::apply_in(&s, &p, canvas, canvas, Some(&half), canvas);
for (x, y) in [(0, 0), (18, 11), (36, 22)] {
let (o, f, m) = (s.pixel(x, y), full.pixel(x, y), mixed.pixel(x, y));
for c in 0..4 {
assert!((m[c] - (o[c] + f[c]) / 2.0).abs() < 1e-5, "({x},{y}) channel {c}");
assert!((m[3] - (o[3] + f[3]) / 2.0).abs() < 1e-5, "({x},{y}) alpha");
for c in 0..3 {
assert!((m[c] * m[3] - (o[c] * o[3] + f[c] * f[3]) / 2.0).abs() < 1e-5, "({x},{y}) premultiplied channel {c}");
}
}
let cancel = || true;
+1 -1
View File
@@ -169,7 +169,7 @@ fn apply_inner(
return None;
}
if let Some(selection) = selection {
crate::mix_selection(&mut data, output, selection, &src);
crate::mix_selection(&mut data, output, selection, &src, fmt.alpha);
}
if ctl.cancelled() {
return None;
+2 -2
View File
@@ -280,7 +280,7 @@ fn motion_fft_pipeline_supports_depths_models_edges_selections_and_tile_seams()
for mask in [None, Some(&selection)] {
let mut expected_data = filtered.clone();
if let Some(mask) = mask {
crate::mix_selection(&mut expected_data, rect, mask, &src);
crate::mix_selection(&mut expected_data, rect, mask, &src, alpha);
}
let mut expected = source.clone();
expected.write_region(rect, &expected_data);
@@ -362,7 +362,7 @@ fn motion_fft_production_crosses_real_tile_seams_and_leaves_other_pixels_untouch
let mut expected = source.clone();
let mut data = filtered.clone();
if let Some(mask) = mask {
crate::mix_selection(&mut data, area, mask, &src);
crate::mix_selection(&mut data, area, mask, &src, true);
}
expected.write_region(area, &data);
let expected = Image::read(&expected, check);
+1 -1
View File
@@ -69,7 +69,7 @@ fn row_surface(surface: &Surface, params: &FilterParams, area: Rect, bounds: Rec
let ctx = Ctx { bounds, mode: format.mode, alpha: format.alpha };
let mut data = motion_rows(&src, area, &ctx, *angle, *distance);
if let Some(selection) = selection {
crate::mix_selection(&mut data, area, selection, &src);
crate::mix_selection(&mut data, area, selection, &src, format.alpha);
}
let mut result = surface.clone();
result.write_region(area, &data);
+87 -24
View File
@@ -9,9 +9,10 @@
//! with the guided filter); negative amounts add haze toward the atmospheric light.
//! 3. **Tone** on luminance with colour ratios kept: Highlights / Shadows as exposure changes
//! weighted by an edge-preserving base layer (guided filter: K. He, J. Sun, X. Tang, *Guided
//! Image Filtering*, ECCV 2010), so local contrast survives; Whites / Blacks move the ends of
//! the curve; Contrast is an S-curve about middle grey; Clarity (large radius, midtone
//! weighted) and Texture (small radius) add band-pass local contrast.
//! Image Filtering*, ECCV 2010), so local contrast survives; Contrast is an S-curve about
//! middle grey; Clarity (large radius, midtone weighted) and Texture (small radius) add
//! band-pass local contrast. Whites / Blacks follow as Photoshop's Camera Raw applies them:
//! image-adaptive global curves measured on Photoshop 25.4 (see `whites_blacks`).
//! 4. **Presence**: Vibrance (weighted toward less saturated colours) and Saturation.
//! 5. **Tone Curve**: parametric regions (Highlights, Lights, Darks, Shadows with movable splits)
//! then point curves (master and per channel), monotone cubic interpolation (F. Fritsch,
@@ -54,6 +55,38 @@ pub fn validate_curve(points: &[[f32; 2]]) -> Result<(), String> {
use crate::photo_util::{hash01, linear_to_srgb, par_rows, srgb_to_linear};
mod local_tone;
mod whites_blacks;
/// How the pixels encode light: the sRGB curve (gamma 0) or a pure power law (ProPhoto's 1.8,
/// Adobe RGB's 2.2). The tone controls work on light, so the document's own curve decodes it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Encoding(f32);
impl Encoding {
pub(crate) fn of(gamma: f32) -> Self {
Encoding(if gamma.is_finite() && gamma > 0.0 { gamma.clamp(0.5, 4.0) } else { 0.0 })
}
pub(crate) fn decode(self, v: f32) -> f32 {
if self.0 > 0.0 { v.max(0.0).powf(self.0) } else { srgb_to_linear(v) }
}
pub(crate) fn encode(self, v: f32) -> f32 {
if self.0 > 0.0 { v.max(0.0).powf(1.0 / self.0) } else { linear_to_srgb(v) }
}
}
fn srgb_encode(v: f32) -> f32 {
linear_to_srgb(v)
}
fn srgb_decode(v: f32) -> f32 {
srgb_to_linear(v)
}
fn luma_linear(c: [f32; 3]) -> f32 {
luma(c)
}
/// One colour grading wheel.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
@@ -136,6 +169,9 @@ pub struct CameraRaw {
pub seed: u32,
/// Pixel-size multiplier for previews on downsampled proxies (radii in px scale by it).
pub pixel_scale: f32,
/// The document's transfer curve: 0 for sRGB's, else the gamma of a power-law space (1.8
/// for ProPhoto). Set from the document's profile; Whites and Blacks act on light.
pub encoding_gamma: f32,
}
impl Default for CameraRaw {
@@ -191,6 +227,7 @@ impl Default for CameraRaw {
vignette_style: "highlightPriority".into(),
seed: 0,
pixel_scale: 1.0,
encoding_gamma: 0.0,
}
}
}
@@ -491,7 +528,11 @@ impl CameraRaw {
self.temperature == 0.0 && self.tint == 0.0 && self.exposure == 0.0
}
fn tone_neutral(&self) -> bool {
[self.contrast, self.highlights, self.shadows, self.whites, self.blacks, self.texture, self.clarity].iter().all(|v| *v == 0.0)
self.local_tone_neutral() && self.whites == 0.0 && self.blacks == 0.0
}
/// Contrast, Highlights, Shadows, Texture and Clarity (the luminance stage).
fn local_tone_neutral(&self) -> bool {
[self.contrast, self.highlights, self.shadows, self.texture, self.clarity].iter().all(|v| *v == 0.0)
}
fn hsl_neutral(&self) -> bool {
self.hsl_hue.iter().chain(&self.hsl_sat).chain(&self.hsl_lum).all(|v| *v == 0.0)
@@ -561,16 +602,13 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
if p.dehaze != 0.0 {
dehaze(px, w, h, p.dehaze / 100.0);
}
// 3. Tone on luminance.
if !p.tone_neutral() {
// 3. Tone. Highlights and Shadows: a local Laplacian filter (see `local_tone`).
if p.highlights != 0.0 || p.shadows != 0.0 {
local_tone::apply(px, w, h, p.shadows, p.highlights, Encoding::of(p.encoding_gamma));
}
if !p.local_tone_neutral() && [p.contrast, p.texture, p.clarity].iter().any(|v| *v != 0.0) {
let l: Vec<f32> = px.iter().map(|q| luma([q[0], q[1], q[2]]).max(0.0)).collect();
let lc: Vec<f32> = l.iter().map(|v| v.min(1.0)).collect();
let base = if p.highlights != 0.0 || p.shadows != 0.0 {
let r = ((long * 0.015) as usize).max(2);
Some(guided_fast(&lc, &lc, w, h, r, 0.02))
} else {
None
};
let clar = (p.clarity != 0.0).then(|| guided_fast(&lc, &lc, w, h, ((long * 0.02) as usize).max(3), 0.005));
let tex = (p.texture != 0.0).then(|| gauss(&lc, w, h, (long * 0.002).max(1.5 * ps)));
let k_con = p.contrast / 100.0 * 0.6;
@@ -580,12 +618,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
let i = y * w + x;
let l0 = l[i];
let mut lv = lc[i];
if let Some(b) = &base {
let bv = b[i].clamp(0.0, 1.0);
let hm = smoothstep(0.45, 0.95, bv);
let sm = 1.0 - smoothstep(0.05, 0.5, bv);
lv *= 2f32.powf(p.highlights / 100.0 * 1.2 * hm + p.shadows / 100.0 * 1.4 * sm);
}
if let Some(c) = &clar {
let mw = 1.0 - (2.0 * lv.clamp(0.0, 1.0) - 1.0).powi(2);
lv += p.clarity / 100.0 * 0.8 * (lc[i] - c[i]) * mw;
@@ -593,12 +625,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
if let Some(t) = &tex {
lv += p.texture / 100.0 * 0.9 * (lc[i] - t[i]).clamp(-0.1, 0.1);
}
if p.whites != 0.0 {
lv += p.whites / 100.0 * 0.25 * smoothstep(0.4, 1.0, lv);
}
if p.blacks != 0.0 {
lv += p.blacks / 100.0 * 0.15 * (1.0 - smoothstep(0.0, 0.45, lv));
}
if k_con != 0.0 {
let xx = lv.clamp(0.0, 1.0);
lv += -k_con * (tau * xx).sin() / tau;
@@ -611,6 +637,13 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
}
});
}
// 3b. Whites and Blacks: global curves adapted to the image's black and white points.
if p.whites != 0.0 || p.blacks != 0.0 {
let enc = Encoding::of(p.encoding_gamma);
let (bp, wp) = luminance_points(px, w, h, enc);
let (xw, xb) = whites_blacks::amounts(p.whites.clamp(-100.0, 100.0), p.blacks.clamp(-100.0, 100.0), bp, wp);
whites_blacks::apply(px, w, xw, xb, enc);
}
// 4–7: per-pixel colour stages.
let need_color = p.vibrance != 0.0 || p.saturation != 0.0 || !p.curves_neutral() || !p.hsl_neutral() || !p.grade_neutral();
if need_color {
@@ -732,6 +765,36 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
}
}
/// The image's black and white points as sRGB-encoded grey levels: the 3rd percentile of the
/// luminance and the 99.5th of the brightest channel, on a copy at most 256 pixels across (so a
/// preview proxy and the full image agree).
fn luminance_points(px: &[[f32; 4]], w: usize, h: usize, enc: Encoding) -> (f32, f32) {
let f = w.max(h).div_ceil(256).max(1);
let (sw, sh) = (w.div_ceil(f), h.div_ceil(f));
let mut sum = vec![[0.0f64; 4]; sw * sh];
for y in 0..h {
for x in 0..w {
let q = px[y * w + x];
let s = &mut sum[(y / f) * sw + x / f];
for c in 0..3 {
s[c] += f64::from(enc.decode(q[c]).clamp(0.0, 1.0));
}
s[3] += 1.0;
}
}
let cells: Vec<[f32; 3]> = sum.iter().filter(|s| s[3] > 0.0).map(|s| [0, 1, 2].map(|c| (s[c] / s[3]) as f32)).collect();
if cells.is_empty() {
return (0.0, 1.0);
}
let pct = |mut v: Vec<f32>, q: f32| {
v.sort_by(f32::total_cmp);
v[((v.len() - 1) as f32 * q).round() as usize]
};
let bp = pct(cells.iter().map(|c| luma(*c)).collect(), 0.03);
let wp = pct(cells.iter().map(|c| c[0].max(c[1]).max(c[2])).collect(), 0.995);
(linear_to_srgb(bp), linear_to_srgb(wp))
}
fn value_noise(x: f32, y: f32, seed: u64) -> f32 {
let (xi, yi) = (x.floor(), y.floor());
let (tx, ty) = (x - xi, y - yi);
+428
View File
@@ -0,0 +1,428 @@
//! Shadows and Highlights as a local Laplacian filter.
//!
//! Adobe has said Camera Raw's process version 2012 rests on local Laplacian filters (S. Paris,
//! S. W. Hasinoff, J. Kautz, *Local Laplacian Filters: Edge-aware Image Processing with a
//! Laplacian Pyramid*, SIGGRAPH 2011). This follows that paper, in its fast form (M. Aubry et
//! al., *Fast Local Laplacian Filters*, ACM TOG 2014: the remapping sampled at a few reference
//! values, each pyramid coefficient interpolated between them), on the log2 luminance:
//!
//! - **Shadows** raises pixels darker than their surroundings, by an amount set by their own and
//! their surroundings' place in the image's range (1st to 99th percentile); **Highlights**
//! pulls pixels brighter than their surroundings toward them. Differences under a threshold
//! are left alone, so fine detail stays.
//! - Shadows then keeps the image's 99th percentile, Highlights its 1st (Photoshop keeps the
//! brightest content under Shadows and the darkest under Highlights).
//! - A uniform image is unchanged, edges keep no halo, and the result does not depend on the
//! image size.
//!
//! The strengths were fitted to Photoshop 25.4's Camera Raw Filter on ten public-domain photos
//! (black-box measurements, no Adobe code or data). It is an approximation: on those photos the
//! mean error against Photoshop is 2.8–9.4 levels (of 255) where the previous heuristic's was
//! 3.7–14.3.
use super::{Encoding, luma};
use crate::photo_util::par_rows;
/// Spacing of the fast filter's reference values in stops. Its remapping changes within about
/// half a stop, and coefficients interpolated across wider gaps show as contour lines on smooth
/// gradients (12 references over a whole raw's range left visible steps; at an eighth of a stop a
/// ramp's slope varies by under a third, as the remapping itself makes it).
const REF_STEP: f32 = 0.125;
/// The filter runs on a copy at most this many pixels across; the change it makes is brought back
/// to full size by a guided upsampling, so a preview and the full image agree.
const WORK_SIDE: usize = 1600;
/// Knots of the strength curves over the surroundings' place in the image range (0 … 1).
const KNOTS: usize = 5;
/// Shadows: how far a pixel darker than its surroundings rises, in stops per unit slider, as
/// a function of its own place in the image range (`own`) times a factor of its surroundings'
/// place (`around`); it rises fully once it is `sigma` stops below them. The lift depends on
/// the pixel's tone, not on the size of the difference, so it is the same at every pyramid
/// level and a big edge keeps no halo. Lowering (negative values) is `down` times as strong.
const SHADOWS: Lift = Lift { own: [-0.017, 0.466, 0.886, 1.221, -0.081], around: [4.566, 4.067, 1.088, 0.149, 0.878], sigma: 0.571, down: 0.808 };
const HIGHLIGHTS: Side = Side { pull: [0.179, 0.141, 0.196, 0.629, 2.07], push: [0.156, 0.08, 0.175, 0.049, 1.261], sigma: 0.05 };
/// See [`SHADOWS`].
struct Lift {
own: [f32; KNOTS],
around: [f32; KNOTS],
sigma: f32,
down: f32,
}
/// Highlights: strengths per unit slider over the surroundings' place in the image range, for
/// lowering (`pull`, toward the surroundings) and raising (`push`), and the detail threshold in
/// stops.
struct Side {
pull: [f32; KNOTS],
push: [f32; KNOTS],
sigma: f32,
}
/// One channel, `w × h`.
struct Plane {
w: usize,
h: usize,
v: Vec<f32>,
}
/// Mirrors `i` into `0..n` (the edge sample is not repeated).
fn mirror(i: isize, n: usize) -> usize {
let n = n as isize;
if n == 1 {
return 0;
}
let p = 2 * (n - 1);
let m = i.rem_euclid(p);
(if m >= n { p - m } else { m }) as usize
}
const TAPS: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
/// The binomial 5-tap blur, sampled every `step` pixels from 0 (1 = full resolution).
fn blur_step(p: &Plane, step: usize) -> Plane {
let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step));
// Columns first at the kept rows, then rows at the kept columns.
let mut tmp = vec![0.0f32; p.w * h2];
par_rows(&mut tmp, p.w, 1, |y2, row| {
let y = (y2 * step) as isize;
for (x, o) in row.iter_mut().enumerate() {
*o = (0..5).map(|k| TAPS[k] * p.v[mirror(y + k as isize - 2, p.h) * p.w + x]).sum();
}
});
let mut out = vec![0.0f32; w2 * h2];
par_rows(&mut out, w2, 1, |y2, row| {
let src = &tmp[y2 * p.w..(y2 + 1) * p.w];
for (x2, o) in row.iter_mut().enumerate() {
let x = (x2 * step) as isize;
*o = (0..5).map(|k| TAPS[k] * src[mirror(x + k as isize - 2, p.w)]).sum();
}
});
Plane { w: w2, h: h2, v: out }
}
/// The next coarser level.
fn down(p: &Plane) -> Plane {
blur_step(p, 2)
}
/// `p` brought up to `w × h`: zeros between samples, then the blur times four.
fn up(p: &Plane, w: usize, h: usize) -> Plane {
let mut z = Plane { w, h, v: vec![0.0; w * h] };
for y in 0..p.h.min(h.div_ceil(2)) {
for x in 0..p.w.min(w.div_ceil(2)) {
z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0;
}
}
blur_step(&z, 1)
}
/// Levels down to about 2 pixels on the short side.
fn depth(w: usize, h: usize) -> usize {
let mut s = w.min(h);
let mut n = 1;
while s > 2 {
s = s.div_ceil(2);
n += 1;
}
n
}
fn gaussian(p: Plane, n: usize) -> Vec<Plane> {
let mut g = vec![p];
for _ in 1..n {
let next = down(&g[g.len() - 1]);
g.push(next);
}
g
}
/// The local Laplacian filter of `u` with `remap(value, reference)`; the coarsest level is kept.
fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
let n = depth(u.w, u.h);
let g = gaussian(Plane { w: u.w, h: u.h, v: u.v.clone() }, n);
// References span the image's tones; the deepest 0.1 % (noise down to the floor) share the
// lowest reference instead of stretching the spacing.
let hi = u.v.iter().fold(f32::NEG_INFINITY, |a, &v| a.max(v));
let lo = quantile(&u.v, 0.001).min(hi);
if hi - lo <= 1e-6 || (hi - lo).is_nan() {
return Plane { w: u.w, h: u.h, v: u.v.clone() };
}
let refs = (((hi - lo) / REF_STEP).ceil() as usize + 1).clamp(2, 96);
let step = (hi - lo) / (refs - 1) as f32;
let mut lap: Vec<Vec<f32>> = g[..n - 1].iter().map(|p| vec![0.0; p.v.len()]).collect();
for k in 0..refs {
let r = lo + step * k as f32;
let mut m = Plane { w: u.w, h: u.h, v: u.v.clone() };
par_rows(&mut m.v, u.w, 1, |_, row| {
for v in row.iter_mut() {
*v = remap(*v, r);
}
});
let gr = gaussian(m, n);
for l in 0..n - 1 {
let coarse = up(&gr[l + 1], gr[l].w, gr[l].h);
let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w);
par_rows(&mut lap[l], lw, 1, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = y * lw + x;
let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0);
if wgt > 0.0 {
*o += wgt * (fine[i] - coarse.v[i]);
}
}
});
}
}
let mut o = Plane { w: g[n - 1].w, h: g[n - 1].h, v: g[n - 1].v.clone() };
for l in (0..n - 1).rev() {
let mut next = up(&o, g[l].w, g[l].h);
for (a, b) in next.v.iter_mut().zip(&lap[l]) {
*a += b;
}
o = next;
}
o
}
/// The `q` quantile (0 … 1) of `v`.
fn quantile(v: &[f32], q: f32) -> f32 {
let mut s = v.to_vec();
s.sort_by(f32::total_cmp);
s[((s.len() - 1) as f32 * q).round() as usize]
}
/// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across.
fn range(u: &Plane) -> (f32, f32) {
let f = u.w.max(u.h).div_ceil(256).max(1);
let (sw, sh) = (u.w.div_ceil(f), u.h.div_ceil(f));
let mut sum = vec![(0.0f64, 0u32); sw * sh];
for y in 0..u.h {
for x in 0..u.w {
let c = &mut sum[(y / f) * sw + x / f];
c.0 += f64::from(u.v[y * u.w + x]);
c.1 += 1;
}
}
let cells: Vec<f32> = sum.iter().filter(|c| c.1 > 0).map(|c| (c.0 / f64::from(c.1)) as f32).collect();
(quantile(&cells, 0.01), quantile(&cells, 0.99))
}
/// `d` past the detail threshold: about 0 below `sigma`, `d − sigma` well above it.
fn excess(d: f32, sigma: f32) -> f32 {
d - sigma * (d / sigma).tanh()
}
fn knot(t: f32, k: &[f32; KNOTS]) -> f32 {
let x = t.clamp(0.0, 1.0) * (KNOTS - 1) as f32;
let i = (x as usize).min(KNOTS - 2);
k[i] + (k[i + 1] - k[i]) * (x - i as f32)
}
/// The Highlights pass on the log luminance `u` (`amount` −1 … 1): pixels brighter than their
/// surroundings move toward them (or away, for positive values) by the part of the difference
/// past `sigma`, then the darkest content is put back where it was.
fn highlights_pass(u: &Plane, amount: f32, side: &Side) -> Plane {
let (lo, hi) = range(u);
let span = (hi - lo).max(1.0);
let strengths = if amount < 0.0 { &side.pull } else { &side.push };
let o = llf(u, |x, g| x + knot((g - lo) / span, strengths) * amount * excess((x - g).max(0.0), side.sigma));
let shift = quantile(&u.v, 0.01) - quantile(&o.v, 0.01);
Plane { w: o.w, h: o.h, v: o.v.iter().map(|v| v + shift).collect() }
}
/// The Shadows pass on the log luminance `u` (`amount` −1 … 1).
fn shadows_pass(u: &Plane, amount: f32) -> Plane {
let (lo, hi) = range(u);
let span = (hi - lo).max(1.0);
let k = if amount > 0.0 { amount } else { amount * SHADOWS.down };
let o = llf(u, |x, g| {
let below = ((g - x) / SHADOWS.sigma).clamp(0.0, 1.0);
if below == 0.0 {
return x;
}
let w = below * below * (3.0 - 2.0 * below);
x + k * knot((x - lo) / span, &SHADOWS.own) * knot((g - lo) / span, &SHADOWS.around) * w
});
// Keep the brightest content.
let shift = quantile(&u.v, 0.99) - quantile(&o.v, 0.99);
Plane { w: o.w, h: o.h, v: o.v.iter().map(|v| v + shift).collect() }
}
/// Applies Shadows and Highlights (−100 … 100) to straight RGBA pixels encoded with `enc`.
pub(crate) fn apply(px: &mut [[f32; 4]], w: usize, h: usize, shadows: f32, highlights: f32, enc: Encoding) {
if (shadows == 0.0 && highlights == 0.0) || w < 2 || h < 2 {
return;
}
const FLOOR: f32 = 1.0 / 65536.0;
let u = Plane { w, h, v: px.iter().map(|q| (luma([0, 1, 2].map(|c| enc.decode(q[c]))).max(0.0) + FLOOR).log2()).collect() };
// The filter on a copy at most WORK_SIDE across (box-averaged in log).
let f = w.max(h).div_ceil(WORK_SIDE).max(1);
let small = if f == 1 { Plane { w, h, v: u.v.clone() } } else { shrink(&u, f) };
let mut o = Plane { w: small.w, h: small.h, v: small.v.clone() };
if highlights != 0.0 {
o = highlights_pass(&o, highlights.clamp(-100.0, 100.0) / 100.0, &HIGHLIGHTS);
}
if shadows != 0.0 {
o = shadows_pass(&o, shadows.clamp(-100.0, 100.0) / 100.0);
}
// The change in log luminance, at full size.
let change: Vec<f32> = if f == 1 {
o.v.iter().zip(&u.v).map(|(a, b)| a - b).collect()
} else {
let d: Vec<f32> = o.v.iter().zip(&small.v).map(|(a, b)| a - b).collect();
guided_upsample(&small, &d, &u)
};
par_rows(px, w, 1, |y, row| {
for (x, q) in row.iter_mut().enumerate() {
let gain = change[y * w + x].exp2();
for c in 0..3 {
q[c] = enc.encode(enc.decode(q[c]) * gain);
}
}
});
}
/// `u` averaged over `f × f` blocks.
fn shrink(u: &Plane, f: usize) -> Plane {
let (sw, sh) = (u.w.div_ceil(f), u.h.div_ceil(f));
let mut v = vec![0.0f32; sw * sh];
par_rows(&mut v, sw, 1, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let (x0, y0) = (x * f, y * f);
let (x1, y1) = ((x0 + f).min(u.w), (y0 + f).min(u.h));
let mut s = 0.0f64;
for yy in y0..y1 {
for xx in x0..x1 {
s += f64::from(u.v[yy * u.w + xx]);
}
}
*o = (s / ((x1 - x0) * (y1 - y0)) as f64) as f32;
}
});
Plane { w: sw, h: sh, v }
}
/// The change `d` computed on `small` brought to the size of `full`, following its edges: a local
/// linear model of the change against the log luminance (the guided filter of K. He, J. Sun,
/// *Fast Guided Filter*, 2015), fitted on the small copy and evaluated on the full one.
fn guided_upsample(small: &Plane, d: &[f32], full: &Plane) -> Vec<f32> {
let (sw, sh) = (small.w, small.h);
let r = 2;
let eps = 0.01;
let mean = |v: &[f32]| super::box_mean(v, sw, sh, r);
let ud: Vec<f32> = small.v.iter().zip(d).map(|(a, b)| a * b).collect();
let uu: Vec<f32> = small.v.iter().map(|a| a * a).collect();
let (mu, md, mud, muu) = (mean(&small.v), mean(d), mean(&ud), mean(&uu));
let a: Vec<f32> = (0..sw * sh).map(|i| (mud[i] - mu[i] * md[i]) / (muu[i] - mu[i] * mu[i] + eps)).collect();
let b: Vec<f32> = (0..sw * sh).map(|i| md[i] - a[i] * mu[i]).collect();
let (ma, mb) = (mean(&a), mean(&b));
let (fx, fy) = (sw as f32 / full.w as f32, sh as f32 / full.h as f32);
let mut out = vec![0.0f32; full.w * full.h];
par_rows(&mut out, full.w, 1, |y, row| {
let sy = (y as f32 + 0.5) * fy - 0.5;
for (x, o) in row.iter_mut().enumerate() {
let sx = (x as f32 + 0.5) * fx - 0.5;
let av = crate::photo_util::bilinear(&ma, sw, sh, 1, 0, sx, sy);
let bv = crate::photo_util::bilinear(&mb, sw, sh, 1, 0, sx, sy);
*o = av * full.v[y * full.w + x] + bv;
}
});
out
}
#[cfg(test)]
mod tests {
use super::*;
const SRGB: Encoding = Encoding(0.0);
fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> {
(0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect()
}
#[test]
fn a_flat_image_is_unchanged() {
let mut px = grey(64, 48, |_, _| 0.4);
apply(&mut px, 64, 48, 100.0, -100.0, SRGB);
assert!(px.iter().all(|q| (q[0] - 0.4).abs() < 1e-5));
}
#[test]
fn shadows_raise_a_dark_area_without_a_halo() {
// A 0.1 | 0.6 step (the dark half is the image's black point, which Photoshop barely
// moves either): the bright half stays, and no halo forms on either side of the edge.
let (w, h) = (256, 128);
let mut px = grey(w, h, |x, _| if x < w / 2 { 0.1 } else { 0.6 });
apply(&mut px, w, h, 100.0, 0.0, SRGB);
let row = &px[64 * w..65 * w];
assert!((row[w / 2 + 1][0] - 0.6).abs() < 0.002 && (row[w - 5][0] - 0.6).abs() < 0.002);
let (far, near) = (row[10][0], row[w / 2 - 1][0]);
assert!((far - 0.1).abs() < 0.004 && (far - near).abs() < 0.004, "{far} {near}");
}
#[test]
fn grey_moves_as_in_photoshop() {
// Mid-grey with 1 % black (0.015) and white (0.97) corners, Photoshop 25.4: Shadows +100
// takes the grey from 127.5 to 165.3, Highlights −100 to 89.6 (of 255).
let n = 256;
let image = |c: f32| {
grey(n, n, |x, y| {
if x >= n - 26 && y >= n - 26 {
0.97
} else if x >= n - 26 && y < 26 {
0.015
} else {
c
}
})
};
for (s, hl, want) in [(100.0, 0.0, 165.3), (0.0, -100.0, 89.6)] {
let mut px = image(0.5);
apply(&mut px, n, n, s, hl, SRGB);
let got = px[175 * n + 100][0] * 255.0;
assert!((got - want).abs() < 5.0, "{s} {hl}: {got} vs {want}");
}
}
#[test]
fn highlights_lower_a_bright_area_and_keep_the_darkest() {
let (w, h) = (192, 192);
let mut px = grey(w, h, |x, y| if (64..128).contains(&x) && (64..128).contains(&y) { 0.8 } else { 0.2 });
apply(&mut px, w, h, 0.0, -100.0, SRGB);
assert!(px[96 * w + 96][0] < 0.75, "{}", px[96 * w + 96][0]);
assert!((px[5 * w + 5][0] - 0.2).abs() < 0.01, "{}", px[5 * w + 5][0]);
}
#[test]
fn smooth_gradients_stay_smooth() {
// A soft ramp over eight stops, as in an out-of-focus background. Coefficients interpolated
// between references too far apart drew contour lines across such ramps (Shadows +100 on
// a D4 raw): the output must rise steadily, without ripples.
let (w, h) = (640, 48);
let mut px = grey(w, h, |x, _| crate::photo_util::linear_to_srgb((x as f32 / (w - 1) as f32 * 8.0 - 9.0).exp2()));
for (s, hl) in [(100.0, 0.0), (0.0, -100.0), (-100.0, 100.0)] {
let mut p = px.clone();
apply(&mut p, w, h, s, hl, SRGB);
let row: Vec<f32> = (0..w).map(|x| crate::photo_util::srgb_to_linear(p[24 * w + x][0]).max(1e-6).log2()).collect();
let steps: Vec<f32> = row.windows(2).map(|d| d[1] - d[0]).collect();
assert!(steps.iter().all(|d| *d > 0.0), "{s} {hl}: the ramp reverses");
let jumps: Vec<f32> = steps.windows(2).map(|d| (d[1] - d[0]).abs()).collect();
let ripple = jumps[16..jumps.len() - 16].iter().fold(0.0f32, |a, b| a.max(*b));
// The ramp rises 0.0125 stops per pixel; its slope may change by a third at most.
assert!(ripple < 0.004, "{s} {hl}: slope jumps by {ripple} stops per pixel");
}
px.clear();
}
#[test]
fn the_result_does_not_depend_on_the_image_size() {
let at = |n: usize| {
let mut px = grey(n, n, |x, y| if x < n / 3 || y < n / 4 { 0.08 } else { 0.7 });
apply(&mut px, n, n, 70.0, -60.0, SRGB);
[px[(n / 8) * n + n / 8][0], px[(3 * n / 4) * n + 3 * n / 4][0]]
};
let (a, b) = (at(96), at(384));
assert!((a[0] - b[0]).abs() < 0.02 && (a[1] - b[1]).abs() < 0.02, "{a:?} {b:?}");
}
}
+418
View File
@@ -0,0 +1,418 @@
//! Whites and Blacks as Adobe Camera Raw (process version 2012) applies them.
//!
//! Measured as a black box on the Camera Raw Filter of Photoshop 25.4: grey blocks with chosen
//! histograms went through the filter by script and came back exactly; no Adobe code or data
//! was used.
//!
//! - Both are global curves of the grey level, applied after Shadows and Highlights, Whites
//! first. On an image whose darkest and lightest percent reach black and white the curve of a
//! slider value `x` is the measured row at `x` of [`WHITES`] or [`BLACKS`].
//! - The direction that only compresses (Whites down, Blacks up) is that fixed curve on every
//! image. The direction that clips (Whites up, Blacks down) adapts: the row used is
//! `x · A(BP, WP)`. On the block images BP and WP are their darkest and lightest levels; on
//! photographs Photoshop's amounts follow best with BP the 3rd percentile of the luminance and
//! WP the 99.5th of the brightest channel, and Blacks at least 1.2 times. A low
//! white point makes Whites +100 stronger (up to 2.7 times), a raised black point Blacks
//! (up to 1.45 times at −40). Whites rows past +100 come from images where `A > 1`.
//! - Not reproduced: past −40 Photoshop's Blacks grows faster on images with a raised black
//! point (on one spanning 0.6–1 its −100 blacks out everything below 0.74, here below about
//! 0.15); its curves there are not rows of the full-range family.
//! - Colour: the clipping directions act on each channel, the compressing ones scale the pixel
//! by the curve's change of its luminance (closest to Photoshop on saturated colours).
use super::Encoding;
use crate::photo_util::par_rows;
/// Grey levels the curves are sampled at: 0, 1/32, …, 1 (sRGB-encoded).
const STEPS: usize = 33;
/// The first row's internal amount; rows are 10 apart.
const WHITES_FROM: f32 = -100.0;
const BLACKS_FROM: f32 = -100.0;
// Measured levels; some happen to lie near π/8 and π/4.
/// Whites curves at internal amounts -100, -90, …, 270 (rows) over grey levels 0, 1/32, …, 1.
#[allow(clippy::approx_constant)]
static WHITES: [[f32; 33]; 38] = [
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1523, 0.1817, 0.2107, 0.2391, 0.2671, 0.2946, 0.3215, 0.3480, 0.3738, 0.3995, 0.4256, 0.4517, 0.4774, 0.5028,
0.5278, 0.5524, 0.5768, 0.6008, 0.6245, 0.6482, 0.6717, 0.6957, 0.7207, 0.7483, 0.7814, 0.8263, 0.8949, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1523, 0.1817, 0.2107, 0.2392, 0.2672, 0.2947, 0.3217, 0.3482, 0.3742, 0.4000, 0.4263, 0.4526, 0.4785, 0.5042,
0.5296, 0.5548, 0.5797, 0.6044, 0.6291, 0.6539, 0.6789, 0.7046, 0.7317, 0.7617, 0.7969, 0.8425, 0.9074, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1524, 0.1818, 0.2108, 0.2393, 0.2673, 0.2950, 0.3221, 0.3487, 0.3748, 0.4008, 0.4274, 0.4540, 0.4803, 0.5064,
0.5323, 0.5580, 0.5837, 0.6093, 0.6350, 0.6610, 0.6875, 0.7149, 0.7438, 0.7756, 0.8122, 0.8576, 0.9184, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1524, 0.1818, 0.2109, 0.2395, 0.2677, 0.2954, 0.3227, 0.3495, 0.3759, 0.4022, 0.4291, 0.4561, 0.4828, 0.5094,
0.5360, 0.5624, 0.5888, 0.6154, 0.6423, 0.6695, 0.6973, 0.7261, 0.7566, 0.7897, 0.8271, 0.8716, 0.9280, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1525, 0.1820, 0.2111, 0.2398, 0.2682, 0.2961, 0.3237, 0.3508, 0.3775, 0.4042, 0.4317, 0.4591, 0.4864, 0.5136,
0.5408, 0.5680, 0.5953, 0.6229, 0.6508, 0.6791, 0.7082, 0.7382, 0.7698, 0.8037, 0.8412, 0.8843, 0.9365, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0927, 0.1227, 0.1527, 0.1822, 0.2116, 0.2404, 0.2690, 0.2972, 0.3251, 0.3527, 0.3799, 0.4071, 0.4351, 0.4630, 0.4910, 0.5189,
0.5469, 0.5749, 0.6031, 0.6316, 0.6605, 0.6899, 0.7199, 0.7509, 0.7832, 0.8174, 0.8544, 0.8958, 0.9439, 0.9999,
],
[
0.0000, 0.0298, 0.0621, 0.0927, 0.1229, 0.1529, 0.1827, 0.2122, 0.2414, 0.2702, 0.2989, 0.3272, 0.3553, 0.3831, 0.4110, 0.4396, 0.4682, 0.4968, 0.5254,
0.5542, 0.5830, 0.6121, 0.6414, 0.6712, 0.7014, 0.7322, 0.7637, 0.7964, 0.8305, 0.8668, 0.9062, 0.9503, 0.9999,
],
[
0.0000, 0.0298, 0.0622, 0.0928, 0.1231, 0.1534, 0.1834, 0.2132, 0.2427, 0.2720, 0.3011, 0.3300, 0.3587, 0.3872, 0.4160, 0.4452, 0.4745, 0.5038, 0.5332,
0.5627, 0.5923, 0.6221, 0.6522, 0.6826, 0.7134, 0.7447, 0.7765, 0.8092, 0.8429, 0.8781, 0.9155, 0.9559, 1.0000,
],
[
0.0000, 0.0298, 0.0623, 0.0930, 0.1236, 0.1541, 0.1844, 0.2145, 0.2445, 0.2744, 0.3041, 0.3337, 0.3632, 0.3925, 0.4221, 0.4520, 0.4820, 0.5120, 0.5421,
0.5723, 0.6026, 0.6330, 0.6636, 0.6946, 0.7257, 0.7572, 0.7890, 0.8214, 0.8545, 0.8885, 0.9237, 0.9608, 1.0000,
],
[
0.0000, 0.0298, 0.0624, 0.0934, 0.1242, 0.1551, 0.1858, 0.2164, 0.2470, 0.2775, 0.3080, 0.3383, 0.3687, 0.3989, 0.4293, 0.4600, 0.4906, 0.5213, 0.5520,
0.5827, 0.6136, 0.6445, 0.6755, 0.7067, 0.7380, 0.7694, 0.8011, 0.8330, 0.8652, 0.8978, 0.9310, 0.9651, 1.0000,
],
[
0.0000, 0.0312, 0.0625, 0.0938, 0.1250, 0.1562, 0.1875, 0.2188, 0.2500, 0.2812, 0.3125, 0.3438, 0.3750, 0.4062, 0.4375, 0.4688, 0.5000, 0.5312, 0.5625,
0.5938, 0.6250, 0.6562, 0.6875, 0.7188, 0.7500, 0.7812, 0.8125, 0.8438, 0.8750, 0.9062, 0.9375, 0.9688, 1.0000,
],
[
0.0000, 0.0299, 0.0626, 0.0940, 0.1253, 0.1567, 0.1881, 0.2195, 0.2510, 0.2825, 0.3140, 0.3456, 0.3772, 0.4089, 0.4405, 0.4720, 0.5036, 0.5351, 0.5667,
0.5983, 0.6298, 0.6613, 0.6929, 0.7244, 0.7559, 0.7874, 0.8189, 0.8503, 0.8816, 0.9128, 0.9438, 0.9742, 1.0000,
],
[
0.0000, 0.0299, 0.0627, 0.0941, 0.1257, 0.1573, 0.1890, 0.2208, 0.2526, 0.2846, 0.3167, 0.3488, 0.3811, 0.4134, 0.4456, 0.4777, 0.5098, 0.5419, 0.5741,
0.6062, 0.6383, 0.6704, 0.7025, 0.7345, 0.7664, 0.7983, 0.8301, 0.8617, 0.8931, 0.9241, 0.9543, 0.9829, 1.0000,
],
[
0.0000, 0.0299, 0.0628, 0.0944, 0.1262, 0.1582, 0.1903, 0.2225, 0.2550, 0.2876, 0.3204, 0.3534, 0.3867, 0.4200, 0.4529, 0.4858, 0.5186, 0.5516, 0.5845,
0.6174, 0.6502, 0.6830, 0.7157, 0.7482, 0.7807, 0.8131, 0.8451, 0.8769, 0.9082, 0.9385, 0.9673, 0.9928, 1.0000,
],
[
0.0000, 0.0299, 0.0629, 0.0948, 0.1269, 0.1594, 0.1920, 0.2249, 0.2582, 0.2917, 0.3256, 0.3597, 0.3942, 0.4289, 0.4628, 0.4966, 0.5305, 0.5644, 0.5983,
0.6321, 0.6658, 0.6994, 0.7327, 0.7660, 0.7989, 0.8316, 0.8639, 0.8956, 0.9263, 0.9553, 0.9815, 1.0000, 1.0000,
],
[
0.0000, 0.0299, 0.0631, 0.0953, 0.1280, 0.1610, 0.1944, 0.2282, 0.2625, 0.2973, 0.3325, 0.3683, 0.4045, 0.4407, 0.4758, 0.5109, 0.5460, 0.5811, 0.6161,
0.6510, 0.6857, 0.7201, 0.7541, 0.7879, 0.8213, 0.8543, 0.8864, 0.9175, 0.9468, 0.9733, 0.9950, 1.0000, 1.0000,
],
[
0.0000, 0.0299, 0.0633, 0.0961, 0.1293, 0.1632, 0.1976, 0.2327, 0.2684, 0.3048, 0.3419, 0.3798, 0.4183, 0.4562, 0.4928, 0.5294, 0.5660, 0.6024, 0.6387,
0.6748, 0.7104, 0.7456, 0.7803, 0.8145, 0.8480, 0.8807, 0.9121, 0.9417, 0.9684, 0.9907, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0300, 0.0637, 0.0971, 0.1313, 0.1663, 0.2020, 0.2387, 0.2763, 0.3149, 0.3546, 0.3952, 0.4365, 0.4763, 0.5149, 0.5532, 0.5914, 0.6294, 0.6670,
0.7041, 0.7406, 0.7763, 0.8113, 0.8456, 0.8788, 0.9104, 0.9401, 0.9667, 0.9888, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0300, 0.0641, 0.0984, 0.1338, 0.1703, 0.2079, 0.2468, 0.2870, 0.3286, 0.3717, 0.4161, 0.4602, 0.5024, 0.5430, 0.5833, 0.6232, 0.6627, 0.7015,
0.7394, 0.7764, 0.8123, 0.8472, 0.8808, 0.9126, 0.9420, 0.9681, 0.9896, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0302, 0.0647, 0.1003, 0.1374, 0.1760, 0.2160, 0.2579, 0.3016, 0.3472, 0.3949, 0.4436, 0.4908, 0.5355, 0.5784, 0.6207, 0.6622, 0.7029, 0.7424,
0.7806, 0.8176, 0.8530, 0.8868, 0.9185, 0.9474, 0.9726, 0.9926, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0304, 0.0656, 0.1029, 0.1423, 0.1837, 0.2271, 0.2731, 0.3215, 0.3726, 0.4262, 0.4791, 0.5294, 0.5769, 0.6220, 0.6660, 0.7086, 0.7498, 0.7893,
0.8272, 0.8631, 0.8969, 0.9281, 0.9560, 0.9795, 0.9967, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0305, 0.0679, 0.1094, 0.1509, 0.1968, 0.2459, 0.2956, 0.3510, 0.4094, 0.4680, 0.5248, 0.5781, 0.6278, 0.6742, 0.7195, 0.7624, 0.8031, 0.8417,
0.8779, 0.9113, 0.9414, 0.9675, 0.9886, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0306, 0.0702, 0.1160, 0.1623, 0.2144, 0.2709, 0.3243, 0.3885, 0.4556, 0.5199, 0.5807, 0.6369, 0.6884, 0.7355, 0.7799, 0.8217, 0.8606, 0.8964,
0.9288, 0.9570, 0.9803, 0.9958, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0308, 0.0725, 0.1225, 0.1737, 0.2320, 0.2960, 0.3641, 0.4393, 0.5133, 0.5825, 0.6460, 0.7032, 0.7544, 0.8003, 0.8426, 0.8817, 0.9168, 0.9475,
0.9733, 0.9924, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0322, 0.0751, 0.1303, 0.1918, 0.2594, 0.3348, 0.4189, 0.5044, 0.5841, 0.6565, 0.7208, 0.7771, 0.8262, 0.8692, 0.9074, 0.9408, 0.9686, 0.9900,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0331, 0.0818, 0.1482, 0.2171, 0.2977, 0.3892, 0.4865, 0.5779, 0.6602, 0.7322, 0.7943, 0.8470, 0.8915, 0.9286, 0.9600, 0.9844, 0.9996, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0340, 0.0884, 0.1661, 0.2544, 0.3540, 0.4658, 0.5729, 0.6681, 0.7496, 0.8183, 0.8748, 0.9208, 0.9568, 0.9832, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.3356, 0.5799, 0.6166, 0.6166, 0.6166, 0.6166, 0.6624, 0.7556, 0.8321, 0.8935, 0.9411, 0.9755, 0.9970, 0.9998, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.5007, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.1655, 0.1655, 0.2972, 0.4728, 0.6327, 0.7616, 0.8598, 0.9317, 0.9790, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0457, 0.1830, 0.3836, 0.5790, 0.7379, 0.8549, 0.9360, 0.9869, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0520, 0.2329, 0.4864, 0.6989, 0.8449, 0.9403, 0.9884, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0773, 0.3027, 0.6040, 0.8058, 0.9302, 0.9930, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.1454, 0.4738, 0.7615, 0.9074, 0.9908, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.0000, 0.0964, 0.5416, 0.8589, 0.9897, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.1538, 0.1538, 0.7080, 0.9596, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.8439, 0.8439, 0.8439, 0.9993, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
[
0.9228, 0.9228, 0.9228, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000,
],
];
/// Blacks curves at internal amounts -100, …, 100 in steps of 10.
#[allow(clippy::approx_constant)]
static BLACKS: [[f32; 33]; 21] = [
[
0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0290, 0.0572, 0.0879, 0.1222, 0.1580, 0.1935, 0.2287, 0.2636, 0.2988, 0.3355, 0.3741, 0.4151, 0.4571,
0.4990, 0.5406, 0.5818, 0.6226, 0.6631, 0.7031, 0.7424, 0.7811, 0.8192, 0.8568, 0.8937, 0.9300, 0.9655, 1.0000,
],
[
0.0000, 0.0000, 0.0000, 0.0000, 0.0015, 0.0363, 0.0640, 0.0956, 0.1300, 0.1651, 0.2000, 0.2345, 0.2686, 0.3026, 0.3369, 0.3727, 0.4105, 0.4495, 0.4885,
0.5272, 0.5657, 0.6039, 0.6419, 0.6796, 0.7169, 0.7538, 0.7903, 0.8264, 0.8621, 0.8974, 0.9322, 0.9664, 1.0000,
],
[
0.0000, 0.0000, 0.0000, 0.0000, 0.0346, 0.0625, 0.0934, 0.1271, 0.1616, 0.1959, 0.2298, 0.2635, 0.2969, 0.3301, 0.3638, 0.3990, 0.4357, 0.4727, 0.5094,
0.5460, 0.5824, 0.6187, 0.6547, 0.6906, 0.7262, 0.7615, 0.7965, 0.8313, 0.8657, 0.8998, 0.9336, 0.9671, 1.0000,
],
[
0.0000, 0.0000, 0.0000, 0.0235, 0.0554, 0.0850, 0.1175, 0.1514, 0.1852, 0.2187, 0.2519, 0.2849, 0.3178, 0.3505, 0.3837, 0.4183, 0.4537, 0.4891, 0.5243,
0.5594, 0.5944, 0.6292, 0.6639, 0.6985, 0.7329, 0.7671, 0.8010, 0.8347, 0.8683, 0.9016, 0.9347, 0.9676, 1.0000,
],
[
0.0000, 0.0000, 0.0020, 0.0438, 0.0724, 0.1035, 0.1365, 0.1699, 0.2030, 0.2359, 0.2686, 0.3011, 0.3336, 0.3659, 0.3986, 0.4327, 0.4669, 0.5011, 0.5352,
0.5692, 0.6031, 0.6369, 0.6706, 0.7043, 0.7378, 0.7711, 0.8043, 0.8373, 0.8702, 0.9029, 0.9355, 0.9679, 1.0000,
],
[
0.0000, 0.0000, 0.0230, 0.0567, 0.0865, 0.1182, 0.1510, 0.1839, 0.2164, 0.2489, 0.2812, 0.3133, 0.3455, 0.3775, 0.4100, 0.4433, 0.4767, 0.5100, 0.5433,
0.5765, 0.6096, 0.6426, 0.6756, 0.7086, 0.7414, 0.7741, 0.8067, 0.8392, 0.8716, 0.9039, 0.9361, 0.9682, 1.0000,
],
[
0.0000, 0.0000, 0.0368, 0.0670, 0.0976, 0.1294, 0.1619, 0.1943, 0.2265, 0.2586, 0.2906, 0.3225, 0.3544, 0.3862, 0.4184, 0.4512, 0.4839, 0.5166, 0.5492,
0.5818, 0.6144, 0.6469, 0.6793, 0.7117, 0.7441, 0.7763, 0.8085, 0.8406, 0.8727, 0.9046, 0.9365, 0.9683, 1.0000,
],
[
0.0000, 0.0000, 0.0444, 0.0749, 0.1058, 0.1377, 0.1699, 0.2020, 0.2339, 0.2657, 0.2975, 0.3292, 0.3609, 0.3926, 0.4245, 0.4568, 0.4891, 0.5213, 0.5535,
0.5857, 0.6179, 0.6499, 0.6820, 0.7140, 0.7460, 0.7779, 0.8098, 0.8416, 0.8734, 0.9052, 0.9368, 0.9685, 1.0000,
],
[
0.0000, 0.0086, 0.0511, 0.0817, 0.1128, 0.1445, 0.1763, 0.2080, 0.2397, 0.2713, 0.3029, 0.3344, 0.3660, 0.3975, 0.4292, 0.4611, 0.4930, 0.5249, 0.5567,
0.5886, 0.6204, 0.6522, 0.6839, 0.7157, 0.7475, 0.7791, 0.8108, 0.8424, 0.8740, 0.9056, 0.9371, 0.9686, 1.0000,
],
[
0.0000, 0.0198, 0.0564, 0.0874, 0.1186, 0.1501, 0.1817, 0.2133, 0.2447, 0.2762, 0.3076, 0.3390, 0.3705, 0.4018, 0.4333, 0.4649, 0.4965, 0.5281, 0.5597,
0.5912, 0.6227, 0.6543, 0.6858, 0.7173, 0.7488, 0.7802, 0.8117, 0.8431, 0.8745, 0.9059, 0.9373, 0.9686, 1.0000,
],
[
0.0000, 0.0312, 0.0625, 0.0938, 0.1250, 0.1562, 0.1875, 0.2188, 0.2500, 0.2812, 0.3125, 0.3438, 0.3750, 0.4062, 0.4375, 0.4688, 0.5000, 0.5312, 0.5625,
0.5938, 0.6250, 0.6562, 0.6875, 0.7188, 0.7500, 0.7812, 0.8125, 0.8438, 0.8750, 0.9062, 0.9375, 0.9688, 1.0000,
],
[
0.0000, 0.0313, 0.0650, 0.0979, 0.1303, 0.1624, 0.1942, 0.2260, 0.2576, 0.2891, 0.3206, 0.3520, 0.3833, 0.4146, 0.4455, 0.4763, 0.5070, 0.5378, 0.5684,
0.5991, 0.6298, 0.6605, 0.6912, 0.7219, 0.7526, 0.7833, 0.8141, 0.8450, 0.8758, 0.9067, 0.9377, 0.9688, 1.0000,
],
[
0.0000, 0.0326, 0.0677, 0.1022, 0.1357, 0.1686, 0.2010, 0.2332, 0.2652, 0.2969, 0.3286, 0.3601, 0.3915, 0.4227, 0.4532, 0.4835, 0.5138, 0.5440, 0.5741,
0.6042, 0.6344, 0.6645, 0.6946, 0.7248, 0.7550, 0.7853, 0.8156, 0.8461, 0.8766, 0.9072, 0.9380, 0.9689, 1.0000,
],
[
0.0000, 0.0341, 0.0706, 0.1066, 0.1413, 0.1749, 0.2079, 0.2405, 0.2728, 0.3048, 0.3366, 0.3682, 0.3996, 0.4307, 0.4608, 0.4906, 0.5203, 0.5500, 0.5796,
0.6092, 0.6387, 0.6683, 0.6979, 0.7276, 0.7573, 0.7871, 0.8170, 0.8472, 0.8773, 0.9077, 0.9382, 0.9690, 1.0000,
],
[
0.0000, 0.0354, 0.0736, 0.1113, 0.1472, 0.1814, 0.2149, 0.2479, 0.2805, 0.3127, 0.3446, 0.3763, 0.4077, 0.4385, 0.4681, 0.4974, 0.5267, 0.5558, 0.5849,
0.6139, 0.6429, 0.6720, 0.7011, 0.7303, 0.7595, 0.7889, 0.8184, 0.8481, 0.8780, 0.9081, 0.9384, 0.9690, 1.0000,
],
[
0.0000, 0.0368, 0.0768, 0.1162, 0.1531, 0.1881, 0.2221, 0.2554, 0.2882, 0.3206, 0.3527, 0.3843, 0.4158, 0.4461, 0.4753, 0.5041, 0.5328, 0.5615, 0.5900,
0.6185, 0.6470, 0.6755, 0.7041, 0.7328, 0.7616, 0.7906, 0.8197, 0.8491, 0.8787, 0.9085, 0.9386, 0.9691, 1.0000,
],
[
0.0000, 0.0381, 0.0802, 0.1214, 0.1593, 0.1949, 0.2293, 0.2630, 0.2961, 0.3286, 0.3607, 0.3924, 0.4236, 0.4536, 0.4823, 0.5106, 0.5388, 0.5669, 0.5949,
0.6229, 0.6509, 0.6789, 0.7070, 0.7352, 0.7636, 0.7922, 0.8209, 0.8500, 0.8793, 0.9089, 0.9388, 0.9691, 1.0000,
],
[
0.0000, 0.0394, 0.0837, 0.1267, 0.1656, 0.2018, 0.2367, 0.2707, 0.3039, 0.3365, 0.3686, 0.4003, 0.4314, 0.4608, 0.4890, 0.5168, 0.5445, 0.5721, 0.5996,
0.6271, 0.6546, 0.6822, 0.7098, 0.7375, 0.7655, 0.7937, 0.8221, 0.8509, 0.8799, 0.9092, 0.9390, 0.9692, 1.0000,
],
[
0.0000, 0.0407, 0.0875, 0.1322, 0.1720, 0.2088, 0.2441, 0.2784, 0.3118, 0.3444, 0.3765, 0.4082, 0.4389, 0.4679, 0.4955, 0.5229, 0.5501, 0.5771, 0.6042,
0.6312, 0.6582, 0.6853, 0.7124, 0.7398, 0.7673, 0.7952, 0.8233, 0.8517, 0.8804, 0.9096, 0.9391, 0.9692, 1.0000,
],
[
0.0000, 0.0421, 0.0915, 0.1379, 0.1786, 0.2160, 0.2516, 0.2861, 0.3196, 0.3523, 0.3844, 0.4159, 0.4462, 0.4747, 0.5019, 0.5287, 0.5554, 0.5820, 0.6085,
0.6351, 0.6616, 0.6882, 0.7149, 0.7419, 0.7690, 0.7965, 0.8243, 0.8524, 0.8810, 0.9099, 0.9393, 0.9693, 1.0000,
],
[
0.0000, 0.0436, 0.0957, 0.1438, 0.1853, 0.2232, 0.2592, 0.2938, 0.3274, 0.3601, 0.3922, 0.4235, 0.4533, 0.4813, 0.5080, 0.5343, 0.5605, 0.5866, 0.6127,
0.6387, 0.6648, 0.6910, 0.7173, 0.7439, 0.7707, 0.7978, 0.8253, 0.8532, 0.8815, 0.9102, 0.9394, 0.9693, 1.0000,
],
];
const BPS: [f32; 9] = [0.0000, 0.0200, 0.0500, 0.1000, 0.2000, 0.3000, 0.4500, 0.6000, 0.8000];
const WPS: [f32; 8] = [0.1000, 0.2000, 0.3000, 0.4500, 0.6000, 0.8000, 0.9000, 1.0000];
static AW: [[f32; 8]; 9] = [
[1.7750, 2.3875, 2.4000, 2.1625, 1.9438, 1.6125, 1.3875, 1.0000],
[1.8125, 2.5250, 2.3937, 2.1688, 1.9375, 1.6062, 1.3938, 0.9875],
[2.7000, 2.4688, 2.2750, 2.0688, 1.8875, 1.6000, 1.3875, 0.9938],
[2.3250, 2.3250, 2.1875, 1.9750, 1.7937, 1.5188, 1.3250, 1.0437],
[2.0187, 2.0187, 2.0187, 1.8625, 1.7063, 1.4062, 1.2250, 0.9250],
[1.7500, 1.7500, 1.7500, 1.7500, 1.6438, 1.3562, 1.1687, 0.8625],
[1.4937, 1.4937, 1.4937, 1.4937, 1.4937, 1.2875, 1.1000, 0.7875],
[1.1687, 1.1687, 1.1687, 1.1687, 1.1687, 1.1687, 1.0375, 0.7562],
[0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.7562],
];
static AB: [[f32; 8]; 9] = [
[0.9938, 0.9812, 1.0000, 1.0125, 0.9563, 0.9688, 1.0437, 1.0000],
[1.0437, 1.0250, 1.0125, 1.0000, 1.0000, 1.0375, 1.0000, 0.9938],
[1.2312, 1.2063, 1.1875, 1.1625, 1.1438, 1.1438, 1.1375, 1.1250],
[1.2500, 1.2500, 1.2312, 1.2125, 1.2125, 1.2375, 1.2375, 1.2375],
[1.1750, 1.1750, 1.1750, 1.1750, 1.2063, 1.2688, 1.3125, 1.3375],
[1.1812, 1.1812, 1.1812, 1.1812, 1.2125, 1.2812, 1.3313, 1.4125],
[1.2375, 1.2375, 1.2375, 1.2375, 1.2375, 1.3062, 1.3562, 1.4375],
[1.3250, 1.3250, 1.3250, 1.3250, 1.3250, 1.3250, 1.3687, 1.4500],
[1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.4312],
];
/// A curve row at a fractional internal amount `x`, at grey level `v` (both interpolated).
fn curve(rows: &[[f32; STEPS]], from: f32, x: f32, v: f32) -> f32 {
if v >= 1.0 {
return 1.0;
}
let r = ((x - from) / 10.0).clamp(0.0, (rows.len() - 1) as f32);
let i = (r as usize).min(rows.len() - 2);
let t = r - i as f32;
let at = |row: &[f32; STEPS]| {
let p = v.max(0.0) * (STEPS - 1) as f32;
let j = (p as usize).min(STEPS - 2);
let s = p - j as f32;
row[j] + (row[j + 1] - row[j]) * s
};
at(&rows[i]) * (1.0 - t) + at(&rows[i + 1]) * t
}
/// Whites at internal amount `x` (−100 … 270) of grey level `v`.
pub(crate) fn whites(v: f32, x: f32) -> f32 {
curve(&WHITES, WHITES_FROM, x, v)
}
/// Blacks at internal amount `x` (−100 … 100) of grey level `v`.
pub(crate) fn blacks(v: f32, x: f32) -> f32 {
curve(&BLACKS, BLACKS_FROM, x, v)
}
/// The least Blacks adaptation on photographs.
const PHOTO_BLACKS: f32 = 1.2;
/// Bilinear lookup of an adaptation table at black point `bp` and white point `wp`.
fn adapt(table: &[[f32; 8]; 9], bp: f32, wp: f32) -> f32 {
let idx = |grid: &[f32], v: f32| {
let v = v.clamp(grid[0], grid[grid.len() - 1]);
let i = grid.windows(2).position(|w| v <= w[1]).unwrap_or(grid.len() - 2);
(i, (v - grid[i]) / (grid[i + 1] - grid[i]))
};
let (i, s) = idx(&BPS, bp);
let (j, t) = idx(&WPS, wp);
let row = |i: usize| table[i][j] * (1.0 - t) + table[i][j + 1] * t;
row(i) * (1.0 - s) + row(i + 1) * s
}
/// The internal amounts of the Whites and Blacks sliders (−100 … 100) for an image whose 1st
/// and 99th luminance percentiles are the grey levels `bp` and `wp` (sRGB-encoded).
pub(crate) fn amounts(whites: f32, blacks: f32, bp: f32, wp: f32) -> (f32, f32) {
let w = if whites > 0.0 { whites * adapt(&AW, bp, wp) } else { whites };
// On photographs Photoshop's Blacks is about 1.2 times the block images' amount even where
// their black point is 0 (ten public-domain photos: 1.08–1.36).
let b = if blacks < 0.0 { blacks * adapt(&AB, bp, wp).max(PHOTO_BLACKS) } else { blacks };
(w.clamp(-100.0, 270.0), b.clamp(-100.0, 100.0))
}
/// Applies Whites and Blacks (internal amounts `w`, `b`) to straight RGBA pixels (`width` per
/// row) encoded with `enc`. Values above 1 (float documents) keep their excess.
pub(crate) fn apply(px: &mut [[f32; 4]], width: usize, w: f32, b: f32, enc: Encoding) {
if w == 0.0 && b == 0.0 {
return;
}
// A grey level's new level, both sRGB-encoded.
let grey = |v: f32| {
let v = if w != 0.0 { whites(v, w) } else { v };
if b != 0.0 { blacks(v, b) } else { v }
};
let per_channel = w > 0.0 || b < 0.0;
par_rows(px, width, 1, |_, row| {
for q in row.iter_mut() {
let lin = [0, 1, 2].map(|c| enc.decode(q[c]));
if per_channel {
for c in 0..3 {
let over = (lin[c] - 1.0).max(0.0);
q[c] = enc.encode(super::srgb_decode(grey(super::srgb_encode(lin[c].min(1.0)))) + over);
}
} else {
let y = super::luma_linear(lin).max(0.0);
if y <= 1e-7 {
continue;
}
let y2 = super::srgb_decode(grey(super::srgb_encode(y.min(1.0)))) + (y - 1.0).max(0.0);
let k = y2 / y;
for c in 0..3 {
q[c] = enc.encode(lin[c] * k);
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn full_range_rows_match_photoshop() {
// Photoshop 25.4 on a 64-level block image spanning black to white (sRGB levels).
assert!((whites(97.1 / 255.0, 100.0) * 255.0 - 137.4).abs() < 0.6);
assert!((whites(194.3 / 255.0, -100.0) * 255.0 - 167.6).abs() < 0.6);
assert!((blacks(97.1 / 255.0, -100.0) * 255.0 - 60.0).abs() < 0.6);
assert!((blacks(48.6 / 255.0, 100.0) * 255.0 - 67.0).abs() < 0.6);
// The compressing directions keep black and white.
assert_eq!(whites(1.0, -100.0), 1.0);
assert_eq!(blacks(0.0, 100.0), 0.0);
}
#[test]
fn the_clipping_directions_adapt_to_the_image() {
// A dark, low-contrast image needs more: Whites +50 acts like +94.5 on [0.05, 0.6].
let (w, _) = amounts(50.0, 0.0, 0.05, 0.6);
assert!((w - 94.5).abs() < 2.0, "{w}");
// A raised black point strengthens Blacks −40 to −58 on [0.6, 1].
let (_, b) = amounts(0.0, -40.0, 0.6, 1.0);
assert!((b + 58.0).abs() < 1.0, "{b}");
// Never below the photographs' 1.2.
assert!((amounts(0.0, -50.0, 0.0, 1.0).1 + 60.0).abs() < 1e-3);
// The compressing directions never adapt.
assert_eq!(amounts(-50.0, 50.0, 0.3, 0.6), (-50.0, 50.0));
}
}
+20
View File
@@ -662,6 +662,26 @@ mod tests {
assert!(err < 0.12, "mean error {err}");
}
/// A vertical sky gradient (each row one colour) filled at every adaptation level stays the
/// gradient: no tinted blotch where the copied rows' colours were shifted per channel.
#[test]
fn color_adaptation_leaves_a_smooth_gradient_untinted() {
let (w, h) = (120, 60);
let row = |y: usize| {
let t = y as f32 / (h - 1) as f32;
[0.27 + 0.45 * t, 0.53 + 0.32 * t, 0.86 + 0.1 * t]
};
let img: Vec<f32> = (0..w * h).flat_map(|i| row(i / w)).collect();
let hole: Vec<bool> = (0..w * h).map(|i| (50..70).contains(&(i % w)) && (20..40).contains(&(i / w))).collect();
let src = vec![true; w * h];
for level in ["none", "default", "high", "veryHigh"] {
let out = fill(w, h, 3, &img, &hole, &src, &FillOptions { color_adaptation: color_level(level), ..Default::default() });
let (out, img) = (&out, &img);
let worst = (0..w * h).filter(|i| hole[*i]).flat_map(|i| (0..3).map(move |c| (out[i * 3 + c] - img[i * 3 + c]).abs())).fold(0.0f32, f32::max);
assert!(worst < 3.0 / 255.0, "{level}: off by {}", worst * 255.0);
}
}
/// I.i.d. noise: every value occurs once, so a filled pixel equal to an image pixel was copied.
fn noise(w: usize, h: usize, seed: u64) -> Vec<f32> {
let mut z = seed;
+990
View File
@@ -0,0 +1,990 @@
//! Depth from focus for Edit › Auto-Blend Layers › Stack Images: a dense, sub-layer depth map
//! from the aligned stack, the index of the layer each pixel is sharpest in.
//!
//! The pipeline is the non-learned "focus volume" recipe, written from the papers:
//!
//! 1. **Focus measure** per layer on luminance at full resolution: the *ring difference filter*
//! of Jeon, Surh, Im & Kweon, *Ring Difference Filter for Fast and Noise Robust Depth From
//! Focus*, IEEE TIP 29 (2019), the magnitude of the difference between the mean of a small
//! disk and the mean of the ring around it, a band-pass whose support averages sensor noise
//! away while staying local. Block-averaged to a working grid (half resolution).
//! 2. **Cost aggregation**: each slice of the focus volume is filtered with the *guided filter*
//! (He, Sun & Tang, PAMI 2013) using the all-in-focus luminance as guide, edge-aware
//! aggregation as in fast cost-volume filtering (Hosni et al., PAMI 2013).
//! 3. **Peak search** per pixel over the layer axis, one slice at a time: the global peak and its
//! two neighbours for the *Gaussian interpolation* of Nayar & Nakagawa, *Shape from Focus*,
//! PAMI 1994 (sub-layer depth), the second-best local maximum for a peak-ratio confidence,
//! the profile mean for a prominence term and the profile minimum for a noise gate.
//! 4. **Regularisation**: the sub-layer depth is smoothed and holes filled by an edge-aware
//! weighted least squares energy (Farbman, Fattal, Lischinski & Szeliski, SIGGRAPH 2008)
//! whose data term is the confidence, solved by conjugate gradients preconditioned by a
//! multigrid V-cycle from the separable sweeps of the *fast global smoother* (Min, Choi, Lu,
//! Ham, Sohn & Do, IEEE TIP 2014) as the start, with one Huber reweighting so isolated
//! outliers stop pulling.
//! 5. **Upsampling** to full resolution with the guided filter's linear coefficients.
//!
//! Everything is deterministic; the slices are folded one at a time, so memory is one working
//! grid per layer plus a few planes.
use crate::photo_util::{par_map, par_rows, par_rows2};
/// Depth-from-focus settings.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DepthParams {
/// The working grid is `1 / 2^scale` of full resolution (1 = half).
pub scale: usize,
/// Ring difference filter radii: the disk `r ≤ r_in`, the ring `r_in < r ≤ r_out`.
pub r_in: usize,
pub r_out: usize,
/// Guided-filter aggregation radius (working-grid pixels) and regulariser.
pub agg_radius: usize,
pub agg_eps: f32,
/// Smoothness weight and guide-edge sensitivity (luminance units) of the regularisation.
pub lambda: f32,
pub sigma_c: f32,
/// Noise gate: full confidence needs a peak ≥ `(1 + gate) ×` the noise floor (the median
/// over pixels of the profile minimum). 0 = off.
pub gate: f32,
/// Robust data term: after the first solve the data weights are scaled by the Huber factor
/// `min(1, robust / |d − u|)` (in layers) and the system is solved once more. 0 = off.
pub robust: f32,
}
impl Default for DepthParams {
fn default() -> Self {
DepthParams { scale: 1, r_in: 1, r_out: 3, agg_radius: 3, agg_eps: 1e-4, lambda: 3.0, sigma_c: 0.04, gate: 1.0, robust: 1.0 }
}
}
/// The result of [`depth_from_focus`].
#[derive(Clone, Debug, PartialEq)]
pub struct DepthMap {
/// Full-resolution fractional layer index per pixel, in `0 ..= n − 1`.
pub depth: Vec<f32>,
/// Full-resolution confidence in `0 ..= 1`.
pub conf: Vec<f32>,
pub w: usize,
pub h: usize,
}
/// Mean over `k × k` blocks (partial blocks at the far edges use their own count).
pub fn block_mean(src: &[f32], w: usize, h: usize, k: usize) -> (Vec<f32>, usize, usize) {
let k = k.max(1);
let (dw, dh) = (w.div_ceil(k), h.div_ceil(k));
if k == 1 {
return (src.to_vec(), dw, dh);
}
let mut out = vec![0.0f32; dw * dh];
par_rows(&mut out, dw, 1, |oy, row| {
let (y0, y1) = (oy * k, (oy * k + k).min(h));
for y in y0..y1 {
let s = &src[y * w..y * w + w];
for (ox, o) in row.iter_mut().enumerate() {
*o += s[ox * k..(ox * k + k).min(w)].iter().sum::<f32>();
}
}
for (ox, o) in row.iter_mut().enumerate() {
*o /= ((y1 - y0) * ((ox * k + k).min(w) - ox * k)) as f32;
}
});
(out, dw, dh)
}
/// Bilinear resampling of a working-grid plane (block size `k`, samples at block centres) to
/// `w × h`.
pub fn upsample_bilinear(g: &[f32], dw: usize, dh: usize, w: usize, h: usize, k: usize) -> Vec<f32> {
let mut out = vec![0.0f32; w * h];
if dw == 0 || dh == 0 {
return out;
}
let inv = 1.0 / k.max(1) as f32;
par_rows(&mut out, w, 1, |y, row| {
let fy = ((y as f32 + 0.5) * inv - 0.5).clamp(0.0, (dh - 1) as f32);
let y0 = fy as usize;
let y1 = (y0 + 1).min(dh - 1);
let ty = fy - y0 as f32;
let (r0, r1) = (&g[y0 * dw..y0 * dw + dw], &g[y1 * dw..y1 * dw + dw]);
for (x, o) in row.iter_mut().enumerate() {
let fx = ((x as f32 + 0.5) * inv - 0.5).clamp(0.0, (dw - 1) as f32);
let x0 = fx as usize;
let x1 = (x0 + 1).min(dw - 1);
let tx = fx - x0 as f32;
let top = r0[x0] + (r0[x1] - r0[x0]) * tx;
let bot = r1[x0] + (r1[x1] - r1[x0]) * tx;
*o = top + (bot - top) * ty;
}
});
out
}
/// Box means over a `(2r + 1)²` window clamped to the plane (partial windows at the borders use
/// their own count): a horizontal moving sum per row in parallel, then a vertical moving sum
/// carried down the rows as one accumulator row.
struct BoxFilter {
w: usize,
h: usize,
r: usize,
inv_count: Vec<f32>,
}
impl BoxFilter {
fn new(w: usize, h: usize, r: usize) -> BoxFilter {
let cnt = |n: usize, i: usize| ((i + r + 1).min(n) - i.saturating_sub(r)) as f32;
let mut inv_count = vec![0.0f32; w * h];
par_rows(&mut inv_count, w, 1, |y, row| {
let cy = cnt(h, y);
for (x, o) in row.iter_mut().enumerate() {
*o = 1.0 / (cy * cnt(w, x));
}
});
BoxFilter { w, h, r, inv_count }
}
fn mean(&self, src: &[f32]) -> Vec<f32> {
let (w, h, r) = (self.w, self.h, self.r);
let mut tmp = vec![0.0f32; w * h];
par_rows(&mut tmp, w, 1, |y, row| {
let s = &src[y * w..y * w + w];
let mut acc: f64 = s[..(r + 1).min(w)].iter().map(|v| f64::from(*v)).sum();
for x in 0..w {
row[x] = acc as f32;
if x + r + 1 < w {
acc += f64::from(s[x + r + 1]);
}
if x >= r {
acc -= f64::from(s[x - r]);
}
}
});
let mut out = vec![0.0f32; w * h];
let mut acc = vec![0.0f64; w];
for y in 0..(r + 1).min(h) {
for (a, v) in acc.iter_mut().zip(&tmp[y * w..y * w + w]) {
*a += f64::from(*v);
}
}
for y in 0..h {
for ((o, a), c) in out[y * w..y * w + w].iter_mut().zip(&acc).zip(&self.inv_count[y * w..y * w + w]) {
*o = *a as f32 * c;
}
if y + r + 1 < h {
for (a, v) in acc.iter_mut().zip(&tmp[(y + r + 1) * w..(y + r + 2) * w]) {
*a += f64::from(*v);
}
}
if y >= r {
for (a, v) in acc.iter_mut().zip(&tmp[(y - r) * w..(y - r + 1) * w]) {
*a -= f64::from(*v);
}
}
}
out
}
}
/// Guided filter (He, Sun & Tang 2013) with a fixed grayscale guide: made once per guide and
/// radius, it keeps the guide's statistics so a slice costs only the arithmetic.
struct GuidedFilter {
guide: Vec<f32>,
mean_i: Vec<f32>,
var_i: Vec<f32>,
bf: BoxFilter,
eps: f32,
}
impl GuidedFilter {
fn new(guide: Vec<f32>, w: usize, h: usize, r: usize, eps: f32) -> GuidedFilter {
let bf = BoxFilter::new(w, h, r);
let mean_i = bf.mean(&guide);
let ii: Vec<f32> = guide.iter().map(|v| v * v).collect();
let var_i: Vec<f32> = bf.mean(&ii).iter().zip(&mean_i).map(|(v, m)| (v - m * m).max(0.0)).collect();
GuidedFilter { guide, mean_i, var_i, bf, eps }
}
/// The box-averaged linear coefficients `(ā, b̄)` such that `q ≈ ā·I + b̄`.
fn coeffs(&self, p: &[f32]) -> (Vec<f32>, Vec<f32>) {
let mean_p = self.bf.mean(p);
let ip: Vec<f32> = self.guide.iter().zip(p).map(|(i, p)| i * p).collect();
let corr_ip = self.bf.mean(&ip);
let (mut a, mut b) = (vec![0.0f32; p.len()], vec![0.0f32; p.len()]);
for k in 0..p.len() {
let cov = corr_ip[k] - self.mean_i[k] * mean_p[k];
a[k] = cov / (self.var_i[k] + self.eps);
b[k] = mean_p[k] - a[k] * self.mean_i[k];
}
(self.bf.mean(&a), self.bf.mean(&b))
}
fn filter(&self, p: &[f32]) -> Vec<f32> {
let (a, b) = self.coeffs(p);
(0..p.len()).map(|k| a[k] * self.guide[k] + b[k]).collect()
}
}
/// Ring difference filter taps `(dy, dx, weight)`: `+1/|disk|` for `r ≤ r_in`, `−1/|ring|` for
/// `r_in < r ≤ r_out`.
fn rdf_taps(r_in: usize, r_out: usize) -> Vec<(i64, i64, f32)> {
let (ri2, ro2) = ((r_in * r_in) as i64, (r_out * r_out) as i64);
let (mut disk, mut ring) = (Vec::new(), Vec::new());
let r = r_out as i64;
for dy in -r..=r {
for dx in -r..=r {
let d2 = dx * dx + dy * dy;
if d2 <= ri2 {
disk.push((dy, dx));
} else if d2 <= ro2 {
ring.push((dy, dx));
}
}
}
let (wd, wr) = (1.0 / disk.len().max(1) as f32, -1.0 / ring.len().max(1) as f32);
disk.into_iter().map(|(y, x)| (y, x, wd)).chain(ring.into_iter().map(|(y, x)| (y, x, wr))).collect()
}
/// Per-pixel focus measure of a luminance plane: the magnitude of the ring difference filter,
/// clamped borders.
pub fn focus_measure(y: &[f32], w: usize, h: usize, r_in: usize, r_out: usize) -> Vec<f32> {
let taps = rdf_taps(r_in, r_out.max(r_in + 1));
let mut out = vec![0.0f32; w * h];
if w == 0 || h == 0 {
return out;
}
par_rows(&mut out, w, 1, |yy, row| {
for &(dy, dx, wt) in &taps {
let sy = (yy as i64 + dy).clamp(0, h as i64 - 1) as usize;
let s = &y[sy * w..sy * w + w];
for (x, o) in row.iter_mut().enumerate() {
*o += wt * s[(x as i64 + dx).clamp(0, w as i64 - 1) as usize];
}
}
row.iter_mut().for_each(|v| *v = v.abs());
});
out
}
/// Per-pixel state of the focus profile, updated one slice at a time.
#[derive(Clone, Copy)]
struct Px {
/// Best local maximum (value, left neighbour, right neighbour; −1 = none) and its index.
c1: f32,
l1: f32,
r1: f32,
i1: u32,
/// Second-best local maximum (−1 = none).
c2: f32,
/// The last two slice values.
prev: f32,
prev2: f32,
sum: f32,
cmin: f32,
}
impl Px {
fn register(&mut self, val: f32, idx: u32, l: f32, r: f32) {
if val > self.c1 {
if self.c1 >= 0.0 {
self.c2 = self.c1;
}
(self.c1, self.i1, self.l1, self.r1) = (val, idx, l, r);
} else if val > self.c2 {
self.c2 = val;
}
}
}
/// The streaming peak search over the layer axis.
struct PeakTracker {
px: Vec<Px>,
m: usize,
}
impl PeakTracker {
fn new(n: usize) -> PeakTracker {
PeakTracker { px: vec![Px { c1: -1.0, l1: -1.0, r1: -1.0, i1: 0, c2: -1.0, prev: 0.0, prev2: 0.0, sum: 0.0, cmin: f32::INFINITY }; n], m: 0 }
}
/// Fold the next slice (aggregated focus values ≥ 0).
fn push(&mut self, c: &[f32]) {
let m = self.m;
for (p, &v) in self.px.iter_mut().zip(c) {
if m >= 1 && (m == 1 || p.prev >= p.prev2) && p.prev > v {
let l = if m >= 2 { p.prev2 } else { -1.0 };
p.register(p.prev, (m - 1) as u32, l, v);
}
p.sum += v;
p.cmin = p.cmin.min(v);
(p.prev2, p.prev) = (p.prev, v);
}
self.m += 1;
}
/// Close the profiles: the sub-layer depth and the raw confidence per pixel.
fn finish(mut self, gate: f32) -> (Vec<f32>, Vec<f32>) {
let n = self.m;
if n == 0 {
return (vec![0.0; self.px.len()], vec![0.0; self.px.len()]);
}
for p in self.px.iter_mut() {
if n == 1 || p.prev >= p.prev2 {
let l = if n >= 2 { p.prev2 } else { -1.0 };
p.register(p.prev, (n - 1) as u32, l, -1.0);
}
}
// The noise floor: the median of the per-pixel profile minimum (subsampled).
let mut mins: Vec<f32> = self.px.iter().step_by(7).map(|p| if p.cmin.is_finite() { p.cmin } else { 0.0 }).collect();
let floor = if mins.is_empty() {
0.0
} else {
let mid = mins.len() / 2;
*mins.select_nth_unstable_by(mid, |a, b| a.total_cmp(b)).1
};
let inv_n = 1.0 / n as f32;
let out: Vec<(f32, f32)> = par_map(self.px.len(), |i| {
let p = &self.px[i];
let (c1, l, r) = (p.c1, p.l1, p.r1);
let mut delta = 0.0f32;
if l >= 0.0 && r >= 0.0 && c1 > 0.0 {
// Gaussian interpolation (Nayar & Nakagawa 1994): a parabola in the log domain.
let (ll, lc, lr) = (l.max(1e-12).ln(), c1.ln(), r.max(1e-12).ln());
let den = ll - 2.0 * lc + lr;
if den < 0.0 {
delta = (0.5 * (ll - lr) / den).clamp(-0.5, 0.5);
}
}
let conf = if c1 > 0.0 {
let prom = (1.0 - p.sum * inv_n / c1).clamp(0.0, 1.0);
// A rival local maximum of similar height means an ambiguous profile.
let pkr = if p.c2 >= 0.0 { (1.0 - p.c2 / c1).clamp(0.0, 1.0) } else { 1.0 };
let g = if gate > 0.0 && floor > 0.0 { ((c1 - floor) / (gate * floor)).clamp(0.0, 1.0) } else { 1.0 };
prom * pkr * g
} else {
0.0
};
(p.i1 as f32 + delta, conf)
});
out.into_iter().unzip()
}
}
/// Scale the confidence so its 90th percentile maps to 1: `lambda` then means the same on every
/// stack.
fn normalize_conf(conf: &mut [f32]) {
let mut sample: Vec<f32> = conf.iter().step_by(7).copied().collect();
if sample.is_empty() {
return;
}
let k = (sample.len() * 9 / 10).min(sample.len() - 1);
let p90 = *sample.select_nth_unstable_by(k, |a, b| a.total_cmp(b)).1;
if p90 > 1e-6 {
conf.iter_mut().for_each(|c| *c = (*c / p90).min(1.0));
}
}
/// 3×3 median, clamped borders.
fn median3(src: &[f32], w: usize, h: usize) -> Vec<f32> {
let mut out = vec![0.0f32; w * h];
par_rows(&mut out, w, 1, |y, row| {
let ys = [y.saturating_sub(1), y, (y + 1).min(h - 1)];
let mut v = [0.0f32; 9];
for (x, o) in row.iter_mut().enumerate() {
let xs = [x.saturating_sub(1), x, (x + 1).min(w - 1)];
for (k, (yy, xx)) in ys.iter().flat_map(|yy| xs.iter().map(move |xx| (*yy, *xx))).enumerate() {
v[k] = src[yy * w + xx];
}
v.sort_unstable_by(|a, b| a.total_cmp(b));
*o = v[4];
}
});
out
}
/// Edge weights between horizontal and vertical neighbours: `exp(−|ΔI| / σ)`; `ax[i]` sits
/// between `(x, y)` and `(x + 1, y)`, `ay[i]` between `(x, y)` and `(x, y + 1)`.
fn edge_weights(guide: &[f32], w: usize, h: usize, sigma: f32) -> (Vec<f32>, Vec<f32>) {
let inv = -1.0 / sigma.max(1e-6);
let (mut ax, mut ay) = (vec![0.0f32; w * h], vec![0.0f32; w * h]);
par_rows2(&mut ax, &mut ay, w, |y, rx, ry| {
let g = &guide[y * w..y * w + w];
for x in 0..w.saturating_sub(1) {
rx[x] = ((g[x] - g[x + 1]).abs() * inv).exp();
}
if y + 1 < h {
let g1 = &guide[(y + 1) * w..(y + 2) * w];
for x in 0..w {
ry[x] = ((g[x] - g1[x]).abs() * inv).exp();
}
}
});
(ax, ay)
}
/// One 1-D weighted-least-squares line `(wd_i + λ(a_{i−1} + a_i)) u_i − λ a_{i−1} u_{i−1} −
/// λ a_i u_{i+1} = wd_i f_i` by the Thomas algorithm; `a[i]` is the weight between `i` and
/// `i + 1`; `scratch` holds the sweep's two coefficient lines.
fn solve_line(u: &mut [f32], f: &[f32], wd: &[f32], a: &[f32], lam: f32, scratch: &mut [f32]) {
let n = u.len();
if n == 0 || scratch.len() < 2 * n {
return;
}
let (cp, dp) = scratch.split_at_mut(n);
let mut prev_a = 0.0f32;
for i in 0..n {
let ai = if i + 1 < n { a[i] } else { 0.0 };
let diag = wd[i] + lam * (prev_a + ai);
let lower = -lam * prev_a;
let upper = -lam * ai;
let (c0, d0) = if i > 0 { (cp[i - 1], dp[i - 1]) } else { (0.0, 0.0) };
let m = diag - lower * c0;
let m = if m.abs() < 1e-12 { 1e-12 } else { m };
cp[i] = upper / m;
dp[i] = (wd[i] * f[i] - lower * d0) / m;
prev_a = ai;
}
u[n - 1] = dp[n - 1];
for i in (0..n - 1).rev() {
u[i] = dp[i] - cp[i] * u[i + 1];
}
}
/// The row sweeps of the fast global smoother, in parallel over rows.
fn solve_rows(u: &mut [f32], f: &[f32], wd: &[f32], ax: &[f32], w: usize, lam: f32) {
par_rows(u, w, 1, |y, row| {
let o = y * w;
let mut scratch = vec![0.0f32; 2 * w];
solve_line(row, &f[o..o + w], &wd[o..o + w], &ax[o..o + w], lam, &mut scratch);
});
}
/// The column sweeps: the planes are transposed, swept as rows, and transposed back.
fn solve_cols(u: &mut [f32], f: &[f32], wd: &[f32], ay: &[f32], w: usize, h: usize, lam: f32) {
let t = |p: &[f32]| -> Vec<f32> {
let mut o = vec![0.0f32; w * h];
par_rows(&mut o, h, 1, |x, col| {
for (y, v) in col.iter_mut().enumerate() {
*v = p[y * w + x];
}
});
o
};
let (ft, wdt, ayt) = (t(f), t(wd), t(ay));
let mut ut = vec![0.0f32; w * h];
solve_rows(&mut ut, &ft, &wdt, &ayt, h, lam);
par_rows(u, w, 1, |y, row| {
for (x, v) in row.iter_mut().enumerate() {
*v = ut[x * h + y];
}
});
}
/// The fast global smoother's three separable passes with the `λ_t` schedule of Min et al.
/// 2014, the first pass carrying the data weights so holes are filled by interpolation, the
/// later ones the previous output: the initial guess of [`wls_solve`].
fn sweeps(d: &[f32], wd: &[f32], ex: &[f32], ey: &[f32], w: usize, h: usize, lambda: f32) -> Vec<f32> {
const T: i32 = 3;
let ones = vec![1.0f32; w * h];
let mut u = vec![0.0f32; w * h];
let mut f = d.to_vec();
for t in 1..=T {
let lam_t = 1.5 * lambda * 4f32.powi(T - t) / (4f32.powi(T) - 1.0);
let wt: &[f32] = if t == 1 { wd } else { &ones };
solve_rows(&mut u, &f, wt, ex, w, lam_t);
f.copy_from_slice(&u);
solve_cols(&mut u, &f, &ones, ey, w, h, lam_t);
f.copy_from_slice(&u);
}
u
}
/// The WLS operator `A = W + Σ λ a_pq` on one grid of the multigrid hierarchy, with the planes
/// a V-cycle needs there.
struct MgLevel {
w: usize,
h: usize,
/// Data weight per cell; on the coarse grids the sum over the block.
wd: Vec<f32>,
/// `λ·a` between `(x, y)` and `(x + 1, y)`, and between `(x, y)` and `(x, y + 1)`; on the
/// coarse grids the sum of the fine edges the block boundary cuts.
ax: Vec<f32>,
ay: Vec<f32>,
/// `1 / diag(A)`.
dinv: Vec<f32>,
x: Vec<f32>,
b: Vec<f32>,
r: Vec<f32>,
}
/// Damped-Jacobi weight (4/5 is the smoothing optimum of the 5-point stencil).
const MG_OMEGA: f32 = 0.8;
/// Smoothing sweeps before and after the coarse correction, and on the coarsest grid (where
/// they are the solve).
const MG_PRE: usize = 1;
const MG_POST: usize = 1;
const MG_COARSE: usize = 32;
/// Coarsen until the grid is this small on its longer side.
const MG_MIN: usize = 16;
impl MgLevel {
fn new(w: usize, h: usize) -> MgLevel {
let n = w * h;
MgLevel { w, h, wd: vec![0.0; n], ax: vec![0.0; n], ay: vec![0.0; n], dinv: vec![0.0; n], x: vec![0.0; n], b: vec![0.0; n], r: vec![0.0; n] }
}
/// `out_i = f(i, (A x)_i)` over the grid.
fn apply<F: Fn(usize, f32) -> f32 + Sync + Send>(&self, x: &[f32], out: &mut [f32], f: F) {
let (w, h) = (self.w, self.h);
let (wd, ax, ay) = (&self.wd, &self.ax, &self.ay);
par_rows(out, w, 1, |y, row| {
let o = y * w;
for (i, r) in row.iter_mut().enumerate() {
let k = o + i;
let xk = x[k];
let mut v = wd[k] * xk;
if i > 0 {
v += ax[k - 1] * (xk - x[k - 1]);
}
if i + 1 < w {
v += ax[k] * (xk - x[k + 1]);
}
if y > 0 {
v += ay[k - w] * (xk - x[k - w]);
}
if y + 1 < h {
v += ay[k] * (xk - x[k + w]);
}
*r = f(k, v);
}
});
}
fn set_dinv(&mut self) {
let (w, h) = (self.w, self.h);
let (wd, ax, ay) = (&self.wd, &self.ax, &self.ay);
par_rows(&mut self.dinv, w, 1, |y, row| {
for (i, r) in row.iter_mut().enumerate() {
let k = y * w + i;
let mut v = wd[k];
if i > 0 {
v += ax[k - 1];
}
if i + 1 < w {
v += ax[k];
}
if y > 0 {
v += ay[k - w];
}
if y + 1 < h {
v += ay[k];
}
*r = 1.0 / v.max(1e-12);
}
});
}
/// One damped-Jacobi sweep on `x` for `b`: `x += ω D⁻¹ (b − A x)`.
fn jacobi(&mut self, zero_start: bool) {
if zero_start {
for ((x, b), d) in self.x.iter_mut().zip(&self.b).zip(&self.dinv) {
*x = MG_OMEGA * d * b;
}
return;
}
let mut r = std::mem::take(&mut self.r);
{
let (x, b, dinv) = (&self.x, &self.b, &self.dinv);
self.apply(x, &mut r, |k, ax| x[k] + MG_OMEGA * dinv[k] * (b[k] - ax));
}
self.r = std::mem::replace(&mut self.x, r);
}
/// The 2×2 aggregation of this grid: the Galerkin operator of a piecewise-constant
/// prolongation (data weights summed over the block, the fine edges cut by a block boundary
/// summed into the coarse edge).
fn coarsen(&self) -> MgLevel {
let (w, h) = (self.w, self.h);
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let mut c = MgLevel::new(cw, ch);
self.coarsen_wd_into(&mut c);
let (ax, ay) = (&self.ax, &self.ay);
par_rows(&mut c.ax, cw, 1, |yy, row| {
for y in 2 * yy..(2 * yy + 2).min(h) {
for (xx, o) in row.iter_mut().enumerate() {
if xx + 1 < cw {
*o += ax[y * w + 2 * xx + 1];
}
}
}
});
par_rows(&mut c.ay, cw, 1, |yy, row| {
if yy + 1 < ch {
let y = 2 * yy + 1;
for (xx, o) in row.iter_mut().enumerate() {
*o = ay[y * w..y * w + w][2 * xx..(2 * xx + 2).min(w)].iter().sum::<f32>();
}
}
});
c
}
/// The data weights of the coarse grid `c` from this grid's (after a reweighting; the edges
/// do not change).
fn coarsen_wd_into(&self, c: &mut MgLevel) {
let (w, h, cw) = (self.w, self.h, c.w);
let wd = &self.wd;
par_rows(&mut c.wd, cw, 1, |yy, row| {
row.fill(0.0);
for y in 2 * yy..(2 * yy + 2).min(h) {
for (xx, o) in row.iter_mut().enumerate() {
*o += wd[y * w..y * w + w][2 * xx..(2 * xx + 2).min(w)].iter().sum::<f32>();
}
}
});
}
}
/// `x += P x_c`: the coarse correction injected (piecewise constant).
fn prolong_add(x: &mut [f32], w: usize, xc: &[f32], cw: usize) {
par_rows(x, w, 1, |y, row| {
let c = &xc[(y / 2) * cw..(y / 2) * cw + cw];
for (i, o) in row.iter_mut().enumerate() {
*o += c[i / 2];
}
});
}
/// `b_c = Pᵀ r`: the residual summed over each block.
fn restrict(r: &[f32], w: usize, h: usize, bc: &mut [f32], cw: usize) {
par_rows(bc, cw, 1, |yy, row| {
row.fill(0.0);
for y in 2 * yy..(2 * yy + 2).min(h) {
let s = &r[y * w..y * w + w];
for (xx, o) in row.iter_mut().enumerate() {
*o += s[2 * xx..(2 * xx + 2).min(w)].iter().sum::<f32>();
}
}
});
}
/// One V-cycle from `levels[0]` (whose `b` is set) into its `x`.
fn vcycle(levels: &mut [MgLevel]) {
let Some((top, rest)) = levels.split_first_mut() else { return };
let Some(next) = rest.first_mut() else {
for k in 0..MG_COARSE {
top.jacobi(k == 0);
}
return;
};
for k in 0..MG_PRE {
top.jacobi(k == 0);
}
{
let mut r = std::mem::take(&mut top.r);
let (x, b) = (&top.x, &top.b);
top.apply(x, &mut r, |k, ax| b[k] - ax);
top.r = r;
}
restrict(&top.r, top.w, top.h, &mut next.b, next.w);
vcycle(rest);
let Some(next) = rest.first() else { return };
prolong_add(&mut top.x, top.w, &next.x, next.w);
for _ in 0..MG_POST {
top.jacobi(false);
}
}
/// Edge-aware WLS: `argmin_u Σ w_p (u_p − d_p)² + λ Σ a_pq (u_p − u_q)²`, the 2-D system
/// `(W + λL) u = W d` solved by conjugate gradients from the separable sweeps' guess,
/// preconditioned by one multigrid V-cycle (2×2 aggregation, damped Jacobi), so the low modes
/// converge where the confidence is low over a large area (a flat wall). The solver keeps its
/// hierarchy between solves, so a reweighting costs no rebuild.
struct WlsSolver {
w: usize,
h: usize,
lambda: f32,
/// Edge weights `exp(−|ΔI| / σ)` (without λ), for the separable sweeps.
ex: Vec<f32>,
ey: Vec<f32>,
levels: Vec<MgLevel>,
}
impl WlsSolver {
fn new(guide: &[f32], w: usize, h: usize, lambda: f32, sigma_c: f32) -> WlsSolver {
let (ex, ey) = edge_weights(guide, w, h, sigma_c);
let mut top = MgLevel::new(w, h);
for (a, e) in top.ax.iter_mut().zip(&ex) {
*a = lambda * e;
}
for (a, e) in top.ay.iter_mut().zip(&ey) {
*a = lambda * e;
}
let mut levels = vec![top];
while levels.last().is_some_and(|l| l.w.max(l.h) > MG_MIN) {
let Some(last) = levels.last() else { break };
let c = last.coarsen();
levels.push(c);
}
WlsSolver { w, h, lambda, ex, ey, levels }
}
/// The data weights: the confidence (plus a floor so the system is definite where nothing
/// is known), aggregated down the hierarchy.
fn set_weights(&mut self, conf: &[f32]) {
const EPS_DATA: f32 = 1e-4;
if let Some(top) = self.levels.first_mut() {
for (w, c) in top.wd.iter_mut().zip(conf) {
*w = c.max(0.0) + EPS_DATA;
}
}
for l in 0..self.levels.len() {
if l > 0 {
let (fine, coarse) = self.levels.split_at_mut(l);
if let (Some(f), Some(c)) = (fine.last(), coarse.first_mut()) {
f.coarsen_wd_into(c);
}
}
self.levels[l].set_dinv();
}
}
/// Solve for the data `d`: the solution and its final relative residual.
fn solve(&mut self, d: &[f32], max_iters: usize) -> (Vec<f32>, f32) {
let (w, h, lambda) = (self.w, self.h, self.lambda);
let n = w * h;
let Some(top) = self.levels.first() else { return (vec![0.0; n], 0.0) };
let mut u = sweeps(d, &top.wd, &self.ex, &self.ey, w, h, lambda);
if w == 1 || h == 1 || n == 0 {
return (u, 0.0);
}
let dot = |a: &[f32], b: &[f32]| -> f64 { a.iter().zip(b).map(|(x, y)| f64::from(*x) * f64::from(*y)).sum() };
let b: Vec<f32> = top.wd.iter().zip(d).map(|(w, d)| w * d).collect();
let bnorm = dot(&b, &b).sqrt().max(1e-30);
let mut r = vec![0.0f32; n];
top.apply(&u, &mut r, |k, au| b[k] - au);
let precond = |levels: &mut [MgLevel], r: &[f32], z: &mut [f32]| {
if let Some(top) = levels.first_mut() {
top.b.copy_from_slice(r);
}
vcycle(levels);
if let Some(top) = levels.first() {
z.copy_from_slice(&top.x);
}
};
let mut z = vec![0.0f32; n];
precond(&mut self.levels, &r, &mut z);
let mut p = z.clone();
let mut ap = vec![0.0f32; n];
let mut rz = dot(&r, &z);
let mut rel = (dot(&r, &r).sqrt() / bnorm) as f32;
for _ in 0..max_iters {
if rel < 1e-5 {
break;
}
let Some(top) = self.levels.first() else { break };
top.apply(&p, &mut ap, |_, v| v);
let pap = dot(&p, &ap);
if pap <= 0.0 {
break;
}
let alpha = (rz / pap) as f32;
for (u, p) in u.iter_mut().zip(&p) {
*u += alpha * p;
}
for (r, ap) in r.iter_mut().zip(&ap) {
*r -= alpha * ap;
}
rel = (dot(&r, &r).sqrt() / bnorm) as f32;
if rel < 1e-5 {
break;
}
precond(&mut self.levels, &r, &mut z);
let rz_new = dot(&r, &z);
let beta = (rz_new / rz) as f32;
rz = rz_new;
for (p, z) in p.iter_mut().zip(&z) {
*p = z + beta * *p;
}
}
(u, rel)
}
}
/// Conjugate-gradient iteration cap of the regularisation.
const CG_ITERS: usize = 200;
/// Depth from focus over the `n` layers' luminance planes `luma` (`w × h`, aligned), guided by
/// the all-in-focus luminance `guide` of the same size. Without layers, or when `guide` or a
/// plane is not `w × h` long, the map is empty: depth 0 and confidence 0 everywhere.
pub fn depth_from_focus(luma: &[&[f32]], guide: &[f32], w: usize, h: usize, p: &DepthParams) -> DepthMap {
let n = luma.len();
let empty = n == 0 || w == 0 || h == 0 || guide.len() != w * h || luma.iter().any(|l| l.len() != w * h);
if empty {
return DepthMap { depth: vec![0.0; w * h], conf: vec![0.0; w * h], w, h };
}
let k = 1usize << p.scale.min(8);
let (g, dw, dh) = block_mean(guide, w, h, k);
let gf = GuidedFilter::new(g.clone(), dw, dh, p.agg_radius, p.agg_eps);
let mut tracker = PeakTracker::new(dw * dh);
for l in luma {
let slice = block_mean(&focus_measure(l, w, h, p.r_in, p.r_out), w, h, k).0;
if p.agg_radius > 0 {
// The guided filter can undershoot; the profile statistics assume ≥ 0.
let agg: Vec<f32> = gf.filter(&slice).into_iter().map(|v| v.max(0.0)).collect();
tracker.push(&agg);
} else {
tracker.push(&slice);
}
}
let (depth_w, mut conf) = tracker.finish(p.gate);
let depth_w = median3(&depth_w, dw, dh);
normalize_conf(&mut conf);
let depth_w = if p.lambda > 0.0 {
let mut solver = WlsSolver::new(&g, dw, dh, p.lambda, p.sigma_c);
solver.set_weights(&conf);
let (u, _) = solver.solve(&depth_w, CG_ITERS);
if p.robust > 0.0 {
// One reweighted step with a Huber loss on the data residual.
let wd2: Vec<f32> = conf.iter().zip(&depth_w).zip(&u).map(|((c, d), u)| c * (p.robust / (d - u).abs().max(1e-6)).min(1.0)).collect();
solver.set_weights(&wd2);
solver.solve(&depth_w, CG_ITERS).0
} else {
u
}
} else {
depth_w
};
let max_d = (n - 1) as f32;
let up = GuidedFilter::new(g, dw, dh, 2, 1e-2);
let depth: Vec<f32> = if k == 1 {
up.filter(&depth_w)
} else {
let (a, b) = up.coeffs(&depth_w);
let a = upsample_bilinear(&a, dw, dh, w, h, k);
let b = upsample_bilinear(&b, dw, dh, w, h, k);
(0..w * h).map(|i| a[i] * guide[i] + b[i]).collect()
};
let depth = depth.into_iter().map(|v| v.clamp(0.0, max_d)).collect();
let conf = if k == 1 { conf } else { upsample_bilinear(&conf, dw, dh, w, h, k) };
DepthMap { depth, conf, w, h }
}
#[cfg(test)]
mod tests {
use super::*;
fn lcg(seed: &mut u64) -> f32 {
*seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
((*seed >> 33) % 10000) as f32 / 10000.0
}
#[test]
fn box_filter_matches_the_naive_window() {
let (w, h, r) = (23, 17, 3);
let mut s = 5u64;
let src: Vec<f32> = (0..w * h).map(|_| lcg(&mut s)).collect();
let m = BoxFilter::new(w, h, r).mean(&src);
for y in 0..h {
for x in 0..w {
let (mut acc, mut cnt) = (0.0, 0.0);
for yy in y.saturating_sub(r)..(y + r + 1).min(h) {
for xx in x.saturating_sub(r)..(x + r + 1).min(w) {
acc += src[yy * w + xx];
cnt += 1.0;
}
}
assert!((m[y * w + x] - acc / cnt).abs() < 1e-4, "({x},{y})");
}
}
}
#[test]
fn guided_filter_with_a_flat_guide_is_a_double_box_mean() {
let (w, h) = (40, 30);
let mut s = 9u64;
let p: Vec<f32> = (0..w * h).map(|_| lcg(&mut s)).collect();
let q = GuidedFilter::new(vec![0.5; w * h], w, h, 2, 1e-3).filter(&p);
let bf = BoxFilter::new(w, h, 2);
let m = bf.mean(&bf.mean(&p));
assert!(q.iter().zip(&m).all(|(a, b)| (a - b).abs() < 1e-4));
}
#[test]
fn ring_difference_is_zero_mean_and_sees_texture() {
let taps = rdf_taps(1, 3);
assert!(taps.iter().map(|t| t.2).sum::<f32>().abs() < 1e-6);
let (w, h) = (32, 32);
assert!(focus_measure(&vec![0.4; w * h], w, h, 1, 3).iter().all(|v| v.abs() < 1e-6));
let check: Vec<f32> = (0..w * h).map(|i| if ((i % w) / 2 + (i / w) / 2).is_multiple_of(2) { 0.2 } else { 0.8 }).collect();
assert!(focus_measure(&check, w, h, 1, 3).iter().cloned().fold(0.0, f32::max) > 0.1);
let (m, dw, dh) = block_mean(&check, w, h, 2);
assert_eq!((dw, dh, m.len()), (16, 16, 256));
assert!(m.iter().all(|v| (v - 0.2).abs() < 1e-6 || (v - 0.8).abs() < 1e-6));
let up = upsample_bilinear(&m, dw, dh, w, h, 2);
assert_eq!(up.len(), w * h);
}
#[test]
fn peak_search_interpolates_between_layers() {
// A profile peaking between layers 2 and 3 (log-parabola), one flat profile.
let mut t = PeakTracker::new(2);
for m in 0..6 {
let v = (-((m as f32 - 2.4).powi(2)) / 2.0).exp();
t.push(&[v, 0.1]);
}
let (d, c) = t.finish(0.0);
assert!((d[0] - 2.4).abs() < 0.05, "{d:?}");
assert!(c[0] > 0.5 && c[1] < 1e-6, "{c:?}");
}
#[test]
fn smoothing_fills_holes_and_respects_edges() {
let (w, h) = (40, 20);
// A guide with a hard edge at x = 20; data known only at two pixels, one per side.
let guide: Vec<f32> = (0..w * h).map(|i| if i % w < 20 { 0.0 } else { 1.0 }).collect();
let mut d = vec![0.0f32; w * h];
let mut wd = vec![1e-4f32; w * h];
(d[10 * w + 5], wd[10 * w + 5]) = (1.0, 1.0);
(d[10 * w + 35], wd[10 * w + 35]) = (5.0, 1.0);
let mut solver = WlsSolver::new(&guide, w, h, 3.0, 0.04);
solver.set_weights(&wd);
let (u, rel) = solver.solve(&d, CG_ITERS);
assert!(rel < 1e-4, "residual {rel}");
// Each side follows its own sample (the data floor on the unknown pixels pulls a little
// toward zero), and the edge keeps them apart.
assert!((u[3 * w + 15] - 1.0).abs() < 0.15, "left side follows its sample: {}", u[3 * w + 15]);
assert!((u[15 * w + 25] - 5.0).abs() < 0.6, "right side follows its sample: {}", u[15 * w + 25]);
assert!(u[10 * w + 19] < 1.5 && u[10 * w + 20] > 4.0, "the edge holds: {} | {}", u[10 * w + 19], u[10 * w + 20]);
// The sweeps alone leave the holes near zero; the solve fills them.
let guess = sweeps(&d, &wd, &solver.ex, &solver.ey, w, h, 3.0);
assert!(guess[3 * w + 15] < 0.5);
assert_eq!(median3(&[0.5; 9], 3, 3)[4], 0.5);
}
/// Three layers: the left third sharp in layer 0, the middle in 1, the right in 2. The depth
/// map says so, sub-layer in between, with high confidence on the texture.
#[test]
fn depth_from_focus_finds_the_sharp_layer() {
let (w, h) = (96, 48);
let texture = |x: usize, y: usize| if ((x / 2) + (y / 2)).is_multiple_of(2) { 0.2 } else { 0.8 };
let flat = 0.5;
let layers: Vec<Vec<f32>> = (0..3).map(|k| (0..w * h).map(|i| if (i % w) / 32 == k { texture(i % w, i / w) } else { flat }).collect()).collect();
let guide: Vec<f32> = (0..w * h).map(|i| texture(i % w, i / w)).collect();
let refs: Vec<&[f32]> = layers.iter().map(Vec::as_slice).collect();
let dm = depth_from_focus(&refs, &guide, w, h, &DepthParams::default());
assert_eq!((dm.w, dm.h, dm.depth.len()), (w, h, w * h));
for (x, want) in [(8, 0.0), (48, 1.0), (88, 2.0)] {
let d = dm.depth[24 * w + x];
assert!((d - want).abs() < 0.35, "x {x}: depth {d}, want {want}");
assert!(dm.conf[24 * w + x] > 0.3, "x {x}: conf {}", dm.conf[24 * w + x]);
}
assert!(dm.depth.iter().all(|v| (0.0..=2.0).contains(v)));
// Degenerate inputs are fine.
assert_eq!(depth_from_focus(&[], &guide, w, h, &DepthParams::default()).depth, vec![0.0; w * h]);
// A guide or a plane of the wrong length is no input: the empty map, never a panic.
let empty = DepthMap { depth: vec![0.0; w * h], conf: vec![0.0; w * h], w, h };
assert_eq!(depth_from_focus(&refs, &guide[1..], w, h, &DepthParams::default()), empty);
let short = &refs[0][..w * h - 1];
assert_eq!(depth_from_focus(&[refs[0], short], &guide, w, h, &DepthParams::default()), empty);
assert_eq!(depth_from_focus(&refs, &guide, w + 1, h, &DepthParams::default()).w, w + 1);
let one = depth_from_focus(&refs[..1], &guide, w, h, &DepthParams::default());
assert!(one.depth.iter().all(|v| *v == 0.0));
}
}
+18 -7
View File
@@ -160,19 +160,30 @@ pub(crate) fn polar(src: &Image, out: Rect, ctx: &Ctx, mode: PolarMode) -> Vec<f
let (w, h) = (b.width().max(1) as f32, b.height().max(1) as f32);
let (cx, cy) = (b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0);
let rmax = w.min(h) / 2.0;
remap(src, out, ctx, Edge::Transparent, |x, y| match mode {
// Photoshop 27.11: the angle runs counter-clockwise from 12 o'clock, and points past the
// source's edge repeat its edge pixels (the corners outside the circle are filled, not empty).
// Photoshop evaluates the map at pixel corners and reads the source at the resulting index
// (half a pixel from `remap`'s centres): within 2 levels on a whole test chart in both directions.
remap(src, out, ctx, Edge::Repeat, |x, y| polar_map(x - 0.5, y - 0.5, mode, b, (w, h), (cx, cy), rmax))
}
fn polar_map(x: f32, y: f32, mode: PolarMode, b: Rect, (w, h): (f32, f32), (cx, cy): (f32, f32), rmax: f32) -> (f32, f32) {
let (sx, sy) = match mode {
PolarMode::RectangularToPolar => {
// Output is polar: angle from 12 o'clock clockwise → source x,
// radius → source y.
// Output is polar: angle from 12 o'clock counter-clockwise → source x, radius → source y.
let (dx, dy) = (x - cx, y - cy);
let theta = dx.atan2(-dy).rem_euclid(std::f32::consts::TAU);
// The 12 o'clock seam itself reads the source's right edge (360°, not 0°).
let theta = (-dx).atan2(-dy);
let theta = if theta <= 0.0 { theta + std::f32::consts::TAU } else { theta };
let r = (dx * dx + dy * dy).sqrt();
(b.x0 as f32 + theta / std::f32::consts::TAU * w, b.y0 as f32 + r / rmax * h)
}
PolarMode::PolarToRectangular => {
let theta = (x - b.x0 as f32) / w * std::f32::consts::TAU;
// Photoshop's angle is one column ahead of its radius row (fitted exactly).
let theta = (x + 1.0 - b.x0 as f32) / w * std::f32::consts::TAU;
let r = (y - b.y0 as f32) / h * rmax;
(cx + r * theta.sin(), cy - r * theta.cos())
(cx - r * theta.sin(), cy - r * theta.cos())
}
})
};
(sx + 0.5, sy + 0.5)
}
+12 -1
View File
@@ -18,7 +18,8 @@ pub fn magic_erase(target: &mut Surface, region: &Region, amount: f32, selection
let amount = if amount.is_finite() { amount.clamp(0.0, 1.0) } else { 0.0 };
let bbox = region.bbox;
let bw = bbox.width() as usize;
if bbox.is_empty() || amount <= 0.0 || region.mask.len() != bw * bbox.height() as usize {
let Some(pixels) = bw.checked_mul(bbox.height() as usize) else { return Rect::EMPTY };
if bbox.is_empty() || amount <= 0.0 || region.mask.len() != pixels {
return Rect::EMPTY;
}
let fmt = target.format();
@@ -147,6 +148,16 @@ mod tests {
assert_eq!(l.rgba(5, 5), [1.0, 1.0, 1.0, 1.0]);
}
#[test]
fn oversized_region_mask_is_rejected_without_overflow() {
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]);
let before = s.pixel(0, 0);
let oversized = Region { bbox: Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX), mask: vec![255] };
assert_eq!(magic_erase(&mut s, &oversized, 1.0, None, None), Rect::EMPTY);
assert_eq!(s.pixel(0, 0), before);
}
#[test]
fn degenerate_inputs_do_nothing() {
let mut s = Surface::new(PixelFormat::RGBA8);
+32 -1
View File
@@ -508,6 +508,9 @@ struct Solver<'a> {
ctl: photocraft_raster::Interrupt<'a>,
}
/// A channel deviation under which a patch counts as flat for [`fit`] (about 5 levels of 255).
const FLAT: f32 = 0.02;
/// Gain and bias of one channel for target moments `(Σt, Σt²)` and source moments `(Σs, Σs²)`
/// over `n` samples, within the limits.
#[inline]
@@ -516,7 +519,20 @@ pub(crate) fn fit(a: Adapt, n: f32, st: f32, stt: f32, ss: f32, sss: f32) -> (f3
let vt = (stt / n - mt * mt).max(0.0);
let vs = (sss / n - ms * ms).max(0.0);
let g = if vs > 1e-10 { (vt / vs).sqrt() } else { 1.0 };
let g = g.clamp(a.gain.0, a.gain.1);
let (mut lo, mut hi) = a.gain;
// Nearly flat patches of about the same colour (their means within the bias of each other):
// the ratio of their deviations is noise, so match the mean first, keeping the gain where the
// bias can still bring the source's mean to the target's. Gained about zero, a gain at its
// limit moved bright means further than the bias could bring back, and each channel by a
// different amount: a tinted blotch in a smooth gradient. Textured patches, and flat ones
// whose colours differ more (Content-Aware Move's Color), are fitted as before.
if vt.max(vs) < FLAT * FLAT && (mt - ms).abs() <= a.bias && ms > 1e-6 {
let (glo, ghi) = ((mt - a.bias) / ms, (mt + a.bias) / ms);
if glo.max(lo) <= ghi.min(hi) {
(lo, hi) = (glo.max(lo), ghi.min(hi));
}
}
let g = g.clamp(lo, hi);
let b = (mt - g * ms).clamp(-a.bias, a.bias);
(g, b)
}
@@ -1959,6 +1975,21 @@ fn min_cut(across: usize, along: usize, err: impl Fn(usize, usize) -> f32) -> Ve
mod tests {
use super::*;
/// The mean is matched first: on a nearly flat bright patch with a noisy deviation ratio the
/// gain stays where the bias can still reach the target's mean.
#[test]
fn fit_matches_the_mean_before_the_contrast() {
let a = Adapt { gain: (0.9, 1.1), bias: 0.04 };
// Target mean 0.70, source mean 0.70; deviations 0.01 vs 0.004 (ratio 2.5).
let n = 25.0;
let (mt, ms, sdt, sds) = (0.70f32, 0.70f32, 0.01f32, 0.004f32);
let (g, b) = fit(a, n, mt * n, (sdt * sdt + mt * mt) * n, ms * n, (sds * sds + ms * ms) * n);
assert!((g * ms + b - mt).abs() < 1e-5, "mean {} vs {mt} (g {g}, b {b})", g * ms + b);
// Where the mean can't be reached anyway, the limits hold as before.
let (g, b) = fit(a, n, 0.2 * n, 0.04 * n, 0.9 * n, 0.81 * n);
assert!((a.gain.0..=a.gain.1).contains(&g) && b.abs() <= a.bias + 1e-6);
}
/// Smooth gradient plus deterministic fine noise.
fn texture(w: usize, h: usize) -> Vec<f32> {
let mut rng = Rng::new(7);
+87 -20
View File
@@ -26,6 +26,7 @@ pub mod camera_raw;
pub mod color_to_alpha;
pub mod content_aware;
mod denoise;
pub mod dff;
pub mod direct;
mod distort;
mod distort2;
@@ -552,7 +553,8 @@ impl FilterParams {
let g = |sigma: f32| Halo::Radius((sigma.max(0.0) * 3.0).ceil() as i32 + 1);
match self {
FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius),
FilterParams::UnsharpMask { radius, .. } | FilterParams::SmartSharpen { radius, .. } => g(*radius),
FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)),
FilterParams::SmartSharpen { radius, .. } => g(*radius),
FilterParams::BoxBlur { radius }
| FilterParams::SurfaceBlur { radius, .. }
| FilterParams::Median { radius }
@@ -598,6 +600,13 @@ impl FilterParams {
self.halo() == Halo::Bounds
}
/// Whether the filter renders new pixels without reading the layer's own (Fibers): it fills
/// the reference bounds even where the layer is transparent, as Photoshop's does on an
/// empty layer, while still reading no neighbourhood.
pub fn renders_from_nothing(&self) -> bool {
matches!(self, FilterParams::Fibers { .. })
}
/// Human-readable name.
pub fn label(&self) -> &'static str {
match self {
@@ -740,7 +749,7 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
FilterParams::Mosaic { cell_size } => stylize::mosaic(src, out, ctx, *cell_size),
FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount),
FilterParams::FindEdges => stylize::find_edges(src, out, ctx),
FilterParams::Solarize => stylize::per_pixel(src, out, ctx, |v| if v > 0.5 { 1.0 - v } else { v }),
FilterParams::Solarize => stylize::solarize(src, out, ctx),
FilterParams::Invert => stylize::per_pixel(src, out, ctx, |v| 1.0 - v),
FilterParams::Desaturate => stylize::desaturate(src, out, ctx),
FilterParams::Twirl { angle } => distort::twirl(src, out, ctx, *angle),
@@ -903,12 +912,13 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
pub const TILE: i32 = 256;
/// The area a filter writes for a layer whose pixels cover `content`:
/// global filters write the reference bounds; others the content grown by
/// the halo (blurs spread into transparent areas). Clipped to the
/// selection's bounds when there is one.
/// global filters and renders that ignore the layer's pixels (Fibers) write the reference
/// bounds; others the content grown by the halo (blurs spread into transparent areas).
/// Clipped to the selection's bounds when there is one.
pub fn output_area(params: &FilterParams, content: Rect, bounds: Rect, selection_bounds: Option<Rect>) -> Rect {
let mut area = match params.halo_for(bounds) {
Halo::Bounds => bounds,
Halo::Radius(_) if params.renders_from_nothing() => bounds,
Halo::Radius(r) => {
if content.is_empty() {
Rect::EMPTY
@@ -937,7 +947,8 @@ pub fn apply_in(surface: &Surface, params: &FilterParams, area: Rect, bounds: Re
}
/// [`apply_in`] that can be cancelled (checked before each tile) and reports progress per tile
/// group. `None` when `ctl` was cancelled; the input surface is never modified.
/// group. `None` when cancelled, or when a checked filter rejects geometry/allocation;
/// the input surface is never modified.
pub fn apply_in_with(
surface: &Surface,
params: &FilterParams,
@@ -969,13 +980,13 @@ pub fn apply_tiled(
tile: i32,
extent: Option<Rect>,
) -> Surface {
// Preserve the input if a checked Shape Blur allocation cannot be completed.
// Preserve the input if a checked filter allocation cannot be completed.
apply_tiled_with(surface, params, area, bounds, selection, tile, extent, &photocraft_raster::Interrupt::NONE).unwrap_or_else(|| surface.clone())
}
/// [`apply_tiled`] with cancellation (checked before each tile, so a cancel takes effect within
/// one tile's work) and progress (after each group of tiles). `None` when cancelled,
/// or when Shape Blur cannot allocate a tile.
/// or when a checked filter rejects geometry or cannot allocate a tile.
#[allow(clippy::too_many_arguments)]
pub fn apply_tiled_with(
surface: &Surface,
@@ -1000,15 +1011,27 @@ fn apply_tiled_with_impl(
tile: i32,
extent: Option<Rect>,
ctl: &photocraft_raster::Interrupt,
prepare_radial: bool,
prepare_fast_paths: bool,
) -> Option<Surface> {
if ctl.cancelled() {
return None;
}
if let FilterParams::UnsharpMask { amount, radius, threshold } = *params
&& (!sharpen::exact::valid_params(amount, radius, threshold)
|| !sharpen::exact::valid_area(area)
|| !sharpen::exact::valid_area(bounds)
|| extent.is_some_and(|e| !sharpen::exact::valid_area(e)))
{
return None;
}
let mut out = surface.clone();
if area.is_empty() {
return Some(out);
}
#[cfg(not(target_arch = "wasm32"))]
if prepare_fast_paths && let Some(result) = sharpen::fft::apply(surface, params, area, selection, extent, ctl) {
return result;
}
if let Some(boxes) = blur::box_widths(params) {
return apply_box_blur(out, surface, area, extent, selection, &boxes, ctl);
}
@@ -1016,15 +1039,15 @@ fn apply_tiled_with_impl(
return result;
}
// The public tile override must not create a zero-step loop or an
// unbounded Shape Blur scratch buffer. Standard automatic tiles are unchanged.
let tile = if matches!(params, FilterParams::ShapeBlur { .. }) { tile.clamp(1, 2048) } else { tile };
// unbounded scratch buffer for these checked filters. Automatic tiles are unchanged.
let tile = if matches!(params, FilterParams::ShapeBlur { .. } | FilterParams::UnsharpMask { .. }) { tile.clamp(1, 2048) } else { tile };
let fmt = surface.format();
let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha };
// Prepare the immutable sample transforms once for all output tiles. If the
// optional table allocation fails, use the existing sampler; only a real
// cancellation returns None. Source reads keep their upstream semantics.
let radial = match params {
FilterParams::RadialBlur { amount, method, quality, center_x, center_y } if prepare_radial => {
FilterParams::RadialBlur { amount, method, quality, center_x, center_y } if prepare_fast_paths => {
blur::radial::Plan::for_area(bounds, *amount, *method, *quality, (*center_x, *center_y), area, ctl)
}
_ => None,
@@ -1033,6 +1056,17 @@ fn apply_tiled_with_impl(
return None;
}
let halo = params.halo_for(bounds);
if matches!(params, FilterParams::UnsharpMask { .. }) {
let Halo::Radius(reach) = halo else { return None };
let (w, h) = (area.width().min(tile as u32) as usize, area.height().min(tile as u32) as usize);
let read_samples = w.checked_add(2 * reach as usize)?.checked_mul(h.checked_add(2 * reach as usize)?)?.checked_mul(fmt.channels())?;
let tile_count = u64::from(area.width()).div_ceil(tile as u64).checked_mul(u64::from(area.height()).div_ceil(tile as u64))?;
// Reject hostile source-window sizes before Image::read allocates them.
// Ordinary filter parameters and automatic tiles fit these bounds.
if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::<f32>())? > isize::MAX as usize || tile_count > 1_048_576 {
return None;
}
}
let shared = (halo == Halo::Bounds).then(|| Image::read(surface, bounds.union(&area)));
// Shape rasterization depends only on params, not on a tile's pixels.
let shape = match params {
@@ -1090,12 +1124,15 @@ fn apply_tiled_with_impl(
None if ctl.cancelled() => None,
None => Some(kernel(params, src, *t, &ctx)),
},
(None, None) => Some(kernel(params, src, *t, &ctx)),
(None, None) => match *params {
FilterParams::UnsharpMask { amount, radius, threshold } => sharpen::exact::run(src, *t, &ctx, amount, radius, threshold, ctl),
_ => Some(kernel(params, src, *t, &ctx)),
},
}) else {
return (*t, Vec::new());
};
if let Some(sel) = selection {
mix_selection(&mut data, *t, sel, src);
mix_selection(&mut data, *t, sel, src, fmt.alpha);
}
tick();
(*t, data)
@@ -1123,9 +1160,11 @@ fn apply_tiled_with_impl(
if ctl.cancelled() {
return None;
}
// Shape Blur can reject malformed geometry or an allocation failure.
// Checked filters can reject malformed geometry or an allocation failure.
// Do not hand an incomplete tile to Surface::write_region.
if shape.is_some() && results.iter().any(|(t, data)| data.len() != t.width() as usize * t.height() as usize * fmt.channels()) {
if (shape.is_some() || matches!(params, FilterParams::UnsharpMask { .. }))
&& results.iter().any(|(t, data)| data.len() != t.width() as usize * t.height() as usize * fmt.channels())
{
return None;
}
for (t, data) in results {
@@ -1179,7 +1218,7 @@ fn apply_box_blur(
let write = |(s, v): &mut (Surface, Vec<blur::Strip>)| {
for (t, data) in v.iter_mut() {
if let Some(sel) = selection {
mix_selection(data, *t, sel, &Image::read(surface, *t));
mix_selection(data, *t, sel, &Image::read(surface, *t), fmt.alpha);
}
s.write_region(*t, data);
}
@@ -1202,15 +1241,40 @@ fn apply_box_blur(
}
/// Mixes filtered `data` for `t` back toward the original pixels in `orig` by the selection's
/// coverage (channel 0 of `sel`).
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image) {
/// coverage (channel 0 of `sel`). Changing alpha requires premultiplied colour interpolation;
/// otherwise hidden colours leak into feathered edges and alpha-removing filters darken them.
fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image, alpha: bool) {
let w = t.width() as usize;
// The selection over `t` read once, not a lookup and an allocation per pixel (#4533).
let mask = sel.read_region(t);
let sc = sel.channels().max(1);
for (i, px) in data.chunks_exact_mut(orig.ch.max(1)).enumerate() {
let (x, y) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32);
let k = sel.pixel(x, y)[0].clamp(0.0, 1.0);
let k = mask.get(i * sc).copied().unwrap_or(0.0).clamp(0.0, 1.0);
if k >= 1.0 {
continue;
}
if k <= 0.0 {
for (c, v) in px.iter_mut().enumerate() {
*v = orig.get(x, y, c);
}
continue;
}
if alpha {
let a = px.len() - 1;
let original_alpha = orig.get(x, y, a);
let filtered_alpha = px[a];
if original_alpha != filtered_alpha {
let mixed_alpha = original_alpha + (filtered_alpha - original_alpha) * k;
let original_weight = original_alpha * (1.0 - k);
let filtered_weight = filtered_alpha * k;
for (c, v) in px[..a].iter_mut().enumerate() {
*v = if mixed_alpha > 0.0 { (orig.get(x, y, c) * original_weight + *v * filtered_weight) / mixed_alpha } else { 0.0 };
}
px[a] = mixed_alpha;
continue;
}
}
for (c, v) in px.iter_mut().enumerate() {
let o = orig.get(x, y, c);
*v = o + (*v - o) * k;
@@ -1229,6 +1293,9 @@ mod tests_ext;
#[cfg(test)]
mod tests_mosaic;
#[cfg(test)]
mod tests_selection_mix;
/// The current upstream sampler through the identical source/selection/tile pipeline.
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
+196 -56
View File
@@ -154,7 +154,7 @@ impl LiquifyStroke {
}
/// The displacement field plus the freeze mask, over `bounds` (document pixels).
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug)]
pub struct LiquifyField {
pub bounds: Rect,
/// Document pixels per field node.
@@ -165,6 +165,38 @@ pub struct LiquifyField {
pub d: Vec<[f32; 2]>,
/// Freeze mask per node (0 = free, 1 = frozen).
pub freeze: Vec<f32>,
/// Where the brush stroke in progress stands, so dab spacing runs on across its segments.
cursor: Option<StrokeCursor>,
}
/// Two fields are equal when their displacement and freeze mask are: the stroke cursor is
/// transient brush state, not part of the result.
impl PartialEq for LiquifyField {
fn eq(&self, o: &Self) -> bool {
self.bounds == o.bounds && self.cell == o.cell && self.w == o.w && self.h == o.h && self.d == o.d && self.freeze == o.freeze
}
}
/// The brush stroke in progress: the point its last segment ended at, its last dab and how far
/// the brush has travelled along the path since that dab.
#[derive(Clone, Copy, Debug, PartialEq)]
struct StrokeCursor {
end: [f64; 2],
last_dab: [f64; 2],
carry: f64,
}
/// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a
/// drag is many small pushes and its edge stays smooth.
fn dab_spacing(r: f64) -> f64 {
(r * 0.05).max(0.5)
}
/// Per-dab strength of a stationary tool (Twirl, Pucker, Bloat, Reconstruct, Smooth, Freeze,
/// Thaw) while it moves: scaled to the spacing so its effect per distance travelled stays what it
/// was with dabs a fifth of the radius apart.
fn moving_weight(r: f64) -> f64 {
dab_spacing(r) / (r * 0.2).max(0.5)
}
/// The default field resolution for `bounds`: 2 px per node up to 4 MP, 4 px beyond.
@@ -177,7 +209,7 @@ impl LiquifyField {
let cell = cell.clamp(1.0, 64.0);
let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2;
let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2;
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h] }
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None }
}
/// Document position of node (i, j) (node 0 sits on the first pixel centre).
@@ -220,6 +252,7 @@ impl LiquifyField {
/// Applies one stroke. Returns the document rectangle whose output changed.
pub fn apply_stroke(&mut self, s: &LiquifyStroke) -> Rect {
self.cursor = None;
match s.tool {
LiquifyTool::LassoMask => return self.apply_lasso(s),
LiquifyTool::ReconstructAll => {
@@ -306,77 +339,120 @@ impl LiquifyField {
/// callers use this plus [`Self::stroke_segment`] per new point, which is exactly what
/// [`Self::apply_stroke`] does with the whole point list.
pub fn stroke_begin(&mut self, s: &LiquifyStroke, p: [f64; 3]) -> Rect {
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0)) } else { Rect::EMPTY }
self.cursor = Some(StrokeCursor { end: [p[0], p[1]], last_dab: [p[0], p[1]], carry: 0.0 });
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0), 1.0) } else { Rect::EMPTY }
}
/// Continues a stroke from `a` to `b`: dabs every fifth of the brush radius (a stationary
/// tool held still dabs once at `b`).
/// Continues a stroke from `a` to `b`. Dabs fall every [`dab_spacing`] of distance travelled
/// along the whole stroke, carried from one segment to the next, so the result is the same
/// however finely the pointer path was sampled (a fast and a slow mouse give one warp). A
/// stationary tool held still dabs once at `b`. When `a` is not where the previous segment
/// ended, the stroke starts afresh at `a`.
pub fn stroke_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
self.walk_segment(s, a, b, true)
}
/// [`Self::stroke_segment`]; `skip` leaves out the dabs that cannot reach a field node (they
/// would change nothing), which tests compare with the full walk.
fn walk_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3], skip: bool) -> Rect {
let mut dirty = Rect::EMPTY;
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
let stationary = s.tool.is_stationary();
let cur = match self.cursor {
Some(c) if c.end == [a[0], a[1]] => c,
_ => StrokeCursor { end: [a[0], a[1]], last_dab: [a[0], a[1]], carry: 0.0 },
};
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len = dx.hypot(dy);
if len == 0.0 {
return if s.tool.is_stationary() { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb) } else { dirty };
self.cursor = Some(cur);
return if stationary { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb, 1.0) } else { dirty };
}
// NaN or a length that overflowed: nothing sensible to walk; the next segment restarts.
if !len.is_finite() {
self.cursor = None;
return dirty;
}
let r = (s.size / 2.0).max(0.5);
let spacing = (r * 0.2).max(0.5);
let steps = (len / spacing).ceil().max(1.0) as usize;
// Walk only the steps whose dab can reach a field node: the rest return from `dab` before
// touching anything, and a segment to a far-off point has trillions of them (#937).
let Some((k0, k1)) = self.reachable_steps(a, b, steps, r, s.tool.is_stationary()) else { return dirty };
for k in k0..=k1 {
let t0 = (k - 1) as f64 / steps as f64;
let t1 = k as f64 / steps as f64;
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
let pr = pa + (pb - pa) * t1;
// Forward Warp centres the dab where the brush was (it drags what is under it).
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
dirty = dirty.union(&self.dab(s, centre, delta, pr));
let spacing = dab_spacing(r);
let weight = if stationary { moving_weight(r) } else { 1.0 };
// Distance along this segment of its first dab, then one every `spacing`.
let first = (spacing - cur.carry).clamp(0.0, spacing);
// Distances are compared with a little slack: a dab that rounding puts a hair past the
// segment's end still falls on it, so cutting one segment in two never adds or drops a dab.
let slack = spacing * 1e-6;
if first > len + slack {
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: cur.last_dab, carry: cur.carry + len });
return dirty;
}
let last_k = ((len - first + slack) / spacing).floor().max(0.0);
let at = |dist: f64| [a[0] + dx * dist / len, a[1] + dy * dist / len];
let (mut k0, mut k1) = (0.0f64, last_k);
if skip {
match self.reachable_span(a, [dx, dy], len, r) {
// A dragging dab is centred one spacing behind its position: pad by that and a step.
Some((d0, d1)) => {
k0 = k0.max(((d0 - spacing - first) / spacing).floor() - 1.0);
k1 = k1.min(((d1 + spacing - first) / spacing).ceil() + 1.0);
}
None => k1 = -1.0,
}
}
if k0 <= k1 {
let mut prev = if k0 > 0.0 { at(first + (k0 - 1.0) * spacing) } else { cur.last_dab };
for k in (k0 as u64)..=(k1 as u64) {
let dist = (first + k as f64 * spacing).min(len);
let c = at(dist);
let delta = [c[0] - prev[0], c[1] - prev[1]];
// Forward Warp centres the dab where the brush was (it drags what is under it).
let centre = if stationary { c } else { prev };
dirty = dirty.union(&self.dab(s, centre, delta, pa + (pb - pa) * dist / len, weight));
prev = c;
}
}
let last = (first + last_k * spacing).min(len);
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: at(last), carry: (len - last).clamp(0.0, spacing) });
dirty
}
/// The steps `k0..=k1` of a segment walked in `steps` whose dab can touch a field node, or
/// `None` when none can. Step `k` centres its dab at `a + (b - a)·(k - o)/steps`, with `o` = 1
/// for the dragging tools and 0 for the stationary ones (see `stroke_segment`). A dab reaches a
/// node only when its centre is within `r` plus a node of the node grid, so the window below
/// pads that by another node and a step: the dabs it leaves out would each change nothing.
fn reachable_steps(&self, a: [f64; 3], b: [f64; 3], steps: usize, r: f64, stationary: bool) -> Option<(usize, usize)> {
/// The distances along a segment from `a` (direction `dir`, length `len`) whose points lie
/// within a dab radius `r` plus two nodes of the node grid, or `None` when none do. Only dabs
/// near that span can touch a node; a segment to a far-off point has trillions of others
/// (#937).
fn reachable_span(&self, a: [f64; 3], dir: [f64; 2], len: f64, r: f64) -> Option<(f64, f64)> {
if self.w == 0 || self.h == 0 {
return None;
}
let pad = r + 2.0 * self.cell;
let lo = [f64::from(self.bounds.x0) + 0.5 - pad, f64::from(self.bounds.y0) + 0.5 - pad];
let hi = [lo[0] + (self.w - 1) as f64 * self.cell + 2.0 * pad, lo[1] + (self.h - 1) as f64 * self.cell + 2.0 * pad];
let (n, o) = (steps as f64, if stationary { 0.0 } else { 1.0 });
let (mut k0, mut k1) = (1.0f64, n);
let (mut u0, mut u1) = (0.0f64, 1.0f64);
for ax in 0..2 {
let d = b[ax] - a[ax];
let d = dir[ax];
if d == 0.0 {
if a[ax].is_nan() || a[ax] < lo[ax] || a[ax] > hi[ax] {
return None;
}
continue;
}
let (u0, u1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
k0 = k0.max((u0.min(u1) * n + o).floor() - 1.0);
k1 = k1.min((u0.max(u1) * n + o).ceil() + 1.0);
let (v0, v1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
u0 = u0.max(v0.min(v1));
u1 = u1.min(v0.max(v1));
}
// NaN: a segment whose length overflowed.
if k0.is_nan() || k1.is_nan() || k0 > k1 {
if u0.is_nan() || u1.is_nan() || u0 > u1 {
return None;
}
Some((k0 as usize, k1 as usize))
Some((u0 * len, u1 * len))
}
/// One dab at `c` with brush motion `delta`. Returns the document rect it affected.
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64) -> Rect {
/// One dab at `c` with brush motion `delta`, its strength scaled by `weight`. Returns the
/// document rect it affected.
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64, weight: f64) -> Rect {
if s.tool == LiquifyTool::LassoMask {
return Rect::EMPTY; // the lasso is polygon-only; never a brush dab
}
let r = (s.size / 2.0).max(0.5);
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure;
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure * weight;
let rate = (s.rate / 100.0).clamp(0.0, 1.0);
let density = (s.density / 100.0).clamp(0.0, 1.0);
if strength <= 0.0 {
@@ -921,21 +997,9 @@ mod tests {
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
/// The segment walk before #937: every step, whether or not its dab can reach the field.
/// The segment walk before #937: every dab, whether or not it can reach the field.
fn full_walk(f: &mut LiquifyField, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
let mut dirty = Rect::EMPTY;
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
let r = (s.size / 2.0).max(0.5);
let steps = (len / (r * 0.2).max(0.5)).ceil().max(1.0) as usize;
for k in 1..=steps {
let (t0, t1) = ((k - 1) as f64 / steps as f64, k as f64 / steps as f64);
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
dirty = dirty.union(&f.dab(s, centre, delta, pa + (pb - pa) * t1));
}
dirty
f.walk_segment(s, a, b, false)
}
#[test]
@@ -945,7 +1009,7 @@ mod tests {
// every result bit-identical to the full walk.
use LiquifyTool::*;
let bounds = Rect::new(-7, 3, 89, 67);
let segments: [([f64; 3], [f64; 3]); 9] = [
let segments: [([f64; 3], [f64; 3]); 10] = [
([10.0, 20.0, 1.0], [70.0, 50.0, 0.5]), // inside
([-900.0, 30.0, 1.0], [40.0, 31.0, 1.0]), // in from far left
([40.0, 30.0, 1.0], [40.0, 4000.0, 0.2]), // out downwards
@@ -955,6 +1019,7 @@ mod tests {
([88.5, -50.0, 1.0], [88.5, 120.0, 1.0]), // vertical, along the right edge
([200.0, 200.0, 1.0], [201.0, 900.0, 1.0]), // entirely outside
([0.0, 0.0, 1.0], [0.25, 0.1, 1.0]), // shorter than one step
([60.0, 40.0, 1.0], [-2000.0, 45.0, 1.0]), // continues the stroke before it, out left
];
for tool in [ForwardWarp, PushLeft, Reconstruct, Smooth, TwirlCw, TwirlCcw, Pucker, Bloat, Freeze, Thaw] {
for size in [6.0, 40.0] {
@@ -973,6 +1038,81 @@ mod tests {
}
}
/// The same path sampled by `parts` pointer events per corner-to-corner leg, unevenly.
fn sampled(path: &[[f64; 3]], parts: usize) -> Vec<Vec<f64>> {
let mut out = vec![path[0].to_vec()];
for w in path.windows(2) {
let (a, b) = (w[0], w[1]);
// Uneven steps, as a real pointer gives: t grows quadratically.
for k in 1..=parts {
let t = (k * k) as f64 / (parts * parts) as f64;
out.push((0..3).map(|i| a[i] + (b[i] - a[i]) * t).collect());
}
}
out
}
/// #2606: dabs restarted at every pointer event, so a fast mouse (few far-apart events) and a
/// slow one (many close ones) warped the same path differently. Spacing now runs on along the
/// whole stroke: any sampling of a path gives the same field.
#[test]
fn the_result_does_not_depend_on_how_finely_the_path_was_sampled() {
use LiquifyTool::*;
let path = [[20.0, 20.0, 1.0], [62.0, 26.0, 0.6], [70.0, 50.0, 0.9], [28.0, 55.0, 1.0], [30.0, 30.0, 1.0]];
for tool in [ForwardWarp, PushLeft, TwirlCw, Bloat, Reconstruct, Smooth, Freeze] {
for size in [12.0, 40.0] {
let field = |parts: usize| {
let mut warp = LiquifyStroke::new(ForwardWarp, 30.0);
warp.points = vec![vec![30.0, 30.0], vec![50.0, 40.0]];
let mut s = LiquifyStroke::new(tool, size);
s.points = sampled(&path, parts);
// Some displacement first, so Reconstruct and Smooth have something to change.
LiquifyField::from_strokes(bounds(), 2.0, &[warp, s])
};
let coarse = field(1);
for parts in [3, 17, 60] {
let fine = field(parts);
let dd = coarse.d.iter().zip(&fine.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
let df = coarse.freeze.iter().zip(&fine.freeze).map(|(p, q)| (p - q).abs()).fold(0.0f32, f32::max);
assert!(dd < 1e-3 && df < 1e-4, "{tool:?} {size}px, {parts} events per leg: field differs by {dd} px, mask by {df}");
}
}
}
}
/// A long drag whose events land exactly on dab positions, along a diagonal: rounding must not
/// add or drop the dab at a segment's end, or the deep displacement a long Forward Warp builds
/// up differs by a whole dab between a slow and a fast pointer.
#[test]
fn a_long_diagonal_drag_gives_one_field_at_any_event_spacing() {
let field = |step: f64| {
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 40.0);
s.points = (0..=(300.0 / step) as usize).map(|k| vec![40.0 + k as f64 * step * 0.6, 20.0 + k as f64 * step * 0.8]).collect();
LiquifyField::from_strokes(Rect::new(0, 0, 320, 300), 2.0, &[s])
};
let (slow, fast) = (field(2.0), field(60.0));
let dd = slow.d.iter().zip(&fast.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
assert!(slow.max_displacement() > 50.0, "the drag builds up a deep displacement");
assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px");
}
#[test]
fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() {
// A segment that does not start where the last one ended begins a new run of dabs there.
let s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 20.0);
let mut f = LiquifyField::new(bounds(), 2.0);
f.stroke_begin(&s, [10.0, 10.0, 1.0]);
f.stroke_segment(&s, [10.0, 10.0, 1.0], [10.3, 10.0, 1.0]);
let mut g = LiquifyField::new(bounds(), 2.0);
f.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
g.stroke_begin(&s, [40.0, 30.0, 1.0]);
g.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
assert_eq!(f, g);
// Hostile points: nothing panics and the next segment starts afresh.
f.stroke_segment(&s, [f64::NAN, 3.0, 1.0], [4.0, f64::INFINITY, 1.0]);
f.stroke_segment(&s, [4.0, 3.0, f64::NAN], [9.0, 3.0, 1.0]);
}
#[test]
fn a_segment_to_a_far_off_point_returns() {
// The issue's request: about 3.3e14 steps before the fix. Also a segment whose length
@@ -1110,8 +1250,8 @@ mod tests {
/// dab's circle (a hard-edged block at Density 0, half strength at the default 50).
#[test]
fn forward_warp_leaves_the_brush_rim_in_place() {
// A drag of one dab spacing (a fifth of the radius): one dab, centred where it started.
let (r, drag) = (20.0, 4.0);
// A drag of one dab spacing (a twentieth of the radius): one dab, centred where it started.
let (r, drag) = (20.0, 1.0);
for density in [0.0, 50.0, 100.0] {
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 2.0 * r);
s.density = density;
+51 -5
View File
@@ -35,7 +35,8 @@ pub struct RefineParams {
pub feather: f32,
/// 0–100.
pub contrast: f32,
/// −100–100 (% of `max(radius, 4 px)`).
/// −100–100. 100% moves the edge by `max(radius, 4)` pixels. Values between whole
/// pixels blend the two neighbouring shifts, so the slider does not jump.
pub shift_edge: f32,
}
@@ -59,8 +60,18 @@ impl RefineParams {
fn feather_sigma(&self) -> f32 {
self.feather.max(0.0) / 2.0
}
/// Signed shift in pixels, fractional. 100% is `max(radius, 4)` px, clamped to ±64.
fn shift_amount(&self) -> f32 {
let px = self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0);
if px.is_finite() { px.clamp(-64.0, 64.0) } else { 0.0 }
}
/// How many pixels the mask can move: the farther of the two integer radii a fractional
/// shift blends. Whole-pixel amounts stay exact (`ceil(4) == 4`).
fn shift_px(&self) -> i32 {
(self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0)).round().clamp(-64.0, 64.0) as i32
let px = self.shift_amount();
let reach = px.abs().ceil();
let signed = if px.is_sign_negative() { -reach } else { reach };
if signed.is_finite() { signed as i32 } else { 0 }
}
fn uses_image(&self) -> bool {
self.radius > 0.0
@@ -370,13 +381,38 @@ where
*v = ((*v - 0.5) * gain + 0.5).clamp(0.0, 1.0);
}
}
let d = p.shift_px();
if d != 0 {
a = morphology(&a, w, h, d.unsigned_abs() as usize, d > 0);
let amount = p.shift_amount();
if amount.abs() >= 1.0e-4 {
a = shift_mask(&a, w, h, amount, morphology);
}
a
}
/// Grow or shrink `src` by a fractional pixel distance. Whole pixels use one morphology;
/// the remainder blends that result with the next pixel, so 0.3 px is not the same step as 1 px.
fn shift_mask<F>(src: &[f32], w: usize, h: usize, amount: f32, morphology: F) -> Vec<f32>
where
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy,
{
let mag = amount.abs();
if !mag.is_finite() || mag < 1.0e-4 {
return src.to_vec();
}
let grow = amount > 0.0;
let whole = mag.floor() as usize;
let frac = mag - whole as f32;
if frac < 1.0e-4 {
return morphology(src, w, h, whole, grow);
}
let lo = if whole == 0 { src.to_vec() } else { morphology(src, w, h, whole, grow) };
let hi = morphology(src, w, h, whole + 1, grow);
let n = w.saturating_mul(h);
if lo.len() < n || hi.len() < n || lo.len() != hi.len() {
return hi;
}
lo.into_iter().zip(hi).map(|(a, b)| a + (b - a) * frac).collect()
}
/// Refines a selection mask. `mask(r)` returns coverage over `r` (row-major); `content` bounds
/// its non-zero pixels; the result is clipped to `canvas`. `sampler` supplies the guide image
/// (only read when `radius > 0`). Returns `None` when nothing remains selected.
@@ -720,6 +756,16 @@ mod tests {
let shrunk = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -50.0, ..Default::default() });
assert_eq!(shrunk[5 * w + 27], 1.0);
assert_eq!(shrunk[5 * w + 28], 0.0);
// Radius 0 scales 100% to 4 px, so 12.5% is half a pixel and 25% is one pixel.
// The pixel just outside a hard edge fades in instead of snapping on.
let half = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 12.5, ..Default::default() });
let one = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 25.0, ..Default::default() });
assert_eq!(m[5 * w + 30], 0.0);
assert!((half[5 * w + 30] - 0.5).abs() < 0.02, "half pixel {}", half[5 * w + 30]);
assert_eq!(one[5 * w + 30], 1.0);
assert_eq!(half[5 * w + 31], 0.0, "the blend must not jump a second pixel");
let pulled = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -12.5, ..Default::default() });
assert!((pulled[5 * w + 29] - 0.5).abs() < 0.02, "half-pixel contract {}", pulled[5 * w + 29]);
let sm = refine_buffer(None, &m, w, h, &RefineParams { smooth: 50.0, ..Default::default() });
assert!(sm[5 * w + 10] == 1.0 && sm[5 * w + 50] == 0.0);
}
+80 -2
View File
@@ -128,12 +128,32 @@ pub(crate) fn edge_of(u: UndefinedAreas) -> Edge {
/// Shifts the bounds' content by whole pixels.
pub(crate) fn offset(src: &Image, out: Rect, ctx: &Ctx, dx: i32, dy: i32, undefined: UndefinedAreas) -> Vec<f32> {
let n = src.ch;
let edge = edge_of(undefined);
// Subtraction can leave i32 even for valid command parameters. Apply the edge
// policy before narrowing: saturating first would change the wrapped pixel.
let coordinate = |position: i32, distance: i32, start: i32, end: i32| -> Option<i32> {
if start >= end {
return None;
}
let p = i64::from(position) - i64::from(distance);
let (start, end) = (i64::from(start), i64::from(end));
let mapped = match undefined {
UndefinedAreas::Wrap => start + (p - start).rem_euclid(end - start),
UndefinedAreas::Repeat => p.clamp(start, end - 1),
UndefinedAreas::Transparent if p >= start && p < end => p,
UndefinedAreas::Transparent => return None,
};
i32::try_from(mapped).ok()
};
let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n);
for y in out.y0..out.y1 {
let sy = coordinate(y, dy, ctx.bounds.y0, ctx.bounds.y1);
for x in out.x0..out.x1 {
let sx = coordinate(x, dx, ctx.bounds.x0, ctx.bounds.x1);
for c in 0..n {
res.push(src.get_edge(x - dx, y - dy, c, edge, ctx.bounds));
res.push(match (sx, sy) {
(Some(sx), Some(sy)) => src.get(sx, sy, c),
_ => 0.0,
});
}
}
}
@@ -186,3 +206,61 @@ mod tests {
}
}
}
#[cfg(test)]
mod offset_overflow_tests {
use super::*;
#[test]
fn offset_limits_match_equivalent_small_offsets_for_every_edge_policy() {
for bounds in [Rect::new(0, 0, 3, 5), Rect::new(-7, 11, -4, 16)] {
let mut src = Image::new(bounds, 4);
// Include signed/HDR values and varying alpha; offset must only move samples.
for (i, sample) in src.data.iter_mut().enumerate() {
*sample = i as f32 * 0.125 - 2.0;
}
let ctx = Ctx { bounds, mode: photocraft_color::ColorMode::Rgb, alpha: true };
for policy in [UndefinedAreas::Wrap, UndefinedAreas::Repeat, UndefinedAreas::Transparent] {
for dx in [i32::MIN, i32::MAX, -8, 0, 7] {
for dy in [i32::MIN, i32::MAX, -9, 0, 6] {
let reduce = |d: i32, size: i32| match policy {
UndefinedAreas::Wrap => d.rem_euclid(size),
_ => d.clamp(-100, 100),
};
let (rx, ry) = (reduce(dx, 3), reduce(dy, 5));
let mut expected = Vec::new();
for y in bounds.y0..bounds.y1 {
for x in bounds.x0..bounds.x1 {
for c in 0..4 {
expected.push(src.get_edge(x - rx, y - ry, c, edge_of(policy), bounds));
}
}
}
assert_eq!(offset(&src, bounds, &ctx, dx, dy, policy), expected, "{bounds:?} {policy:?} {dx} {dy}");
}
}
}
}
}
#[test]
fn offset_wrap_handles_bounds_at_signed_coordinate_limits() {
for start in [i32::MIN, i32::MAX - 3] {
let bounds = Rect::new(start, start, start + 3, start + 3);
let mut src = Image::new(bounds, 1);
src.data = (0..9).map(|i| i as f32).collect();
let ctx = Ctx { bounds, mode: photocraft_color::ColorMode::Grayscale, alpha: false };
assert_eq!(offset(&src, bounds, &ctx, i32::MIN, i32::MIN, UndefinedAreas::Wrap), vec![8.0, 6.0, 7.0, 2.0, 0.0, 1.0, 5.0, 3.0, 4.0]);
}
}
#[test]
fn offset_wrap_accepts_minimum_signed_distance() {
let bounds = Rect::new(0, 0, 3, 1);
let mut src = Image::new(bounds, 1);
src.data = vec![0.1, 0.5, 0.9];
let ctx = Ctx { bounds, mode: photocraft_color::ColorMode::Grayscale, alpha: false };
let got = offset(&src, bounds, &ctx, i32::MIN, 0, UndefinedAreas::Wrap);
assert_eq!(got, vec![0.9, 0.1, 0.5]);
}
}
+52 -2
View File
@@ -63,6 +63,7 @@ fn composite_area(
coverage: impl Fn(i32, i32) -> f32 + Sync,
color: impl Fn(i32, i32) -> [f32; 4] + Sync,
lock_transparency: bool,
inks: Option<[f32; 4]>,
) {
// Composite tile by tile (in parallel on native); tiles with zero coverage are left untouched,
// so their copy-on-write storage (and GPU residency) survives.
@@ -93,6 +94,14 @@ fn composite_area(
o[3] = d[3];
}
from_rgba_into(&fmt, o, &mut enc);
// A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is.
if let Some(i) = inks
&& fmt.mode == photocraft_color::ColorMode::Cmyk
&& blend_mode == BlendMode::Normal
&& k >= 1.0
{
enc[..4].copy_from_slice(&i);
}
px.copy_from_slice(&enc[..n]);
}
}
@@ -144,6 +153,7 @@ pub fn paint_gradient(
c
},
lock_transparency,
None,
);
}
@@ -179,6 +189,25 @@ pub fn bucket_fill_src(
selection: Option<&Surface>,
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
) -> bool {
bucket_fill_inks(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, src, None)
}
/// [`bucket_fill_src`] where `inks` (CMYK, 0..1) are what a fully covered pixel of a CMYK target
/// gets instead of `src`'s sRGB stand-in (a colour that was picked as CMYK).
#[allow(clippy::too_many_arguments)]
pub fn bucket_fill_inks(
s: &mut Surface,
area: Rect,
seed: (i32, i32),
tolerance: f32,
contiguous: bool,
anti_alias: bool,
opacity: f32,
selection: Option<&Surface>,
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
inks: Option<[f32; 4]>,
) -> bool {
if !area.contains(seed.0, seed.1) {
return false;
@@ -186,7 +215,7 @@ pub fn bucket_fill_src(
let img = crate::selection::rgba8_image(s, area);
let Some(region) = crate::selection::wand_region(&img, area, seed, tolerance, contiguous, anti_alias) else { return false };
drop(img);
bucket_fill_region_src(s, &region, opacity, selection, lock_transparency, src);
bucket_fill_region_inks(s, &region, opacity, selection, lock_transparency, src, inks);
true
}
@@ -199,10 +228,31 @@ pub fn bucket_fill_region_src(
selection: Option<&Surface>,
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
) {
bucket_fill_region_inks(s, region, opacity, selection, lock_transparency, src, None);
}
/// [`bucket_fill_region_src`] with the CMYK `inks` of [`bucket_fill_inks`].
pub fn bucket_fill_region_inks(
s: &mut Surface,
region: &crate::selection::Region,
opacity: f32,
selection: Option<&Surface>,
lock_transparency: bool,
src: impl Fn(i32, i32) -> [f32; 4] + Sync,
inks: Option<[f32; 4]>,
) {
// Composite only over the filled region's box.
let b = region.bbox;
composite_area(s, b, BlendMode::Normal, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, lock_transparency);
composite_area(
s,
b,
BlendMode::Normal,
|x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)),
src,
lock_transparency,
inks,
);
}
#[cfg(test)]
+13 -2
View File
@@ -115,8 +115,11 @@ pub(crate) fn color_halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32,
res
}
/// Crystallize: Voronoi cells around jittered points, each filled with the
/// (premultiplied) average colour of its pixels.
/// Crystallize: Voronoi cells around jittered points, each filled with the colour at its
/// point, as Photoshop's are: cells stay solid, so a black-and-white image (a layer mask
/// being distressed) keeps hard edges and its weight instead of greying where cells straddle
/// an edge. A cell whose point falls outside the image takes the (premultiplied) average
/// colour of its pixels.
pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, seed: u32) -> Vec<f32> {
let n = src.ch;
let cell = cell_size.max(2.0);
@@ -177,6 +180,14 @@ pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, see
continue;
}
let (cx, cy) = if win.contains(x, y) { owners[i] } else { owner(x, y) };
let (sx, sy) = cell_point(cx, cy, cell, seed, origin);
let (sx, sy) = (sx.floor() as i32, sy.floor() as i32);
if src.rect.contains(sx, sy) && b.contains(sx, sy) {
for (c, v) in px.iter_mut().enumerate() {
*v = src.get(sx, sy, c);
}
continue;
}
if cx < c0.0 || cy < c0.1 || cx > c1.0 || cy > c1.1 {
continue;
}
+1 -1
View File
@@ -153,7 +153,7 @@ fn tiled_filters_match_scalar_at_all_depths_with_soft_selection() {
FilterParams::ColorHalftone { max_radius, angles } => reference::halftone(&image, bounds, &ctx, max_radius, angles),
_ => Vec::new(),
};
crate::mix_selection(&mut expected_data, bounds, &selection, &image);
crate::mix_selection(&mut expected_data, bounds, &selection, &image, ctx.alpha);
let mut expected = surface.clone();
expected.write_region(bounds, &expected_data);
for tile in [1, 7, 17, 256] {
+86 -2
View File
@@ -1277,11 +1277,18 @@ pub const MAX_WARP_PIXELS: f64 = (1u64 << 30) as f64;
/// Applies a puppet warp to `src` (content inside `bounds`). `None` when the deformed mesh would
/// span more than [`MAX_WARP_PIXELS`].
pub fn puppet_warp(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp) -> Option<Surface> {
puppet_warp_with_mask(src, bounds, w, interp, None).map(|(out, _)| out)
}
/// [`puppet_warp`], also moving `mask` (a one-channel layer mask linked to `src`) with the same
/// mesh, as Photoshop moves a linked mask with its layer (#4910). The mask is `None` in the
/// result when none was given.
pub fn puppet_warp_with_mask(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp, mask: Option<&Surface>) -> Option<(Surface, Option<Surface>)> {
let (solver, v, order) = deform(src, bounds, w, ITERATIONS);
// No triangles (an expansion that shrinks every pixel away): nothing to warp. Rendering the
// empty mesh would drop every pixel of the layer.
if solver.mesh.tris.is_empty() {
return Some(src.clone());
return Some((src.clone(), mask.cloned()));
}
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in &v {
@@ -1291,7 +1298,57 @@ pub fn puppet_warp(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp)
if area.is_nan() || area > MAX_WARP_PIXELS {
return None;
}
Some(render(src, bounds, &solver.mesh, &v, &order, interp))
let out = render(src, bounds, &solver.mesh, &v, &order, interp);
Some((out, mask.map(|m| render_mask(m, &solver.mesh, &v, &order, interp))))
}
/// Moves a one-channel layer mask with a deformed mesh: the mask under the mesh is lifted,
/// deformed like the layer's pixels and laid over the rest. The area the mesh leaves takes the
/// mask's default value (as [`crate::warp::warp_mesh_gray`] leaves it); outside the mesh the
/// mask keeps its values.
pub fn render_mask(mask: &Surface, mesh: &PuppetMesh, deformed: &[[f64; 2]], order: &[usize], interp: Interp) -> Surface {
let mut out = mask.clone();
if mesh.tris.is_empty() || deformed.len() != mesh.verts.len() || mask.format().channels() != 1 {
return out;
}
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in &mesh.verts {
(x0, y0, x1, y1) = (x0.min(p[0]), y0.min(p[1]), x1.max(p[0]), y1.max(p[1]));
}
if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) {
return out;
}
// The mesh's source area, a pixel wider so its edge samples stay inside.
let lim = f64::from(1 << 20);
let src = Rect::new(x0.max(-lim).floor() as i32 - 1, y0.max(-lim).floor() as i32 - 1, x1.min(lim).ceil() as i32 + 1, y1.min(lim).ceil() as i32 + 1);
if src.is_empty() {
return out;
}
let default = mask.default_pixel().first().copied().unwrap_or(0.0);
let fmt = mask.format();
let mut tmp = Surface::new(photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true));
tmp.write_region(src, &mask.read_region(src).iter().flat_map(|g| [*g, 1.0]).collect::<Vec<f32>>());
let tris: Vec<[usize; 3]> = order.iter().filter_map(|&t| mesh.tris.get(t).copied()).collect();
// Lift: where the undeformed mesh covers the mask, the mask takes its default value.
let still: Vec<([f64; 2], [f64; 2])> = mesh.verts.iter().map(|s| (*s, *s)).collect();
let lifted = crate::warp::warp_triangles(&tmp, src, &still, &tris, Interp::Nearest);
let b = lifted.content_bounds();
if !b.is_empty() {
let under = out.read_region(b);
let flat: Vec<f32> = lifted.read_region(b).as_chunks::<2>().0.iter().zip(&under).map(|(p, u)| default * p[1] + u * (1.0 - p[1])).collect();
out.write_region(b, &flat);
}
// Lay the deformed mask over what is left.
let moved: Vec<([f64; 2], [f64; 2])> = deformed.iter().zip(&mesh.verts).map(|(d, s)| (*d, *s)).collect();
let warped = crate::warp::warp_triangles(&tmp, src, &moved, &tris, interp);
let b = warped.content_bounds();
if !b.is_empty() {
let under = out.read_region(b);
let flat: Vec<f32> = warped.read_region(b).as_chunks::<2>().0.iter().zip(&under).map(|(p, u)| p[0] * p[1] + u * (1.0 - p[1])).collect();
out.write_region(b, &flat);
}
out.prune();
out
}
#[cfg(test)]
@@ -1434,6 +1491,33 @@ mod tests {
assert!(!near.content_bounds().is_empty());
}
#[test]
fn a_linked_mask_moves_with_the_mesh_and_leaves_its_default_behind() {
// #4910. A hide-all mask (default 0) that reveals the bar's top (y 10..30): moved down by
// 8, the revealed patch moves along, what it left is hidden again, and the mask away
// from the puppet keeps its values.
let s = blob(SampleType::U8);
let b = s.content_bounds();
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut mask = Surface::with_default(PixelFormat::new(ColorMode::Grayscale, sample, false), &[0.0]);
mask.fill_rect(Rect::new(20, 10, 36, 30), &[1.0]);
mask.fill_rect(Rect::new(90, 90, 94, 94), &[0.5]);
let w = PuppetWarp {
pins: vec![PuppetPin { src: [28.0, 30.0], dst: [28.0, 38.0], rotate: None, depth: 0 }],
mode: PuppetMode::Rigid,
density: PuppetDensity::Normal,
expansion: 2.0,
};
let (_, moved) = puppet_warp_with_mask(&s, b, &w, Interp::Bilinear, Some(&mask)).expect("warps");
let m = moved.expect("a mask in, a mask out");
let at = |x, y| m.sample_channel(x, y, 0);
assert!(at(28, 20) > 0.99 && at(28, 36) > 0.99, "{sample:?}: the revealed patch moved down");
assert!(at(28, 15) < 0.01, "{sample:?}: its old top is hidden again");
assert!((at(91, 91) - 0.5).abs() <= 1.0 / 255.0, "{sample:?}: away from the mesh nothing changes");
assert!(puppet_warp_with_mask(&s, b, &w, Interp::Bilinear, None).expect("warps").1.is_none());
}
}
#[test]
fn a_single_pin_translates_rigidly() {
let s = blob(SampleType::U8);
+33 -29
View File
@@ -117,49 +117,53 @@ pub fn sponge(c: [f32; 3], amount: f32, saturate: bool, vibrance: bool) -> [f32;
pub fn local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usize>, win: (usize, usize, usize, usize), sigma: f32) -> Vec<f32> {
let (x0, y0, x1, y1) = win;
let (ow, oh) = (x1 - x0, y1 - y0);
if ow == 0 || oh == 0 || ch == 0 {
return Vec::new();
}
let r = (sigma * 3.0).ceil().max(1.0) as i32;
let k: Vec<f32> = (-r..=r).map(|i| (-((i * i) as f32) / (2.0 * sigma * sigma).max(1e-6)).exp()).collect();
let ks: f32 = k.iter().sum();
let k: Vec<f32> = k.into_iter().map(|v| v / ks).collect();
let get = |x: i32, y: i32, out: &mut [f32]| {
let (xx, yy) = (x.clamp(0, w as i32 - 1) as usize, y.clamp(0, h as i32 - 1) as usize);
let i = (yy * w + xx) * ch;
out.copy_from_slice(&data[i..i + ch]);
if let Some(a) = alpha {
let av = out[a];
for (c, v) in out.iter_mut().enumerate() {
if c != a {
*v *= av;
// Horizontal pass over the window rows extended vertically by r.
let th = oh + 2 * r as usize;
let stride = ow * ch;
let mut tmp = vec![0.0f32; stride * th];
let mut row = vec![0.0f32; (ow + 2 * r as usize) * ch];
for ty in 0..th {
let y = (y0 as i32 + ty as i32 - r).clamp(0, h as i32 - 1) as usize;
// Clamp and premultiply each source pixel once, rather than once for every kernel tap.
for (rx, px) in row.chunks_exact_mut(ch).enumerate() {
let x = (x0 as i32 + rx as i32 - r).clamp(0, w as i32 - 1) as usize;
let i = (y * w + x) * ch;
px.copy_from_slice(&data[i..i + ch]);
if let Some(a) = alpha {
let av = px[a];
for (c, v) in px.iter_mut().enumerate() {
if c != a {
*v *= av;
}
}
}
}
};
// Horizontal pass over the window rows extended vertically by r.
let th = oh + 2 * r as usize;
let mut tmp = vec![0.0f32; ow * th * ch];
let mut px = vec![0.0f32; ch];
for ty in 0..th {
let y = y0 as i32 + ty as i32 - r;
for ox in 0..ow {
let o = (ty * ow + ox) * ch;
for (i, kv) in k.iter().enumerate() {
get(x0 as i32 + ox as i32 + i as i32 - r, y, &mut px);
for c in 0..ch {
tmp[o + c] += px[c] * kv;
}
let dst = &mut tmp[ty * stride..(ty + 1) * stride];
// Contiguous accumulation can be vectorized for any channel layout. Each output still
// sums the same products in the same kernel order as the scalar convolution.
for (i, kv) in k.iter().enumerate() {
for (d, s) in dst.iter_mut().zip(&row[i * ch..i * ch + stride]) {
*d += s * kv;
}
}
}
let mut out = vec![0.0f32; ow * oh * ch];
for oy in 0..oh {
let dst = &mut out[oy * stride..(oy + 1) * stride];
for (i, kv) in k.iter().enumerate() {
for (d, s) in dst.iter_mut().zip(&tmp[(oy + i) * stride..(oy + i + 1) * stride]) {
*d += s * kv;
}
}
for ox in 0..ow {
let o = (oy * ow + ox) * ch;
for (i, kv) in k.iter().enumerate() {
let s = ((oy + i) * ow + ox) * ch;
for c in 0..ch {
out[o + c] += tmp[s + c] * kv;
}
}
if let Some(a) = alpha {
let av = out[o + a];
for c in 0..ch {
+161 -4
View File
@@ -7,9 +7,10 @@
//! `γ e^{−β‖z_m − z_n‖²} / dist(m, n)` (γ = 50, β from the mean squared neighbour difference).
use photocraft_geom::Rect;
use photocraft_raster::{Cancelled, Interrupt};
use super::gmm::{DEFAULT_REG, Gmm};
use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut, subsample};
use super::{FREE, HARD_BG, HARD_FG, Region, RgbImage, Sampler, contrast_beta, data_costs, grid_cut_cancellable, subsample};
/// Definite background.
pub const BG: u8 = 0;
@@ -33,6 +34,13 @@ fn is_fg(t: u8) -> bool {
/// Runs `iters` GrabCut iterations, updating the probable labels of `trimap` in place. Returns
/// the final (foreground, background) models, or `None` if either side has no pixels.
pub fn grabcut(img: &RgbImage, trimap: &mut [u8], iters: usize) -> Option<(Gmm, Gmm)> {
grabcut_with(img, trimap, iters, &Interrupt::NONE).unwrap_or_default()
}
/// [`grabcut`] that stops with `Err(Cancelled)` once `ctl` is cancelled, asked each iteration
/// and inside its min cut. `trimap` is then partly updated.
pub fn grabcut_with(img: &RgbImage, trimap: &mut [u8], iters: usize, ctl: &Interrupt) -> Result<Option<(Gmm, Gmm)>, Cancelled> {
let cancel = || ctl.cancelled();
let beta = contrast_beta(img);
let fixed: Vec<u8> = trimap
.iter()
@@ -44,17 +52,22 @@ pub fn grabcut(img: &RgbImage, trimap: &mut [u8], iters: usize) -> Option<(Gmm,
.collect();
let mut models: Option<(Gmm, Gmm)> = None;
for _ in 0..iters.max(1) {
ctl.check()?;
let (mut fs, mut bs) = (Vec::new(), Vec::new());
for (p, t) in img.px.iter().zip(trimap.iter()) {
if is_fg(*t) { fs.push(*p) } else { bs.push(*p) }
}
let (fs, bs) = (subsample(&fs, MAX_SAMPLES), subsample(&bs, MAX_SAMPLES));
let (fg, bg) = match &models {
None => (Gmm::fit(&fs, K, DEFAULT_REG)?, Gmm::fit(&bs, K, DEFAULT_REG)?),
None => match (Gmm::fit(&fs, K, DEFAULT_REG), Gmm::fit(&bs, K, DEFAULT_REG)) {
(Some(f), Some(b)) => (f, b),
_ => return Ok(None),
},
Some((f, b)) => (f.refit(&fs).unwrap_or_else(|| f.clone()), b.refit(&bs).unwrap_or_else(|| b.clone())),
};
let (cf, cb) = data_costs(img, &fg, &bg);
let cut = grid_cut(img, &cf, &cb, &fixed, GAMMA, beta);
// `grid_cut`'s weight, with the cut stopping on cancel.
let cut = grid_cut_cancellable(img, &cf, &cb, &fixed, |d2| GAMMA * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?;
let mut changed = 0usize;
for (t, fgnd) in trimap.iter_mut().zip(&cut) {
if *t == PR_BG || *t == PR_FG {
@@ -68,7 +81,7 @@ pub fn grabcut(img: &RgbImage, trimap: &mut [u8], iters: usize) -> Option<(Gmm,
break;
}
}
models
Ok(models)
}
/// Object Selection (rectangle mode): GrabCut initialised from `rect` (outside = background,
@@ -116,3 +129,147 @@ pub fn object_select(sampler: &dyn Sampler, canvas: Rect, rect: Rect, work_px: u
let low = super::clean_mask(&low, lw, lh, 0.1, 0.002);
super::finish_region(sampler, window, step, &low, lw, lh, Some((&fg, &bg)))
}
/// Working-resolution budget (pixels) for [`object_at`]: small enough to run while the pointer
/// hovers.
pub const OBJECT_AT_WORK_PX: usize = 60_000;
/// Object Selection's Object Finder (hover / click): the object under document point `(x, y)`.
///
/// A heuristic, not a semantic segmenter (that is #2788): on a working-resolution copy of the
/// canvas the area around the point is grown over pixels of similar colour (CIELAB, bounded both
/// by the distance to the seed's running mean and by the local edge step), so it stops at the
/// object's outline. A region that grows over most of the canvas is the background: nothing is
/// found. Otherwise GrabCut refines it inside its padded bounding box (the grown area probable
/// foreground, the cursor definite foreground, outside the box definite background), the part
/// connected to the cursor is kept and the boundary is re-cut at full resolution, as the
/// rectangle mode does.
pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: usize) -> Option<Region> {
if canvas.width() < 4 || canvas.height() < 4 || !x.is_finite() || !y.is_finite() {
return None;
}
let (px, py) = (x.floor() as i32, y.floor() as i32);
if !canvas.contains(px, py) {
return None;
}
let step = super::scale_for(canvas, work_px.max(1024));
let img = sampler.rgb_scaled(canvas, step);
let (w, h) = (img.w, img.h);
if w < 2 || h < 2 {
return None;
}
// Lightly smoothed, so noise and texture don't stop the growth; edges survive.
let lab: Vec<[f32; 3]> = box3(&box3(&img.px, w, h), w, h).iter().map(|c| super::slic::rgb_to_lab(*c)).collect();
let sx = (((px - canvas.x0) as usize) / step).min(w - 1);
let sy = (((py - canvas.y0) as usize) / step).min(h - 1);
let grown = grow_similar(&lab, w, h, sx, sy);
let count = grown.iter().filter(|g| **g).count();
// Background (or a flat image): no single object under the cursor.
if count * 10 > w * h * 6 || count < 4 {
return None;
}
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize);
for (i, g) in grown.iter().enumerate() {
if *g {
let (gx, gy) = (i % w, i / w);
(x0, y0, x1, y1) = (x0.min(gx), y0.min(gy), x1.max(gx + 1), y1.max(gy + 1));
}
}
let pad = ((x1 - x0).max(y1 - y0) / 6).max(2);
let (bx0, by0, bx1, by1) = (x0.saturating_sub(pad), y0.saturating_sub(pad), (x1 + pad).min(w), (y1 + pad).min(h));
let mut trimap = vec![BG; w * h];
let mut any_bg = false;
for yy in 0..h {
for xx in 0..w {
let i = yy * w + xx;
let inside = xx >= bx0 && xx < bx1 && yy >= by0 && yy < by1;
trimap[i] = if grown[i] {
PR_FG
} else if inside {
PR_BG
} else {
any_bg = true;
BG
};
}
}
if !any_bg {
// The box is the whole canvas: the frame is background.
for yy in 0..h {
for xx in 0..w {
if (xx == 0 || yy == 0 || xx + 1 == w || yy + 1 == h) && !(xx == sx && yy == sy) {
trimap[yy * w + xx] = BG;
}
}
}
}
trimap[sy * w + sx] = FG;
let (fg, bg) = grabcut(&img, &mut trimap, 5)?;
let low: Vec<bool> = trimap.iter().map(|t| is_fg(*t)).collect();
let mut seeds = vec![false; w * h];
seeds[sy * w + sx] = true;
let low = super::keep_seeded(&low, &seeds, w, h);
let low = super::clean_mask(&low, w, h, 0.1, 0.01);
if !low.iter().any(|v| *v) {
return None;
}
super::finish_region(sampler, canvas, step, &low, w, h, Some((&fg, &bg)))
}
/// Region growing (4-connected) from `(sx, sy)` over pixels whose CIELAB colour is within a
/// distance of the seed area's running mean and whose step from the neighbour it was reached
/// from is small, so it stops at edges.
fn grow_similar(lab: &[[f32; 3]], w: usize, h: usize, sx: usize, sy: usize) -> Vec<bool> {
const MEAN_DIST: f32 = 32.0;
const EDGE_STEP: f32 = 10.0;
let d = |a: [f32; 3], b: [f32; 3]| ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
let mut out = vec![false; w * h];
let Some(&seed) = lab.get(sy * w + sx) else { return out };
let (mut sum, mut n) = ([seed[0] as f64, seed[1] as f64, seed[2] as f64], 1.0f64);
let mut stack = vec![(sx, sy)];
out[sy * w + sx] = true;
while let Some((x, y)) = stack.pop() {
let c = lab[y * w + x];
let mean = [(sum[0] / n) as f32, (sum[1] / n) as f32, (sum[2] / n) as f32];
let nbrs = [(x.wrapping_sub(1), y), (x + 1, y), (x, y.wrapping_sub(1)), (x, y + 1)];
for (nx, ny) in nbrs {
if nx >= w || ny >= h {
continue;
}
let i = ny * w + nx;
if out[i] {
continue;
}
let q = lab[i];
if d(q, c) <= EDGE_STEP && d(q, mean) <= MEAN_DIST {
out[i] = true;
sum = [sum[0] + q[0] as f64, sum[1] + q[1] as f64, sum[2] + q[2] as f64];
n += 1.0;
stack.push((nx, ny));
}
}
}
out
}
/// 3×3 box blur (clamped at the edges).
fn box3(px: &[[f32; 3]], w: usize, h: usize) -> Vec<[f32; 3]> {
let mut out = vec![[0.0f32; 3]; px.len()];
for y in 0..h {
for x in 0..w {
let (mut acc, mut n) = ([0.0f32; 3], 0.0f32);
for yy in y.saturating_sub(1)..(y + 2).min(h) {
for xx in x.saturating_sub(1)..(x + 2).min(w) {
if let Some(p) = px.get(yy * w + xx) {
acc = [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]];
n += 1.0;
}
}
}
if let Some(o) = out.get_mut(y * w + x) {
*o = acc.map(|v| v / n.max(1.0));
}
}
}
out
}
@@ -71,7 +71,10 @@ impl Graph {
let ai = a as usize;
let j = self.head[ai] as usize;
work += 1;
if self.r_cap[ai] > EPS && self.dist[i] == self.dist[j].saturating_add(1) {
// `checked_add`: two nodes a global relabel left unreachable (`u32::MAX`) must
// not count as admissible for each other, or float-stranded excess ping-pongs
// between them forever.
if self.r_cap[ai] > EPS && self.dist[j].checked_add(1) == Some(self.dist[i]) {
let delta = (excess[i] as f32).min(self.r_cap[ai]);
self.r_cap[ai] -= delta;
self.r_cap[ai ^ 1] += delta;
@@ -179,3 +179,28 @@ fn solver_workload_release_comparison() {
}
}
}
#[test]
fn stranded_excess_terminates_and_matches_bk() {
// Source capacities far beyond f32 precision (ulp 32 at 3e8) next to small links: rounding
// strands excess on nodes a global relabel finds reachable from neither terminal. Those
// nodes (height u32::MAX) used to count as admissible for each other and pushed the excess
// back and forth forever (Remove Background never finished on some photos).
let mut g = Graph::with_capacity(6, 7);
for (i, &(s, t)) in [(33123824.0, 0.0), (347622050.0, 0.0), (0.0, 5539047.5), (76592.22, 0.0), (3514.0022, 0.0), (0.0, 13670.268)].iter().enumerate() {
g.add_tweights(i, s, t);
}
for (i, j, c) in [(0, 1, 0.0), (0, 3, 0.0007301044), (1, 2, 2.803e-42), (1, 4, 8.160078), (2, 5, 3.1224866e-7), (3, 4, 28.512966), (4, 5, 4.099367)] {
g.add_edge(i, j, c, c);
}
let mut bk = g.clone();
let checks = std::cell::Cell::new(0);
let cancel = || {
checks.set(checks.get() + 1);
checks.get() > 1000
};
let flow = g.push_relabel(&cancel).expect("push/relabel did not terminate");
let bk_flow = bk.maxflow_bk();
assert!((flow - bk_flow).abs() <= 1e-6 * bk_flow, "{flow} vs {bk_flow}");
assert_eq!((0..6).map(|i| g.in_source(i)).collect::<Vec<_>>(), (0..6).map(|i| bk.in_source(i)).collect::<Vec<_>>());
}
+48 -9
View File
@@ -35,7 +35,7 @@ pub mod slic;
pub mod subject;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use photocraft_raster::{Cancelled, Interrupt, Surface};
pub use crate::selection::Region;
use gmm::Gmm;
@@ -288,6 +288,9 @@ fn grid_cut_impl<const OPTIMIZED: bool>(
if count == 0 {
return Some(out);
}
if cancel() {
return None;
}
let mut g = Graph::with_capacity(count as usize, count as usize * 4);
for i in 0..n {
if idx[i] != u32::MAX {
@@ -330,6 +333,9 @@ fn grid_cut_impl<const OPTIMIZED: bool>(
}
}
}
if cancel() {
return None;
}
if OPTIMIZED {
g.maxflow_with(cancel)?;
} else {
@@ -539,6 +545,22 @@ pub fn trim_region(r: Region) -> Option<Region> {
/// pixels wide around the upsampled boundary, using the given colour models, tile by tile.
/// The result is anti-aliased by one pixel.
pub fn finish_region(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], lw: usize, lh: usize, models: Option<(&Gmm, &Gmm)>) -> Option<Region> {
finish_region_with(sampler, window, step, low, lw, lh, models, &Interrupt::NONE).unwrap_or_default()
}
/// [`finish_region`] that stops with `Err(Cancelled)` once `ctl` is cancelled, asked before each
/// band tile and inside its min cut.
#[allow(clippy::too_many_arguments)]
pub fn finish_region_with(
sampler: &dyn Sampler,
window: Rect,
step: usize,
low: &[bool],
lw: usize,
lh: usize,
models: Option<(&Gmm, &Gmm)>,
ctl: &Interrupt,
) -> Result<Option<Region>, Cancelled> {
let (w, h) = (window.width() as usize, window.height() as usize);
let mut mask = vec![0u8; w * h];
if step <= 1 {
@@ -576,16 +598,28 @@ pub fn finish_region(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bo
#[cfg(target_arch = "wasm32")]
mask.chunks_mut(w).enumerate().for_each(|(y, r)| fill(y, r));
if let Some((fg, bg)) = models {
refine_band(sampler, window, step, low, &band, lw, lh, fg, bg, &mut mask);
refine_band(sampler, window, step, low, &band, lw, lh, fg, bg, &mut mask, ctl)?;
}
}
antialias_u8(&mut mask, w, h);
trim_region(Region { bbox: window, mask })
Ok(trim_region(Region { bbox: window, mask }))
}
/// Full-resolution graph cut restricted to the band blocks (see [`finish_region`]).
#[allow(clippy::too_many_arguments)]
fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], band: &[bool], lw: usize, lh: usize, fg: &Gmm, bg: &Gmm, mask: &mut [u8]) {
fn refine_band(
sampler: &dyn Sampler,
window: Rect,
step: usize,
low: &[bool],
band: &[bool],
lw: usize,
lh: usize,
fg: &Gmm,
bg: &Gmm,
mask: &mut [u8],
ctl: &Interrupt,
) -> Result<(), Cancelled> {
const T: i32 = 192;
const HALO: i32 = 12;
let w = window.width() as usize;
@@ -606,7 +640,9 @@ fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], b
}
ty += T;
}
let run = |t: &Rect| -> (Rect, Vec<bool>) {
let cancel = || ctl.cancelled();
let run = |t: &Rect| -> Result<(Rect, Vec<bool>), Cancelled> {
ctl.check()?;
let r = t.inflate(HALO).intersect(&window);
let img = sampler.rgb(r);
let (rw, rh) = (r.width() as usize, r.height() as usize);
@@ -629,7 +665,8 @@ fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], b
}
}
let beta = contrast_beta(&img);
let cut = grid_cut(&img, &cfg, &cbg, &fixed, 50.0, beta);
// `grid_cut`'s weight, with the cut stopping on cancel.
let cut = grid_cut_cancellable(&img, &cfg, &cbg, &fixed, |d2| 50.0 * (-beta * d2).exp(), &cancel).ok_or(Cancelled)?;
let (cw, ch) = (t.width() as usize, t.height() as usize);
let mut core = Vec::with_capacity(cw * ch);
for yy in 0..ch {
@@ -637,15 +674,16 @@ fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], b
let sx = (t.x0 - r.x0) as usize;
core.extend_from_slice(&cut[sy * rw + sx..sy * rw + sx + cw]);
}
(*t, core)
Ok((*t, core))
};
// A cancelled tile ends the collect; tiles not yet started see `ctl` and return at once.
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<(Rect, Vec<bool>)> = {
use rayon::prelude::*;
tiles.par_iter().map(run).collect()
tiles.par_iter().map(run).collect::<Result<_, _>>()?
};
#[cfg(target_arch = "wasm32")]
let results: Vec<(Rect, Vec<bool>)> = tiles.iter().map(run).collect();
let results: Vec<(Rect, Vec<bool>)> = tiles.iter().map(run).collect::<Result<_, _>>()?;
for (t, core) in results {
let cw = t.width() as usize;
for (yy, row) in core.chunks_exact(cw).enumerate() {
@@ -655,6 +693,7 @@ fn refine_band(sampler: &dyn Sampler, window: Rect, step: usize, low: &[bool], b
}
}
}
Ok(())
}
/// Deterministic pseudo-random numbers (xorshift) for sampling and tests.
+119 -19
View File
@@ -88,6 +88,19 @@ pub fn quick_select_with(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f3
}
pub(super) fn quick_select_using<const OPTIMIZED: bool>(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f32)], size: f32, t: &Tuning) -> Option<Region> {
quick_select_batched::<OPTIMIZED>(sampler, canvas, points, size, t, SPECULATE).map(|(region, _)| region)
}
/// [`quick_select_using`] cutting `batch` window sizes at a time (1: one after another). Also
/// returns how many window sizes, smallest first, it took to find the kept one.
pub(super) fn quick_select_batched<const OPTIMIZED: bool>(
sampler: &dyn Sampler,
canvas: Rect,
points: &[(f32, f32)],
size: f32,
t: &Tuning,
batch: usize,
) -> Option<(Region, usize)> {
if points.is_empty() || canvas.is_empty() {
return None;
}
@@ -107,8 +120,15 @@ pub(super) fn quick_select_using<const OPTIMIZED: bool>(sampler: &dyn Sampler, c
let step = working_step(canvas.width() as usize, canvas.height() as usize) as i32;
let rw = r / step as f32;
// Work in a window around the stroke; grow it while the selection runs into its border.
let mut margin = (48.0f32).max(2.0 * rw) as i32 * step;
loop {
// The window's sides are free like the canvas' edges, so a selection that stays clear of
// them is kept; one that reaches a side inside the canvas is recomputed in a larger window.
// Index of the smallest window kept so far in this batch: larger ones stop cutting.
let kept = std::sync::atomic::AtomicUsize::new(usize::MAX);
let attempt = |index: usize, margin: i32| -> Attempt {
let cancel = || kept.load(std::sync::atomic::Ordering::Relaxed) < index;
if cancel() {
return Attempt::Cancelled;
}
let snap0 = |v: i32, o: i32| o + (v - o).div_euclid(step) * step;
let grown = bbox.inflate(margin).intersect(&canvas);
let window = Rect::new(snap0(grown.x0, canvas.x0), snap0(grown.y0, canvas.y0), grown.x1, grown.y1);
@@ -118,13 +138,13 @@ pub(super) fn quick_select_using<const OPTIMIZED: bool>(sampler: &dyn Sampler, c
let pts: Vec<(f32, f32)> = points.iter().map(|&(x, y)| ((x - window.x0 as f32) / s, (y - window.y0 as f32) / s)).collect();
let seeds = stroke_mask(&pts, rw.max(0.5), lw, lh);
if !seeds.iter().any(|v| *v) {
return None;
return Attempt::NoSeeds;
}
if cancel() {
return Attempt::Cancelled;
}
// The window's sides are free like the canvas' edges, so a selection that stays clear of
// them is the best over the whole canvas too (outside the window it could only add cost).
// One that reaches a side inside the canvas is recomputed in a larger window.
let inner = [window.x0 > canvas.x0, window.y0 > canvas.y0, window.x1 < canvas.x1, window.y1 < canvas.y1];
let low = select_working_using::<OPTIMIZED>(&img, &seeds, rw, t);
let Some(low) = select_working_using::<OPTIMIZED>(&img, &seeds, rw, t, &cancel) else { return Attempt::Cancelled };
let touches = |side: usize| match side {
0 => (0..lh).any(|y| low[y * lw]),
1 => (0..lw).any(|x| low[x]),
@@ -132,21 +152,87 @@ pub(super) fn quick_select_using<const OPTIMIZED: bool>(sampler: &dyn Sampler, c
_ => (0..lw).any(|x| low[(lh - 1) * lw + x]),
};
if (0..4).any(|k| inner[k] && touches(k)) {
margin = margin.saturating_mul(2);
continue;
return Attempt::Touches;
}
let mask = upsample(&low, lw, lh, step as usize, window.width() as usize, window.height() as usize);
return super::trim_region(Region { bbox: window, mask });
kept.fetch_min(index, std::sync::atomic::Ordering::Relaxed);
Attempt::Done { window, low, lw, lh }
};
let mut margin = (48.0f32).max(2.0 * rw) as i32 * step;
let mut sizes = 0;
loop {
// The attempts don't depend on each other, so a few window sizes are cut at once and the
// first in growing order that stays clear of its sides is kept: the same result as trying
// them one after another, without waiting for the smaller ones to fail first. Once one is
// kept, the larger ones still cutting stop, so a stroke settled by a small window costs
// about what that window alone costs.
let batch = margins_to_try(margin, bbox, canvas, batch);
kept.store(usize::MAX, std::sync::atomic::Ordering::Relaxed);
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<Attempt> = {
use rayon::prelude::*;
batch.par_iter().enumerate().map(|(i, &m)| attempt(i, m)).collect()
};
#[cfg(target_arch = "wasm32")]
let results: Vec<Attempt> = batch.iter().enumerate().map(|(i, &m)| attempt(i, m)).collect();
for result in results {
sizes += 1;
match result {
Attempt::NoSeeds => return None,
// Only a window larger than one already kept is cancelled, and that one comes
// first in this order, so a cancelled attempt is never reached.
Attempt::Touches | Attempt::Cancelled => continue,
Attempt::Done { window, low, lw, lh } => {
let mask = upsample(&low, lw, lh, step as usize, window.width() as usize, window.height() as usize);
return super::trim_region(Region { bbox: window, mask }).map(|region| (region, sizes));
}
}
}
let &last = batch.last()?;
margin = last.saturating_mul(2);
}
}
/// One window size's outcome in [`quick_select_using`].
enum Attempt {
/// The stroke misses the window.
NoSeeds,
/// The selection reaches a side of the window inside the canvas.
Touches,
/// A smaller window of the same batch was kept first.
Cancelled,
Done {
window: Rect,
low: Vec<bool>,
lw: usize,
lh: usize,
},
}
/// Window sizes cut together: most strokes settle within this many doublings.
const SPECULATE: usize = 4;
/// The next `count` margins from `margin`, doubling, stopping at the first one whose window
/// already covers the whole canvas (a larger one would be the same window).
pub(super) fn margins_to_try(margin: i32, bbox: Rect, canvas: Rect, count: usize) -> Vec<i32> {
let mut out = Vec::with_capacity(count.max(1));
let mut m = margin;
for _ in 0..count.max(1) {
out.push(m);
if bbox.inflate(m).intersect(&canvas) == canvas {
break;
}
m = m.saturating_mul(2);
}
out
}
/// The model at working resolution: `seeds` (the brush footprint, `radius` working pixels) are
/// selected; returns the selection.
pub fn select_working(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) -> Vec<bool> {
select_working_using::<true>(img, seeds, radius, t)
select_working_using::<true>(img, seeds, radius, t, &|| false).unwrap_or_default()
}
fn select_working_using<const OPTIMIZED: bool>(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) -> Vec<bool> {
fn select_working_using<const OPTIMIZED: bool>(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning, cancel: &dyn Fn() -> bool) -> Option<Vec<bool>> {
let (w, h) = (img.w, img.h);
let d = crate::selection::edt(seeds, w, h);
let grow = (t.grow + t.grow_frac * radius).max(0.0);
@@ -154,7 +240,10 @@ fn select_working_using<const OPTIMIZED: bool>(img: &RgbImage, seeds: &[bool], r
let fixed: Vec<u8> = d.iter().map(|d| if *d <= grow { HARD_FG } else { FREE }).collect();
let (floor, sigma, k) = (t.floor, t.sigma.max(1e-4), t.k);
let weight = move |d2: f32| floor + 1.0 / (1.0 + (d2.sqrt() / sigma).powf(k));
cut_levels::<OPTIMIZED>(img, &fixed, &cost_fg, &weight, 1.0, t.direct_px.max(1024))
if cancel() {
return None;
}
cut_levels::<OPTIMIZED>(img, &fixed, &cost_fg, &weight, 1.0, t.direct_px.max(1024), cancel)
}
/// Band half-width (pixels) re-cut at each finer level.
@@ -164,11 +253,19 @@ const BAND: f32 = 3.0;
/// problem (colours averaged, pixel costs summed, cut weights doubled since a coarse side spans two
/// fine ones) gives a boundary, and the full-size cut is redone only within `BAND` of it. Seeds
/// stay foreground.
fn cut_levels<const OPTIMIZED: bool>(img: &RgbImage, fixed: &[u8], cost_fg: &[f32], weight: &dyn Fn(f32) -> f32, scale: f32, direct_px: usize) -> Vec<bool> {
fn cut_levels<const OPTIMIZED: bool>(
img: &RgbImage,
fixed: &[u8],
cost_fg: &[f32],
weight: &dyn Fn(f32) -> f32,
scale: f32,
direct_px: usize,
cancel: &dyn Fn() -> bool,
) -> Option<Vec<bool>> {
let (w, h) = (img.w, img.h);
let zeros = vec![0.0f32; w * h];
if w * h <= direct_px || w < 8 || h < 8 {
return super::grid_cut_weighted::<OPTIMIZED>(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2));
return super::grid_cut_impl::<OPTIMIZED>(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2), cancel);
}
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
let coarse_img = img.downsample(2);
@@ -183,7 +280,10 @@ fn cut_levels<const OPTIMIZED: bool>(img: &RgbImage, fixed: &[u8], cost_fg: &[f3
}
}
}
let coarse = cut_levels::<OPTIMIZED>(&coarse_img, &cfix, &ccost, weight, scale * 2.0, direct_px);
let coarse = cut_levels::<OPTIMIZED>(&coarse_img, &cfix, &ccost, weight, scale * 2.0, direct_px, cancel)?;
if cancel() {
return None;
}
let proj: Vec<bool> = (0..w * h).map(|i| coarse[(i / w / 2) * cw + (i % w) / 2]).collect();
// Band: within BAND of the projected boundary.
let edge: Vec<bool> = (0..w * h)
@@ -194,7 +294,7 @@ fn cut_levels<const OPTIMIZED: bool>(img: &RgbImage, fixed: &[u8], cost_fg: &[f3
})
.collect();
if !edge.iter().any(|e| *e) {
return (0..w * h).map(|i| proj[i] || fixed[i] == HARD_FG).collect();
return Some((0..w * h).map(|i| proj[i] || fixed[i] == HARD_FG).collect());
}
let near = crate::selection::edt(&edge, w, h);
let banded: Vec<u8> = (0..w * h)
@@ -210,7 +310,7 @@ fn cut_levels<const OPTIMIZED: bool>(img: &RgbImage, fixed: &[u8], cost_fg: &[f3
}
})
.collect();
super::grid_cut_weighted::<OPTIMIZED>(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2))
super::grid_cut_impl::<OPTIMIZED>(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2), cancel)
}
/// Pixels within `radius` (working pixels) of the polyline `pts`, plus each point's own pixel.
+34
View File
@@ -132,9 +132,31 @@ pub fn sky_mask_low(img: &RgbImage, threshold: f32) -> Option<Vec<bool>> {
}
}
let mask = clean_mask(&mask, w, h, 0.002, 0.01);
if is_backdrop(img, &mask, top) {
return None;
}
mask.iter().any(|v| *v).then_some(mask)
}
/// A neutral region that also covers most of the bottom rows is a studio or product backdrop,
/// not an overcast sky (which never reaches the bottom of a photo); blue sky is never one.
fn is_backdrop(img: &RgbImage, mask: &[bool], rows: usize) -> bool {
let (w, h) = (img.w, img.h);
let bottom = (h - rows.min(h)) * w..h * w;
let Some(bottom_rows) = mask.get(bottom) else { return false };
let covered = bottom_rows.iter().filter(|m| **m).count();
if covered * 10 < bottom_rows.len() * 6 {
return false;
}
let (mut sat, mut n) = (0.0f32, 0usize);
for (p, _) in img.px.iter().zip(mask).filter(|(_, m)| **m) {
let (mx, mn) = (p[0].max(p[1]).max(p[2]), p[0].min(p[1]).min(p[2]));
sat += if mx > 1e-4 { (mx - mn) / mx } else { 0.0 };
n += 1;
}
n > 0 && sat / (n as f32) < 0.1
}
/// Selects the sky over `canvas`. `None` if no sky was found.
pub fn select_sky(sampler: &dyn Sampler, canvas: Rect, p: SkyParams) -> Option<Region> {
if canvas.width() < 4 || canvas.height() < 4 {
@@ -269,6 +291,18 @@ mod tests {
assert!(r.at(30, 10) > 0.9 && r.at(30, 50) < 0.1);
}
/// #4634: a near-white studio backdrop around a product is not an overcast sky, but blue sky
/// reaching the bottom (a photo taken upward) still is.
#[test]
fn a_white_backdrop_is_not_sky_but_a_blue_sky_down_to_the_bottom_is() {
let disc = |x: usize, y: usize| ((x as f32 - 75.0).powi(2) + (y as f32 - 50.0).powi(2)).sqrt() < 30.0;
let img = RgbImage::from_fn(150, 100, |x, y| if disc(x, y) { [0.12, 0.12, 0.12] } else { [0.95, 0.95, 0.94] });
assert!(select_sky(&ImageSampler { img: &img, origin: (0, 0) }, Rect::new(0, 0, 150, 100), SkyParams::default()).is_none());
let img = RgbImage::from_fn(150, 100, |x, y| if disc(x, y) { [0.12, 0.3, 0.1] } else { [0.35, 0.6, 0.95] });
let r = select_sky(&ImageSampler { img: &img, origin: (0, 0) }, Rect::new(0, 0, 150, 100), SkyParams::default()).expect("blue sky");
assert!(r.at(5, 95) > 0.9 && r.at(75, 50) < 0.1);
}
#[test]
fn colour_prior_ranks_sky_over_foliage() {
assert!(colour_prior([0.4, 0.6, 0.95]) > 0.8);
+13 -5
View File
@@ -23,6 +23,7 @@ use std::cmp::Ordering;
use std::collections::BinaryHeap;
use photocraft_geom::Rect;
use photocraft_raster::{Cancelled, Interrupt};
use super::grabcut::{self, BG, PR_BG, PR_FG};
use super::slic::{rgb_to_lab, slic_lab};
@@ -35,13 +36,20 @@ const SIGMA_SPA: f32 = 0.25;
/// Selects the most salient object on the canvas.
pub fn select_subject(sampler: &dyn Sampler, canvas: Rect) -> Option<Region> {
select_subject_with(sampler, canvas, &Interrupt::NONE).unwrap_or_default()
}
/// [`select_subject`] that stops with `Err(Cancelled)` once `ctl` is cancelled, asked between
/// stages and inside every min cut (the GrabCut iterations and the full-resolution re-cut).
pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt) -> Result<Option<Region>, Cancelled> {
if canvas.width() < 4 || canvas.height() < 4 {
return None;
return Ok(None);
}
let step = super::scale_for(canvas, WORK_PX);
let img = sampler.rgb_scaled(canvas, step);
let (w, h) = (img.w, img.h);
let sal = saliency(&img);
ctl.check()?;
let t = otsu(&sal).clamp(0.15, 0.85);
let mut trimap = vec![PR_BG; w * h];
let mut any_fg = false;
@@ -60,15 +68,15 @@ pub fn select_subject(sampler: &dyn Sampler, canvas: Rect) -> Option<Region> {
}
}
if !any_fg {
return None;
return Ok(None);
}
let (fg, bg) = grabcut::grabcut(&img, &mut trimap, 6)?;
let Some((fg, bg)) = grabcut::grabcut_with(&img, &mut trimap, 6, ctl)? else { return Ok(None) };
let low: Vec<bool> = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect();
let low = super::clean_mask(&low, w, h, 0.2, 0.05);
if !low.iter().any(|v| *v) {
return None;
return Ok(None);
}
super::finish_region(sampler, canvas, step, &low, w, h, Some((&fg, &bg)))
super::finish_region_with(sampler, canvas, step, &low, w, h, Some((&fg, &bg)), ctl)
}
/// Per-pixel saliency in `0..=1` (see the module docs).
+141
View File
@@ -344,6 +344,45 @@ fn quick_select_with_points_far_off_canvas_selects_nothing() {
}
}
#[test]
fn quick_select_cuts_window_sizes_in_parallel_with_the_same_result() {
// Soft gradients and faint blobs let a selection spread, so most strokes need more than one
// window size; cutting several at once must keep exactly what one-after-another keeps.
let (w, h) = (1200usize, 900usize);
let img = RgbImage::from_fn(w, h, |x, y| {
let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32);
let blob = (((fx * 9.0).sin() * (fy * 7.0).cos()) * 0.5 + 0.5) * 0.06;
[0.3 + 0.4 * fx + blob, 0.5 + 0.2 * fy, 0.7 - 0.3 * fx + blob]
});
let s = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, w as i32, h as i32);
let t = quick::Tuning::default();
let strokes: [(&[(f32, f32)], f32); 4] = [
(&[(300.0, 300.0)], 30.0),
(&[(500.0, 400.0), (580.0, 400.0), (660.0, 420.0)], 60.0),
(&[(200.0, 700.0), (260.0, 650.0), (320.0, 700.0), (380.0, 650.0)], 30.0),
(&[(900.0, 200.0)], 120.0),
];
let mut most = 0;
for (pts, size) in strokes {
let one = quick::quick_select_batched::<true>(&s, canvas, pts, size, &t, 1);
let many = quick::quick_select_batched::<true>(&s, canvas, pts, size, &t, 4);
assert_eq!(one.as_ref().map(|(r, n)| (r.bbox, &r.mask, n)), many.as_ref().map(|(r, n)| (r.bbox, &r.mask, n)), "{pts:?} size {size}");
most = most.max(one.map_or(0, |(_, n)| n));
}
assert!(most > 1, "no stroke needed a larger window, so the test exercises nothing");
}
#[test]
fn quick_select_window_sizes_stop_at_the_canvas() {
let canvas = Rect::new(0, 0, 1000, 800);
let bbox = Rect::new(400, 300, 460, 360);
assert_eq!(quick::margins_to_try(96, bbox, canvas, 4), vec![96, 192, 384, 768]);
// 768 already covers the canvas: no larger (identical) window is cut.
assert_eq!(quick::margins_to_try(384, bbox, canvas, 4), vec![384, 768]);
assert_eq!(quick::margins_to_try(96, bbox, canvas, 0), vec![96]);
}
#[test]
fn quick_select_grows_without_leaking() {
let img = two_regions(140, 100);
@@ -526,6 +565,87 @@ fn select_subject_finds_centred_object() {
assert!(v > 0.9, "IoU {v}");
}
/// Counts full-resolution reads; the working copy goes through `rgb_scaled` uncounted, so each
/// read is one band tile of the full-resolution re-cut.
struct TileReads<'a>(ImageSampler<'a>, std::sync::atomic::AtomicUsize);
impl Sampler for TileReads<'_> {
fn rgba(&self, r: Rect) -> Vec<[f32; 4]> {
self.1.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.0.rgba(r)
}
fn rgb_scaled(&self, r: Rect, step: usize) -> RgbImage {
self.0.rgb_scaled(r, step)
}
}
#[test]
fn finish_region_cancel_stops_inside_the_band_cut() {
use photocraft_raster::{Cancelled, Interrupt};
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
// One 180-pixel window is one band tile, and nothing asks after the band re-cut, so a cancel
// seen after the tile's own check can only have been seen inside its min cut.
let (n, step) = (180, 3);
let (img, truth) = disc_scene(n, n, 90.0, 90.0, 55.0, 4);
let s = ImageSampler { img: &img, origin: (0, 0) };
let ln = n / step;
let low: Vec<bool> = (0..ln * ln).map(|i| truth[((i / ln) * step + 1) * n + (i % ln) * step + 1]).collect();
let (fs, bs): (Vec<_>, Vec<_>) = img.px.iter().zip(&truth).partition(|(_, t)| **t);
let fit = |v: Vec<(&[f32; 3], &bool)>| Gmm::fit(&v.iter().map(|(p, _)| **p).collect::<Vec<_>>(), 5, gmm::DEFAULT_REG).unwrap();
let (fg, bg) = (fit(fs), fit(bs));
let window = Rect::new(0, 0, n as i32, n as i32);
let polls = AtomicUsize::new(0);
let finish = |cancel_from: usize| {
polls.store(0, SeqCst);
let cancel = || polls.fetch_add(1, SeqCst) + 1 >= cancel_from;
finish_region_with(&s, window, step, &low, ln, ln, Some((&fg, &bg)), &Interrupt::cancel_only(&cancel))
};
let reg = finish(usize::MAX).unwrap().unwrap();
assert!(polls.load(SeqCst) >= 2, "the cut polls too ({} polls)", polls.load(SeqCst));
assert!(iou(&reg, &truth, n, n) > 0.95);
assert_eq!(finish_region(&s, window, step, &low, ln, ln, Some((&fg, &bg))), Some(reg));
// Cancelled at the cut's first poll (the second in all): no region.
assert_eq!(finish(2), Err(Cancelled));
assert_eq!(polls.load(SeqCst), 2);
assert_eq!(finish(1), Err(Cancelled), "cancelled before the tile");
}
#[test]
fn select_subject_cancelled_in_the_band_recut_reads_no_more_tiles() {
use photocraft_raster::{Cancelled, Interrupt};
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
let (w, h) = (3000, 2000);
let (img, truth) = disc_scene(w, h, 1500.0, 1000.0, 700.0, 6);
let canvas = Rect::new(0, 0, w as i32, h as i32);
let sampler = || TileReads(ImageSampler { img: &img, origin: (0, 0) }, AtomicUsize::new(0));
// Not cancelled: the same region as `select_subject`, and the band cuts themselves ask
// (more polls once tiles are read than the one check before each tile).
let s = sampler();
let band_polls = AtomicUsize::new(0);
let count = || {
if s.1.load(SeqCst) > 0 {
band_polls.fetch_add(1, SeqCst);
}
false
};
let reg = subject::select_subject_with(&s, canvas, &Interrupt::cancel_only(&count)).unwrap().unwrap();
let tiles = s.1.load(SeqCst);
assert!(tiles > 4, "{tiles} band tiles");
assert!(band_polls.load(SeqCst) > tiles, "{} polls over {tiles} tiles", band_polls.load(SeqCst));
assert!(iou(&reg, &truth, w, h) > 0.9);
assert_eq!(subject::select_subject(&sampler(), canvas), Some(reg));
// Cancelled once the first tile is read: the run stops there instead of re-cutting every
// tile. One thread, so no other tile is already in flight.
let s = sampler();
let cancel = || s.1.load(SeqCst) > 0;
let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap();
let r = pool.install(|| subject::select_subject_with(&s, canvas, &Interrupt::cancel_only(&cancel)));
assert_eq!(r, Err(Cancelled));
assert_eq!(s.1.load(SeqCst), 1, "tiles read after the cancel");
}
#[test]
fn saliency_highlights_the_object() {
let (img, truth) = disc_scene(120, 90, 60.0, 45.0, 22.0, 8);
@@ -620,3 +740,24 @@ fn dump_quick_scenes() {
save("thin_line", &line);
save("disc_scene", &disc_scene(600, 500, 300.0, 250.0, 180.0, 4).0);
}
#[test]
fn object_at_finds_the_disc_under_the_cursor() {
let (img, truth) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7);
let s = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, 320, 240);
let reg = grabcut::object_at(&s, canvas, 165.0, 118.0, 20_000).unwrap();
let v = iou(&reg, &truth, 320, 240);
assert!(v > 0.9, "IoU {v}");
}
#[test]
fn object_at_on_the_background_or_off_canvas_finds_nothing() {
let (img, _) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7);
let s = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, 320, 240);
assert!(grabcut::object_at(&s, canvas, 10.0, 10.0, 20_000).is_none());
assert!(grabcut::object_at(&s, canvas, -5.0, 10.0, 20_000).is_none());
assert!(grabcut::object_at(&s, canvas, f32::NAN, 10.0, 20_000).is_none());
assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none());
}
+282 -35
View File
@@ -166,7 +166,8 @@ fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
/// around the seed. `px` covers `area`.
pub fn magic_wand(px: &[[f32; 4]], area: Rect, seed: (i32, i32), tolerance: f32, contiguous: bool, anti_alias: bool) -> Vec<f32> {
let (w, h) = (area.width() as usize, area.height() as usize);
let mut m = vec![0.0f32; w * h];
let Some(n) = w.checked_mul(h).filter(|&n| n <= px.len()) else { return Vec::new() };
let mut m = vec![0.0f32; n];
if !area.contains(seed.0, seed.1) {
return m;
}
@@ -245,7 +246,9 @@ impl Region {
if !self.bbox.contains(x, y) {
return 0.0;
}
self.mask[(y - self.bbox.y0) as usize * self.bbox.width() as usize + (x - self.bbox.x0) as usize] as f32 / 255.0
let row = (y.abs_diff(self.bbox.y0) as usize).checked_mul(self.bbox.width() as usize);
let value = row.and_then(|i| i.checked_add(x.abs_diff(self.bbox.x0) as usize)).and_then(|i| self.mask.get(i)).copied().unwrap_or(0);
f32::from(value) / 255.0
}
}
@@ -257,6 +260,9 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32
return None;
}
let (w, h) = (area.width() as usize, area.height() as usize);
if w.checked_mul(h).is_none_or(|n| n > img.len()) {
return None;
}
let target = img[(seed.1 - area.y0) as usize * w + (seed.0 - area.x0) as usize];
let tol = (tolerance + 1e-3).floor().max(0.0) as i32;
// Copying a selection can leave RGB under zero alpha. Those invisible colours must not
@@ -676,18 +682,57 @@ fn hue_chroma([r, g, b]: [f32; 3]) -> (f32, f32) {
/// Color Range › Select: Reds, Yellows, Greens, Cyans, Blues or Magentas (`center` = 0, 60,
/// 120, 180, 240, 300°). A pixel belongs to a family by how close its hue is (full at the
/// family's hue, none 60° away, so an orange is half red, half yellow) times its chroma, so
/// neutrals are never selected and muted colours only partly.
/// family's hue, none 60° away, so an orange is half red, half yellow) times its chroma
/// (max − min), so neutrals are never selected and muted colours only partly. Darker colours
/// weigh more, by `2 − max`: measured against Photoshop 2025's presets on 126 colours each for
/// Reds, Yellows and Blues (hue ±40°, saturation 5..100 %, brightness 33..100 %), this matches
/// its selection to within one level, where chroma alone fell up to 61 levels short (#5243).
pub fn hue_range(px: &[[f32; 4]], center: f32) -> Vec<f32> {
px.iter()
.map(|&[r, g, b, a]| {
if a <= 0.0 {
return 0.0;
}
let (h, chroma) = hue_chroma([r, g, b].map(|v| v.clamp(0.0, 1.0)));
let rgb = [r, g, b].map(|v| v.clamp(0.0, 1.0));
let (h, chroma) = hue_chroma(rgb);
let max = rgb[0].max(rgb[1]).max(rgb[2]);
let dh = (h - center).rem_euclid(360.0);
let dh = dh.min(360.0 - dh);
((1.0 - dh / 60.0).clamp(0.0, 1.0) * chroma).clamp(0.0, 1.0)
((1.0 - dh / 60.0).clamp(0.0, 1.0) * chroma * (2.0 - max)).clamp(0.0, 1.0)
})
.collect()
}
/// Color Range › Select: Skin Tones. Skin of every complexion falls in a compact region of the
/// YCbCr chroma plane (Chai & Ngan, "Face segmentation using skin-color map", 1999: Cb about
/// 77..127, Cr about 133..173, Rec. 601 on 0..255) that runs from pale (higher Cb, lower Cr) to
/// saturated (lower Cb, higher Cr). Pixels inside an ellipse along that run are fully selected;
/// outside, selection falls off linearly over `fuzziness` (0..200, as the dialog's Fuzziness: 100
/// is one ellipse radius). Pale neutrals such as cream or beige sit just off its pale end, so they
/// stay out. Near-black and near-white pixels, whose chroma is noise, are faded out.
pub fn skin_tones(px: &[[f32; 4]], fuzziness: f32) -> Vec<f32> {
const CB: f32 = 104.0;
const CR: f32 = 158.0;
/// Radii along the run and across it, and the run's angle from the Cb axis.
const ALONG: f32 = 22.0;
const ACROSS: f32 = 14.0;
const ANGLE: f32 = -40.0;
let (sin, cos) = ANGLE.to_radians().sin_cos();
let ramp = (fuzziness / 100.0).max(1e-3);
px.iter()
.map(|&[r, g, b, a]| {
if a <= 0.0 {
return 0.0;
}
let [r, g, b] = [r, g, b].map(|v| v.clamp(0.0, 1.0) * 255.0);
let y = 0.299 * r + 0.587 * g + 0.114 * b;
let cb = 128.0 - 0.168_736 * r - 0.331_264 * g + 0.5 * b - CB;
let cr = 128.0 + 0.5 * r - 0.418_688 * g - 0.081_312 * b - CR;
let d = ((cos * cb + sin * cr) / ALONG).hypot((cos * cr - sin * cb) / ACROSS);
let chroma = (1.0 - (d - 1.0) / ramp).clamp(0.0, 1.0);
let dark = ((y - 25.0) / 30.0).clamp(0.0, 1.0);
let bright = ((250.0 - y) / 20.0).clamp(0.0, 1.0);
chroma * dark * bright
})
.collect()
}
@@ -766,47 +811,84 @@ pub fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
}
/// Anti-aliased polygon (even-odd) coverage over `area`: exact horizontal
/// coverage, 4 sub-rows per pixel row (or 1 when not anti-aliased).
/// coverage, 4 sub-rows per pixel row (or 1 when not anti-aliased). Bands of
/// rows are filled in parallel, and each sub-row tests only the edges that
/// cross it (an active edge list over the edges sorted by their top).
pub fn polygon(points: &[(f32, f32)], area: Rect, anti_alias: bool) -> Vec<f32> {
let (w, h) = (area.width() as usize, area.height() as usize);
let mut m = vec![0.0f32; w * h];
if points.len() < 3 {
if points.len() < 3 || w == 0 {
return m;
}
let sub = if anti_alias { 4 } else { 1 };
let mut xs: Vec<f32> = Vec::new();
for py in 0..h {
for s in 0..sub {
let y = area.y0 as f32 + py as f32 + (s as f32 + 0.5) / sub as f32;
xs.clear();
for i in 0..points.len() {
let (a, b) = (points[i], points[(i + 1) % points.len()]);
if (a.1 <= y && b.1 > y) || (b.1 <= y && a.1 > y) {
xs.push(a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0));
}
let sample_y = |py: usize, s: usize| area.y0 as f32 + py as f32 + (s as f32 + 0.5) / sub as f32;
// A sub-row at y crosses the edge a→b iff top <= y < bottom; horizontal and NaN edges
// never cross one.
struct Edge {
top: f32,
bottom: f32,
a: (f32, f32),
b: (f32, f32),
}
let mut edges: Vec<Edge> = points
.iter()
.zip(points.iter().cycle().skip(1))
.filter_map(|(&a, &b)| {
if a.1 < b.1 {
Some(Edge { top: a.1, bottom: b.1, a, b })
} else if b.1 < a.1 {
Some(Edge { top: b.1, bottom: a.1, a, b })
} else {
None
}
xs.sort_by(|a, b| a.total_cmp(b));
for span in xs.as_chunks::<2>().0 {
let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32));
if x1 <= x0 {
})
.collect();
edges.sort_by(|p, q| p.top.total_cmp(&q.top));
const BAND: usize = 64;
par_rows(&mut m, w, BAND, |bi, rows| {
let py0 = bi * BAND;
let (first, last) = (sample_y(py0, 0), sample_y(py0 + rows.len() / w - 1, sub - 1));
let reach = edges.partition_point(|e| e.top <= last);
let band_edges = edges.get(..reach).unwrap_or_default();
let mut pending = band_edges.iter().filter(|e| e.bottom > first).peekable();
let mut active: Vec<&Edge> = Vec::new();
let mut xs: Vec<f32> = Vec::new();
for (r, row) in rows.chunks_exact_mut(w).enumerate() {
for s in 0..sub {
// Sub-row ys never decrease, so an edge whose bottom is passed stays out.
let y = sample_y(py0 + r, s);
while let Some(e) = pending.next_if(|e| e.top <= y) {
active.push(e);
}
active.retain(|e| e.bottom > y);
if active.is_empty() {
continue;
}
let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w));
for cx in c0..c1 {
let cov = if anti_alias {
((cx + 1) as f32).min(x1) - (cx as f32).max(x0)
} else {
let c = cx as f32 + 0.5;
if c >= x0 && c < x1 { 1.0 } else { 0.0 }
};
m[py * w + cx] += cov.max(0.0) / sub as f32;
xs.clear();
xs.extend(active.iter().map(|&&Edge { a, b, .. }| a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0)));
xs.sort_by(|a, b| a.total_cmp(b));
for span in xs.as_chunks::<2>().0 {
let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32));
if x1 <= x0 {
continue;
}
let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w));
for (cx, v) in row.iter_mut().enumerate().take(c1).skip(c0) {
let cov = if anti_alias {
((cx + 1) as f32).min(x1) - (cx as f32).max(x0)
} else {
let c = cx as f32 + 0.5;
if c >= x0 && c < x1 { 1.0 } else { 0.0 }
};
*v += cov.max(0.0) / sub as f32;
}
}
}
}
}
for v in &mut m {
*v = v.min(1.0);
}
for v in rows {
*v = v.min(1.0);
}
});
m
}
@@ -865,6 +947,28 @@ mod tests {
}
}
#[test]
fn skin_tones_selects_skin_and_not_cream_or_white() {
let px = |rgb: [u8; 3]| [rgb[0], rgb[1], rgb[2]].map(|v| f32::from(v) / 255.0);
let rgba = |rgb: [u8; 3]| {
let [r, g, b] = px(rgb);
[r, g, b, 1.0]
};
// Skin in light and shadow, then a cream shirt, a near-white wall, blue denim and black.
let skin =
[[183, 119, 81], [177, 132, 93], [180, 121, 84], [120, 76, 52], [173, 107, 75], [255, 219, 172], [194, 140, 104], [141, 85, 36], [92, 51, 23]]
.map(rgba);
let m = skin_tones(&skin, 40.0);
assert!(m.iter().all(|&v| v > 0.95), "skin {m:?}");
let other = [[231, 225, 202], [252, 253, 247], [40, 60, 110], [5, 5, 5], [128, 128, 128], [220, 40, 40], [200, 160, 40]].map(rgba);
let m = skin_tones(&other, 40.0);
assert!(m.iter().all(|&v| v < 0.05), "not skin {m:?}");
// Fuzziness widens the region; transparent pixels are never selected.
let pale = [rgba([230, 215, 200])];
assert!(skin_tones(&pale, 200.0)[0] > skin_tones(&pale, 0.0)[0]);
assert_eq!(skin_tones(&[[0.7, 0.45, 0.3, 0.0]], 40.0), vec![0.0]);
}
#[test]
fn color_range_falloff() {
let px = vec![[1.0, 0.0, 0.0, 1.0], [0.9, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [1.0, 0.0, 0.0, 0.0]];
@@ -1081,6 +1185,17 @@ mod tests {
assert!((hue_range(&[[1.0, 0.0, 0.5, 1.0]], 300.0)[0] - 0.5).abs() < 1e-5);
}
#[test]
fn hue_families_weigh_dark_colours_like_photoshop() {
// Photoshop 2025's Blues on an 8-bit sRGB document (selection levels 0..255, #5243): the
// muted navy #3d3f54, a dark blue (0, 0, 84) and a mid blue (61, 61, 153).
let c = |r: u8, g: u8, b: u8| [f32::from(r) / 255.0, f32::from(g) / 255.0, f32::from(b) / 255.0, 1.0];
let blues = hue_range(&[c(0x3d, 0x3f, 0x54), c(0, 0, 84), c(61, 61, 153)], 240.0);
for (got, want) in blues.iter().zip([35.0, 140.0, 129.0]) {
assert!((got * 255.0 - want).abs() <= 1.0, "{} vs Photoshop {want}", got * 255.0);
}
}
#[test]
fn tonal_ranges() {
let g = |v: f32| [v / 255.0, v / 255.0, v / 255.0, 1.0];
@@ -1161,6 +1276,107 @@ mod tests {
assert!(polygon(&[(0.0, 0.0), (1.0, 1.0)], a, true).iter().all(|v| *v == 0.0));
}
/// The previous polygon fill (every edge tested on every sub-row), kept as the oracle.
fn old_polygon(points: &[(f32, f32)], area: Rect, anti_alias: bool) -> Vec<f32> {
let (w, h) = (area.width() as usize, area.height() as usize);
let mut m = vec![0.0f32; w * h];
if points.len() < 3 {
return m;
}
let sub = if anti_alias { 4 } else { 1 };
let mut xs: Vec<f32> = Vec::new();
for py in 0..h {
for s in 0..sub {
let y = area.y0 as f32 + py as f32 + (s as f32 + 0.5) / sub as f32;
xs.clear();
for i in 0..points.len() {
let (a, b) = (points[i], points[(i + 1) % points.len()]);
if (a.1 <= y && b.1 > y) || (b.1 <= y && a.1 > y) {
xs.push(a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0));
}
}
xs.sort_by(|a, b| a.total_cmp(b));
for span in xs.as_chunks::<2>().0 {
let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32));
if x1 <= x0 {
continue;
}
let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w));
for cx in c0..c1 {
let cov = if anti_alias {
((cx + 1) as f32).min(x1) - (cx as f32).max(x0)
} else {
let c = cx as f32 + 0.5;
if c >= x0 && c < x1 { 1.0 } else { 0.0 }
};
m[py * w + cx] += cov.max(0.0) / sub as f32;
}
}
}
}
for v in &mut m {
*v = v.min(1.0);
}
m
}
#[test]
fn polygon_matches_the_every_edge_fill_bit_for_bit() {
let mut seed = 0x2545_f491_u32;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed as f32 / u32::MAX as f32
};
// Taller than one 64-row band, offset, and a 1-pixel-wide area.
let areas = [Rect::new(0, 0, 90, 150), Rect::new(-13, 7, 60, 211), Rect::new(5, -40, 6, 30)];
let mut cases: Vec<Vec<(f32, f32)>> = vec![
// Self-intersecting star, horizontal edges, repeated points, collinear runs.
(0..11)
.map(|i| {
let t = i as f32 * 4.0 * std::f32::consts::PI / 11.0;
(40.0 + 35.0 * t.cos(), 80.0 + 70.0 * t.sin())
})
.collect(),
vec![(10.0, 10.0), (50.0, 10.0), (50.0, 10.0), (50.0, 60.0), (30.0, 60.0), (30.0, 30.0), (10.0, 30.0)],
vec![(0.0, 0.0), (20.0, 0.0), (40.0, 0.0), (40.0, 0.0), (0.0, 0.0)],
// Vertices on the sub-row centres and the pixel edges.
vec![(3.125, 2.375), (70.5, 2.375), (70.875, 140.625), (3.0, 140.0), (35.0, 64.0), (35.0, 64.125)],
// Entirely outside, and straddling every side.
vec![(-500.0, -500.0), (-400.0, -500.0), (-450.0, -300.0)],
vec![(-80.0, -60.0), (300.0, 20.0), (60.0, 400.0)],
// Non-finite coordinates and a negative zero.
vec![(10.0, 10.0), (f32::NAN, 40.0), (60.0, 70.0), (20.0, f32::INFINITY), (-0.0, -0.0), (0.0, 0.0)],
];
for n in [3, 7, 40, 400] {
for spread in [1.0, 2.0] {
cases.push((0..n).map(|_| (rnd() * 100.0 * spread - 50.0 * (spread - 1.0), rnd() * 220.0 * spread - 60.0 * spread)).collect());
// Lasso-like: a random walk snapped to quarter pixels.
let (mut x, mut y) = (45.0f32, 75.0f32);
cases.push(
(0..n)
.map(|_| {
x = ((x + (rnd() - 0.5) * 12.0 * spread) * 4.0).round() / 4.0;
y = ((y + (rnd() - 0.5) * 12.0 * spread) * 4.0).round() / 4.0;
(x, y)
})
.collect(),
);
}
}
for pts in &cases {
for &area in &areas {
for aa in [true, false] {
let (new, old) = (polygon(pts, area, aa), old_polygon(pts, area, aa));
assert_eq!(new.len(), old.len());
let diff = new.iter().zip(&old).position(|(a, b)| a.to_bits() != b.to_bits());
assert!(diff.is_none(), "{pts:?} {area:?} aa={aa}: first difference at {diff:?}");
}
}
}
}
#[test]
fn combine_modes() {
let a = Rect::new(0, 0, 4, 1);
@@ -1262,6 +1478,37 @@ mod tests {
.collect()
}
#[test]
fn magic_wand_rejects_incomplete_and_overflowing_pixel_inputs() {
let area = Rect::new(0, 0, 4, 4);
let short = vec![[255u8, 0, 0, 255]; 4];
let floats: Vec<[f32; 4]> = short.iter().map(|p| p.map(|v| f32::from(v) / 255.0)).collect();
assert_eq!(magic_wand(&floats, area, (1, 1), 0.0, true, false), Vec::<f32>::new());
assert_eq!(wand_region(&short, area, (1, 1), 0.0, true, false), None);
let huge = Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX);
assert!(magic_wand(&floats, huge, (0, 0), 0.0, false, false).is_empty());
assert_eq!(wand_region(&short, huge, (0, 0), 0.0, false, false), None);
let valid = Rect::new(0, 0, 2, 2);
assert_eq!(magic_wand(&floats, valid, (0, 0), 0.0, false, false), vec![1.0; 4]);
assert!(wand_region(&short, valid, (0, 0), 0.0, false, false).is_some());
}
#[test]
fn region_coverage_outside_a_short_mask_is_zero() {
let region = Region { bbox: Rect::new(-2, -3, 1, -1), mask: vec![255, 128] };
assert_eq!(region.at(-2, -3), 1.0);
assert!((region.at(-1, -3) - 128.0 / 255.0).abs() < 1e-6);
assert_eq!(region.at(0, -3), 0.0);
assert_eq!(region.at(-2, -2), 0.0);
assert_eq!(region.at(-3, -3), 0.0);
let extreme = Region { bbox: Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX), mask: vec![] };
assert_eq!(extreme.at(i32::MAX - 1, i32::MAX - 1), 0.0);
assert_eq!(extreme.at(i32::MIN, i32::MIN), 0.0);
}
#[test]
fn wand_region_matches_reference_magic_wand() {
let (w, h) = (37, 29);
+4 -1
View File
@@ -569,11 +569,14 @@ pub(crate) fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
let s = f64::from(sigma);
let (kh, kv) = (Kernel::new(s, r, gw), Kernel::new(s, r, gh));
// Rows: only the bounding box's rows are non-zero.
// The run table is scratch: one buffer per worker thread, reused across rows instead of
// one allocation per row (thousands of 8-byte-per-sample Vecs on a 24 MP mask).
thread_local! { static RUNS: std::cell::RefCell<Vec<usize>> = const { std::cell::RefCell::new(Vec::new()) } }
let mut tmp = vec![0.0f32; b.h() * gw];
par_rows(&mut tmp, gw, 1, |y, row| {
let o = (b.y0 + y) * w + win.x0;
if let Some(s) = m.get(o..o + gw) {
kh.line(s, row, &mut Vec::new());
RUNS.with(|runs| kh.line(s, row, &mut runs.borrow_mut()));
}
});
// Columns, a strip of columns at a time.
+1 -1
View File
@@ -129,7 +129,7 @@ fn surfaces_exact_depth_models_alpha_selection_and_tile_boundaries() {
};
let spans = Spans::new(radius, shape_inside(shape));
let mut expected = conv_spans(&src, area, &ctx, &spans);
crate::mix_selection(&mut expected, area, &selection, &src);
crate::mix_selection(&mut expected, area, &selection, &src, alpha);
let mut reference = surface.clone();
reference.write_region(area, &expected);
for tile in [17, 256] {
+12 -6
View File
@@ -6,6 +6,17 @@ use crate::Ctx;
use crate::blur::{conv_sep, gaussian_kernel};
use crate::image::Image;
pub(crate) mod exact;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) mod fft;
#[cfg(test)]
mod tests;
#[cfg(all(test, not(target_arch = "wasm32")))]
mod fft_bench;
fn with_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, mut f: impl FnMut(f32, f32) -> f32) -> Vec<f32> {
let k = gaussian_kernel(radius);
let blurred = conv_sep(src, out, &k, &k, ctx.alpha);
@@ -24,12 +35,7 @@ fn with_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, mut f: impl FnMut(f
}
pub(crate) fn unsharp(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, threshold: f32) -> Vec<f32> {
let k = amount / 100.0;
let t = threshold / 255.0;
with_blur(src, out, ctx, radius, |o, b| {
let d = o - b;
if d.abs() + 1e-6 >= t { o + k * d } else { o }
})
exact::run(src, out, ctx, amount, radius, threshold, &photocraft_raster::Interrupt::NONE).unwrap_or_default()
}
pub(crate) fn smart(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, reduce_noise: f32) -> Vec<f32> {
+134
View File
@@ -0,0 +1,134 @@
//! Exact separable convolution for Unsharp Mask: stream one source row and reuse
//! the blurred output for the final arithmetic. Each sample retains its tap order.
use crate::image::premultiply;
use crate::{Ctx, Image};
use photocraft_geom::Rect;
use photocraft_raster::Interrupt;
const MAX_SAMPLES: usize = 128 * 1024 * 1024;
// The filter dialog caps radius at 1000. Preserve Details resampling can ask
// for more; permit that while the unchanged kernel's i32 square is safe.
const MAX_RADIUS: f32 = 15_446.0;
pub(crate) fn valid_params(amount: f32, radius: f32, threshold: f32) -> bool {
amount.is_finite() && threshold.is_finite() && radius.is_finite() && (0.0..=MAX_RADIUS).contains(&radius)
}
pub(crate) fn halo(radius: f32) -> i32 {
if radius.is_finite() && (0.0..=MAX_RADIUS).contains(&radius) { (radius * 3.0).ceil() as i32 + 1 } else { 0 }
}
pub(crate) fn valid_area(r: Rect) -> bool {
r.x0 <= r.x1
&& r.y0 <= r.y1
&& r.width() <= 300_000
&& r.height() <= 300_000
&& [r.x0, r.x1, r.y0, r.y1].iter().all(|v| (-1_000_000..=1_000_000).contains(v))
}
fn zeros(len: usize) -> Option<Vec<f32>> {
if len > MAX_SAMPLES {
return None;
}
let mut data = Vec::new();
data.try_reserve_exact(len).ok()?;
data.resize(len, 0.0);
Some(data)
}
/// Checked contiguous row copy, with the same zero extension as Image::get.
fn read_row(src: &Image, x0: i32, x1: i32, y: i32, dst: &mut [f32]) -> Option<()> {
dst.fill(0.0);
if y < src.rect.y0 || y >= src.rect.y1 {
return Some(());
}
let (lo, hi) = (x0.max(src.rect.x0), x1.min(src.rect.x1));
if lo >= hi {
return Some(());
}
let n = src.ch;
let count = usize::try_from(i64::from(hi) - i64::from(lo)).ok()?.checked_mul(n)?;
let offset = usize::try_from(i64::from(lo) - i64::from(x0)).ok()?.checked_mul(n)?;
let row = usize::try_from(i64::from(y) - i64::from(src.rect.y0)).ok()?;
let col = usize::try_from(i64::from(lo) - i64::from(src.rect.x0)).ok()?;
let start = row.checked_mul(src.rect.width() as usize)?.checked_add(col)?.checked_mul(n)?;
dst.get_mut(offset..offset.checked_add(count)?)?.copy_from_slice(src.data.get(start..start.checked_add(count)?)?);
Some(())
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn run(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, threshold: f32, ctl: &Interrupt) -> Option<Vec<f32>> {
if ctl.cancelled() || !valid_params(amount, radius, threshold) || !valid_area(out) || !valid_area(src.rect) {
return None;
}
let n = src.ch;
if n == 0 || n > crate::fxutil::MAXC {
return None;
}
if src.data.len() != (src.rect.width() as usize).checked_mul(src.rect.height() as usize)?.checked_mul(n)? {
return None;
}
if out.is_empty() {
return Some(Vec::new());
}
let kernel = super::gaussian_kernel(radius);
let r = kernel.len() / 2;
let (ow, oh) = (out.width() as usize, out.height() as usize);
let row_len = ow.checked_mul(n)?;
let th = oh.checked_add(2 * r)?;
let ww = ow.checked_add(2 * r)?;
let (x0, x1, y0) = (out.x0.checked_sub(r as i32)?, out.x1.checked_add(r as i32)?, out.y0.checked_sub(r as i32)?);
let mut row = zeros(ww.checked_mul(n)?)?;
let mut tmp = zeros(row_len.checked_mul(th)?)?;
let mut result = zeros(row_len.checked_mul(oh)?)?;
for (y, dst) in tmp.chunks_exact_mut(row_len).enumerate() {
if ctl.cancelled() {
return None;
}
read_row(src, x0, x1, y0.checked_add(y as i32)?, &mut row)?;
premultiply(&mut row, n, ctx.alpha);
// Tap-major traversal exposes contiguous independent samples to LLVM. No
// reduction, tap merging, symmetry or fused multiply-add changes the sums.
for (i, weight) in kernel.iter().enumerate() {
let start = i.checked_mul(n)?;
let samples = row.get(start..start.checked_add(row_len)?)?;
for (d, s) in dst.iter_mut().zip(samples) {
*d += s * weight;
}
}
}
let (amount, threshold) = (amount / 100.0, threshold / 255.0);
let cc = n - usize::from(ctx.alpha);
for (y, dst) in result.chunks_exact_mut(row_len).enumerate() {
if ctl.cancelled() {
return None;
}
for (i, weight) in kernel.iter().enumerate() {
let start = y.checked_add(i)?.checked_mul(row_len)?;
let samples = tmp.get(start..start.checked_add(row_len)?)?;
for (d, s) in dst.iter_mut().zip(samples) {
*d += s * weight;
}
}
let originals = row.get_mut(..row_len)?;
read_row(src, out.x0, out.x1, out.y0.checked_add(y as i32)?, originals)?;
for (blurred, original) in dst.chunks_exact_mut(n).zip(originals.chunks_exact(n)) {
if ctx.alpha && *original.last()? <= 0.0 {
blurred.copy_from_slice(original);
continue;
}
let a = if ctx.alpha { *blurred.last()? } else { 1.0 };
for (b, o) in blurred.iter_mut().zip(original).take(cc) {
// Keep the original unpremultiply cut-off and division, followed by
// the threshold epsilon and inclusive comparison, at every depth.
let b_value = if ctx.alpha { if a > 1e-7 { *b / a } else { 0.0 } } else { *b };
let d = o - b_value;
*b = if d.abs() + 1e-6 >= threshold { o + amount * d } else { *o };
}
if ctx.alpha {
*blurred.last_mut()? = *original.last()?;
}
}
}
Some(result)
}
+325
View File
@@ -0,0 +1,325 @@
//! Wide exact-kernel Gaussian convolution using FFTs. Summation order changes:
//! positive thresholds and unstable samples retain the sequential f32 fallback.
use crate::{FilterParams, Image};
use photocraft_geom::Rect;
use photocraft_raster::{Interrupt, Surface};
use rayon::prelude::*;
use rustfft::{Fft, FftPlanner, num_complex::Complex};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
const MAX_BYTES: usize = 1 << 30;
const MAX_SIDE: usize = 32_768;
const STRIPE: usize = 32;
type Number = Complex<f64>;
fn buffer<T: Clone>(len: usize, value: T) -> Option<Vec<T>> {
if len.checked_mul(std::mem::size_of::<T>())? > MAX_BYTES {
return None;
}
let mut data = Vec::new();
data.try_reserve_exact(len).ok()?;
data.resize(len, value);
Some(data)
}
struct Plan {
size: usize,
forward: Arc<dyn Fft<f64>>,
inverse: Arc<dyn Fft<f64>>,
spectrum: Vec<Number>,
scratch: usize,
}
impl Plan {
fn new(needed: usize, kernel: &[f32], planner: &mut FftPlanner<f64>) -> Option<Self> {
let size = needed.checked_next_power_of_two()?;
if size > MAX_SIDE || kernel.len() > size {
return None;
}
let forward = planner.plan_fft_forward(size);
let inverse = planner.plan_fft_inverse(size);
let scratch = forward.get_inplace_scratch_len().max(inverse.get_inplace_scratch_len());
let mut spectrum = buffer(size, Number::default())?;
for (dst, &weight) in spectrum.iter_mut().zip(kernel) {
dst.re = f64::from(weight) / size as f64;
}
forward.process_with_scratch(&mut spectrum, &mut buffer(scratch, Number::default())?);
Some(Self { size, forward, inverse, spectrum, scratch })
}
fn work(&self) -> Option<(Vec<Number>, Vec<Number>)> {
Some((buffer(self.size, Number::default())?, buffer(self.scratch, Number::default())?))
}
fn convolve(&self, data: &mut [Number], scratch: &mut [Number]) -> Option<()> {
if data.len() != self.size || scratch.len() < self.scratch {
return None;
}
self.forward.process_with_scratch(data, scratch);
for (d, k) in data.iter_mut().zip(&self.spectrum) {
*d *= k;
}
self.inverse.process_with_scratch(data, scratch);
Some(())
}
}
// Bound the complete temporary working set, including all concurrently live
// column gathers, originals, FFT scratch and result stripes. Document tiles are
// excluded, just as in the existing tile scheduler's scratch budget.
fn group_size(resident: usize, worker: usize, count: usize, workers: usize) -> Option<usize> {
let group = MAX_BYTES.checked_sub(resident)?.checked_div(worker.max(1))?.min(workers).min(count);
(group > 0).then_some(group)
}
/// `None` declines the optimization, `Some(None)` propagates cancellation.
#[allow(clippy::too_many_arguments)]
pub(crate) fn apply(
surface: &Surface,
params: &FilterParams,
area: Rect,
selection: Option<&Surface>,
extent: Option<Rect>,
ctl: &Interrupt,
) -> Option<Option<Surface>> {
let FilterParams::UnsharpMask { amount, radius, threshold } = *params else { return None };
// At a positive threshold, even a tiny sum-order difference can switch the
// entire sharpen contribution. Zero/negative thresholds always keep it.
if !(64.0..=1000.0).contains(&radius)
|| threshold > 0.0
|| amount == 0.0
|| !super::exact::valid_params(amount, radius, threshold)
|| !super::exact::valid_area(area)
|| area.is_empty()
|| u64::from(area.width()) * u64::from(area.height()) < 65_536
|| !(1..=crate::fxutil::MAXC).contains(&surface.channels())
|| selection.is_some_and(|s| s.channels() == 0)
|| extent.is_some_and(|e| !super::exact::valid_area(e) || area.intersect(&e) != area)
{
return None;
}
if ctl.cancelled() {
return Some(None);
}
match run(surface, area, amount, radius, selection, extent, ctl) {
Some(out) => Some(Some(out)),
None if ctl.cancelled() => Some(None),
None => None,
}
}
#[allow(clippy::too_many_arguments)]
fn run(surface: &Surface, area: Rect, amount: f32, radius: f32, selection: Option<&Surface>, extent: Option<Rect>, ctl: &Interrupt) -> Option<Surface> {
let (w, h, n) = (area.width() as usize, area.height() as usize, surface.channels());
let alpha = surface.format().alpha;
let kernel = super::gaussian_kernel(radius);
let r = kernel.len() / 2;
let reach = i32::try_from(r).ok()?;
let (x0, x1) = (area.x0.checked_sub(reach)?, area.x1.checked_add(reach)?);
let (y0, y1) = (area.y0.checked_sub(reach)?, area.y1.checked_add(reach)?);
let (read_x0, read_x1, read_y0, read_y1) = match extent {
Some(e) => (x0.max(e.x0), x1.min(e.x1), y0.max(e.y0), y1.min(e.y1)),
None => (x0, x1, y0, y1),
};
let rows = usize::try_from(i64::from(read_y1) - i64::from(read_y0)).ok()?;
let read_width = usize::try_from(i64::from(read_x1) - i64::from(read_x0)).ok()?;
let row_len = w.checked_mul(n)?;
let samples = row_len.checked_mul(rows)?;
let resident = samples.checked_mul(4)?;
let mut planner = FftPlanner::<f64>::new();
let horizontal = Plan::new(w.checked_add(2 * r)?, &kernel, &mut planner)?;
let vertical = Plan::new(h.checked_add(2 * r)?, &kernel, &mut planner)?;
// A conservative allowance includes FFT plans/twiddles in addition to spectra.
let plans = horizontal.size.checked_add(vertical.size)?.checked_mul(256)?;
let resident = resident.checked_add(plans)?;
let workers = rayon::current_num_threads().max(1);
let horizontal_worker = read_width.checked_mul(n)?.checked_mul(4)?.checked_add(horizontal.size.checked_add(horizontal.scratch)?.checked_mul(16)?)?;
let vertical_worker = STRIPE
.checked_mul(rows)?
.checked_mul(n)?
.checked_mul(4)?
.checked_add(STRIPE.checked_mul(h)?.checked_mul(n)?.checked_mul(12)?)?
.checked_add(vertical.size.checked_add(vertical.scratch)?.checked_mul(16)?)?;
let group = group_size(resident, horizontal_worker.max(vertical_worker), w.div_ceil(STRIPE), workers)?;
let mut tmp = buffer(samples, 0.0f32)?;
let finished = AtomicUsize::new(0);
// Read each needed real row once. Outside a repeated extent, horizontal
// results are repeated lazily by the vertical gather, not transformed again.
for band in tmp.chunks_mut(row_len.checked_mul(group)?) {
let first = finished.load(Ordering::Relaxed);
let ok: Option<Vec<()>> = band
.par_chunks_exact_mut(row_len)
.enumerate()
.map(|(offset, dst)| {
if ctl.cancelled() {
return None;
}
let y = read_y0.checked_add(i32::try_from(first.checked_add(offset)?).ok()?)?;
let mut row = buffer(read_width.checked_mul(n)?, 0.0f32)?;
// Reservation above covers resize, so the reusable raster read cannot allocate.
surface.read_region_into(Rect::new(read_x0, y, read_x1, y.checked_add(1)?), &mut row);
let mut scales = [0.0f64; crate::fxutil::MAXC];
for px in row.chunks_exact_mut(n) {
if px.iter().any(|v| !v.is_finite() || v.abs() > 1e6) || (alpha && !px.last().is_some_and(|a| (0.0..=1.0).contains(a))) {
return None;
}
let a = if alpha { *px.last()? } else { 1.0 };
for (c, v) in px.iter_mut().enumerate() {
if alpha && c + 1 < n {
*v *= a;
}
let scale = scales.get_mut(c)?;
*scale = scale.max(f64::from(v.abs()));
}
}
let (mut data, mut scratch) = horizontal.work()?;
for channel in (0..n).step_by(2) {
if ctl.cancelled() {
return None;
}
data.fill(Number::default());
for (i, d) in data.iter_mut().take(w + 2 * r).enumerate() {
let sx = x0.checked_add(i32::try_from(i).ok()?)?.clamp(read_x0, read_x1.checked_sub(1)?);
let start = usize::try_from(sx - read_x0).ok()?.checked_mul(n)?;
let px = row.get(start..start.checked_add(n)?)?;
let sample = |c: usize| -> f64 {
match (px.get(c), scales.get(c)) {
(Some(v), Some(s)) if *s > 0.0 => f64::from(*v) / s,
_ => 0.0,
}
};
*d = Number::new(sample(channel), sample(channel + 1));
}
horizontal.convolve(&mut data, &mut scratch)?;
for (px, d) in dst.chunks_exact_mut(n).zip(data.get(2 * r..2 * r + w)?) {
*px.get_mut(channel)? = (d.re * scales.get(channel)?) as f32;
if let Some(v) = px.get_mut(channel + 1) {
*v = (d.im * scales.get(channel + 1)?) as f32;
}
}
}
finished.fetch_add(1, Ordering::Relaxed);
Some(())
})
.collect();
ok?;
ctl.progress(0.5 * finished.load(Ordering::Relaxed) as f32 / rows as f32);
if ctl.cancelled() {
return None;
}
}
let mut out = surface.clone();
let count = w.div_ceil(STRIPE);
for first in (0..count).step_by(group) {
let end = first.checked_add(group)?.min(count);
let results: Option<Vec<_>> = (first..end)
.into_par_iter()
.map(|index| {
if ctl.cancelled() {
return None;
}
let x = index.checked_mul(STRIPE)?;
let width = STRIPE.min(w.checked_sub(x)?);
let mut columns = buffer(width.checked_mul(rows)?.checked_mul(n)?, 0.0f32)?;
for (y, row) in tmp.chunks_exact(row_len).enumerate() {
if y % 64 == 0 && ctl.cancelled() {
return None;
}
for (c, px) in row.get(x * n..(x + width) * n)?.chunks_exact(n).enumerate() {
let start = (c * rows + y) * n;
columns.get_mut(start..start + n)?.copy_from_slice(px);
}
}
let rect = Rect::new(area.x0.checked_add(x as i32)?, area.y0, area.x0.checked_add((x + width) as i32)?, area.y1);
let len = width.checked_mul(h)?.checked_mul(n)?;
let mut result = buffer(len, 0.0f32)?;
let mut original = buffer(len, 0.0f32)?;
surface.read_region_into(rect, &mut original);
let (mut data, mut scratch) = vertical.work()?;
for (c, samples) in columns.chunks_exact(rows * n).enumerate() {
if ctl.cancelled() {
return None;
}
let mut scales = [0.0f64; crate::fxutil::MAXC];
for px in samples.chunks_exact(n) {
for (s, v) in scales.iter_mut().zip(px) {
*s = s.max(f64::from(v.abs()));
}
}
for channel in (0..n).step_by(2) {
if ctl.cancelled() {
return None;
}
data.fill(Number::default());
for (i, d) in data.iter_mut().take(h + 2 * r).enumerate() {
let sy = y0.checked_add(i as i32)?.clamp(read_y0, read_y1.checked_sub(1)?);
let start = usize::try_from(sy - read_y0).ok()?.checked_mul(n)?;
let px = samples.get(start..start + n)?;
let sample = |c: usize| -> f64 {
match (px.get(c), scales.get(c)) {
(Some(v), Some(s)) if *s > 0.0 => f64::from(*v) / s,
_ => 0.0,
}
};
*d = Number::new(sample(channel), sample(channel + 1));
}
vertical.convolve(&mut data, &mut scratch)?;
for (y, d) in data.get(2 * r..2 * r + h)?.iter().enumerate() {
let start = (y * width + c) * n;
*result.get_mut(start + channel)? = (d.re * scales.get(channel)?) as f32;
if let Some(v) = result.get_mut(start + channel + 1).filter(|_| channel + 1 < n) {
*v = (d.im * scales.get(channel + 1)?) as f32;
}
}
}
}
for (px, o) in result.chunks_exact_mut(n).zip(original.chunks_exact(n)) {
if alpha && *o.last()? <= 0.0 {
px.copy_from_slice(o);
continue;
}
let a = if alpha { *px.last()? } else { 1.0 };
// Near the original unpremultiply cutoff, cancellation/FFT noise
// or f32 sum order can expose colour. Decline the whole fast path.
let uncertainty = 4.0 * kernel.len() as f32 * f32::EPSILON;
if alpha && a <= 1e-7 + uncertainty {
return None;
}
for (v, o) in px.iter_mut().zip(o).take(n - usize::from(alpha)) {
let b = if alpha { *v / a } else { *v };
*v = o + amount / 100.0 * (o - b);
if !v.is_finite() {
return None;
}
}
if alpha {
*px.last_mut()? = *o.last()?;
}
}
if let Some(sel) = selection {
crate::mix_selection(&mut result, rect, sel, &Image { rect, ch: n, data: original }, alpha);
}
Some((rect, result))
})
.collect();
for (rect, data) in results? {
if ctl.cancelled() {
return None;
}
out.try_write_region(rect, &data).ok()?;
}
ctl.progress(0.5 + 0.48 * end as f32 / count as f32);
}
if ctl.cancelled() {
return None;
}
out.prune();
if ctl.cancelled() {
return None;
}
ctl.progress(1.0);
Some(out)
}
#[cfg(test)]
mod tests;
+146
View File
@@ -0,0 +1,146 @@
use super::*;
use crate::sharpen::tests::{reference, source};
use crate::{Ctx, image::Image};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
fn compare(old: &Surface, new: &Surface, area: Rect) {
let tolerance = match old.format().sample {
SampleType::U8 => 1.0 / 255.0 + 1e-7,
SampleType::U16 => 2.0 / 65535.0 + 1e-7,
SampleType::F32 => 3e-5,
};
for (a, b) in old.read_region(area).iter().zip(new.read_region(area)) {
let limit = if old.format().sample == SampleType::F32 { tolerance * (1.0 + a.abs()) } else { tolerance };
assert!((a - b).abs() <= limit, "{a} != {b}, tolerance {limit}");
}
}
#[test]
fn fft_preserves_gaussian_quality_at_every_depth_and_model() {
let area = Rect::new(-3, 2, 10, 13);
for mode in [
ColorMode::Bitmap,
ColorMode::Grayscale,
ColorMode::Duotone,
ColorMode::Indexed,
ColorMode::Rgb,
ColorMode::Cmyk,
ColorMode::Lab,
ColorMode::Multichannel,
] {
for depth in SampleType::ALL {
for alpha in [false, true] {
let s = source(area, PixelFormat::new(mode, depth, alpha));
let ctx = Ctx { bounds: area, mode, alpha };
for radius in [3.25, 64.0] {
let src = Image::read_clamped(&s, area.inflate((radius * 3.0_f32).ceil() as i32 + 1), area);
let pixels = reference::unsharp(&src, area, &ctx, 500.0, radius, 0.0);
let mut expected = s.clone();
expected.write_region(area, &pixels);
let actual = run(&s, area, 500.0, radius, None, Some(area), &Interrupt::NONE).unwrap();
compare(&expected, &actual, area);
if alpha {
for (a, b) in s.read_region(area).chunks_exact(s.channels()).zip(actual.read_region(area).chunks_exact(s.channels())) {
assert_eq!(a.last(), b.last());
if a.last() == Some(&0.0) {
assert_eq!(a, b);
}
}
}
}
}
}
}
}
#[test]
fn fft_keeps_partial_regions_selections_and_edge_policies() {
let extent = Rect::new(-7, -9, 35, 39);
let area = Rect::new(-3, 2, 19, 23);
let mut sel = Surface::new(PixelFormat::GRAY8);
sel.fill_rect(area, &[0.4]);
for depth in SampleType::ALL {
let s = source(extent, PixelFormat::new(ColorMode::Rgb, depth, true));
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 64.0, threshold: 0.0 };
for edge in [None, Some(extent)] {
let expected = crate::sharpen::tests::apply_reference(&s, &p, area, extent, Some(&sel), edge, 256);
let actual = run(&s, area, 150.0, 64.0, Some(&sel), edge, &Interrupt::NONE).unwrap();
compare(&expected, &actual, extent);
}
}
}
#[test]
fn fft_falls_back_for_thresholds_and_unstable_samples() {
let area = Rect::new(0, 0, 256, 256);
let mut s = source(area, PixelFormat::RGBA32F);
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 64.0, threshold: 8.0 };
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none());
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 64.0, threshold: 0.0 };
s.fill_rect(Rect::new(0, 0, 1, 1), &[f32::NAN, 0.5, 0.5, 1.0]);
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none());
s.fill_rect(area, &[0.5, 0.5, 0.5, 1e-8]);
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none());
s.fill_rect(area, &[0.5, 0.5, 0.5, -0.1]);
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::NONE).is_none());
}
#[test]
fn fft_cancellation_discards_partial_work_and_bounds_scratch() {
let area = Rect::new(0, 0, 256, 256);
let s = source(area, PixelFormat::RGBA8);
let original = s.to_interleaved(area);
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 300.0, threshold: 0.0 };
let flag = std::sync::atomic::AtomicBool::new(false);
let cancel = || flag.load(Ordering::Relaxed);
let progress = |_| flag.store(true, Ordering::Relaxed);
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::new(&cancel, &progress)).unwrap().is_none());
assert_eq!(s.to_interleaved(area), original);
flag.store(false, Ordering::Relaxed);
let after_writes = |value| {
if value > 0.55 {
flag.store(true, Ordering::Relaxed);
}
};
assert!(apply(&s, &p, area, None, Some(area), &Interrupt::new(&cancel, &after_writes)).unwrap().is_none());
assert_eq!(s.to_interleaved(area), original);
assert_eq!(group_size(MAX_BYTES, 1, 1, 1), None);
assert_eq!(group_size(0, usize::MAX, 1, 1), None);
assert!(Plan::new(usize::MAX, &[1.0], &mut FftPlanner::new()).is_none());
assert!(buffer::<f32>(usize::MAX, 0.0).is_none());
}
#[test]
fn positive_threshold_dispatch_retains_exact_pixel_decisions() {
let area = Rect::new(-3, 2, 253, 258);
for depth in SampleType::ALL {
let s = source(area, PixelFormat::new(ColorMode::Rgb, depth, true));
let p = FilterParams::UnsharpMask { amount: 500.0, radius: 64.0, threshold: 8.0 };
let expected = crate::sharpen::tests::apply_reference(&s, &p, area, area, None, Some(area), 256);
let actual = crate::apply_in(&s, &p, area, area, None, area);
assert_eq!(expected.to_interleaved(area), actual.to_interleaved(area));
}
}
#[test]
fn fft_handles_maximum_radius_and_hdr_alpha_channel_pairs() {
let area = Rect::new(0, 0, 1, 1);
let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false));
s.fill_rect(area, &[0.5]);
let ctx = Ctx { bounds: area, mode: ColorMode::Grayscale, alpha: false };
let src = Image::read_clamped(&s, area.inflate(3001), area);
let pixels = reference::unsharp(&src, area, &ctx, 500.0, 1000.0, 0.0);
let mut expected = s.clone();
expected.write_region(area, &pixels);
compare(&expected, &run(&s, area, 500.0, 1000.0, None, Some(area), &Interrupt::NONE).unwrap(), area);
let area = Rect::new(-2, 3, 11, 14);
let mut s = Surface::new(PixelFormat::RGBA32F);
s.fill_rect(area, &[1e6, -1e5, 1e-4, 0.02]);
let ctx = Ctx { bounds: area, mode: ColorMode::Rgb, alpha: true };
let src = Image::read_clamped(&s, area.inflate(193), area);
let pixels = reference::unsharp(&src, area, &ctx, 500.0, 64.0, 0.0);
let mut expected = s.clone();
expected.write_region(area, &pixels);
compare(&expected, &run(&s, area, 500.0, 64.0, None, Some(area), &Interrupt::NONE).unwrap(), area);
}
+112
View File
@@ -0,0 +1,112 @@
//! Opt-in benchmark of the adaptive production path against the frozen original.
use super::tests::{apply_reference, source};
use crate::FilterParams;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use std::time::Instant;
#[test]
#[ignore = "adaptive large-radius full-operation quality and performance; run alone"]
fn unsharp_fft_performance() {
let number = |name: &str, default| std::env::var(name).ok().and_then(|v| v.parse::<i32>().ok()).unwrap_or(default);
let (w, h) = (number("PHOTOCRAFT_UNSHARP_WIDTH", 1920), number("PHOTOCRAFT_UNSHARP_HEIGHT", 1080));
let reps = number("PHOTOCRAFT_UNSHARP_REPS", 3);
assert!((1..=8192).contains(&w) && (1..=8192).contains(&h) && (1..=5).contains(&reps));
let depth = match number("PHOTOCRAFT_UNSHARP_DEPTH", 8) {
8 => SampleType::U8,
16 => SampleType::U16,
32 => SampleType::F32,
_ => panic!("depth"),
};
let area = Rect::new(0, 0, w, h);
let surface = source(area, PixelFormat::new(ColorMode::Rgb, depth, true));
let cases = std::env::var("PHOTOCRAFT_UNSHARP_CASES").unwrap_or_else(|_| "100:150:0,300:150:0,500:150:8".into());
for case in cases.split(',') {
let v: Vec<f32> = case.split(':').map(|v| v.parse().unwrap()).collect();
let (radius, amount, threshold) = (v[0], v[1], v[2]);
let params = FilterParams::UnsharpMask { amount, radius, threshold };
let baseline = || apply_reference(&surface, &params, area, area, None, Some(area), crate::auto_tile(&params, area));
let candidate = || crate::apply_in(&surface, &params, area, area, None, area);
for _ in 0..2 {
std::hint::black_box(baseline());
std::hint::black_box(candidate());
}
let mut before = Vec::new();
let mut after = Vec::new();
let mut max_error = 0.0f32;
let mut changed = 0usize;
let mut sum_error = 0.0f64;
for rep in 0..reps {
let timed = |f: &dyn Fn() -> Surface| {
let t = Instant::now();
let output = f();
(output, t.elapsed().as_secs_f64() * 1000.0)
};
let ((old, a), (new, b)) = if rep % 2 == 0 {
(timed(&baseline), timed(&candidate))
} else {
let b = timed(&candidate);
(timed(&baseline), b)
};
before.push(a);
after.push(b);
for (a, b) in old.read_region(area).iter().zip(new.read_region(area)) {
let e = (a - b).abs();
let limit = match depth {
SampleType::U8 => 1.0 / 255.0 + 1e-7,
SampleType::U16 => 3.0 / 65535.0 + 1e-7,
SampleType::F32 => 3e-5 * (1.0 + a.abs()),
};
assert!(e <= limit, "quality regression: {a} != {b} at radius {radius}, threshold {threshold}");
max_error = max_error.max(e);
if rep == 0 {
sum_error += f64::from(e);
changed += usize::from(a.to_bits() != b.to_bits());
}
}
}
let median = |v: &[f64]| {
let mut v = v.to_vec();
v.sort_by(f64::total_cmp);
v[v.len() / 2]
};
println!(
"{}",
serde_json::json!({"experiment":"adaptive FFT same Gaussian, changed summation","width":w,"height":h,"depth":number("PHOTOCRAFT_UNSHARP_DEPTH",8),"radius":radius,"amount":amount,"threshold":threshold,"before_ms":before,"after_ms":after,"before_median_ms":median(&before),"after_median_ms":median(&after),"speedup":median(&before)/median(&after),"max_error":max_error,"mean_error":sum_error/(w as f64*h as f64*4.0),"changed_samples":changed})
);
}
}
#[test]
#[ignore = "4K wide-radius cancellation latency; run alone in release"]
fn unsharp_fft_cancel_latency() {
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
let area = Rect::new(0, 0, 3840, 2160);
let surface = source(area, PixelFormat::RGBA8);
let input = surface.to_interleaved(area);
let p = FilterParams::UnsharpMask { amount: 150.0, radius: 1000.0, threshold: 0.0 };
let mut latencies = Vec::new();
for _ in 0..5 {
let flag = AtomicBool::new(false);
let cancel_at = AtomicU64::new(0);
let started = Instant::now();
std::thread::scope(|scope| {
let timer = scope.spawn(|| {
std::thread::sleep(std::time::Duration::from_millis(10));
cancel_at.store(started.elapsed().as_micros() as u64, Ordering::Relaxed);
flag.store(true, Ordering::Release);
});
let cancel = || flag.load(Ordering::Acquire);
let result = crate::apply_in_with(&surface, &p, area, area, None, area, &photocraft_raster::Interrupt::cancel_only(&cancel));
let elapsed = started.elapsed().as_micros() as u64;
timer.join().unwrap();
assert!(result.is_none());
latencies.push(elapsed.saturating_sub(cancel_at.load(Ordering::Relaxed)) as f64 / 1000.0);
});
assert_eq!(surface.to_interleaved(area), input);
}
println!(
"{}",
serde_json::json!({"width":3840,"height":2160,"radius":1000,"cancel_after_ms":10,"cancel_return_latencies_ms":latencies,"scope":"native full-resolution algo dispatch, excludes GUI scheduling/proxy creation"})
);
}
+113
View File
@@ -0,0 +1,113 @@
//! Frozen exact Unsharp Mask from a28d7866, independent of production helpers.
use crate::Ctx;
use crate::image::{Image, premultiply, unpremultiply};
use photocraft_geom::Rect;
pub(crate) fn gaussian_kernel(sigma: f32) -> Vec<f32> {
if sigma < 0.05 {
return vec![1.0];
}
let r = (sigma * 3.0).ceil() as i32;
let k: Vec<f32> = (-r..=r).map(|i| (-(i * i) as f32 / (2.0 * sigma * sigma)).exp()).collect();
let s: f32 = k.iter().sum();
k.into_iter().map(|v| v / s).collect()
}
/// Separable convolution of `src` (premultiplied internally) for `out`.
/// `src` must cover `out` grown by the kernel radii.
pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bool) -> Vec<f32> {
let n = src.ch;
let (rx, ry) = ((kx.len() / 2) as i32, (ky.len() / 2) as i32);
let (ow, oh) = (out.width() as usize, out.height() as usize);
let th = oh + 2 * ry as usize;
// Premultiplied copy of the needed source window.
let win = Rect::new(out.x0 - rx, out.y0 - ry, out.x1 + rx, out.y1 + ry);
let ww = win.width() as usize;
let mut p = vec![0.0f32; ww * win.height() as usize * n];
for y in win.y0..win.y1 {
for x in win.x0..win.x1 {
let o = ((y - win.y0) as usize * ww + (x - win.x0) as usize) * n;
for c in 0..n {
p[o + c] = src.get(x, y, c);
}
}
}
premultiply(&mut p, n, alpha);
// Horizontal pass: (ow × th).
let mut tmp = vec![0.0f32; ow * th * n];
for ty in 0..th {
let row = &p[ty * ww * n..(ty + 1) * ww * n];
let dst = &mut tmp[ty * ow * n..(ty + 1) * ow * n];
match n {
1 => conv_row::<1>(row, dst, kx),
2 => conv_row::<2>(row, dst, kx),
3 => conv_row::<3>(row, dst, kx),
4 => conv_row::<4>(row, dst, kx),
5 => conv_row::<5>(row, dst, kx),
_ => {
for ox in 0..ow {
let d = &mut dst[ox * n..(ox + 1) * n];
for (i, kv) in kx.iter().enumerate() {
let s = &row[(ox + i) * n..(ox + i + 1) * n];
for c in 0..n {
d[c] += s[c] * kv;
}
}
}
}
}
}
// Vertical pass.
let mut res = vec![0.0f32; ow * oh * n];
for oy in 0..oh {
let d = &mut res[oy * ow * n..(oy + 1) * ow * n];
for (i, kv) in ky.iter().enumerate() {
let s = &tmp[(oy + i) * ow * n..(oy + i + 1) * ow * n];
for (dv, sv) in d.iter_mut().zip(s) {
*dv += sv * kv;
}
}
}
unpremultiply(&mut res, n, alpha);
res
}
// Fixed channel counts let LLVM keep one pixel's accumulators in registers and vectorize
// channels. The tap order is unchanged; unusual multichannel images keep the generic path.
fn conv_row<const N: usize>(src: &[f32], dst: &mut [f32], kernel: &[f32]) {
let pixels = src.as_chunks::<N>().0;
for (x, out) in dst.as_chunks_mut::<N>().0.iter_mut().enumerate() {
let mut acc = [0.0f32; N];
for (pixel, weight) in pixels.get(x..).unwrap_or(&[]).iter().zip(kernel) {
for c in 0..N {
acc[c] += pixel[c] * weight;
}
}
*out = acc;
}
}
fn with_blur(src: &Image, out: Rect, ctx: &Ctx, radius: f32, mut f: impl FnMut(f32, f32) -> f32) -> Vec<f32> {
let k = gaussian_kernel(radius);
let blurred = conv_sep(src, out, &k, &k, ctx.alpha);
let mut res = src.crop(out);
let n = src.ch;
let cc = if ctx.alpha { n - 1 } else { n };
for (px, bl) in res.chunks_exact_mut(n).zip(blurred.chunks_exact(n)) {
if ctx.alpha && px[n - 1] <= 0.0 {
continue;
}
for c in 0..cc {
px[c] = f(px[c], bl[c]);
}
}
res
}
pub(crate) fn unsharp(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, threshold: f32) -> Vec<f32> {
let k = amount / 100.0;
let t = threshold / 255.0;
with_blur(src, out, ctx, radius, |o, b| {
let d = o - b;
if d.abs() + 1e-6 >= t { o + k * d } else { o }
})
}
+434
View File
@@ -0,0 +1,434 @@
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::{Interrupt, Surface};
use std::time::Instant;
#[path = "reference.rs"]
pub(super) mod reference;
pub(super) fn source(rect: Rect, fmt: PixelFormat) -> Surface {
let mut s = Surface::new(fmt);
for y in rect.y0..rect.y1 {
let data: Vec<f32> = (rect.x0..rect.x1)
.flat_map(|x| {
(0..fmt.channels()).map(move |c| {
if fmt.alpha && c + 1 == fmt.channels() {
if (x + y).rem_euclid(17) == 0 { 0.0 } else { ((x ^ y).rem_euclid(255) + 1) as f32 / 255.0 }
} else {
let value = ((x * (7 + c as i32) + y * 13).rem_euclid(257)) as f32 / 256.0;
if fmt.sample == SampleType::F32 { 3.0 * value - 0.5 } else { value }
}
})
})
.collect();
s.write_region(Rect::new(rect.x0, y, rect.x1, y + 1), &data);
}
s
}
// Preserve the surrounding tiled workload as well as the old convolution: halo reads,
// automatic tiles, grouping, selection mixing, writes and pruning are timed in both paths.
#[allow(clippy::too_many_arguments)]
pub(super) fn apply_reference(
s: &Surface,
p: &crate::FilterParams,
area: Rect,
bounds: Rect,
sel: Option<&Surface>,
extent: Option<Rect>,
tile: i32,
) -> Surface {
let crate::FilterParams::UnsharpMask { amount, radius, threshold } = *p else { panic!("unsharp params") };
let crate::Halo::Radius(halo) = p.halo_for(bounds) else { panic!("radius halo") };
let fmt = s.format();
let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha };
let tiles: Vec<_> = (area.y0..area.y1)
.step_by(tile as usize)
.flat_map(|y| (area.x0..area.x1).step_by(tile as usize).map(move |x| Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1))))
.collect();
let group = (crate::RESULT_BUDGET / ((tile as usize).pow(2) * fmt.channels() * 4)).max(workers()).max(1);
let finished = std::sync::atomic::AtomicUsize::new(0);
let ctl = Interrupt::NONE;
let run = |t: &Rect| {
let src = match extent {
Some(e) => Image::read_clamped(s, t.inflate(halo), e),
None => Image::read(s, t.inflate(halo)),
};
let mut data = reference::unsharp(&src, *t, &ctx, amount, radius, threshold);
if let Some(sel) = sel {
crate::mix_selection(&mut data, *t, sel, &src, fmt.alpha);
}
let done = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
ctl.progress(0.98 * done as f32 / tiles.len().max(1) as f32);
(*t, data)
};
let mut out = s.clone();
for chunk in tiles.chunks(group) {
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<_> = {
use rayon::prelude::*;
chunk.par_iter().map(run).collect()
};
#[cfg(target_arch = "wasm32")]
let results: Vec<_> = chunk.iter().map(run).collect();
for (t, data) in results {
out.write_region(t, &data);
}
}
out.prune();
out
}
fn workers() -> usize {
#[cfg(not(target_arch = "wasm32"))]
{
rayon::current_num_threads()
}
#[cfg(target_arch = "wasm32")]
{
1
}
}
#[test]
fn frozen_reference_matches_current_kernel() {
let out = Rect::new(-3, 2, 16, 15);
let src = Image { rect: out.inflate(13), ch: 4, data: (0..(out.width() + 26) * (out.height() + 26) * 4).map(|i| (i % 127) as f32 / 126.0).collect() };
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true };
for radius in [0.1, 0.6, 1.5, 4.0] {
assert_eq!(unsharp(&src, out, &ctx, 150.0, radius, 8.0), reference::unsharp(&src, out, &ctx, 150.0, radius, 8.0));
}
}
fn same_bits(a: &[f32], b: &[f32]) {
assert_eq!(a.len(), b.len());
for (i, (&a, &b)) in a.iter().zip(b).enumerate() {
assert!(a.to_bits() == b.to_bits() || a.is_nan() && b.is_nan(), "sample {i}: {a:?} ({:x}) != {b:?} ({:x})", a.to_bits(), b.to_bits());
}
}
#[test]
fn exact_kernel_across_models_depths_edges_and_thresholds() {
let modes = [
ColorMode::Bitmap,
ColorMode::Grayscale,
ColorMode::Duotone,
ColorMode::Indexed,
ColorMode::Rgb,
ColorMode::Cmyk,
ColorMode::Lab,
ColorMode::Multichannel,
];
for mode in modes {
for depth in SampleType::ALL {
for alpha in [false, true] {
let fmt = PixelFormat::new(mode, depth, alpha);
let area = Rect::new(-3, 2, 8, 7);
let mut surface = source(area, fmt);
if depth == SampleType::F32 {
// HDR, negative, signed zero, subnormal and nonfinite samples; colour is
// never clamped by this filter. Transparent hidden colour must survive.
let px: Vec<f32> = (0..fmt.channels()).map(|c| if alpha && c + 1 == fmt.channels() { 0.0 } else { -4.0 }).collect();
surface.fill_rect(Rect::new(0, 3, 1, 4), &px);
for (x, value) in [3.5, -0.0, f32::MIN_POSITIVE, f32::INFINITY, f32::NAN].into_iter().enumerate() {
let mut px = vec![value; fmt.channels()];
if alpha {
*px.last_mut().unwrap() = if x == 2 { 1e-8 } else { 1.0 };
}
surface.fill_rect(Rect::new(x as i32, 5, x as i32 + 1, 6), &px);
}
}
let ctx = Ctx { bounds: area, mode, alpha };
for radius in [0.0, 0.049, 0.1, 0.6, 1.5, 4.0, 17.25] {
let src = Image::read_clamped(&surface, area.inflate((radius * 3.0_f32).ceil() as i32 + 1), area);
for (amount, threshold) in [(0.0, 0.0), (50.0, 0.0), (500.0, 8.0), (300.0, 255.0)] {
same_bits(&unsharp(&src, area, &ctx, amount, radius, threshold), &reference::unsharp(&src, area, &ctx, amount, radius, threshold));
}
// An incomplete halo has zero extension, even with alpha.
let src = Image::read(&surface, area);
let out = Rect::new(-5, 1, 10, 8);
same_bits(&unsharp(&src, out, &ctx, 150.0, radius, 8.0), &reference::unsharp(&src, out, &ctx, 150.0, radius, 8.0));
}
}
}
}
}
#[test]
fn exact_threshold_boundary_and_degenerate_images() {
for (w, h, n) in [(1, 1, 1), (1, 7, 2), (9, 1, 3), (7, 5, 4), (2, 3, 5), (5, 2, 7)] {
let out = Rect::new(-2, 3, w - 2, h + 3);
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: n % 2 == 0 };
let src = Image { rect: out, ch: n, data: (0..w * h * n as i32).map(|i| if i % 7 == 0 { -0.0 } else { (i % 17) as f32 / 16.0 }).collect() };
for radius in [0.0, 0.1, 0.75, 8.5, 64.0] {
let k = reference::gaussian_kernel(radius);
let blurred = reference::conv_sep(&src, out, &k, &k, ctx.alpha);
for (&o, &b) in src.data.iter().zip(&blurred).take(8) {
let boundary = ((o - b).abs() + 1e-6) * 255.0;
for threshold in [boundary, f32::from_bits(boundary.to_bits().saturating_sub(1)), f32::from_bits(boundary.to_bits() + 1)] {
same_bits(&unsharp(&src, out, &ctx, 500.0, radius, threshold), &reference::unsharp(&src, out, &ctx, 500.0, radius, threshold));
}
}
}
}
let empty = Image { rect: Rect::EMPTY, ch: 4, data: Vec::new() };
let ctx = Ctx { bounds: Rect::EMPTY, mode: ColorMode::Rgb, alpha: true };
assert!(unsharp(&empty, Rect::EMPTY, &ctx, 100.0, 1.0, 0.0).is_empty());
}
#[test]
fn exact_tiled_selected_outputs_and_cancellation() {
let bounds = Rect::new(-13, -7, 281, 271);
let area = Rect::new(-3, 5, 273, 265);
let mut sel = Surface::new(PixelFormat::GRAY8);
sel.fill_rect(area, &[0.4]);
sel.fill_rect(Rect::new(250, 250, 270, 265), &[1.0]);
for depth in SampleType::ALL {
let surface = source(bounds, PixelFormat::new(ColorMode::Rgb, depth, true));
for radius in [0.6, 3.0, 10.0] {
let p = crate::FilterParams::UnsharpMask { amount: 150.0, radius, threshold: 8.0 };
for extent in [None, Some(bounds)] {
let expected = apply_reference(&surface, &p, area, bounds, Some(&sel), extent, 256);
for tile in [63, 256, 512] {
let actual = crate::apply_tiled(&surface, &p, area, bounds, Some(&sel), tile, extent);
assert_eq!(expected.to_interleaved(bounds), actual.to_interleaved(bounds));
}
}
let cancelled = std::sync::atomic::AtomicBool::new(false);
let cancel = || cancelled.load(std::sync::atomic::Ordering::Relaxed);
let progress = |_| cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
let ctl = Interrupt::new(&cancel, &progress);
let input = surface.to_interleaved(bounds);
assert!(crate::apply_in_with(&surface, &p, area, bounds, Some(&sel), bounds, &ctl).is_none());
assert_eq!(surface.to_interleaved(bounds), input);
}
}
}
#[test]
fn preserve_details_resampling_keeps_the_same_sharpening() {
use crate::resample::{Resample, resize_surface};
let src = Rect::new(-3, 2, 19, 17);
let (sx, sy) = (2.0, 1.5);
let dst = crate::resample::scaled_rect(src, sx, sy);
for depth in SampleType::ALL {
for mode in [ColorMode::Grayscale, ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab] {
let s = source(src, PixelFormat::new(mode, depth, true));
let resized = resize_surface(&s, sx, sy, Resample::Lanczos);
let p = crate::FilterParams::UnsharpMask { amount: 30.0, radius: 0.6 * sx.max(sy) as f32, threshold: 0.0 };
let area = crate::output_area(&p, resized.content_bounds(), dst, None);
let expected = apply_reference(&resized, &p, area, dst, None, None, crate::auto_tile(&p, dst));
let actual = resize_surface(&s, sx, sy, Resample::PreserveDetails);
assert_eq!(actual.to_interleaved(area), expected.to_interleaved(area));
}
}
}
#[test]
fn flat_fields_and_sharp_edges_keep_exact_halos() {
let out = Rect::new(0, 0, 17, 9);
for alpha in [false, true] {
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha };
for radius in [0.1, 0.6, 3.25, 64.0] {
let rect = out.inflate((radius * 3.0_f32).ceil() as i32 + 1);
for edge in [false, true] {
let n = 3 + usize::from(alpha);
let data = (rect.y0..rect.y1)
.flat_map(|_| {
(rect.x0..rect.x1).flat_map(|x| {
(0..n).map(move |c| {
if alpha && c + 1 == n {
0.7
} else if edge && x >= 8 {
0.75
} else {
0.25
}
})
})
})
.collect();
let src = Image { rect, ch: n, data };
same_bits(&unsharp(&src, out, &ctx, 300.0, radius, 0.0), &reference::unsharp(&src, out, &ctx, 300.0, radius, 0.0));
}
}
}
}
#[test]
fn malformed_inputs_and_allocations_stop_gracefully() {
let rect = Rect::new(0, 0, 2, 2);
let src = Image { rect, ch: 1, data: vec![0.5; 4] };
let ctx = Ctx { bounds: rect, mode: ColorMode::Grayscale, alpha: false };
for (amount, radius, threshold) in [(f32::NAN, 1.0, 0.0), (100.0, f32::INFINITY, 0.0), (100.0, -1.0, 0.0), (100.0, f32::MAX, 0.0), (100.0, 1.0, f32::NAN)] {
assert!(exact::run(&src, rect, &ctx, amount, radius, threshold, &Interrupt::NONE).is_none());
let s = source(rect, PixelFormat::GRAY8);
let p = crate::FilterParams::UnsharpMask { amount, radius, threshold };
assert!(crate::apply_in_with(&s, &p, rect, rect, None, rect, &Interrupt::NONE).is_none());
assert_eq!(crate::apply_in(&s, &p, rect, rect, None, rect), s);
}
for bad in [Image { ch: 0, ..src.clone() }, Image { data: vec![0.0], ..src.clone() }, Image { rect: Rect::new(5, 0, 0, 2), ..src.clone() }] {
assert!(exact::run(&bad, rect, &ctx, 100.0, 1.0, 0.0, &Interrupt::NONE).is_none());
}
for rect in [Rect::new(i32::MIN, 0, i32::MAX, 2), Rect::new(0, 0, 300_000, 300_000), Rect::new(2, 0, 0, 2)] {
assert!(exact::run(&src, rect, &ctx, 100.0, 1.0, 0.0, &Interrupt::NONE).is_none());
}
let s = source(Rect::new(0, 0, 2, 2), PixelFormat::GRAY8);
let p = crate::FilterParams::UnsharpMask { amount: 100.0, radius: 1.0, threshold: 0.0 };
for tile in [i32::MIN, 0, i32::MAX] {
let r = s.content_bounds();
assert_eq!(crate::apply_tiled(&s, &p, r, r, None, tile, Some(r)), crate::apply_in(&s, &p, r, r, None, r));
}
// Allocation/geometry guard prevents a huge halo read before allocating it.
let p = crate::FilterParams::UnsharpMask { amount: 100.0, radius: 15_446.0, threshold: 0.0 };
assert!(crate::apply_in_with(&s, &p, s.content_bounds(), s.content_bounds(), None, s.content_bounds(), &Interrupt::NONE).is_none());
}
#[test]
fn maximum_supported_radius_matches_the_frozen_kernel() {
let out = Rect::new(0, 0, 1, 1);
let src = Image { rect: out, ch: 1, data: vec![0.75] };
let ctx = Ctx { bounds: out, mode: ColorMode::Grayscale, alpha: false };
same_bits(&unsharp(&src, out, &ctx, 500.0, 1000.0, 8.0), &reference::unsharp(&src, out, &ctx, 500.0, 1000.0, 8.0));
}
#[test]
fn cancellation_stops_inside_a_tile_before_publishing() {
let rect = Rect::new(0, 0, 16, 16);
let s = source(rect, PixelFormat::RGBA8);
let src = Image::read(&s, rect);
let ctx = Ctx { bounds: rect, mode: ColorMode::Rgb, alpha: true };
let checks = std::sync::atomic::AtomicUsize::new(0);
let cancel = || checks.fetch_add(1, std::sync::atomic::Ordering::Relaxed) >= 10;
assert!(exact::run(&src, rect, &ctx, 150.0, 30.0, 8.0, &Interrupt::cancel_only(&cancel)).is_none());
assert_eq!(src, Image::read(&s, rect));
}
#[test]
#[ignore = "release full-operation baseline/candidate comparison; run alone"]
fn unsharp_performance() {
let number = |name: &str, default: i32| std::env::var(name).ok().and_then(|s| s.parse::<i32>().ok()).unwrap_or(default);
let (w, h, reps) = (number("PHOTOCRAFT_UNSHARP_WIDTH", 1500), number("PHOTOCRAFT_UNSHARP_HEIGHT", 1000), number("PHOTOCRAFT_UNSHARP_REPS", 5));
assert!((1..=8192).contains(&w) && (1..=8192).contains(&h) && (1..=15).contains(&reps));
let depth = match number("PHOTOCRAFT_UNSHARP_DEPTH", 8) {
8 => SampleType::U8,
16 => SampleType::U16,
32 => SampleType::F32,
_ => panic!("depth"),
};
let area = Rect::new(0, 0, w, h);
let channels = number("PHOTOCRAFT_UNSHARP_CHANNELS", 4);
let (mode, alpha) = match channels {
1 => (ColorMode::Grayscale, false),
2 => (ColorMode::Grayscale, true),
3 => (ColorMode::Rgb, false),
4 => (ColorMode::Rgb, true),
5 => (ColorMode::Cmyk, true),
_ => panic!("channels 1..5"),
};
let surface = source(area, PixelFormat::new(mode, depth, alpha));
let measure = std::env::var("PHOTOCRAFT_UNSHARP_MODE").unwrap_or_else(|_| "paired".into());
assert!(matches!(measure.as_str(), "paired" | "reference" | "candidate"));
let cases = std::env::var("PHOTOCRAFT_UNSHARP_CASES").unwrap_or_else(|_| "0.6:150:0,3:150:8,10:300:16,30:150:0".into());
for case in cases.split(',') {
let values: Vec<f32> = case.split(':').map(|v| v.parse().unwrap()).collect();
let (radius, amount, threshold) = (values[0], values[1], values[2]);
let p = crate::FilterParams::UnsharpMask { amount, radius, threshold };
let tile = crate::auto_tile(&p, area);
let before = || apply_reference(&surface, &p, area, area, None, Some(area), tile);
// Isolate the streamed exact path from the new adaptive FFT dispatcher.
let after = || crate::apply_tiled_with_impl(&surface, &p, area, area, None, tile, Some(area), &Interrupt::NONE, false).unwrap();
if measure != "paired" {
let f = if measure == "reference" { &before as &dyn Fn() -> Surface } else { &after as &dyn Fn() -> Surface };
let mut times = Vec::new();
for rep in 0..reps + 2 {
let start = Instant::now();
let output = f();
let ms = start.elapsed().as_secs_f64() * 1000.0;
std::hint::black_box(output);
if rep >= 2 {
times.push(ms);
}
}
println!(
"{}",
serde_json::json!({"mode":measure,"width":w,"height":h,"channels":channels,"depth":number("PHOTOCRAFT_UNSHARP_DEPTH",8),"radius":radius,"amount":amount,"threshold":threshold,"workers":workers(),"runs_ms":times})
);
continue;
}
for _ in 0..2 {
std::hint::black_box(before());
std::hint::black_box(after());
}
let (mut a, mut b) = (Vec::new(), Vec::new());
for rep in 0..reps {
let timed = |f: &dyn Fn() -> Surface| {
let start = Instant::now();
let s = f();
(s, start.elapsed().as_secs_f64() * 1000.0)
};
let ((old, old_ms), (new, new_ms)) = if rep % 2 == 0 {
(timed(&before), timed(&after))
} else {
let b = timed(&after);
(timed(&before), b)
};
assert_eq!(old.to_interleaved(area), new.to_interleaved(area));
a.push(old_ms);
b.push(new_ms);
}
let median = |v: &[f64]| {
let mut v = v.to_vec();
v.sort_by(f64::total_cmp);
v[v.len() / 2]
};
println!(
"{}",
serde_json::json!({"width":w,"height":h,"channels":channels,"depth":number("PHOTOCRAFT_UNSHARP_DEPTH",8),"radius":radius,"amount":amount,"threshold":threshold,"workers":workers(),"before_ms":a,"after_ms":b,"before_median_ms":median(&a),"after_median_ms":median(&b),"speedup":median(&a)/median(&b),"max_error":0})
);
}
}
#[test]
#[ignore = "unchanged-filter stage profile; run alone in release"]
fn unsharp_baseline_profile() {
let out = Rect::new(0, 0, 256, 256);
for radius in [0.6, 3.0, 10.0, 30.0] {
let reach = (radius * 3.0_f32).ceil() as i32 + 1;
let s = source(out.inflate(reach), PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
let src = Image::read(&s, out.inflate(reach));
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true };
let k = reference::gaussian_kernel(radius);
let mut times = [0.0; 4];
for _ in 0..20 {
let start = Instant::now();
std::hint::black_box(reference::gaussian_kernel(radius));
times[0] += start.elapsed().as_secs_f64();
let start = Instant::now();
let bl = reference::conv_sep(&src, out, &k, &k, true);
times[1] += start.elapsed().as_secs_f64();
let start = Instant::now();
let mut res = src.crop(out);
for (px, b) in res.as_chunks_mut::<4>().0.iter_mut().zip(bl.as_chunks::<4>().0) {
if px[3] <= 0.0 {
continue;
}
for c in 0..3 {
let d = px[c] - b[c];
if d.abs() + 1e-6 >= 8.0 / 255.0 {
px[c] += 1.5 * d;
}
}
}
std::hint::black_box(res);
times[2] += start.elapsed().as_secs_f64();
let start = Instant::now();
std::hint::black_box(reference::unsharp(&src, out, &ctx, 150.0, radius, 8.0));
times[3] += start.elapsed().as_secs_f64();
}
println!(
"{}",
serde_json::json!({"baseline_tile_radius":radius,"kernel_ms":times[0]*50.0,"convolution_ms":times[1]*50.0,"crop_blend_ms":times[2]*50.0,"total_kernel_ms":times[3]*50.0})
);
}
}
+17 -2
View File
@@ -5,6 +5,7 @@ use photocraft_geom::Rect;
use photocraft_raster::{from_rgba, to_rgba};
use crate::Ctx;
use crate::fxutil::subtractive;
use crate::image::Image;
fn fmt(ctx: &Ctx) -> PixelFormat {
@@ -23,6 +24,16 @@ fn set_gray(ctx: &Ctx, px: &mut [f32], g: f32) {
px.copy_from_slice(&v[..px.len()]);
}
/// Solarize: inverts the highlights. CMYK samples are ink, so there the
/// channels below 50% ink (the light ones) invert.
pub(crate) fn solarize(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
if subtractive(ctx) {
per_pixel(src, out, ctx, |v| if v < 0.5 { 1.0 - v } else { v })
} else {
per_pixel(src, out, ctx, |v| if v > 0.5 { 1.0 - v } else { v })
}
}
/// Colour channels through `f` (alpha untouched).
pub(crate) fn per_pixel(src: &Image, out: Rect, ctx: &Ctx, f: impl Fn(f32) -> f32) -> Vec<f32> {
let n = src.ch;
@@ -167,10 +178,13 @@ pub(crate) fn emboss(src: &Image, out: Rect, ctx: &Ctx, angle: f32, height: f32,
res
}
/// Find Edges: Sobel magnitude per colour channel, dark edges on white.
/// Find Edges: Sobel magnitude per colour channel, dark edges on white (CMYK samples are ink, so
/// there the edge strength is the ink itself). At full strength, as Photoshop's: a 60-level step
/// darkens by 240 (Photoshop 27.11; a quarter of that was 4× too pale).
pub(crate) fn find_edges(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
let n = src.ch;
let cc = if ctx.alpha { n - 1 } else { n };
let sub = subtractive(ctx);
let mut res = src.crop(out);
let w = out.width() as usize;
for (i, px) in res.chunks_exact_mut(n).enumerate() {
@@ -179,7 +193,8 @@ pub(crate) fn find_edges(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
let g = |dx: i32, dy: i32| src.get(x + dx, y + dy, c);
let gx = (g(1, -1) + 2.0 * g(1, 0) + g(1, 1)) - (g(-1, -1) + 2.0 * g(-1, 0) + g(-1, 1));
let gy = (g(-1, 1) + 2.0 * g(0, 1) + g(1, 1)) - (g(-1, -1) + 2.0 * g(0, -1) + g(1, -1));
*v = (1.0 - (gx * gx + gy * gy).sqrt() / 4.0).clamp(0.0, 1.0);
let e = (gx * gx + gy * gy).sqrt().clamp(0.0, 1.0);
*v = if sub { e } else { 1.0 - e };
}
}
res
+1 -11
View File
@@ -167,17 +167,7 @@ fn storage_selection_and_tile_sizes_match_original_kernels() {
FilterParams::Wind { method, from_right, seed } => wind_direct(&src, area, &ctx, method, from_right, seed),
_ => continue,
};
let original = s.read_region(area);
for (i, px) in raw.chunks_exact_mut(fmt.channels()).enumerate() {
let (x, y) = (area.x0 + (i % area.width() as usize) as i32, area.y0 + (i / area.width() as usize) as i32);
let k = selection.sample_channel(x, y, 0);
if k >= 1.0 {
continue;
}
for (c, v) in px.iter_mut().enumerate() {
*v = original[i * fmt.channels() + c] + (*v - original[i * fmt.channels() + c]) * k;
}
}
crate::mix_selection(&mut raw, area, &selection, &src, fmt.alpha);
let mut want = s.clone();
want.write_region(area, &raw);
for tile in [1, 7, 17, 256] {
+112
View File
@@ -367,6 +367,30 @@ fn stylize_basics() {
assert!((d[0] - d[1]).abs() < 1e-6 && (d[1] - d[2]).abs() < 1e-6);
}
/// CMYK samples are ink (1 = full ink): Find Edges leaves flat areas white (no ink, not 100% ink)
/// and Solarize inverts the channels below 50% ink, as Photoshop does (#4222). Alpha is untouched.
#[test]
fn find_edges_and_solarize_treat_cmyk_as_ink() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let f = PixelFormat::new(ColorMode::Cmyk, depth, true);
let mut s = Surface::new(f);
s.fill_rect(R, &[0.1, 0.1, 0.1, 0.1, 1.0]);
let e = run(&s, &FilterParams::FindEdges).pixel(20, 15);
assert!(e[..4].iter().all(|v| v.abs() < 1e-6) && (e[4] - 1.0).abs() < 1e-6, "{depth:?} flat CMYK -> white: {e:?}");
let mut s = Surface::new(f);
s.fill_rect(R, &[0.1, 0.2, 0.8, 0.9, 0.6]);
let so = run(&s, &FilterParams::Solarize).pixel(3, 3);
let want = [0.9, 0.8, 0.8, 0.9, 0.6];
assert!(so.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{depth:?} solarize CMYK: {so:?}");
}
// RGB keeps the light-polarity behaviour: flat -> white, channels above 50% invert.
let s = flat(SampleType::U8, R, [0.1, 0.2, 0.8, 1.0]);
let e = run(&s, &FilterParams::FindEdges).pixel(20, 15);
assert!(e[..3].iter().all(|v| (v - 1.0).abs() < 1e-6), "{e:?}");
let so = run(&s, &FilterParams::Solarize).pixel(3, 3);
assert!(so.iter().zip([0.1, 0.2, 0.2, 1.0]).all(|(a, b)| (a - b).abs() < 0.01), "{so:?}");
}
#[test]
fn distortions_are_identity_at_zero() {
let s = pattern(SampleType::U8, R);
@@ -422,6 +446,10 @@ fn halos_and_output_area() {
assert_eq!(output_area(&FilterParams::Twirl { angle: 1.0 }, content, R, None), R);
assert_eq!(output_area(&FilterParams::BoxBlur { radius: 1.0 }, content, R, Some(Rect::new(0, 0, 12, 12))), Rect::new(8, 8, 12, 12));
assert!(output_area(&FilterParams::Invert, Rect::EMPTY, R, None).is_empty());
// Fibers renders without reading the layer: an empty layer still fills the bounds (#5241).
let fibers = FilterParams::Fibers { variance: 16.0, strength: 4.0, seed: 0, foreground: [0.0; 4], background: [1.0; 4] };
assert_eq!(output_area(&fibers, Rect::EMPTY, R, None), R);
assert_eq!(output_area(&fibers, content, R, Some(Rect::new(0, 0, 12, 12))), Rect::new(0, 0, 12, 12));
}
#[test]
@@ -463,3 +491,87 @@ fn layer_filters_repeat_the_canvas_edge() {
assert!(faded.pixel(0, 0)[3] < 0.9, "{depth:?}: unclamped path should read transparency");
}
}
/// Photoshop 27.11's Polar Coordinates turn counter-clockwise from 12 o'clock: just left of the
/// top centre, Rectangular to Polar samples the source's left edge (clockwise would take its right).
#[test]
fn polar_coordinates_turn_counter_clockwise() {
let r = Rect::new(0, 0, 64, 64);
let mut s = flat(SampleType::U8, r, [0.0, 0.0, 1.0, 1.0]);
s.fill_rect(Rect::new(0, 0, 32, 64), &[1.0, 0.0, 0.0, 1.0]);
let p = FilterParams::PolarCoordinates { mode: PolarMode::RectangularToPolar };
let out = apply(&s, &p, r, r, None);
let left_of_top = out.rgba(28, 12);
assert!(left_of_top[0] > 0.9 && left_of_top[2] < 0.1, "{left_of_top:?}");
// The corners outside the circle repeat the source's edge instead of going transparent.
assert!(out.rgba(0, 0)[3] > 0.99, "{:?}", out.rgba(0, 0));
}
/// `mix_selection` before #4533, one selection lookup per pixel: the oracle for the per-tile read.
fn mix_selection_reference(data: &mut [f32], t: Rect, sel: &Surface, orig: &crate::image::Image, alpha: bool) {
let w = t.width() as usize;
for (i, px) in data.chunks_exact_mut(orig.ch.max(1)).enumerate() {
let (x, y) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32);
let k = sel.pixel(x, y)[0].clamp(0.0, 1.0);
if k >= 1.0 {
continue;
}
if k <= 0.0 {
for (c, v) in px.iter_mut().enumerate() {
*v = orig.get(x, y, c);
}
continue;
}
if alpha {
let a = px.len() - 1;
let original_alpha = orig.get(x, y, a);
let filtered_alpha = px[a];
if original_alpha != filtered_alpha {
let mixed_alpha = original_alpha + (filtered_alpha - original_alpha) * k;
let original_weight = original_alpha * (1.0 - k);
let filtered_weight = filtered_alpha * k;
for (c, v) in px[..a].iter_mut().enumerate() {
*v = if mixed_alpha > 0.0 { (orig.get(x, y, c) * original_weight + *v * filtered_weight) / mixed_alpha } else { 0.0 };
}
px[a] = mixed_alpha;
continue;
}
}
for (c, v) in px.iter_mut().enumerate() {
let o = orig.get(x, y, c);
*v = o + (*v - o) * k;
}
}
}
/// Reading the selection once per tile gives exactly the per-pixel result (#4533): a soft
/// selection with fully selected, unselected and partial pixels, with and without alpha, on a
/// tile that crosses the selection's tile boundaries.
#[test]
fn mix_selection_reads_the_selection_once_per_tile() {
let noise = |seed: u32, n: usize| -> Vec<f32> {
(0..n as u32)
.map(|i| {
let mut h = seed.wrapping_mul(0x9e37_79b9) ^ i.wrapping_mul(0x85eb_ca6b);
h ^= h >> 13;
h = h.wrapping_mul(0xc2b2_ae35);
(h >> 16) as f32 / 65535.0
})
.collect()
};
let t = Rect::new(200, 180, 330, 300);
let n = (t.width() * t.height()) as usize;
let mut sel = Surface::new(PixelFormat::GRAY8);
sel.fill_rect(Rect::new(150, 150, 260, 250), &[1.0]);
let soft = Rect::new(260, 150, 330, 250);
sel.write_region(soft, &noise(5, (soft.width() * soft.height()) as usize));
for (alpha, fmt) in [(true, PixelFormat::RGBA8), (false, PixelFormat { alpha: false, ..PixelFormat::RGBA8 })] {
let ch = fmt.channels();
let orig = crate::image::Image { rect: t, ch, data: noise(1, n * ch) };
let filtered = noise(2, n * ch);
let (mut a, mut b) = (filtered.clone(), filtered);
mix_selection(&mut a, t, &sel, &orig, alpha);
mix_selection_reference(&mut b, t, &sel, &orig, alpha);
assert!(a.iter().zip(&b).all(|(x, y)| x.to_bits() == y.to_bits()), "alpha {alpha}: results differ");
}
}
+1 -1
View File
@@ -170,7 +170,7 @@ fn iris_depths_models_selection_tiles_and_cancellation() {
let t = Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1));
let src = Image::read_clamped(&surface, t.inflate(radius), bounds);
let mut data = reference::iris(&src, t, &ctx, &pins);
crate::mix_selection(&mut data, t, &selection, &src);
crate::mix_selection(&mut data, t, &selection, &src, alpha);
want.write_region(t, &data);
}
}
+98
View File
@@ -0,0 +1,98 @@
use super::*;
use photocraft_color::{PixelFormat, SampleType};
fn selection(coverage: f32) -> Surface {
Surface::with_default(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false), &[coverage])
}
#[test]
fn feathered_color_to_alpha_preserves_appearance_over_the_key() {
let r = Rect::new(0, 0, 1, 1);
let filter = FilterParams::ColorToAlpha { color: [1.0; 4], transparency_threshold: 0.0, opacity_threshold: 1.0 };
for sample in SampleType::ALL {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
for alpha in [0.5, 1.0] {
let format = PixelFormat::new(mode, sample, true);
let mut s = Surface::new(format);
let mut p = vec![0.5; format.channels()];
*p.last_mut().unwrap() = alpha;
s.write_pixel(0, 0, &p);
let original = s.pixel(0, 0);
let a = format.channels() - 1;
for coverage in [0.0, 0.25, 0.5, 0.75, 1.0] {
let out = apply(&s, &filter, r, r, Some(&selection(coverage)));
let actual = out.pixel(0, 0);
let tolerance = if sample == SampleType::U8 { 2.0 / 255.0 } else { 1e-4 };
for c in 0..a {
let before = original[c] * original[a] + 1.0 - original[a];
let after = actual[c] * actual[a] + 1.0 - actual[a];
assert!(
(before - after).abs() < tolerance,
"{sample:?}/{mode:?}, alpha={alpha}, coverage={coverage}: {original:?} -> {actual:?}, over white {before} -> {after}"
);
}
}
}
}
}
}
#[test]
fn feathered_offset_ignores_hidden_colour_of_the_original_pixel() {
let r = Rect::new(0, 0, 2, 1);
let filter = FilterParams::Offset { horizontal: 1, vertical: 0, undefined: UndefinedAreas::Wrap };
for sample in SampleType::ALL {
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
s.write_pixel(0, 0, &[1.0, 0.0, 0.0, 0.0]);
s.write_pixel(1, 0, &[0.0, 0.0, 1.0, 1.0]);
let out = apply(&s, &filter, r, r, Some(&selection(0.5)));
let p = out.pixel(0, 0);
assert_eq!(&p[..3], &[0.0, 0.0, 1.0], "{sample:?}: hidden red contaminated the selected blue: {p:?}");
assert!((p[3] - 0.5).abs() < 1.0 / 255.0);
}
}
#[test]
fn feathered_blurs_do_not_reintroduce_hidden_colour() {
let r = Rect::new(0, 0, 3, 3);
let mut s = Surface::new(PixelFormat::RGBA32F);
s.fill_rect(r, &[1.0, 0.0, 0.0, 0.0]);
s.write_pixel(1, 1, &[0.0, 0.0, 1.0, 1.0]);
for filter in [FilterParams::BoxBlur { radius: 1.0 }, FilterParams::MotionBlur { angle: 0.0, distance: 3.0 }] {
let full = apply(&s, &filter, r, r, None);
let out = apply(&s, &filter, r, r, Some(&selection(0.5)));
let p = out.pixel(0, 1);
assert!(p[3] > 0.0, "{filter:?}: expected blur to spread coverage");
assert!((p[3] - full.pixel(0, 1)[3] * 0.5).abs() < 1e-6, "{filter:?}: selection should halve the new coverage");
assert!(p[0].abs() < 1e-6 && (p[2] - 1.0).abs() < 1e-6, "{filter:?}: hidden red reappeared: {p:?}");
}
}
#[test]
fn selection_mixing_keeps_hdr_range_and_exact_endpoints() {
let r = Rect::new(0, 0, 1, 1);
let original = Image { rect: r, ch: 4, data: vec![-2.0, 4.0, 0.5, 0.25] };
let filtered = vec![8.0, -4.0, 0.75, 1.0];
let mut data = filtered.clone();
mix_selection(&mut data, r, &selection(0.5), &original, true);
for (&actual, expected) in data.iter().zip([6.0, -2.4, 0.7, 0.625]) {
assert!((actual - expected).abs() < 1e-6, "{data:?}");
}
for coverage in [0.0, 1.0] {
let mut data = filtered.clone();
mix_selection(&mut data, r, &selection(coverage), &original, true);
assert_eq!(data, if coverage == 0.0 { &original.data } else { &filtered }.clone());
}
}
#[test]
fn selection_mixing_without_alpha_keeps_every_channel_independent() {
let r = Rect::new(0, 0, 1, 1);
// One channel is a mask; three/four channels can be RGB or CMYK without alpha.
for ch in [1, 3, 4] {
let original = Image { rect: r, ch, data: vec![0.25; ch] };
let mut data = vec![0.75; ch];
mix_selection(&mut data, r, &selection(0.25), &original, false);
assert_eq!(data, vec![0.375; ch]);
}
}
+1 -1
View File
@@ -177,7 +177,7 @@ fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() {
None => Image::read(&surface, t.inflate(radius)),
};
let mut data = reference::tilt_shift(&src, t, &ctx, 17.0, (0.3, 0.7), 29.0, 0.07, 0.3);
crate::mix_selection(&mut data, t, &selection, &src);
crate::mix_selection(&mut data, t, &selection, &src, alpha);
want.write_region(t, &data);
}
}
+12 -129
View File
@@ -4,7 +4,8 @@
//! All work on straight (non-premultiplied) RGBA buffers in 0..=1, row-major `w × h`, so callers
//! convert any colour model / bit depth to RGBA first (as `Image › Adjustments` does for the
//! per-pixel adjustments). Formulas are documented approximations of Photoshop's behaviour,
//! derived from its manual and observation, not from its implementation.
//! derived from its manual and observation, not from its implementation; Shadows/Highlights is
//! measured on Photoshop (see [`shadows_highlights`]).
use photocraft_color::convert::{lab_to_srgb, srgb_to_lab};
use serde::{Deserialize, Serialize};
@@ -17,18 +18,6 @@ fn luma(c: [f32; 3]) -> f32 {
0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
}
fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// Blurred luminance (Gaussian, `radius` px ≈ 2σ like Photoshop's radius fields).
fn blurred_luma(px: &[[f32; 4]], w: usize, h: usize, radius: f32) -> Vec<f32> {
let mut l: Vec<f32> = px.iter().map(|p| luma([p[0], p[1], p[2]])).collect();
gauss_blur_n(&mut l, w, h, 1, radius.max(0.0) / 2.0);
l
}
/// Image › Adjustments › Shadows/Highlights parameters (Photoshop's dialog, percentages as 0..=100).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ShadowsHighlights {
@@ -64,102 +53,22 @@ impl Default for ShadowsHighlights {
}
}
/// Local tone mapping: a blurred-luminance mask selects shadows (dark neighbourhoods, up to the
/// tonal width) and highlights; shadows are lifted and highlights pulled down by a gain that
/// preserves hue, then saturation is corrected where the gain moved tones, midtone contrast is
/// applied, and the result is stretched by the clip percentages.
pub fn shadows_highlights(px: &mut [[f32; 4]], w: usize, h: usize, p: &ShadowsHighlights) {
if px.is_empty() {
return;
}
let ls = blurred_luma(px, w, h, p.shadow_radius);
let lh = if (p.highlight_radius - p.shadow_radius).abs() < 1e-3 { ls.clone() } else { blurred_luma(px, w, h, p.highlight_radius) };
let (sa, ha) = (p.shadow_amount / 100.0, p.highlight_amount / 100.0);
let (st, ht) = ((p.shadow_tone / 100.0).max(0.01), (p.highlight_tone / 100.0).max(0.01));
let color = p.color / 100.0;
let mid = p.midtone / 100.0;
par_rows(px, w, 1, |y, pxrow| {
for (x, q) in pxrow.iter_mut().enumerate() {
let i = y * w + x;
if q[3] <= 0.0 {
continue;
}
let c = [q[0], q[1], q[2]];
// Mask weights: 1 in deep shadows (resp. bright highlights), fading out at the tonal width.
let ms = 1.0 - smoothstep(0.0, st, ls[i]);
let mh = smoothstep(1.0 - ht, 1.0, lh[i]);
let mut out = c;
if sa > 0.0 && ms > 0.0 {
// Lift: a gamma-like curve on the pixel, stronger for darker neighbourhoods.
let g = 1.0 / (1.0 + 2.0 * sa * ms);
out = out.map(|v| v.max(0.0).powf(g));
}
if ha > 0.0 && mh > 0.0 {
let g = 1.0 / (1.0 + 2.0 * ha * mh);
out = out.map(|v| 1.0 - (1.0 - v.min(1.0)).powf(g));
}
if mid != 0.0 {
let k = 1.0 + mid * (1.0 - ms - mh).clamp(0.0, 1.0);
out = out.map(|v| (v - 0.5) * k + 0.5);
}
let changed = (ms * sa + mh * ha).min(1.0);
if color != 0.0 && changed > 0.0 {
let l1 = luma(out);
let s = 1.0 + color * changed;
out = out.map(|v| l1 + (v - l1) * s);
}
q[0] = out[0].clamp(0.0, 1.0);
q[1] = out[1].clamp(0.0, 1.0);
q[2] = out[2].clamp(0.0, 1.0);
}
});
clip_stretch(px, p.black_clip, p.white_clip);
}
/// Stretches levels so `black`% of pixels clip to 0 and `white`% to 1 (luminance histogram).
fn clip_stretch(px: &mut [[f32; 4]], black: f32, white: f32) {
if black <= 0.0 && white <= 0.0 {
return;
}
let mut hist = [0u32; 1024];
let mut n = 0u32;
for q in px.iter().filter(|q| q[3] > 0.0) {
hist[((luma([q[0], q[1], q[2]]).clamp(0.0, 1.0)) * 1023.0).round() as usize] += 1;
n += 1;
}
if n == 0 {
return;
}
let find = |pct: f32, rev: bool| -> f32 {
let target = (pct.clamp(0.0, 50.0) / 100.0 * n as f32) as u32;
let mut acc = 0;
for k in 0..1024 {
let i = if rev { 1023 - k } else { k };
acc += hist[i];
if acc > target {
return i as f32 / 1023.0;
}
}
if rev { 0.0 } else { 1.0 }
};
let lo = find(black, false);
let hi = find(white, true);
if hi - lo < 1e-3 {
return;
}
for q in px.iter_mut().filter(|q| q[3] > 0.0) {
for v in &mut q[..3] {
*v = ((*v - lo) / (hi - lo)).clamp(0.0, 1.0);
}
}
}
mod shadows_highlights;
pub use shadows_highlights::shadows_highlights;
/// Image › Adjustments › Replace Color: Color Range-style selection (fuzziness in 0..=200 levels)
/// around `color`, then a hue (±180°), saturation and lightness (±100) shift weighted by it.
/// Returns the selection mask (what the dialog previews).
pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue: f32, saturation: f32, lightness: f32) -> Vec<f32> {
let mask = crate::selection::color_range(px, color, fuzziness);
for (q, &k) in px.iter_mut().zip(&mask) {
replace_color_masked(px, &mask, hue, saturation, lightness);
mask
}
/// Replace Color's hue, saturation and lightness shift, applied to each pixel as far as `mask`
/// (`0..=1`, one per pixel: the colour match, localized or not) covers it.
pub fn replace_color_masked(px: &mut [[f32; 4]], mask: &[f32], hue: f32, saturation: f32, lightness: f32) {
for (q, &k) in px.iter_mut().zip(mask) {
if k <= 0.0 {
continue;
}
@@ -172,7 +81,6 @@ pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue:
q[i] = (c[i] + (o[i] - c[i]) * k).clamp(0.0, 1.0);
}
}
mask
}
/// Lab mean and standard deviation of the opaque pixels, `[L, a, b]` each.
@@ -378,31 +286,6 @@ mod tests {
.collect()
}
#[test]
fn shadows_lift_dark_pixels_more_than_bright() {
let mut px = ramp(32, 8);
let orig = px.clone();
shadows_highlights(&mut px, 32, 8, &ShadowsHighlights { black_clip: 0.0, white_clip: 0.0, ..Default::default() });
let gain = |i: usize| luma([px[i][0], px[i][1], px[i][2]]) - luma([orig[i][0], orig[i][1], orig[i][2]]);
assert!(gain(3) > 0.02, "{}", gain(3));
assert!(gain(3) > gain(30), "{} {}", gain(3), gain(30));
// Zero amounts and no clipping: identity.
let mut px2 = orig.clone();
let id = ShadowsHighlights { shadow_amount: 0.0, highlight_amount: 0.0, color: 0.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
shadows_highlights(&mut px2, 32, 8, &id);
assert_eq!(px2, orig);
}
#[test]
fn highlights_darken_bright_pixels() {
let mut px = ramp(32, 8);
let orig = px.clone();
let p = ShadowsHighlights { shadow_amount: 0.0, highlight_amount: 80.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
shadows_highlights(&mut px, 32, 8, &p);
assert!(px[30][0] < orig[30][0]);
assert!((px[1][0] - orig[1][0]).abs() < 1e-6);
}
#[test]
fn replace_color_only_touches_matching_pixels() {
let mut px = vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]];
+423
View File
@@ -0,0 +1,423 @@
//! Image › Adjustments › Shadows/Highlights, as Photoshop 25.4 does it.
//!
//! Measured on Photoshop as a black box (synthetic 16-bit images through the adjustment, results
//! read back exactly); no Photoshop code was read. The pieces:
//!
//! - **Neighbourhood brightness.** Per pixel, the mean of its brightest channel and its Rec. 601
//! luma. Its neighbourhood value is an edge-aware average: a Gaussian of σ = 1.14 · Radius whose
//! weights fall linearly with the difference from the pixel's own (lightly blurred) brightness,
//! so pixels across a strong edge count less and halos stay small. It is quantised to 8 bits.
//! - **Tonal mask.** Tone sets which neighbourhoods are treated: shadows take the complement of a
//! Beta distribution function of the neighbourhood brightness, from Beta(1.95, 23) at Tone 0 to
//! 1 − L at Tone 95 and over; highlights take the same of 1 − L.
//! - **Curve.** Amount sets a curve that screens the image with itself: 1 − (1 − v)^q, blended
//! between q = 1, 2, 4 and 8 by a strength s = 3 at 100, 2 at 50 and 1 near 15. Shadows lift each
//! channel by mask × (curve − v); highlights darken with the mirrored curve.
//! - **Colour.** Only the luma of that result is kept: the pixel's own colour differences are
//! added back, scaled by the Color slider (by exp(h · ΔY / max Y) for positive values, by
//! 1 + h · ΔY / max Y for negative ones) and shortened where they would leave the gamut.
//! - **Midtone** is a global S-curve on the luma, k · (Y − ½) · (4Y(1 − Y))^p.
use crate::fxutil::gauss_blur_n;
use crate::photo_util::par_rows;
use super::ShadowsHighlights;
const W: [f32; 3] = [0.299, 0.587, 0.114];
fn luma(c: [f32; 3]) -> f32 {
W[0] * c[0] + W[1] * c[1] + W[2] * c[2]
}
/// Neighbourhood σ per pixel of Radius.
const SPATIAL: f32 = 1.1424;
/// σ of the blur of the pixel's own brightness the edge weights compare with.
const GUIDE: f32 = 1.3559;
/// Brightness difference at which a neighbour's weight reaches zero.
const RANGE: f32 = 1.0152;
/// Brightness levels of the piecewise-linear edge-aware average.
const LEVELS: usize = 17;
/// Edge-aware neighbourhood brightness of `lum` (`w × h`) for Photoshop's Radius.
fn neighbourhood(lum: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
let mut guide = lum.to_vec();
gauss_blur_n(&mut guide, w, h, 1, GUIDE);
let sigma = SPATIAL * radius;
if sigma < 0.3 {
return guide;
}
// The average at each of a few brightness levels, interpolated at the pixel's own level
// (Durand and Dorsey, "Fast bilateral filtering", 2002). The averages are smooth, so they are
// taken on a grid `d` times coarser, with σ ≥ 4 cells there.
let d = ((sigma / 4.0).floor() as usize).max(1);
let (rw, rh) = (w.div_ceil(d), h.div_ceil(d));
let cells = rw * rh;
let step = 1.0 / (LEVELS - 1) as f32;
let mut avg = vec![0.0f32; LEVELS * cells];
let mut buf = vec![0.0f32; 2 * cells];
for (k, out) in avg.chunks_exact_mut(cells).enumerate() {
let g = k as f32 * step;
buf.fill(0.0);
for (i, &l) in lum.iter().enumerate() {
let wgt = (1.0 - (l - g).abs() / RANGE).max(0.0);
let c = 2 * ((i / w) / d * rw + (i % w) / d);
buf[c] += wgt * l;
buf[c + 1] += wgt;
}
gauss_blur_n(&mut buf, rw, rh, 2, sigma / d as f32);
for (o, b) in out.iter_mut().zip(buf.as_chunks::<2>().0) {
*o = if b[1] > 1e-6 { b[0] / b[1] } else { g };
}
}
let mut out = vec![0.0f32; lum.len()];
par_rows(&mut out, w, 1, |y, row| {
let fy = ((y as f32 + 0.5) / d as f32 - 0.5).clamp(0.0, (rh - 1) as f32);
let (y0, ty) = (fy.floor() as usize, fy.fract());
let y1 = (y0 + 1).min(rh - 1);
for (x, o) in row.iter_mut().enumerate() {
let fx = ((x as f32 + 0.5) / d as f32 - 0.5).clamp(0.0, (rw - 1) as f32);
let (x0, tx) = (fx.floor() as usize, fx.fract());
let x1 = (x0 + 1).min(rw - 1);
let at = |k: usize| {
let a = &avg[k * cells..(k + 1) * cells];
let top = a[y0 * rw + x0] + (a[y0 * rw + x1] - a[y0 * rw + x0]) * tx;
let bot = a[y1 * rw + x0] + (a[y1 * rw + x1] - a[y1 * rw + x0]) * tx;
top + (bot - top) * ty
};
let t = (guide[y * w + x].clamp(0.0, 1.0) / step).min((LEVELS - 1) as f32 - 1e-4);
let k = t.floor() as usize;
let f = t - k as f32;
*o = at(k) * (1.0 - f) + at(k + 1) * f;
}
});
out
}
/// Beta(α, β) parameters of the tonal mask for Tone 0, 5, …, 95 (fitted to Photoshop's masks
/// within 2/255).
const MASK_BETA: [(f32, f32); 20] = [
(1.9468, 22.9366),
(1.7770, 18.3471),
(1.6403, 14.7559),
(1.5270, 11.9346),
(1.4269, 9.6467),
(1.3470, 7.8430),
(1.2807, 6.4038),
(1.2240, 5.2384),
(1.1772, 4.2967),
(1.1373, 3.5350),
(1.1021, 2.9109),
(1.0755, 2.4186),
(1.0508, 2.0189),
(1.0307, 1.7034),
(1.0140, 1.4583),
(1.0015, 1.2758),
(1.0, 1.1403),
(1.0, 1.0510),
(1.0, 1.0002),
(1.0, 1.0),
];
/// The tonal mask for `tone` (0..=100) at the 256 neighbourhood levels: 1 − I_L(α, β).
fn tonal_mask(tone: f32) -> [f32; 256] {
let t = (tone.clamp(0.0, 95.0)) / 5.0;
let i = (t.floor() as usize).min(MASK_BETA.len() - 2);
let f = t - i as f32;
let (a0, b0) = MASK_BETA[i];
let (a1, b1) = MASK_BETA[i + 1];
let (a, b) = ((a0 + (a1 - a0) * f) as f64, (b0 + (b1 - b0) * f) as f64);
// Midpoint sums of the Beta density, 16 per level.
const SUB: usize = 16;
let n = 255 * SUB;
let dens = |x: f64| ((a - 1.0) * x.ln() + (b - 1.0) * (1.0 - x).ln()).exp();
let mut cum = vec![0.0f64; 256];
let mut acc = 0.0;
for j in 0..n {
acc += dens((j as f64 + 0.5) / n as f64);
if (j + 1) % SUB == 0 {
cum[(j + 1) / SUB] = acc;
}
}
let total = acc.max(f64::MIN_POSITIVE);
std::array::from_fn(|k| (1.0 - cum[k] / total) as f32)
}
/// Curve strength for Amount `a` (0..=100): 0 at 0, 1 near 15, 2 at 50, 3 at 100.
fn strength(a: f32) -> f32 {
let u = (a.clamp(0.0, 100.0) / 100.0).powf(0.6216);
if u <= 0.3 { u / 0.3 } else { (1.0 + 20.0 * u) / 7.0 }
}
/// The full-strength curve of strength `s`: 1 − (1 − v)^q between q = 2^⌊s⌋ and 2^(⌊s⌋+1).
fn lift(s: f32, v: f32) -> f32 {
let n = (s.floor() as i32).clamp(0, 2);
let t = (s - n as f32).clamp(0.0, 1.0);
let c = (1.0 - v).clamp(0.0, 1.0);
let lo = c.powi(1 << n);
let hi = lo * lo;
1.0 - ((1.0 - t) * lo + t * hi)
}
/// Linear interpolation in a table of `(x, y)` sorted by `x`.
fn interp(table: &[(f32, f32)], x: f32) -> f32 {
let Some(&(x0, y0)) = table.first() else { return 0.0 };
if x <= x0 {
return y0;
}
for p in table.windows(2) {
let ((xa, ya), (xb, yb)) = (p[0], p[1]);
if x <= xb {
return ya + (yb - ya) * (x - xa) / (xb - xa);
}
}
table.last().map_or(0.0, |p| p.1)
}
/// Color: the h of the colour-difference gain for positive values (exp(h · F))…
const COLOR_UP: [(f32, f32); 12] = [
(0.0, 0.0),
(1.0, 0.1566),
(2.0, 0.2271),
(5.0, 0.3776),
(10.0, 0.5653),
(20.0, 0.8669),
(30.0, 1.1299),
(40.0, 1.3748),
(50.0, 1.6093),
(60.0, 1.8375),
(75.0, 2.1721),
(100.0, 2.7193),
];
/// … and for negative values (1 + h · F), by −Color.
const COLOR_DOWN: [(f32, f32); 9] =
[(0.0, 0.0), (1.0, -0.0145), (5.0, -0.0709), (10.0, -0.1373), (20.0, -0.2570), (30.0, -0.3612), (50.0, -0.5312), (75.0, -0.6866), (100.0, -0.7968)];
/// Midtone: (value, k, p) of k · (Y − ½) · (4Y(1 − Y))^p.
const MIDTONE: [(f32, f32, f32); 11] = [
(-100.0, -0.6955, 2.267),
(-75.0, -0.5873, 2.362),
(-50.0, -0.4429, 2.492),
(-25.0, -0.2514, 2.667),
(-10.0, -0.1088, 2.803),
(0.0, 0.0, 2.9),
(10.0, 0.1206, 3.016),
(25.0, 0.3262, 3.216),
(50.0, 0.7423, 3.625),
(75.0, 1.2635, 4.160),
(100.0, 1.9032, 4.850),
];
fn midtone(m: f32, y: f32) -> f32 {
if m == 0.0 {
return y;
}
let ks: Vec<(f32, f32)> = MIDTONE.iter().map(|r| (r.0, r.1)).collect();
let ps: Vec<(f32, f32)> = MIDTONE.iter().map(|r| (r.0, r.2)).collect();
let (k, p) = (interp(&ks, m), interp(&ps, m));
(y + k * (y - 0.5) * (4.0 * y * (1.0 - y)).max(0.0).powf(p)).clamp(0.0, 1.0)
}
fn level(v: f32) -> usize {
(v.clamp(0.0, 1.0) * 255.0).round() as usize
}
/// Image › Adjustments › Shadows/Highlights on straight RGBA `w × h`.
pub fn shadows_highlights(px: &mut [[f32; 4]], w: usize, h: usize, p: &ShadowsHighlights) {
if px.is_empty() || px.len() != w * h {
return;
}
let (sa, ha) = (p.shadow_amount, p.highlight_amount);
let lum: Vec<f32> = px.iter().map(|q| (q[0].max(q[1]).max(q[2]) + luma([q[0], q[1], q[2]])) / 2.0).collect();
let ls = (sa > 0.0).then(|| neighbourhood(&lum, w, h, p.shadow_radius));
let lh = (ha > 0.0).then(|| match &ls {
Some(l) if (p.highlight_radius - p.shadow_radius).abs() < 1e-3 => l.clone(),
_ => neighbourhood(&lum, w, h, p.highlight_radius),
});
let (ms, mh) = (tonal_mask(p.shadow_tone), tonal_mask(p.highlight_tone));
let (ss, hs) = (strength(sa), strength(ha));
// The new luma of every pixel.
let mut y2 = vec![0.0f32; px.len()];
par_rows(&mut y2, w, 1, |y, row| {
for (x, out) in row.iter_mut().enumerate() {
let i = y * w + x;
let q = px[i];
let mut adj = [q[0], q[1], q[2]];
if let Some(l) = &ls {
let m = ms[level(l[i])];
for v in &mut adj {
*v += m * (lift(ss, *v) - *v);
}
}
if let Some(l) = &lh {
let m = mh[255 - level(l[i])];
for (v, &o) in adj.iter_mut().zip(&q[..3]) {
*v -= m * (lift(hs, 1.0 - o) - (1.0 - o));
}
}
*out = midtone(p.midtone, luma(adj).clamp(0.0, 1.0));
}
});
clip_levels(&mut y2, px, p.black_clip, p.white_clip);
let gain = |f: f32| {
if p.color >= 0.0 { (interp(&COLOR_UP, p.color) * f).exp() } else { 1.0 + interp(&COLOR_DOWN, -p.color) * f }
};
par_rows(px, w, 1, |y, row| {
for (x, q) in row.iter_mut().enumerate() {
if q[3] <= 0.0 {
continue;
}
let y1 = luma([q[0], q[1], q[2]]);
let yn = y2[y * w + x];
let f = (yn - y1).abs() / yn.max(y1).max(1e-6);
let g = gain(f);
let d = [(q[0] - y1) * g, (q[1] - y1) * g, (q[2] - y1) * g];
// Shorten the colour difference where it would leave 0..=1.
let mut k = 1.0f32;
for v in d {
if v > 1e-9 {
k = k.min((1.0 - yn) / v);
} else if v < -1e-9 {
k = k.min(yn / -v);
}
}
let k = k.clamp(0.0, 1.0);
for c in 0..3 {
q[c] = (yn + k * d[c]).clamp(0.0, 1.0);
}
}
});
}
/// Black and White Clip: stretches the new luma so `black`% of pixels reach 0 and `white`% reach 1.
fn clip_levels(y2: &mut [f32], px: &[[f32; 4]], black: f32, white: f32) {
if black <= 0.0 && white <= 0.0 {
return;
}
let mut hist = vec![0u32; 4096];
let mut n = 0u32;
for (&y, q) in y2.iter().zip(px) {
if q[3] > 0.0 {
hist[(y.clamp(0.0, 1.0) * 4095.0).round() as usize] += 1;
n += 1;
}
}
if n == 0 {
return;
}
let find = |pct: f32, rev: bool| -> f32 {
let target = (pct.clamp(0.0, 50.0) / 100.0 * n as f32) as u32;
let mut acc = 0;
for k in 0..4096 {
let i = if rev { 4095 - k } else { k };
acc += hist[i];
if acc > target {
return i as f32 / 4095.0;
}
}
if rev { 0.0 } else { 1.0 }
};
let (lo, hi) = (if black > 0.0 { find(black, false) } else { 0.0 }, if white > 0.0 { find(white, true) } else { 1.0 });
if hi - lo < 1e-3 {
return;
}
for y in y2.iter_mut() {
*y = ((*y - lo) / (hi - lo)).clamp(0.0, 1.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A flat `w × h` grey or colour image with one different pixel in the middle; returns that
/// pixel after Shadows/Highlights.
fn probe(bg: [f32; 3], px: [f32; 3], p: &ShadowsHighlights) -> [f32; 3] {
let (w, h) = (121, 121);
let mut img = vec![[bg[0], bg[1], bg[2], 1.0]; w * h];
img[60 * w + 60] = [px[0], px[1], px[2], 1.0];
shadows_highlights(&mut img, w, h, p);
let q = img[60 * w + 60];
[q[0], q[1], q[2]]
}
fn levels(v: [i32; 3]) -> [f32; 3] {
v.map(|c| c as f32 / 32768.0)
}
fn off(a: [f32; 3], b: [f32; 3]) -> f32 {
a.iter().zip(b).map(|(x, y)| (x - y).abs() * 255.0).fold(0.0, f32::max)
}
/// Photoshop 25.4's results (16-bit levels of 32768) for grey pixels lifted by the default
/// Shadows (35 %, Tone 50 %, Radius 30 px; Color 0), in black surroundings and in grey 0.5.
#[test]
fn grey_matches_photoshop() {
let p = ShadowsHighlights { color: 0.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
for (bg, want) in [(0.0, [11389, 19422, 28451, 31931]), (0.5, [5211, 9909, 18229, 25700])] {
for (v, w) in [0.125f32, 0.25, 0.5, 0.749969].into_iter().zip(want) {
let got = probe([bg; 3], [v; 3], &p);
assert!(off(got, levels([w; 3])) < 2.0, "bg {bg} v {v}: {} vs {}", got[0] * 32768.0, w);
}
}
}
/// Colour pixels keep their colour differences, scaled by Color (+20 here; Amount 35 %,
/// Radius 10 px), in black, 0.15 and 0.3 grey surroundings: Photoshop's results.
#[test]
fn colour_matches_photoshop() {
let p = ShadowsHighlights { shadow_radius: 10.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() };
for (bg, px, want) in [
(0.0, [3277, 1638, 0], [8235, 5202, 2170]),
(0.0, [8192, 6554, 4915], [19350, 16514, 13676]),
(4915.0 / 32768.0, [4915, 3277, 1638], [10124, 7424, 4723]),
(9830.0 / 32768.0, [11469, 9830, 8192], [16540, 14376, 12214]),
] {
let got = probe([bg; 3], levels(px), &p);
assert!(off(got, levels(want)) < 2.0, "{px:?} on {bg}: {:?} vs {want:?}", got.map(|c| (c * 32768.0).round()));
}
}
/// Across a strong edge the neighbourhood barely leaks (Amount 100 %, Tone 100 %, Radius
/// 10 px; 0.1 | 0.9): Photoshop's results 8, 2 and 1 pixels either side.
#[test]
fn edge_matches_photoshop() {
let (w, h) = (200, 24);
let (a, b) = (3277.0 / 32768.0, 29491.0 / 32768.0);
let mut img: Vec<[f32; 4]> = (0..w * h).map(|i| if i % w < 100 { [a, a, a, 1.0] } else { [b, b, b, 1.0] }).collect();
let p = ShadowsHighlights {
shadow_amount: 100.0,
shadow_tone: 100.0,
shadow_radius: 10.0,
color: 0.0,
black_clip: 0.0,
white_clip: 0.0,
..Default::default()
};
shadows_highlights(&mut img, w, h, &p);
let row = &img[12 * w..13 * w];
for (x, want) in [(92, 16370), (98, 14439), (99, 12146), (100, 30891), (101, 30403), (102, 30146), (108, 29979)] {
let got = row[x][0] * 32768.0;
assert!((got - want as f32).abs() / 32768.0 * 255.0 < 6.0, "x {x}: {got} vs {want}");
}
}
#[test]
fn strength_and_curve_follow_photoshop() {
assert!((strength(50.0) - 2.0).abs() < 2e-3 && (strength(100.0) - 3.0).abs() < 1e-4);
assert!((strength(15.0) - 1.02).abs() < 0.01 && (strength(1.0) - 0.19).abs() < 0.01);
// Amount 50 is the image screened with itself twice: 1 − (1 − v)^4.
for v in [0.1f32, 0.3, 0.6] {
assert!((lift(2.0, v) - (1.0 - (1.0 - v).powi(4))).abs() < 1e-6);
}
}
#[test]
fn tonal_mask_spans_photoshop_tones() {
let m95 = tonal_mask(95.0);
assert!((m95[128] - (1.0 - 128.0 / 255.0)).abs() < 1e-3, "{}", m95[128]);
assert_eq!(tonal_mask(100.0), m95);
// Tone 0 still reaches about 7 % into the levels (Photoshop: half at level 18).
let m0 = tonal_mask(0.0);
assert!(m0[0] > 0.999 && (m0[18] - 0.5).abs() < 0.03 && m0[60] < 0.02, "{} {}", m0[18], m0[60]);
}
}
+225 -27
View File
@@ -3,13 +3,13 @@
//! A transform is described by where the four corners of a source rectangle land (a quad). Scale,
//! rotate, skew and flips are affine special cases; Distort and Perspective use the full
//! homography. Warping is an inverse mapping with premultiplied-alpha sampling (nearest, bilinear
//! or Catmull-Rom bicubic), processed per destination tile (in parallel on native) so memory stays
//! or Photoshop's Keys bicubic), processed per destination tile (in parallel on native) so memory stays
//! bounded by the tile working set. Large reductions are pre-filtered with a proper resize first,
//! so shrinking a layer doesn't alias.
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use photocraft_raster::{Interrupt, Surface};
use serde::{Deserialize, Serialize};
use crate::resample::{Resample, resize_surface};
@@ -43,6 +43,27 @@ impl Homography {
((m[0] * x + m[1] * y + m[2]) / w, (m[3] * x + m[4] * y + m[5]) / w)
}
/// The homogeneous `w` of `(x, y)`: its sign is the side of the horizon the point maps to
/// (0 on the horizon itself).
pub fn w(&self, x: f64, y: f64) -> f64 {
let m = &self.0;
m[6] * x + m[7] * y + m[8]
}
/// The side of the horizon (+1 or −1) that most corners of `r` (`[x0, y0, x1, y1]`) map to,
/// the centre's on a tie. A Distort dragged past the horizon maps the rest of the source to the
/// far side, where it would show as a mirrored copy wrapped round infinity; drawing keeps to
/// this side, as Photoshop's does (#4863).
pub fn front_side(&self, r: [f64; 4]) -> f64 {
let ahead = [(r[0], r[1]), (r[2], r[1]), (r[2], r[3]), (r[0], r[3])].iter().filter(|(x, y)| self.w(*x, *y) > 0.0).count();
match ahead {
3 | 4 => 1.0,
0 | 1 => -1.0,
_ if self.w((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0) >= 0.0 => 1.0,
_ => -1.0,
}
}
pub fn mul(&self, o: &Homography) -> Homography {
let (a, b) = (&self.0, &o.0);
let mut r = [0.0; 9];
@@ -133,24 +154,40 @@ impl Interp {
}
}
fn catmull_rom(t: f64) -> [f64; 4] {
let t2 = t * t;
let t3 = t2 * t;
[-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2]
/// Photoshop's Bicubic: the Keys cubic at a = −0.75 (Catmull-Rom is −0.5), the weights of the
/// four source pixels around a sample `t` (0..1) past the second. Measured on Photoshop 27.11's
/// Free Transform: a 200 % step edge matches within 0.5 levels; Catmull-Rom was 6 off.
pub(crate) fn bicubic(t: f64) -> [f64; 4] {
const A: f64 = -0.75;
let near = |x: f64| (A + 2.0) * x * x * x - (A + 3.0) * x * x + 1.0;
let far = |x: f64| A * (x * x * x - 5.0 * x * x + 8.0 * x - 4.0);
[far(1.0 + t), near(t), near(1.0 - t), far(2.0 - t)]
}
/// Warp `src` (its content inside `src_rect`) by `h` (source → destination document space).
/// The result has the same format as `src`, with alpha added if `src` had none; pixels outside
/// the warped quad are transparent.
pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp) -> Result<Surface, photocraft_raster::AllocationError> {
warp(src, src_rect, h, interp, None)
warp(src, src_rect, h, interp, None, &Interrupt::NONE)
}
/// [`warp_surface`] reporting progress per destination tile; `Ok(None)` once `ctl` is cancelled.
pub fn warp_surface_with(
src: &Surface,
src_rect: Rect,
h: &Homography,
interp: Interp,
ctl: &Interrupt,
) -> Result<Option<Surface>, photocraft_raster::AllocationError> {
let out = warp(src, src_rect, h, interp, None, ctl)?;
Ok((!ctl.cancelled()).then_some(out))
}
/// [`warp_surface`] keeping only the destination pixels inside `clip`. The work is bounded by
/// `clip` even when part of the source lies at or beyond the map's horizon (a Perspective Crop of a
/// layer that reaches far past the crop quad), which would make the full warped extent unbounded.
pub fn warp_surface_clipped(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Rect) -> Result<Surface, photocraft_raster::AllocationError> {
warp(src, src_rect, h, interp, Some(clip))
warp(src, src_rect, h, interp, Some(clip), &Interrupt::NONE)
}
/// Is `q` (TL, TR, BR, BL) a usable Perspective Crop quad: finite, strictly convex with every
@@ -192,7 +229,39 @@ pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option<Homography> {
Homography::rect_to_quad([0.0, 0.0, w, h], *q)?.inverse()
}
fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Option<Rect>) -> Result<Surface, photocraft_raster::AllocationError> {
/// The destination rectangle [`warp_surface`] fills for `src_rect` under `h`: the warped corners
/// plus a pixel for filter support, each side clamped to ±2²⁰. `None` when a corner maps to
/// infinity (the result is then empty). Lets a caller price a warp before allocating it.
pub fn warp_output_bounds(src_rect: Rect, h: &Homography) -> Option<Rect> {
if src_rect.is_empty() || h.inverse().is_none() {
return None;
}
let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)]
.map(|(x, y)| h.apply(x as f64, y as f64));
corner_bounds(&corners)
}
fn corner_bounds(corners: &[(f64, f64); 4]) -> Option<Rect> {
if !corners.iter().all(|c| c.0.is_finite() && c.1.is_finite()) {
return None;
}
let lim = (1 << 20) as f64;
let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1;
let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1;
let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim) as i32 + 1;
let by1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil().min(lim) as i32 + 1;
Some(Rect::new(bx0, by0, bx1, by1))
}
/// A cancelled `ctl` skips the remaining tiles; the caller discards the result.
fn warp(
src: &Surface,
src_rect: Rect,
h: &Homography,
interp: Interp,
clip: Option<Rect>,
ctl: &Interrupt,
) -> Result<Surface, photocraft_raster::AllocationError> {
let mut fmt = src.format();
let converted;
let mut src = if fmt.alpha {
@@ -216,8 +285,12 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
};
let scale = h.local_scale(cx, cy);
let reduced;
if interp != Interp::Nearest && scale < 0.5 && scale > 0.0 {
let f = 2f64.powf(scale.log2().ceil()).min(1.0); // remaining scale lands in [0.5, 1)
// Photoshop reduces with its Bicubic widened by 1 / scale (as Image Size does), so the source is
// brought down by the whole local scale first and the warp samples it at about 1:1; a plain
// 4×4 kernel at 50 % aliased (Photoshop 27.11 Free Transform, 50 % of an impulse image).
// Bilinear samples the source directly, as Photoshop's does (exact at 50 % without a prefilter).
if interp == Interp::Bicubic && scale < 0.999 && scale > 0.0 {
let f = scale.max(1.0 / 64.0);
reduced = resize_surface(src, f, f, Resample::Bicubic);
src = &reduced;
h = h.mul(&Homography([1.0 / f, 0.0, 0.0, 0.0, 1.0 / f, 0.0, 0.0, 0.0, 1.0]));
@@ -234,21 +307,17 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
let fwd_w = [(sx0, sy0), (sx1, sy0), (sx1, sy1), (sx0, sy1)].map(|(x, y)| h.0[6] * x + h.0[7] * y + h.0[8]);
let side = [1.0, -1.0].into_iter().find(|&s| fwd_w.iter().all(|&c| s * c > 0.0));
let w_lo = 1.0 / fwd_w.iter().fold(0.0f64, |m, c| m.max(c.abs()));
let finite = corners.iter().all(|c| c.0.is_finite() && c.1.is_finite());
let lim = 1 << 20;
let bounds = || {
let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1;
let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1;
let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1;
let by1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1;
Rect::new(bx0, by0, bx1, by1)
};
let dst = match clip {
// A source straddling the horizon draws only its front side: the inverse w at a pixel has the
// sign of the forward w at the source point it samples, and the other side would show as a
// mirrored copy wrapped round infinity (#4863).
let front = side.is_none().then(|| h.front_side([src_rect.x0 as f64, src_rect.y0 as f64, src_rect.x1 as f64, src_rect.y1 as f64]));
let bounds = corner_bounds(&corners);
let dst = match (clip, bounds) {
// The warped corners only bound the result while the whole source is in front of the horizon.
Some(c) if finite && side.is_some() => bounds().intersect(&c),
Some(c) => c,
None if finite => bounds(),
None => return Ok(out),
(Some(c), Some(b)) if side.is_some() => b.intersect(&c),
(Some(c), _) => c,
(None, Some(b)) => b,
(None, None) => return Ok(out),
};
if dst.is_empty() {
return Ok(out);
@@ -261,7 +330,16 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
tiles.try_reserve_exact(tile_count).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes: tile_bytes })?;
tiles.extend(dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()));
let src_ref: &Surface = src;
// Tiles finished so far, for progress reported per tile (from any worker thread).
let finished = std::sync::atomic::AtomicUsize::new(0);
let total = tiles.len().max(1);
let work = |t: &Rect| -> Result<Option<(Rect, Vec<f32>)>, photocraft_raster::AllocationError> {
if ctl.cancelled() {
return Ok(None);
}
let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
// The last few percent are the writes.
ctl.progress(0.98 * k as f32 / total as f32);
// Source footprint of this tile (inverse-mapped corners of its sampled part), padded for the filter.
let tc = [(t.x0, t.y0), (t.x1, t.y0), (t.x1, t.y1), (t.x0, t.y1)].map(|(x, y)| (x as f64, y as f64));
let wc = tc.map(|(x, y)| inv.0[6] * x + inv.0[7] * y + inv.0[8]);
@@ -324,6 +402,9 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
let mut acc = [0.0f64; 8];
for y in t.y0..t.y1 {
for x in t.x0..t.x1 {
if front.is_some_and(|s| s * inv.w(x as f64 + 0.5, y as f64 + 0.5) <= 0.0) {
continue;
}
let (u, v) = inv.apply(x as f64 + 0.5, y as f64 + 0.5);
let (u, v) = (u - 0.5, v - 0.5);
if u < src_rect.x0 as f64 - 1.0 || v < src_rect.y0 as f64 - 1.0 || u > src_rect.x1 as f64 || v > src_rect.y1 as f64 {
@@ -350,7 +431,7 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
}
Interp::Bicubic => {
let (ix, iy) = (u.floor() as i32, v.floor() as i32);
let (wx, wy) = (catmull_rom(u - ix as f64), catmull_rom(v - iy as f64));
let (wx, wy) = (bicubic(u - ix as f64), bicubic(v - iy as f64));
for (j, wyj) in wy.iter().enumerate() {
for (i, wxi) in wx.iter().enumerate() {
let k = wxi * wyj;
@@ -368,7 +449,8 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
any = true;
let o = ((y - t.y0) as usize * w + (x - t.x0) as usize) * n;
for c in 0..a {
outp[o + c] = (acc[c] / al).clamp(0.0, 1.0) as f32;
// Integer storage clips on write; float colours retain their signed/HDR range.
outp[o + c] = (acc[c] / al) as f32;
}
outp[o + a] = al as f32;
}
@@ -396,6 +478,9 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
}
}
let done = done.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
if ctl.cancelled() {
return Ok(out);
}
for (r, v) in done {
out.try_write_region(r, &v)?;
}
@@ -407,6 +492,25 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt
mod tests {
use super::*;
#[test]
fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() {
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 600, 400), &[0.2, 0.4, 0.6, 1.0]);
let r = s.content_bounds();
let h = Homography::rect_to_quad([0.0, 0.0, 600.0, 400.0], [[10.0, 0.0], [590.0, 20.0], [600.0, 400.0], [0.0, 380.0]]).unwrap();
let reported = std::sync::Mutex::new(0.0f32);
let progress = |p: f32| {
let mut m = reported.lock().unwrap();
*m = m.max(p);
};
let never = || false;
let out = warp_surface_with(&s, r, &h, Interp::Bicubic, &Interrupt::new(&never, &progress)).unwrap().expect("not cancelled");
assert_eq!(out.read_region(r), warp_surface(&s, r, &h, Interp::Bicubic).unwrap().read_region(r));
assert!(*reported.lock().unwrap() > 0.9);
let always = || true;
assert!(warp_surface_with(&s, r, &h, Interp::Bicubic, &Interrupt::cancel_only(&always)).unwrap().is_none());
}
/// Perspective Crop's map lands each quad corner exactly on its rectangle corner; degenerate,
/// self-crossing, counter-clockwise and non-finite quads have none.
#[test]
@@ -435,6 +539,24 @@ mod tests {
Surface::new(PixelFormat::RGBA8)
}
/// Photoshop 27.11's Free Transform, Bicubic, 200 % about the centre of a 64 px image whose left
/// half is grey 64 and right half grey 192: the row across the edge, columns 26..36.
#[test]
fn bicubic_upscale_matches_photoshop() {
let mut src = rgba();
let g = |v: f32| v / 255.0;
src.fill_rect(Rect::new(0, 0, 32, 64), &[g(64.0), g(64.0), g(64.0), 1.0]);
src.fill_rect(Rect::new(32, 0, 64, 64), &[g(192.0), g(192.0), g(192.0), 1.0]);
let q = [[-32.0, -32.0], [96.0, -32.0], [96.0, 96.0], [-32.0, 96.0]];
let h = Homography::rect_to_quad([0.0, 0.0, 64.0, 64.0], q).unwrap();
let out = warp_surface(&src, Rect::new(0, 0, 64, 64), &h, Interp::Bicubic).unwrap();
let photoshop = [64.0, 64.0, 64.0, 60.0, 51.0, 93.0, 163.0, 206.0, 197.0, 192.0];
for (i, want) in photoshop.iter().enumerate() {
let got = out.pixel(26 + i as i32, 50)[0] * 255.0;
assert!((got - want).abs() <= 1.5, "column {}: {got} vs {want}", 26 + i);
}
}
#[test]
fn homography_maps_rect_corners_and_inverts() {
let r = [10.0, 20.0, 110.0, 70.0];
@@ -469,6 +591,44 @@ mod tests {
}
}
/// #4863: a Distort with the bottom corners pulled in and one dragged past the horizon (a
/// concave quad) leaves part of the source behind the horizon. That part maps to the far side,
/// where it showed as a mirrored copy wrapped round infinity; only the front side is drawn.
#[test]
fn a_distort_past_the_horizon_draws_only_its_front_side() {
let mut s = rgba();
s.fill_rect(Rect::new(0, 0, 100, 100), &[0.0, 0.5, 1.0, 1.0]);
let r = s.content_bounds();
let h = Homography::rect_to_quad([0.0, 0.0, 100.0, 100.0], [[0.0, 0.0], [300.0, 0.0], [60.0, 60.0], [0.0, 300.0]]).unwrap();
let corners = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| h.w(x, y));
assert!(corners.iter().any(|w| *w > 0.0) && corners.iter().any(|w| *w < 0.0), "the source straddles the horizon: {corners:?}");
let front = h.front_side([0.0, 0.0, 100.0, 100.0]);
let inv = h.inverse().unwrap();
let clip = Rect::new(-400, -400, 700, 700);
for interp in [Interp::Nearest, Interp::Bilinear] {
let w = warp_surface_clipped(&s, r, &h, interp, clip).unwrap();
let px = w.read_region(clip);
let (mut drawn, mut behind) = (0, 0);
for y in clip.y0..clip.y1 {
for x in clip.x0..clip.x1 {
let i = ((y - clip.y0) * clip.width() as i32 + (x - clip.x0)) as usize * 4;
if px[i + 3] > 0.0 {
drawn += 1;
// The source point this pixel samples is on the far side of the horizon.
if front * inv.w(x as f64 + 0.5, y as f64 + 0.5) <= 0.0 {
behind += 1;
}
}
}
}
assert!(drawn > 1000, "{interp:?}: the front side is drawn ({drawn} px)");
assert_eq!(behind, 0, "{interp:?}: {behind} pixels come from behind the horizon");
}
// A quad wholly in front of the horizon is drawn exactly as before.
let ok = Homography::rect_to_quad([0.0, 0.0, 100.0, 100.0], [[0.0, 0.0], [300.0, 0.0], [155.0, 155.0], [0.0, 300.0]]).unwrap();
assert_eq!(ok.front_side([0.0, 0.0, 100.0, 100.0]), ok.w(50.0, 50.0).signum());
}
#[test]
fn identity_and_translation_are_exact() {
let mut s = rgba();
@@ -482,6 +642,44 @@ mod tests {
}
}
#[test]
fn translation_preserves_colour_range_at_every_depth() {
use photocraft_color::{ColorMode, SampleType};
let h = Homography([1.0, 0.0, 7.0, 0.0, 1.0, -3.0, 0.0, 0.0, 1.0]);
for sample in SampleType::ALL {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
for alpha in [false, true] {
let mut s = Surface::new(PixelFormat::new(mode, sample, alpha));
let mut pixel = if mode == ColorMode::Rgb { vec![2.0, -0.25, 0.5] } else { vec![2.0] };
if alpha {
pixel.push(0.5);
}
s.fill_rect(Rect::new(4, 4, 8, 8), &pixel);
let mut expected = s.pixel(5, 5);
if !alpha {
expected.push(1.0);
}
for interp in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] {
let out = warp_surface(&s, s.content_bounds(), &h, interp).unwrap();
assert_eq!(out.pixel(12, 2), expected, "{sample:?}/{mode:?}/alpha={alpha}/{interp:?}");
assert_eq!(out.pixel(0, 0).last(), Some(&0.0));
}
}
}
}
}
#[test]
fn bilinear_translation_interpolates_signed_hdr_colours() {
let mut s = Surface::new(PixelFormat::RGBA32F);
s.write_pixel(0, 0, &[2.0, -0.5, 0.25, 0.5]);
s.write_pixel(1, 0, &[4.0, -0.25, 0.75, 0.5]);
let h = Homography([1.0, 0.0, 0.5, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
let out = warp_surface(&s, s.content_bounds(), &h, Interp::Bilinear).unwrap();
assert_eq!(out.pixel(1, 0), vec![3.0, -0.375, 0.5, 0.5]);
}
#[test]
fn scale_doubles_size_and_rotation_keeps_area() {
let mut s = rgba();
+96 -23
View File
@@ -4,27 +4,26 @@
//! The source rectangle is subdivided into a fine grid whose vertices are mapped forward; each
//! cell's two triangles are rasterized in destination space with the source position
//! interpolated barycentrically, then the source is sampled there (premultiplied, nearest /
//! bilinear / Catmull-Rom bicubic, like Free Transform). Cells are a few source pixels across, so
//! bilinear / Photoshop's Keys bicubic, like Free Transform). Cells are a few source pixels across, so
//! the piecewise-affine inverse is within a small fraction of a pixel of the true one; affine
//! maps (the identity included) are reproduced exactly. Where the warp folds over itself the
//! maps (the identity included) are reproduced exactly. Where the warp shrinks the source, each
//! pixel instead averages a grid of bilinear subsamples over its source footprint (about one per
//! source pixel), so thin lines don't alias. Where the warp folds over itself the
//! later (lower-right) cell wins. Work is split per destination tile (parallel on native).
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use crate::transform::Interp;
fn catmull_rom(t: f64) -> [f64; 4] {
let t2 = t * t;
let t3 = t2 * t;
[-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2]
}
use crate::transform::{Interp, bicubic};
/// Grid cell size (source px) used to approximate the map.
const CELL: f64 = 4.0;
/// Most cells along one axis (bounds memory and time on huge layers).
const MAX_CELLS: usize = 512;
/// Most subsamples per axis when a warp shrinks the source; also the longest source step per
/// destination pixel the filter follows (bounds the footprint each tile reads).
const MAX_SS: f64 = 8.0;
/// Places a smart object's source image (`src`, its pixels in `src_rect`) in the document:
/// through `warp` (source space) and then the affine `t` (source → document) in one resampling
@@ -172,14 +171,37 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
let w = t.width() as usize;
let h = t.height() as usize;
// Source position per destination pixel (NaN = not covered).
// Subsamples along one destination axis, from the source step `j` it makes: one where
// the warp keeps or enlarges the source, else about one per source pixel the output pixel
// spans (an area average, as the transform path gets from its pre-reduction; a single
// bicubic sample aliases thin lines when shrinking). The subsamples are bilinear: the
// box does the filtering, and it is ~4x cheaper than bicubic at no loss.
let count = |j: [f64; 2]| if interp == Interp::Nearest { 1 } else { j[0].hypot(j[1]).round().clamp(1.0, MAX_SS) as usize };
// Source position per destination pixel (NaN = not covered) and the triangle it came
// from. Per triangle: its source step per destination pixel along x and y (`[dx_u, dx_v,
// dy_u, dy_v]`, the inverse map's Jacobian, constant over a triangle; zero when
// point-sampled) and the subsamples along each.
let mut uv = vec![[f64::NAN; 2]; w * h];
for tri in tris {
let mut owner = vec![0u32; w * h];
let mut jac: Vec<([f64; 4], usize, usize)> = Vec::with_capacity(tris.len());
for (k, tri) in tris.iter().enumerate() {
let [(p0, s0), (p1, s1), (p2, s2)] = tri.map(|v| verts[v]);
let det = (p1[0] - p0[0]) * (p2[1] - p0[1]) - (p2[0] - p0[0]) * (p1[1] - p0[1]);
if det.abs() < 1e-12 {
jac.push(([0.0; 4], 1, 1));
continue;
}
let (d1, d2) = ([s1[0] - s0[0], s1[1] - s0[1]], [s2[0] - s0[0], s2[1] - s0[1]]);
let (l1x, l1y, l2x, l2y) = ((p2[1] - p0[1]) / det, (p0[0] - p2[0]) / det, (p0[1] - p1[1]) / det, (p1[0] - p0[0]) / det);
// Capped, so a sliver triangle can't ask for an unbounded footprint.
let cap = |j: [f64; 2]| {
let l = j[0].hypot(j[1]);
if l > MAX_SS { [j[0] * MAX_SS / l, j[1] * MAX_SS / l] } else { j }
};
let jx = cap([l1x * d1[0] + l2x * d2[0], l1x * d1[1] + l2x * d2[1]]);
let jy = cap([l1y * d1[0] + l2y * d2[0], l1y * d1[1] + l2y * d2[1]]);
let (nx, ny) = (count(jx), count(jy));
jac.push(if nx * ny == 1 { ([0.0; 4], 1, 1) } else { ([jx[0], jx[1], jy[0], jy[1]], nx, ny) });
let bx0 = (p0[0].min(p1[0]).min(p2[0]) - 0.5).floor().max(f64::from(t.x0)) as i32;
let by0 = (p0[1].min(p1[1]).min(p2[1]) - 0.5).floor().max(f64::from(t.y0)) as i32;
let bx1 = (p0[0].max(p1[0]).max(p2[0]) + 0.5).ceil().min(f64::from(t.x1)) as i32;
@@ -197,16 +219,18 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
let o = (y - t.y0) as usize * w + (x - t.x0) as usize;
uv[o] = [l0 * s0[0] + l1 * s1[0] + l2 * s2[0], l0 * s0[1] + l1 * s1[1] + l2 * s2[1]];
owner[o] = k as u32;
}
}
}
// Source footprint of the covered pixels.
// Source footprint of the covered pixels, including the spread of their subsamples.
let (mut fx0, mut fy0, mut fx1, mut fy1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for q in uv.iter().filter(|q| !q[0].is_nan()) {
fx0 = fx0.min(q[0]);
fy0 = fy0.min(q[1]);
fx1 = fx1.max(q[0]);
fy1 = fy1.max(q[1]);
for (q, j) in uv.iter().zip(&owner).filter(|(q, _)| !q[0].is_nan()).map(|(q, &k)| (q, jac[k as usize].0)) {
let (ex, ey) = (0.5 * (j[0].abs() + j[2].abs()), 0.5 * (j[1].abs() + j[3].abs()));
fx0 = fx0.min(q[0] - ex);
fy0 = fy0.min(q[1] - ey);
fx1 = fx1.max(q[0] + ex);
fy1 = fy1.max(q[1] + ey);
}
if fx0 > fx1 {
return None;
@@ -233,11 +257,9 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
let mut outp = vec![0.0f32; w * h * n];
let mut any = false;
let mut acc = [0.0f64; 8];
for (o, q) in uv.iter().enumerate() {
if q[0].is_nan() {
continue;
}
let (u, v) = (q[0] - 0.5, q[1] - 0.5);
let mut one = [0.0f64; 8];
// One premultiplied sample at source position (u, v) (pixel centres at integers).
let point = |interp: Interp, u: f64, v: f64, acc: &mut [f64; 8]| {
acc[..n].fill(0.0);
match interp {
Interp::Nearest => {
@@ -262,7 +284,7 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
let snap = |t: f64| if (t - t.round()).abs() < 1e-7 { t.round() } else { t };
let (u, v) = (snap(u), snap(v));
let (ix, iy) = (u.floor() as i32, v.floor() as i32);
let (wx, wy) = (catmull_rom(u - f64::from(ix)), catmull_rom(v - f64::from(iy)));
let (wx, wy) = (bicubic(u - f64::from(ix)), bicubic(v - f64::from(iy)));
for (j, wyj) in wy.iter().enumerate() {
for (i, wxi) in wx.iter().enumerate() {
let k = wxi * wyj;
@@ -276,6 +298,31 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
}
}
}
};
for (o, (q, &k)) in uv.iter().zip(&owner).enumerate() {
if q[0].is_nan() {
continue;
}
let (j, nx, ny) = jac[k as usize];
if nx * ny == 1 {
point(interp, q[0] - 0.5, q[1] - 0.5, &mut acc);
} else {
acc[..n].fill(0.0);
for sy in 0..ny {
let t = (sy as f64 + 0.5) / ny as f64 - 0.5;
for sx in 0..nx {
let s = (sx as f64 + 0.5) / nx as f64 - 0.5;
point(Interp::Bilinear, q[0] + s * j[0] + t * j[2] - 0.5, q[1] + s * j[1] + t * j[3] - 0.5, &mut one);
for (c, v) in acc.iter_mut().zip(&one).take(n) {
*c += v;
}
}
}
let k = 1.0 / (nx * ny) as f64;
for c in acc.iter_mut().take(n) {
*c *= k;
}
}
let al = acc[a].clamp(0.0, 1.0);
// Float noise (a vertex a hair off the pixel grid) must not leave invisible
// colour behind in transparent pixels.
@@ -407,6 +454,32 @@ mod tests {
assert!(c.y0 < r.y0 - 2 && c.x0 < r.x0 && c.x1 > r.x1, "the top rises and spreads past the box: {c:?}");
}
#[test]
fn a_shrinking_mesh_warp_is_prefiltered() {
// A warped smart object placed at ~1/5 size: a grid of 1 px lines every 4 px must come
// out its area average (9/16 white) everywhere, not point-sampled moiré (crinkled,
// doubled or missing lines), like the affine path's `large_downscale_is_prefiltered`.
for s in [SampleType::U8, SampleType::U16, SampleType::F32] {
let (w, h) = (400, 320);
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, s, true));
let px: Vec<f32> =
(0..h).flat_map(|y| (0..w).map(move |x| if x % 4 == 0 || y % 4 == 0 { 0.0 } else { 1.0 })).flat_map(|v| [v, v, v, 1.0]).collect();
let r = Rect::new(0, 0, w, h);
src.write_region(r, &px);
let warp = Warp::preset(WarpStyle::Arc, 15.0, [0.0, 0.0, f64::from(w), f64::from(h)]);
let t = photocraft_geom::Affine { m: [0.2, 0.0, 0.0, 0.2, 7.0, 5.0] };
let out = place_source(&src, r, &t, Some(&warp)).unwrap();
let b = out.content_bounds();
let o = out.read_region(b);
let opaque: Vec<f32> = o.as_chunks::<4>().0.iter().filter(|p| p[3] >= 0.999).map(|p| p[0]).collect();
assert!(opaque.len() > 2000, "{s:?}: {} opaque pixels", opaque.len());
let worst = opaque.iter().map(|v| (v - 0.5625).abs()).fold(0.0f32, f32::max);
// An area average over the ~5 px footprint still covers one or two lines per axis
// (up to 0.2 off); point sampling lands wholly on or off a line (up to 0.56 off).
assert!(worst < 0.25, "{s:?}: worst deviation from the area average {worst}");
}
}
#[test]
fn gray_masks_keep_their_default() {
let mut m = Surface::with_default(PixelFormat::GRAY8, &[1.0]);
+90
View File
@@ -0,0 +1,90 @@
//! Frozen scalar convolution from main 5dac5c6, used as an exact-output oracle.
use photocraft_algo::retouch::local_blur;
#[test]
fn matches_scalar_blur_bit_for_bit_across_layouts_edges_and_hdr() {
for (w, h) in [(1, 1), (3, 7), (37, 29)] {
for ch in 1..=5 {
for alpha in std::iter::once(None).chain((0..ch).map(Some)) {
let data: Vec<f32> = (0..w * h * ch)
.map(|i| if Some(i % ch) == alpha { [0.0, 1e-8, 0.25, 0.75, 1.0][(i / ch) % 5] } else { ((i * 73 + 19) % 113) as f32 / 17.0 - 2.0 })
.collect();
for win in [(0, 0, w, h), (w / 2, h / 2, w, h), (0, 0, 1, 1), (0, 0, 0, h)] {
for sigma in [0.0, 0.25, 1.0, 3.0, 4.0, 12.0] {
let expected = scalar_local_blur(&data, w, h, ch, alpha, win, sigma);
let actual = local_blur(&data, w, h, ch, alpha, win, sigma);
assert_eq!(actual.len(), expected.len());
for (i, (a, b)) in actual.iter().zip(&expected).enumerate() {
assert_eq!(a.to_bits(), b.to_bits(), "{w}x{h} ch={ch} alpha={alpha:?} win={win:?} sigma={sigma} sample={i}");
}
}
}
}
}
}
}
#[test]
fn empty_channel_layout_remains_empty() {
assert!(local_blur(&[], 5, 5, 0, None, (0, 0, 5, 5), 4.0).is_empty());
}
#[allow(clippy::too_many_arguments)]
fn scalar_local_blur(data: &[f32], w: usize, h: usize, ch: usize, alpha: Option<usize>, win: (usize, usize, usize, usize), sigma: f32) -> Vec<f32> {
let (x0, y0, x1, y1) = win;
let (ow, oh) = (x1 - x0, y1 - y0);
let r = (sigma * 3.0).ceil().max(1.0) as i32;
let k: Vec<f32> = (-r..=r).map(|i| (-((i * i) as f32) / (2.0 * sigma * sigma).max(1e-6)).exp()).collect();
let ks: f32 = k.iter().sum();
let k: Vec<f32> = k.into_iter().map(|v| v / ks).collect();
let get = |x: i32, y: i32, out: &mut [f32]| {
let (xx, yy) = (x.clamp(0, w as i32 - 1) as usize, y.clamp(0, h as i32 - 1) as usize);
let i = (yy * w + xx) * ch;
out.copy_from_slice(&data[i..i + ch]);
if let Some(a) = alpha {
let av = out[a];
for (c, v) in out.iter_mut().enumerate() {
if c != a {
*v *= av;
}
}
}
};
// Horizontal pass over the window rows extended vertically by r.
let th = oh + 2 * r as usize;
let mut tmp = vec![0.0f32; ow * th * ch];
let mut px = vec![0.0f32; ch];
for ty in 0..th {
let y = y0 as i32 + ty as i32 - r;
for ox in 0..ow {
let o = (ty * ow + ox) * ch;
for (i, kv) in k.iter().enumerate() {
get(x0 as i32 + ox as i32 + i as i32 - r, y, &mut px);
for c in 0..ch {
tmp[o + c] += px[c] * kv;
}
}
}
}
let mut out = vec![0.0f32; ow * oh * ch];
for oy in 0..oh {
for ox in 0..ow {
let o = (oy * ow + ox) * ch;
for (i, kv) in k.iter().enumerate() {
let s = ((oy + i) * ow + ox) * ch;
for c in 0..ch {
out[o + c] += tmp[s + c] * kv;
}
}
if let Some(a) = alpha {
let av = out[o + a];
for c in 0..ch {
if c != a {
out[o + c] = if av > 1e-6 { out[o + c] / av } else { data[((y0 + oy) * w + x0 + ox) * ch + c] };
}
}
}
}
}
out
}
+16 -24
View File
@@ -96,13 +96,15 @@ impl Headless {
st.source_read_only = o.source_read_only;
}
let d = &self.session.documents()[index];
let mut warnings = o.warnings;
warnings.extend(photocraft_engine::type_extra_cmds::missing_font_warnings(&d.doc));
Ok(json!({
"index": index,
"name": d.doc.name,
"width": d.doc.size.width,
"height": d.doc.size.height,
"layers": d.doc.layer_count(),
"warnings": o.warnings,
"warnings": warnings,
}))
}
@@ -228,9 +230,11 @@ impl Headless {
(None, Some(p)) => {
let own = file_cmds::extension(p);
let written = format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| own.clone());
if !file_cmds::saves_in_place(p) || written != own {
if !file_cmds::holds_at(&doc, p) || written != own {
let lost = file_cmds::losses_at(&doc, p);
let why = if lost.is_empty() { String::new() } else { format!(" (it can't keep: {})", lost.join("; ")) };
return Err(AutomationError::BadRequest(format!(
"pass `path`: without one, only a PSD, PSB or .pcraft file is written back, in its own format, so `{p}` was left unchanged"
"pass `path`: without one, only a PSD, PSB, .pcraft or OpenRaster file that holds the whole document is written back, in its own format, so `{p}` was left unchanged{why}"
)));
}
PathBuf::from(p)
@@ -240,7 +244,7 @@ impl Headless {
}
};
let target_text = target.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?;
let warnings = match &self.filesystem {
let mut warnings = match &self.filesystem {
Filesystem::Denied => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
Filesystem::TrustedLocal => {
let writer = self.writers.entry(doc.id.0).or_default();
@@ -252,13 +256,15 @@ impl Headless {
warnings
}
};
// A layered document save (PSD, PSB, OpenRaster or .pcraft) is a full write, not a flattened copy:
// record the current revision as saved so the session stops reporting `dirty`, and make
// the file the document's path. A flat export (PNG, JPEG, …) is a copy and leaves both
// alone (#1547).
warnings.extend(photocraft_engine::type_extra_cmds::missing_font_warnings(&doc));
// A layered document save (PSD, PSB, OpenRaster or .pcraft) that holds the whole document is a
// full write, not a flattened copy: record the current revision as saved so the session
// stops reporting `dirty`, and make the file the document's path. A flat export (PNG,
// JPEG, …), or an OpenRaster file that drops part of it, is a copy and leaves both alone
// (#1547, #3806).
let ext =
format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| target.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()));
if ext.as_deref().is_some_and(file_cmds::layered_extension) {
if ext.as_deref().is_some_and(|e| file_cmds::holds(&doc, e)) {
// Saving by index must not retarget the next automation command.
let previously_active = self.session.active_index();
self.session.set_active(i);
@@ -317,21 +323,7 @@ impl Headless {
}
pub fn command_list(&self) -> Value {
Value::Array(
command_specs()
.iter()
.map(|c| {
json!({
"id": c.id,
"label": c.label,
"menu": c.menu,
"shortcut": c.shortcut,
"params": c.params,
"enabled": self.session.is_enabled(c.id),
})
})
.collect(),
)
Value::Array(command_specs().iter().map(|c| photocraft_engine::commands::list_entry(&self.session, c, self.session.disabled_reason(c.id))).collect())
}
pub fn command_run(&mut self, id: &str, params: Value) -> Result<Value, AutomationError> {
+105 -12
View File
@@ -30,7 +30,7 @@ use serde_json::{Value, json};
use crate::budgets::{BatchReplyBudget, write_reply};
use crate::security::{
ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, discard_rest_of_line,
read_bounded_line,
read_bounded_line, repeat_auth_reply,
};
use crate::{AutomationError, Headless};
@@ -60,8 +60,13 @@ fn bad(msg: impl Into<String>) -> AutomationError {
AutomationError::BadRequest(msg.into())
}
fn index_of(p: &Value) -> Option<usize> {
p.get("index").and_then(Value::as_u64).map(|i| i as usize)
/// The optional `index` param: missing or null means the active document; anything else must be a
/// non-negative integer (never silently fall back to the active document, which `doc.close` would close).
fn index_of(p: &Value) -> Result<Option<usize>, AutomationError> {
match p.get("index") {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).map(Some).ok_or_else(|| bad("`index` must be a non-negative integer")),
}
}
fn str_of<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
@@ -109,20 +114,21 @@ impl Headless {
let mut opts = p.as_object().cloned().unwrap_or_default();
opts.remove("path");
opts.remove("index");
self.place(index_of(&p), path, &Value::Object(opts))
self.place(index_of(&p)?, path, &Value::Object(opts))
}
"doc.new" => self.command_run("file.new", p),
"doc.save" => {
let tga_bits = photocraft_engine::file_cmds::tga_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
let mut opts =
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() };
opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
if let Some(q) = p.get("quality").and_then(Value::as_u64) {
opts.encode.jpeg_quality = q.clamp(1, 100) as u8;
opts.encode.webp_quality = q.clamp(1, 100) as u8;
opts.encode.webp_lossless = false;
}
let path = str_of(&p, "path").map(PathBuf::from);
self.save(index_of(&p), path.as_deref(), str_of(&p, "format"), &opts)
self.save(index_of(&p)?, path.as_deref(), str_of(&p, "format"), &opts)
}
"doc.exportLayers" | "doc.exportDataSets" => {
let dir = str_of(&p, "dir").ok_or_else(|| bad(format!("{method} needs `dir`")))?;
@@ -130,16 +136,16 @@ impl Headless {
opts.remove("dir");
opts.remove("index");
let opts = Value::Object(opts);
if method == "doc.exportLayers" { self.export_layers(index_of(&p), dir, &opts) } else { self.export_data_sets(index_of(&p), dir, &opts) }
if method == "doc.exportLayers" { self.export_layers(index_of(&p)?, dir, &opts) } else { self.export_data_sets(index_of(&p)?, dir, &opts) }
}
"doc.importDataSets" => {
let path = str_of(&p, "path").ok_or_else(|| bad("doc.importDataSets needs `path`"))?;
let mut opts = p.as_object().cloned().unwrap_or_default();
opts.remove("path");
opts.remove("index");
self.import_data_sets(index_of(&p), path, &Value::Object(opts))
self.import_data_sets(index_of(&p)?, path, &Value::Object(opts))
}
"doc.inspect" => self.inspect(index_of(&p)),
"doc.inspect" => self.inspect(index_of(&p)?),
"doc.render" => {
let max = match p.get("maxSide") {
None => 1024,
@@ -147,7 +153,7 @@ impl Headless {
value.as_u64().and_then(|value| u32::try_from(value).ok()).ok_or_else(|| bad("maxSide must be an unsigned 32-bit integer"))?
}
};
let png = self.render_png(index_of(&p), max)?;
let png = self.render_png(index_of(&p)?, max)?;
match str_of(&p, "path") {
Some(path) => {
self.write_render(&PathBuf::from(path), &png)?;
@@ -160,10 +166,10 @@ impl Headless {
}
}
"doc.select" => {
let i = index_of(&p).ok_or_else(|| bad("doc.select needs `index`"))?;
let i = index_of(&p)?.ok_or_else(|| bad("doc.select needs `index`"))?;
self.select(i)
}
"doc.close" => self.close(index_of(&p)),
"doc.close" => self.close(index_of(&p)?),
"batch" => self.batch(&p),
"methods" => Ok(json!(METHODS)),
other => Err(bad(format!("unknown method `{other}` (try `methods`)"))),
@@ -288,7 +294,7 @@ fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token:
LineRead::Line => {}
}
let reply = if authenticated {
respond(h, &line)
repeat_auth_reply(&line).unwrap_or_else(|| respond(h, &line))
} else {
let (reply, ok) = authentication_reply(&line, token);
authenticated = ok;
@@ -357,6 +363,40 @@ mod tests {
}
}
/// Pins the reply shape documented in docs/control-protocol.md › Command list replies
/// (#3684): a bare JSON array (not `{"commands": [...]}`), one object per command with
/// exactly these keys, from `engine.commands` (filtered or not) and `command.list`.
#[test]
fn command_list_replies_are_documented_array_of_objects() {
let h = session();
let mut g = h.lock().unwrap();
let all = g.handle("engine.commands", json!({})).unwrap();
let filtered = g.handle("engine.commands", json!({"filter": "BLUR"})).unwrap();
let engine = g.handle("engine.execute", json!({"command": "command.list"})).unwrap();
for (name, reply) in [("engine.commands", &all), ("engine.commands {filter}", &filtered), ("command.list", &engine)] {
let items = reply.as_array().unwrap_or_else(|| panic!("{name} must reply with a bare array, got {reply}"));
assert!(!items.is_empty(), "{name}");
for c in items {
let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(keys, ["disabledReason", "enabled", "id", "label", "menu", "needsDocument", "params", "shortcut"], "{name}: {c}");
assert!(c["id"].is_string() && c["label"].is_string() && c["params"].is_string() && c["enabled"].is_boolean(), "{c}");
assert!(c["needsDocument"].is_boolean(), "{c}");
assert_eq!(c["disabledReason"].is_null(), c["enabled"] == true, "disabledReason is null exactly when enabled: {c}");
assert!(c["menu"].as_array().unwrap().iter().all(Value::is_string), "{c}");
assert!(c["shortcut"].is_string() || c["shortcut"].is_null(), "{c}");
}
}
let n_all = all.as_array().unwrap().len();
assert_eq!(engine.as_array().unwrap().len(), n_all);
let filtered = filtered.as_array().unwrap();
assert!(filtered.len() < n_all);
assert!(filtered.iter().all(|c| format!("{} {}", c["id"], c["label"]).to_lowercase().contains("blur")));
let new = all.as_array().unwrap().iter().find(|c| c["id"] == "file.new").unwrap();
assert_eq!((&new["menu"], &new["shortcut"], &new["enabled"]), (&json!(["File"]), &json!("Cmd+N"), &json!(true)), "{new}");
assert_eq!((&new["disabledReason"], &new["needsDocument"]), (&Value::Null, &json!(false)), "{new}");
}
#[test]
fn lines_round_trip_and_errors() {
let h = session();
@@ -780,6 +820,36 @@ mod tests {
assert!(v["result"].as_array().unwrap().iter().any(|m| m == "batch"));
}
#[test]
fn tcp_repeat_auth_reports_already_authenticated_and_keeps_serving() {
use std::io::{BufReader, Write as _};
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let h = Arc::new(Mutex::new(Headless::new()));
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = serve_tcp("127.0.0.1:0", h, TOKEN.into(), move |a| tx.send(a).unwrap());
});
let addr = rx.recv().unwrap();
let mut s = std::net::TcpStream::connect(addr).unwrap();
let mut reader = BufReader::new(s.try_clone().unwrap());
let mut line = String::new();
for id in ["auth", "again"] {
writeln!(s, r#"{{"id":"{id}","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
}
let again: Value = serde_json::from_str(&line).unwrap();
assert_eq!(again["id"], "again");
assert_eq!(again["ok"], false);
assert_eq!(again["error"], "already authenticated");
writeln!(s, r#"{{"id":"m","method":"methods"}}"#).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["id"], "m");
assert_eq!(v["ok"], true);
}
#[test]
fn tcp_rejects_requests_before_authentication() {
use std::io::{BufReader, Write as _};
@@ -799,4 +869,27 @@ mod tests {
assert_eq!(v["error"], "authentication required");
assert!(v.get("result").is_none());
}
#[test]
fn invalid_document_index_is_rejected_not_the_active_document() {
let h = session();
for _ in 0..2 {
assert_eq!(respond(&h, r#"{"method":"doc.new","params":{"width":8,"height":8}}"#)["ok"], true);
}
let count = |h: &Mutex<Headless>| h.lock().unwrap().session_list()["documents"].as_array().unwrap().len();
for index in [json!("0"), json!(-1), json!(0.5), json!(true)] {
for method in ["doc.close", "doc.inspect", "doc.select"] {
let reply = respond(&h, &json!({"method": method, "params": {"index": index}}).to_string());
assert_eq!(reply["ok"], false, "{method} {index}: {reply}");
assert!(reply["error"].as_str().unwrap().contains("`index` must be a non-negative integer"), "{reply}");
}
assert_eq!(count(&h), 2, "index {index} closed a document");
}
// Null means "the active document", like a missing `index`.
assert_eq!(respond(&h, r#"{"method":"doc.inspect","params":{"index":null}}"#)["ok"], true);
// A valid index still closes that document.
let reply = respond(&h, r#"{"method":"doc.close","params":{"index":0}}"#);
assert_eq!(reply["ok"], true, "{reply}");
assert_eq!(count(&h), 1);
}
}
+23
View File
@@ -118,6 +118,18 @@ pub fn authentication_reply(line: &str, expected_token: &str) -> (Value, bool) {
}
}
/// Answer a repeat `auth` frame on an already authenticated connection. Returns `None` for any
/// other frame, which the caller dispatches as usual.
/// Without this, a second `auth` fell through to the method dispatcher as an unknown method.
pub fn repeat_auth_reply(line: &str) -> Option<Value> {
let req: Value = serde_json::from_str(line).ok()?;
if req.get("method").and_then(Value::as_str) != Some(AUTH_METHOD) {
return None;
}
let id = req.get("id").cloned().unwrap_or(Value::Null);
Some(json!({"id": id, "ok": false, "error": "already authenticated"}))
}
fn read_token_file(path: &Path) -> Result<String, AutomationError> {
let token = std::fs::read_to_string(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
let token = token.trim().to_owned();
@@ -294,6 +306,17 @@ mod tests {
assert_eq!(reply["result"]["authenticated"], true);
}
#[test]
fn repeat_auth_on_an_authenticated_connection_says_so() {
let line = json!({"id": 3, "method": AUTH_METHOD, "params": {"token": "anything"}}).to_string();
let reply = repeat_auth_reply(&line).unwrap();
assert_eq!(reply["id"], 3);
assert_eq!(reply["ok"], false);
assert_eq!(reply["error"], "already authenticated");
assert!(repeat_auth_reply(r#"{"id":4,"method":"methods"}"#).is_none());
assert!(repeat_auth_reply("{not json").is_none());
}
#[test]
fn token_file_round_trips_between_server_and_client() {
let path = std::env::temp_dir().join(format!("photocraft-control-token-{}-{}.txt", std::process::id(), generate_token().unwrap()));
+17 -3
View File
@@ -167,6 +167,9 @@ pub struct SaveParams {
/// alpha, 24 none. Unset: 32 with an alpha channel or transparency, else 24.
#[serde(default, rename = "tgaBits")]
pub tga_bits: Option<u8>,
/// DPX: 8, 10, 12 or 16 bits per sample. Unset: 8 for an 8-bit document, else 16.
#[serde(default, rename = "dpxBits")]
pub dpx_bits: Option<u8>,
#[serde(default)]
pub index: Option<usize>,
}
@@ -306,7 +309,7 @@ pub struct UiSetParams {
/// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel
/// ({background: bool} picks which swatch the Color panel edits), maskTarget,
/// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio,
/// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark), brushSection, brushTab,
/// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark, softLight, softDark), brushSection, brushTab,
/// brushesView, brushPicker ([x, y] opens the Brush Preset picker there, null closes it),
/// brushPickerName, brushPickerStroke, brushPickerTip (the picker card's parts, at least one
/// on), brushPickerScale (the picker footer's card-scale slider, 0.15 to 2, 1 the standard),
@@ -320,7 +323,8 @@ pub struct UiSetParams {
/// auto_adjust, classic_mode and auto_center_preview), shapeStroke (an object patching width, align, cap, join, miterLimit,
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
/// the Content-Aware Move tool's Transform On Drop). Other fields are an error.
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
/// active layer's storage tiles). Other fields are an error.
pub fields: Value,
}
@@ -792,7 +796,10 @@ impl PhotocraftMcp {
}
}
#[tool(description = "List engine commands: id, label, menu path, shortcut, parameter doc, enabled now.")]
#[tool(description = "List engine commands. Returns a JSON array (no wrapper object) of {id, label, menu: [path], \
shortcut: string|null, params: doc string, enabled: bool, disabledReason: string|null, needsDocument: bool}. \
`disabledReason` says why a command is off (null when enabled); `needsDocument` is true when it is off because no \
document is open: create or open one first (doc_new, doc_open).")]
async fn command_list(&self, Parameters(p): Parameters<ListParams>) -> Result<CallToolResult, McpError> {
let all = if let Some(r) = self.headless_op(|h| Ok(h.command_list())).await {
r
@@ -985,6 +992,11 @@ impl PhotocraftMcp {
Some((_, Some(bits))) => Some(bits),
Some((b, None)) => return Ok(fail(format!("`tgaBits` must be 24 or 32, got {b}"))),
};
let dpx_bits = match p.dpx_bits.map(|b| (b, photocraft_io::DpxBits::from_bits(u64::from(b)))) {
None => None,
Some((_, Some(bits))) => Some(bits),
Some((b, None)) => return Ok(fail(format!("`dpxBits` must be 8, 10, 12 or 16, got {b}"))),
};
if let Some(b) = self.bridge_client() {
// The bridge forwards to the running app's `app.save`, which takes only an optional
// path (without one it writes back to the document's own layered file, as headless
@@ -994,6 +1006,7 @@ impl PhotocraftMcp {
p.format.is_some().then_some("format"),
p.quality.is_some().then_some("quality"),
p.tiff_layers.then_some("tiffLayers"),
dpx_bits.is_some().then_some("dpxBits"),
p.index.is_some().then_some("index"),
]
.into_iter()
@@ -1014,6 +1027,7 @@ impl PhotocraftMcp {
let Some(r) = self
.headless_op(move |h| {
let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() };
opts.encode.dpx_bits = dpx_bits;
if let Some(q) = p.quality {
opts.encode.jpeg_quality = q.clamp(1, 100);
opts.encode.webp_quality = q.clamp(1, 100);
+11 -2
View File
@@ -252,6 +252,7 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati
| "file.fileInfo"
| "file.automate.fitImage"
| "file.automate.conditionalModeChange"
| "file.automate.cropAndStraightenPhotos"
| "file.scripts.flattenAllLayerEffects"
| "file.scripts.flattenAllMasks"
| "file.scripts.deleteAllEmptyLayers"
@@ -362,12 +363,12 @@ fn preference_uses_ambient_filesystem(path: &str) -> bool {
fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
match id {
"brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
"actions.load" | "brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
// `data` wins over `path` in these commands; any `path` without it reads the filesystem.
params.get("data").is_none() && params.get("path").is_some()
}
// With a `path` the file is written there; without one the bytes come back as `data`.
"swatches.export" => params.get("path").is_some(),
"actions.save" | "swatches.export" => params.get("path").is_some(),
"plugin.reload" => true,
_ => false,
}
@@ -695,6 +696,8 @@ mod tests {
assert!(authorize_engine_command(id, &serde_json::json!({})).is_err());
}
assert!(authorize_engine_command("file.new", &serde_json::json!({})).is_ok());
assert!(authorize_engine_command("file.automate.cropAndStraightenPhotos", &serde_json::json!({})).is_ok());
assert!(authorize_desktop_engine_command("file.automate.cropAndStraightenPhotos", &serde_json::json!({})).is_ok());
for id in ["layer.smartObjects.editContents", "layer.smartObjects.convertToLayers", "layer.smartObjects.saveContents"] {
assert!(authorize_desktop_engine_command(id, &serde_json::json!({"layer": 19})).is_ok(), "{id}");
}
@@ -762,6 +765,8 @@ mod tests {
("plugin.install", serde_json::json!({"path": " "})),
("swatches.import", serde_json::json!({"path": "/outside/set.aco"})),
("swatches.export", serde_json::json!({"path": "/outside/set.ase"})),
("actions.load", serde_json::json!({"path": "/outside/set.atn"})),
("actions.save", serde_json::json!({"path": "/outside/set.atn"})),
] {
assert!(authorize_engine_command(id, &params).is_err(), "{id}: {params}");
}
@@ -771,6 +776,8 @@ mod tests {
("plugin.install", serde_json::json!({"data": "QUJD"})),
("swatches.import", serde_json::json!({"data": "QUJD"})),
("swatches.export", serde_json::json!({"format": "ase"})),
("actions.load", serde_json::json!({"data": "QUJD"})),
("actions.save", serde_json::json!({"set": "S"})),
] {
assert!(authorize_engine_command(id, &params).is_ok(), "{id}: {params}");
}
@@ -851,6 +858,8 @@ mod tests {
| "path.clippingPath.set"
| "path.rename"
| "path.stroke"
| "type.attachToPath"
| "type.detachFromPath"
| "select.toWorkPath"
| "layer.vectorMask.add"
| "layer.vectorMask.edit"
+101 -1
View File
@@ -166,7 +166,7 @@ async fn agent_completes_ten_scripted_tasks() {
let outside = pixel(&c, 1, 1).await;
assert!(inside[0] > 0.99 && inside[1] < 0.01, "task 5: centre is red {inside:?}");
assert!(outside[0] < 0.1, "task 5: corner shows the photo {outside:?}");
run(&c, "select.deselect", json!({})).await;
assert_eq!(inspect(&c).await["hasSelection"], false, "task 5: the selection became the mask");
// 6. Save a selection to a channel and load it back.
run(&c, "select.rect", json!({"x": 4, "y": 6, "width": 20, "height": 10})).await;
@@ -331,6 +331,7 @@ async fn agent_play_refuses_a_recorded_file_open() {
name: "Open".into(),
steps: vec![("file.open".into(), json!({"path": "/etc/passwd"})), ("layer.new.layer".into(), json!({}))],
disabled: Default::default(),
..Default::default()
});
let (c, server) = connect_headless(headless).await;
let played = run(&c, "actions.play", json!({"action": "Open"})).await;
@@ -343,3 +344,102 @@ async fn agent_play_refuses_a_recorded_file_open() {
shutdown(c, server).await;
std::fs::remove_dir_all(dir).unwrap();
}
/// The next ten acceptance tasks (11–20): strokes, inverse fills, duplication, merges,
/// flattening, adjustments, feathering, expand, renaming/deleting and an export/reopen round
/// trip — all through MCP tools and checked from outside the engine.
#[tokio::test(flavor = "multi_thread")]
async fn agent_completes_ten_more_scripted_tasks() {
let dir = tmp("tasks2");
let (c, server) = connect(&dir).await;
// 11. Stroke a selection: a red square with a black outside stroke.
tool(&c, "doc_new", json!({"width": 80, "height": 80, "background": "white"})).await;
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await;
run(&c, "edit.fill", json!({"color": "#ff0000"})).await;
run(&c, "edit.stroke", json!({"width": 4, "color": "#000000", "location": "outside"})).await;
let inside = pixel(&c, 40, 40).await;
let edge = pixel(&c, 18, 40).await;
let away = pixel(&c, 5, 5).await;
assert!(inside[0] > 0.99 && inside[1] < 0.01, "task 11: the centre is red {inside:?}");
assert!(edge[0] < 0.1 && edge[1] < 0.1, "task 11: the stroke is black {edge:?}");
assert!(away[0] > 0.99 && away[1] > 0.99, "task 11: outside stays white {away:?}");
run(&c, "select.deselect", json!({})).await;
// 12. Inverse the selection: a blue frame around the red square.
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await;
run(&c, "select.inverse", json!({})).await;
run(&c, "edit.fill", json!({"color": "#0000ff"})).await;
let centre = pixel(&c, 40, 40).await;
let frame = pixel(&c, 4, 4).await;
assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 12: the centre keeps red {centre:?}");
assert!(frame[2] > 0.99 && frame[0] < 0.01, "task 12: the frame is blue {frame:?}");
run(&c, "select.deselect", json!({})).await;
// 13. Paint a green patch on a new layer and duplicate it (offset, as Photoshop's default).
run(&c, "layer.new.layer", json!({"name": "Patch"})).await;
run(&c, "select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).await;
run(&c, "edit.fill", json!({"color": "#00ff00"})).await;
run(&c, "select.deselect", json!({})).await;
run(&c, "layer.duplicate", json!({})).await;
let doc = inspect(&c).await;
assert_eq!(doc["layers"].as_array().unwrap().len(), 3, "task 13: {doc}");
let patches = doc["layers"].as_array().unwrap().iter().filter(|l| l["name"].as_str().is_some_and(|n| n.starts_with("Patch"))).count();
assert_eq!(patches, 2, "task 13: {doc}");
// 14. Merge the copy down; the two patches become one layer.
run(&c, "layer.mergeDown", json!({})).await;
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 2, "task 14");
// 15. Flatten the image; the patch survives.
run(&c, "layer.flattenImage", json!({})).await;
let doc = inspect(&c).await;
assert_eq!(doc["layers"].as_array().unwrap().len(), 1, "task 15: {doc}");
let green = pixel(&c, 20, 20).await;
assert!(green[1] > 0.99 && green[0] < 0.01, "task 15: the patch survives {green:?}");
// 16. A Hue/Saturation adjustment layer turns the red square green without touching pixels.
run(&c, "layer.newAdjustmentLayer.hueSaturation", json!({"hue": 120})).await;
assert_eq!(top_layer(&inspect(&c).await)["kind"], "Adjustment", "task 16");
let shifted = pixel(&c, 40, 40).await;
assert!(shifted[1] > 0.5 && shifted[0] < 0.5, "task 16: red turns green {shifted:?}");
run(&c, "layer.delete", json!({})).await;
// 17. Feather the selection and reveal a mask through it: the edge blends.
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await;
run(&c, "select.modify.feather", json!({"radius": 4})).await;
run(&c, "layer.layerMask.revealSelection", json!({})).await;
let doc = inspect(&c).await;
assert_eq!(top_layer(&doc)["hasMask"], true, "task 17");
let boundary = pixel(&c, 20, 40).await;
assert!(boundary[3] > 0.05 && boundary[3] < 0.95, "task 17: the feathered edge keeps partial alpha {boundary:?}");
assert_eq!(doc["hasSelection"], false, "task 17: the selection became the mask");
// 18. Expand a selection and read its bounds back.
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 20, "height": 20})).await;
run(&c, "select.modify.expand", json!({"radius": 5})).await;
let bounds = inspect(&c).await["selectionBounds"].clone();
let b = bounds.as_array().unwrap_or_else(|| panic!("task 18: {bounds}"));
let (x, w) = (b[0].as_i64().unwrap(), b[2].as_i64().unwrap());
assert!((13..=15).contains(&x) && (30..=32).contains(&w), "task 18: expanded outward {bounds}");
run(&c, "select.deselect", json!({})).await;
// 19. Rename a layer and delete it again.
run(&c, "layer.new.layer", json!({"name": "Scratch"})).await;
run(&c, "layer.renameLayer", json!({"name": "Delivery"})).await;
assert_eq!(top_layer(&inspect(&c).await)["name"], "Delivery", "task 19");
run(&c, "layer.delete", json!({})).await;
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 1, "task 19: deleted");
// 20. Export the final image and reopen the PNG as a flat document.
tool(&c, "doc_export", json!({"path": "final.png"})).await;
tool(&c, "doc_close", json!({})).await;
tool(&c, "doc_open", json!({"path": "final.png"})).await;
let doc = inspect(&c).await;
assert_eq!((doc["width"].as_u64(), doc["height"].as_u64()), (Some(80), Some(80)), "task 20");
assert_eq!(doc["layers"].as_array().unwrap().len(), 1, "task 20: a flat reopen");
tool(&c, "doc_close", json!({})).await;
shutdown(c, server).await;
std::fs::remove_dir_all(dir).unwrap();
}
+184
View File
@@ -0,0 +1,184 @@
//! M11 acceptance, part three: six rounding-out tasks (31–36) over MCP (roadmap M11 DoD).
//!
//! Same contract as the other acceptance files: only MCP tools (`doc_*`, `command_run`), checked
//! through `doc_inspect`, `document.pixel` and exported/saved files.
use photocraft_automation::{AuthorizedWorkspace, PhotocraftMcp};
use rmcp::model::{CallToolRequestParams, CallToolResult, ClientConfig};
use rmcp::service::RunningService;
use rmcp::{ClientHandler, RoleClient, ServiceExt};
use serde_json::{Value, json};
#[derive(Clone, Default)]
struct Client;
impl ClientHandler for Client {
fn get_info(&self) -> ClientConfig {
ClientConfig::default()
}
}
type Conn = RunningService<RoleClient, Client>;
type Server = tokio::task::JoinHandle<()>;
async fn connect(root: &std::path::Path) -> (Conn, Server) {
let (s, c) = tokio::io::duplex(1 << 20);
let workspace = AuthorizedWorkspace::new(Some(root), Some(root)).expect("test workspace");
let server = tokio::spawn(async move {
if let Ok(running) = PhotocraftMcp::headless_with_workspace(workspace).serve(s).await {
let _ = running.waiting().await;
}
});
(Client.serve(c).await.expect("client init"), server)
}
async fn shutdown(c: Conn, server: Server) {
c.cancel().await.unwrap();
server.await.unwrap();
}
fn text(r: &CallToolResult) -> String {
r.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect::<Vec<_>>().join("\n")
}
async fn tool(c: &Conn, name: &str, args: Value) -> Value {
let mut p = CallToolRequestParams::new(name.to_owned());
if let Value::Object(m) = args {
p = p.with_arguments(m);
}
let r = c.call_tool(p).await.expect("call_tool transport");
assert_ne!(r.is_error, Some(true), "{name} failed: {}", text(&r));
serde_json::from_str(&text(&r)).unwrap_or(Value::String(text(&r)))
}
async fn run(c: &Conn, id: &str, params: Value) -> Value {
tool(c, "command_run", json!({"id": id, "params": params})).await
}
async fn inspect(c: &Conn) -> Value {
tool(c, "doc_inspect", json!({})).await
}
async fn pixel(c: &Conn, x: i32, y: i32) -> Vec<f64> {
let v = run(c, "document.pixel", json!({"x": x, "y": y})).await;
let arr = if v.is_array() { v } else { v.get("result").cloned().unwrap_or(v) };
arr.as_array().unwrap_or_else(|| panic!("pixel: {arr}")).iter().map(|x| x.as_f64().unwrap()).collect()
}
fn top_layer(doc: &Value) -> &Value {
&doc["layers"][0]
}
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("pc-agent-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// Six more acceptance tasks (31–36): an undo/redo sweep, a painted barcode, a grayscale
/// conversion with an export check, a native save round trip that preserves type, a layer-style
/// stroke and inverse fills.
#[tokio::test(flavor = "multi_thread")]
async fn agent_completes_six_rounding_out_tasks() {
let dir = tmp("tasks4");
let (c, server) = connect(&dir).await;
// 31. Undo/redo sweep: four fills go away and come back.
tool(&c, "doc_new", json!({"width": 40, "height": 40, "background": "white"})).await;
let h0 = inspect(&c).await["history"].as_array().unwrap().len();
let colors = ["#ff0000", "#00ff00", "#0000ff", "#000000"];
for (i, color) in colors.iter().enumerate() {
let x = 10 * i as i32;
run(&c, "select.rect", json!({"x": x, "y": 0, "width": 10, "height": 10})).await;
run(&c, "edit.fill", json!({"color": color})).await;
}
run(&c, "select.deselect", json!({})).await;
let steps = inspect(&c).await["history"].as_array().unwrap().len() - h0;
assert!(steps >= 4, "at least the four fills are history steps: {steps}");
for _ in 0..steps {
run(&c, "edit.undo", json!({})).await;
}
for i in 0..4 {
let x = 10 * i + 5;
let p = pixel(&c, x, 5).await;
assert!(p[0] > 0.99 && p[1] > 0.99 && p[2] > 0.99, "task 31: fill {i} undone {p:?}");
}
for _ in 0..steps {
run(&c, "edit.redo", json!({})).await;
}
let red = pixel(&c, 5, 5).await;
let black = pixel(&c, 35, 5).await;
assert!(red[0] > 0.99 && red[1] < 0.01, "task 31: the first fill redid {red:?}");
assert!(black[0] < 0.01 && black[1] < 0.01, "task 31: the last fill redid {black:?}");
// 32. Paint a barcode: five black bars on white.
tool(&c, "doc_new", json!({"width": 48, "height": 24, "background": "white"})).await;
for i in 0..5 {
let x = 4 + 8 * i;
run(&c, "select.rect", json!({"x": x, "y": 4, "width": 3, "height": 16})).await;
run(&c, "edit.fill", json!({"color": "#000000"})).await;
}
run(&c, "select.deselect", json!({})).await;
for i in 0..5 {
let bar = pixel(&c, 5 + 8 * i, 12).await;
let gap = pixel(&c, 9 + 8 * i, 12).await;
assert!(bar[0] < 0.01, "task 32: bar {i} is black {bar:?}");
assert!(gap[0] > 0.99, "task 32: gap {i} is white {gap:?}");
}
// 33. Convert to grayscale: the channels flatten and the export keeps them equal.
run(&c, "image.mode.grayscale", json!({})).await;
assert_eq!(inspect(&c).await["mode"], "Grayscale", "task 33");
let g = pixel(&c, 5, 12).await;
assert!((g[0] - g[1]).abs() < 1e-4 && (g[1] - g[2]).abs() < 1e-4, "task 33: the channels agree {g:?}");
tool(&c, "doc_export", json!({"path": "bars.png"})).await;
let img = photocraft_codecs::decode(&std::fs::read(dir.join("bars.png")).unwrap()).unwrap();
let px = img.to_rgba8();
let at = |x: usize, y: usize| px[(y * img.width() as usize + x) * 4];
assert!(at(5, 12) < 8 && at(9, 12) > 247, "task 33: the grayscale export matches the canvas");
tool(&c, "doc_close", json!({})).await;
// 34. A native save round trip preserves a styled type layer.
tool(&c, "doc_new", json!({"width": 160, "height": 80, "background": "white"})).await;
run(&c, "type.create", json!({"x": 16, "y": 48, "text": "Round", "size": 24, "color": "#3366cc"})).await;
tool(&c, "doc_save", json!({"path": "type.pcraft"})).await;
tool(&c, "doc_close", json!({})).await;
tool(&c, "doc_open", json!({"path": "type.pcraft"})).await;
let doc = inspect(&c).await;
assert_eq!(top_layer(&doc)["kind"], "Type", "task 34: {doc}");
assert_eq!(top_layer(&doc)["text"]["text"], "Round", "task 34: {doc}");
tool(&c, "doc_close", json!({})).await;
// 35. A layer-style stroke frames a swatch.
tool(&c, "doc_new", json!({"width": 48, "height": 48, "background": "white"})).await;
run(&c, "layer.new.layer", json!({"name": "Swatch"})).await;
run(&c, "select.rect", json!({"x": 12, "y": 12, "width": 24, "height": 24})).await;
run(&c, "edit.fill", json!({"color": "#ff0000"})).await;
run(&c, "select.deselect", json!({})).await;
run(&c, "layer.layerStyle.stroke", json!({"size": 3, "position": "outside", "color": "#0000ff"})).await;
let doc = inspect(&c).await;
assert_eq!(top_layer(&doc)["effects"]["items"][0]["kind"], "Stroke", "task 35: {doc}");
let border = pixel(&c, 11, 24).await;
let centre = pixel(&c, 24, 24).await;
assert!(border[2] > 0.5 && border[0] < 0.5, "task 35: the stroke is blue {border:?}");
assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 35: the centre stays red {centre:?}");
// 36. Inverse fill: gray around the swatch, then Select All paints everything white.
run(&c, "select.rect", json!({"x": 12, "y": 12, "width": 24, "height": 24})).await;
run(&c, "select.inverse", json!({})).await;
run(&c, "edit.fill", json!({"color": "#808080"})).await;
run(&c, "select.deselect", json!({})).await;
let corner = pixel(&c, 2, 2).await;
let centre = pixel(&c, 24, 24).await;
assert!(corner[0] > 0.4 && corner[0] < 0.6 && (corner[0] - corner[1]).abs() < 0.01, "task 36: the inverse fill is gray {corner:?}");
assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 36: the swatch stays red {centre:?}");
run(&c, "select.all", json!({})).await;
run(&c, "edit.fill", json!({"color": "#ffffff"})).await;
run(&c, "select.deselect", json!({})).await;
let white = pixel(&c, 2, 2).await;
assert!(white[0] > 0.99 && white[1] > 0.99 && white[2] > 0.99, "task 36: everything is white {white:?}");
tool(&c, "doc_close", json!({})).await;
shutdown(c, server).await;
std::fs::remove_dir_all(dir).unwrap();
}
+68
View File
@@ -0,0 +1,68 @@
//! Krita documents through the agent surface (`doc.open`, `engine.execute`, `doc.save`, as MCP
//! and the CLI use it): open a .kra with its layers, edit it, and save it as OpenRaster.
use photocraft_automation::Headless;
use serde_json::json;
/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0).
fn tiles(bgra: [u8; 4]) -> Vec<u8> {
let n = 64 * 64;
let mut out = b"VERSION 2\nTILEWIDTH 64\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 1\n".to_vec();
out.extend(format!("0,0,LZF,{}\n", 1 + n * 4).into_bytes());
out.push(0);
for b in bgra {
out.extend(std::iter::repeat_n(b, n));
}
out
}
fn kra() -> Vec<u8> {
let maindoc = r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE DOC PUBLIC '-//KDE//DTD krita 2.0//EN' 'http://www.calligra.org/DTD/krita-2.0.dtd'>
<DOC xmlns="http://www.calligra.org/DTD/krita" syntaxVersion="2.0"><IMAGE name="art" width="32" height="24" colorspacename="RGBA" x-res="72" y-res="72"><layers>
<layer nodetype="grouplayer" name="Inks" filename="layer1" compositeop="normal" opacity="255" visible="1"><layers>
<layer nodetype="paintlayer" name="Line" colorspacename="RGBA" filename="layer2" compositeop="multiply" opacity="204" visible="1"/>
</layers></layer>
<layer nodetype="shapelayer" name="Speech bubble" filename="layer3"/>
<layer nodetype="paintlayer" name="Paper" colorspacename="RGBA" filename="layer4" compositeop="normal" opacity="255" visible="1"/>
</layers></IMAGE></DOC>"#;
let mut z = photocraft_format::zip::ZipWriter::new();
z.add("mimetype", b"application/x-krita").unwrap();
z.add("maindoc.xml", maindoc.as_bytes()).unwrap();
z.add("art/layers/layer2", &tiles([0, 0, 255, 255])).unwrap();
z.add("art/layers/layer4", &tiles([255, 255, 255, 255])).unwrap();
z.finish().unwrap()
}
fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value {
h.handle("engine.execute", json!({"command": command, "params": params})).unwrap()
}
#[test]
fn krita_documents_open_with_layers_and_save_as_openraster() {
let dir = std::env::temp_dir().join(format!("pc-krita-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("art.kra");
std::fs::write(&path, kra()).unwrap();
let mut h = Headless::trusted_local();
let opened = h.handle("doc.open", json!({"path": path.to_str().unwrap()})).unwrap();
let warnings = opened["warnings"].to_string();
assert!(warnings.contains("vector layers") && warnings.contains("Speech bubble"), "{opened}");
let doc = &h.session.active().unwrap().doc;
let names: Vec<&str> = doc.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["Paper", "Inks"]);
let line = doc.layers[1].children().unwrap()[0].clone();
assert_eq!((line.name.as_str(), line.blend), ("Line", photocraft_doc::BlendMode::Multiply));
let flat = photocraft_compose::flatten(doc);
assert!((flat.px[0][1] - 0.2).abs() < 0.01, "80% red multiplied over white: {:?}", flat.px[0]);
// Read-only source: the file itself is never overwritten as Krita.
assert!(h.handle("doc.save", json!({"path": path.to_str().unwrap()})).is_err());
exec(&mut h, "layer.setProps", json!({"layer": line.id.0, "opacity": 0.5}));
let ora = dir.join("art.ora");
h.handle("doc.save", json!({"path": ora.to_str().unwrap()})).unwrap();
let back = photocraft_io::import("art.ora", &std::fs::read(&ora).unwrap()).unwrap().document;
assert_eq!(back.layers.len(), 2);
assert_eq!(back.layers[1].children().unwrap()[0].opacity, 0.5);
let _ = std::fs::remove_dir_all(&dir);
}
+13 -2
View File
@@ -170,6 +170,12 @@ async fn command_list_filters() {
let enabled = json_of(&call(&client, "command_list", json!({"enabled_only": true})).await);
assert!(enabled.as_array().unwrap().iter().any(|c| c["id"] == "file.new"));
assert!(!enabled.as_array().unwrap().iter().any(|c| c["id"] == "layer.new.layer"), "needs a document");
// The full list says why a document command is off with no document open (#3664).
let layer = all.as_array().unwrap().iter().find(|c| c["id"] == "layer.new.layer").unwrap();
assert_eq!((layer["enabled"].clone(), layer["needsDocument"].clone()), (json!(false), json!(true)), "{layer}");
assert!(layer["disabledReason"].as_str().is_some_and(|r| r.starts_with("no document")), "{layer}");
let new = all.as_array().unwrap().iter().find(|c| c["id"] == "file.new").unwrap();
assert_eq!((new["needsDocument"].clone(), new["disabledReason"].clone()), (json!(false), Value::Null), "{new}");
client.cancel().await.unwrap();
}
@@ -1230,11 +1236,16 @@ async fn convention_resources_are_live_json() {
assert_eq!(read("photocraft://document").await["width"], 8);
// Edit › Purge › All is enabled while the process-wide effect, mask and stack caches hold
// anything, and the other tests in this binary fill and purge those caches concurrently, so
// its `enabled` flag can flip between the two reads. Every other field must match exactly.
// its `enabled` flag and paired `disabledReason` can flip between the two reads. Validate
// each availability pair; every stable field and every other command must match exactly.
let stable = |mut v: Value| {
for c in v.as_array_mut().into_iter().flatten() {
if c["id"] == "edit.purge.all" {
c.as_object_mut().map(|o| o.remove("enabled"));
let reason = if c["enabled"].as_bool().unwrap() { Value::Null } else { json!("nothing to purge") };
assert_eq!(c["disabledReason"], reason, "purge availability stays internally consistent");
let command = c.as_object_mut().unwrap();
command.remove("enabled");
command.remove("disabledReason");
}
}
v
@@ -0,0 +1,57 @@
//! #3391: `doc.save`, `doc.open`, and type commands emit warnings when text uses a missing font.
use photocraft_automation::Headless;
use serde_json::json;
fn tmp_dir() -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"pc-font-warn-{}-{}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn missing_font_warnings_emitted_in_headless_and_type_commands() {
let dir = tmp_dir();
let psd_path = dir.join("card.psd");
let png_path = dir.join("card.png");
let mut h = Headless::trusted_local();
h.handle("doc.new", json!({"width": 600, "height": 300, "background": "white"})).unwrap();
// 1. type.create with missing font
let created = h
.handle("engine.execute", json!({"command": "type.create", "params": {"x": 30, "y": 100, "text": "Headline", "font": "NoSuchFamily123", "size": 40}}))
.unwrap();
assert_eq!(created["warnings"], json!(["font 'NoSuchFamily123' is not installed; text may render using a fallback face"]));
// 2. doc.save card.psd
let saved_psd = h.handle("doc.save", json!({"path": psd_path.to_str().unwrap()})).unwrap();
let warnings = saved_psd["warnings"].as_array().expect("warnings array in doc.save");
assert!(
warnings.iter().any(|w| w == "font 'NoSuchFamily123' is not installed; text may render using a fallback face"),
"doc.save PSD warning: {saved_psd}"
);
// 3. doc.save card.png
let saved_png = h.handle("doc.save", json!({"path": png_path.to_str().unwrap()})).unwrap();
let warnings = saved_png["warnings"].as_array().expect("warnings array in doc.save");
assert!(
warnings.iter().any(|w| w == "font 'NoSuchFamily123' is not installed; text may render using a fallback face"),
"doc.save PNG warning: {saved_png}"
);
// 4. doc.close then doc.open
h.handle("doc.close", json!({})).unwrap();
let opened = h.handle("doc.open", json!({"path": psd_path.to_str().unwrap()})).unwrap();
let warnings = opened["warnings"].as_array().expect("warnings array in doc.open");
assert!(
warnings.iter().any(|w| w.as_str().unwrap_or("").contains("is not installed; text may render using a fallback face")),
"doc.open warning: {opened}"
);
let _ = std::fs::remove_dir_all(dir);
}
+37
View File
@@ -66,3 +66,40 @@ fn a_corrupt_openraster_file_reports_an_error() {
assert!(err.to_string().contains("OpenRaster"), "{err}");
std::fs::remove_dir_all(dir).unwrap();
}
/// #3806: an OpenRaster file holds only what OpenRaster can. Once the document has an adjustment
/// layer, a pathless save refuses to overwrite the .ora (saying what it would drop), and a save to
/// an .ora path writes a copy: the document keeps its file and stays unsaved.
#[test]
fn an_openraster_file_never_overwrites_what_it_cannot_hold() {
let dir = tmp("lossy");
let path = dir.join("art.ora");
let path_str = path.to_str().unwrap();
let mut h = Headless::trusted_local();
h.handle("doc.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap();
exec(&mut h, "layer.new.layer", json!({}));
exec(&mut h, "paint.stroke", json!({"points": [[4, 4], [30, 20]], "size": 6, "hardness": 1.0, "color": "#ff0000"}));
h.handle("doc.save", json!({"path": path_str})).unwrap();
let original = std::fs::read(&path).unwrap();
// A Curves adjustment layer: OpenRaster has no adjustment layers.
exec(&mut h, "layer.newAdjustmentLayer.curves", json!({}));
assert!(h.session.active().unwrap().is_dirty());
let err = h.handle("doc.save", json!({})).unwrap_err().to_string();
assert!(err.contains("adjustment layers"), "{err}");
assert_eq!(std::fs::read(&path).unwrap(), original, "the original is untouched");
assert!(h.session.active().unwrap().is_dirty(), "and the edits are still unsaved");
// Saving to an .ora path explicitly writes a copy (with the warnings), not the document's file.
let copy = dir.join("copy.ora");
let saved = h.handle("doc.save", json!({"path": copy.to_str().unwrap()})).unwrap();
assert!(saved["warnings"].as_array().is_some_and(|w| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("adjustment layers")))), "{saved}");
let st = h.session.active().unwrap();
assert!(st.is_dirty(), "a copy leaves the edits unsaved");
assert_eq!(st.path.as_deref(), Some(path_str), "the document keeps its file");
// As PSD it holds everything, so that becomes the document's file.
let psd = dir.join("art.psd");
h.handle("doc.save", json!({"path": psd.to_str().unwrap()})).unwrap();
let st = h.session.active().unwrap();
assert!(!st.is_dirty());
assert_eq!(st.path.as_deref(), psd.to_str());
std::fs::remove_dir_all(dir).unwrap();
}
+14 -4
View File
@@ -32,6 +32,10 @@ pub enum Builtin {
GrayGamma22,
/// Gray with the sRGB transfer function (sGray): gray `v` looks like RGB `(v, v, v)` in sRGB.
SGray,
/// Gray matching Photoshop's default working gray (Dot Gain 20 %) with our own curve: a pure
/// gamma of 1.732, fitted on Photoshop 27.11's RGB → Grayscale (sRGB 64 / 128 / 192 and pure red
/// become 46 / 105 / 177 / 107, all within 1 level). Not Adobe's profile.
DotGain20Compat,
/// CIE L*a*b* D50 identity (device Lab = PCS Lab, ICC v4 encoding).
LabD50,
/// Synthetic coated-offset CMYK (300 % total ink, medium GCR); see [`crate::synth`].
@@ -39,7 +43,7 @@ pub enum Builtin {
}
impl Builtin {
pub const ALL: [Builtin; 10] = [
pub const ALL: [Builtin; 11] = [
Builtin::Srgb,
Builtin::DisplayP3,
Builtin::AdobeRgbCompat,
@@ -48,6 +52,7 @@ impl Builtin {
Builtin::Rec2020,
Builtin::GrayGamma22,
Builtin::SGray,
Builtin::DotGain20Compat,
Builtin::LabD50,
Builtin::CoatedCmyk,
];
@@ -63,6 +68,7 @@ impl Builtin {
Builtin::Rec2020 => "rec2020",
Builtin::GrayGamma22 => "gray-gamma-2.2",
Builtin::SGray => "sgray",
Builtin::DotGain20Compat => "dot-gain-20-compat",
Builtin::LabD50 => "lab-d50",
Builtin::CoatedCmyk => "coated-cmyk",
}
@@ -79,6 +85,7 @@ impl Builtin {
Builtin::Rec2020 => "Rec. ITU-R BT.2020 (Photocraft)",
Builtin::GrayGamma22 => "Gray Gamma 2.2 (Photocraft)",
Builtin::SGray => "sGray (sRGB tone curve, Photocraft)",
Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, Photocraft)",
Builtin::LabD50 => "Lab D50 identity (Photocraft)",
Builtin::CoatedCmyk => crate::synth::DESCRIPTION,
}
@@ -96,6 +103,7 @@ impl Builtin {
"rec2020" | "bt2020" => Some(Builtin::Rec2020),
"gray" | "graygamma22" | "gamma22" => Some(Builtin::GrayGamma22),
"sgray" | "graysrgb" => Some(Builtin::SGray),
"dotgain20" | "dotgain20compat" | "dotgain" => Some(Builtin::DotGain20Compat),
"lab" | "labd50" | "cielab" => Some(Builtin::LabD50),
"cmyk" | "coatedcmyk" | "defaultcmyk" | "photocraftcoatedcmyk" => Some(Builtin::CoatedCmyk),
_ => None,
@@ -105,18 +113,19 @@ impl Builtin {
/// The profile (built once per process).
pub fn profile(self) -> &'static Profile {
static CELLS: [OnceLock<Profile>; 10] = [const { OnceLock::new() }; 10];
static CELLS: [OnceLock<Profile>; 11] = [const { OnceLock::new() }; 11];
// `ALL` lists the variants in declaration order (checked by `all_in_declaration_order`),
// so the discriminant is the cell index.
CELLS[self as usize].get_or_init(|| build(self))
}
}
/// The default profile for a colour space: sRGB, sGray, the coated CMYK profile, Lab D50.
/// The default profile for a colour space: sRGB, Dot Gain 20 %-compatible gray (Photoshop's default
/// working gray), the coated CMYK profile, Lab D50.
pub fn default_for(cs: ColorSpace) -> Option<&'static Profile> {
Some(match cs {
ColorSpace::Rgb => Builtin::Srgb.profile(),
ColorSpace::Gray => Builtin::SGray.profile(),
ColorSpace::Gray => Builtin::DotGain20Compat.profile(),
ColorSpace::Cmyk => Builtin::CoatedCmyk.profile(),
ColorSpace::Lab => Builtin::LabD50.profile(),
_ => return None,
@@ -268,6 +277,7 @@ fn build(b: Builtin) -> Profile {
}
Builtin::GrayGamma22 => gray(b.description(), Curve::Gamma(2.2)),
Builtin::SGray => gray(b.description(), srgb_trc()),
Builtin::DotGain20Compat => gray(b.description(), Curve::Gamma(1.732)),
Builtin::LabD50 => lab(),
// The shipped bytes are `synth::coated_cmyk()` (checked by the `regen` test); rebuild
// them rather than crash if they ever fail to parse.
+7
View File
@@ -296,6 +296,13 @@ impl Profile {
self.a2b.iter().all(Option::is_none) && (self.matrix.is_some() && self.trc.is_some() || self.gray_trc.is_some())
}
/// Whether colours can be converted into this profile for every intent. Input profiles with
/// only AToB tables (Apple's Wide Color Sharing Profile on iPhone photos) can't: they
/// describe a source but have no PCS → device path.
pub fn can_be_destination(&self) -> bool {
[Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation].into_iter().all(|i| self.pcs_to_device(i).is_ok())
}
fn lut_for(luts: &[Option<Lut>; 3], intent: Intent) -> Option<&Lut> {
let i = match intent {
Intent::Perceptual => 0,
+68 -1
View File
@@ -450,6 +450,73 @@ fn malformed_profiles_are_rejected_without_panicking() {
}
}
// A press's full-K lightness depends on its ink and paper (newsprint can exceed L* 40).
// Keep the relative paper-white check and require finite endpoints with darker, bounded ink.
fn cmyk_endpoints_valid(paper: [f64; 3], black: [f64; 3]) -> bool {
// Allow only float rounding at the physical L* limits, not a different printing condition.
let lightness = -1e-4..=100.0 + 1e-4;
paper.iter().chain(&black).all(|v| v.is_finite())
&& lightness.contains(&paper[0])
&& lightness.contains(&black[0])
&& paper[0] > 90.0
&& black[0] < paper[0]
}
#[test]
fn a_light_black_cmyk_lut_has_the_known_relative_ramp() {
use photocraft_cms::Curve;
use photocraft_cms::pipeline::Stage;
use photocraft_cms::profile::{Lut, LutKind};
let mut p = cmyk().clone();
// Independent linear test model: L* = 100 - 52 K, neutral a*/b*. Exercise A2B1 only;
// the inherited coated profile's inverse is not an inverse of this generated model.
p.a2b = [None, None, None];
p.b2a = [None, None, None];
p.a2b[1] = Some(Lut {
kind: LutKind::Ab,
inputs: 4,
outputs: 3,
stages: vec![
Stage::Curves(vec![Curve::Identity; 4]),
Stage::Clut(Clut::sample(vec![2; 4], 3, |ink, out| {
out.copy_from_slice(&enc([100.0 - 52.0 * f64::from(ink[3]), 0.0, 0.0]));
})),
Stage::Curves(vec![Curve::Identity; 3]),
],
});
let p = Profile::parse(&p.with_encoded_bytes().to_bytes()).unwrap();
let paper = to_lab(&p, &[0.0; 4], Intent::RelativeColorimetric);
let black = to_lab(&p, &[0.0, 0.0, 0.0, 1.0], Intent::RelativeColorimetric);
assert!(cmyk_endpoints_valid(paper, black), "{paper:?} {black:?}");
// Explicit expected values, not the validation predicate: also catch wrong channels,
// intent selection, constant output and incorrect LUT encoding/interpolation.
for (k, expected) in [(0.0, 100.0), (0.25, 87.0), (0.5, 74.0), (0.75, 61.0), (1.0, 48.0)] {
let actual = to_lab(&p, &[0.0, 0.0, 0.0, k], Intent::RelativeColorimetric);
// The writer quantizes the CLUT to 16 bits: at most 0.000763 L* error.
assert!((actual[0] - expected).abs() < 0.001 && actual[1].abs() < 1e-4 && actual[2].abs() < 1e-4, "K={k}: {actual:?}");
}
}
#[test]
fn cmyk_endpoints_reject_invalid_values() {
let paper = [100.0, 0.0, 0.0];
let black = [48.0, 0.0, 0.0];
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
for channel in 0..3 {
let mut p = paper;
let mut k = black;
p[channel] = bad;
k[channel] = bad;
assert!(!cmyk_endpoints_valid(p, black));
assert!(!cmyk_endpoints_valid(paper, k));
}
}
for (p, k) in [(90.0, 48.0), (100.01, 48.0), (100.0, -0.01), (100.0, 100.01), (100.0, 100.0), (99.0, 100.0)] {
assert!(!cmyk_endpoints_valid([p, 0.0, 0.0], [k, 0.0, 0.0]), "L*: paper {p}, black {k}");
}
}
/// Parses every profile the OS ships (macOS ColorSync; read-only, nothing is copied) and
/// converts through each.
#[test]
@@ -500,7 +567,7 @@ fn system_profiles() {
// Real CMYK profile: paper lighter than 100 % K.
let w = to_lab(&p, &[0.0; 4], Intent::RelativeColorimetric);
let k = to_lab(&p, &[0.0, 0.0, 0.0, 1.0], Intent::RelativeColorimetric);
assert!(w[0] > 90.0 && k[0] < 40.0, "{}: {w:?} {k:?}", path.display());
assert!(cmyk_endpoints_valid(w, k), "{}: {w:?} {k:?}", path.display());
// And it round-trips through its own BToA.
if p.b2a[1].is_some() {
let to = Transform::new(lab(), &p, Intent::RelativeColorimetric, false).unwrap();
+3 -1
View File
@@ -22,7 +22,7 @@ heif = ["dep:photocraft-heif"]
thiserror.workspace = true
image = { workspace = true, features = ["gif", "bmp", "tga", "ico", "qoi", "hdr"] }
png = "0.18"
tiff = { version = "0.11", default-features = false, features = ["deflate", "lzw"] }
tiff = { version = "0.11", default-features = false, features = ["deflate", "fax", "jpeg", "lzw"] }
exr = { version = "1.73", default-features = false }
half = "2"
serde_json.workspace = true
@@ -36,6 +36,8 @@ photocraft-heif = { path = "../heif", optional = true }
# Orientation is applied a band of rows per task on native; wasm stays single-threaded.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rayon.workspace = true
# EXR block (de)compression runs on the rayon pool natively; wasm stays single-threaded.
exr = { version = "1.73", default-features = false, features = ["rayon"] }
[dev-dependencies]
proptest.workspace = true
+24
View File
@@ -40,11 +40,13 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| BMP | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| ICO | yes | yes | U8 | RGBA (at most 256×256) | yes | no | no | no | no | no | no | `image` |
| DDS | yes | **no** | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | n/a | built in |
| Netpbm (PBM/PGM/PPM/PAM/PFM) | yes | yes | U8, U16, F32 (float: PFM gray/RGB only) | all six (PAM `CMYK`/`CMYK_ALPHA`) | yes (PAM) | no | no | no | no | no | no | built in |
| QOI | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` |
| FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in |
| DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in |
| AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` |
| HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` |
@@ -61,8 +63,17 @@ the encode plan, and `fidelity_warnings` reports the conversion when it loses in
`Image::convert` applies the same ICC rule: it keeps the profile only when the colour model stays
the same.
Palette BMP and PNG pixels decode as RGB(A) in `Image`; `bmp_palette` and `png_palette` expose the
embedded table (and a PNG's transparent entry) so `photocraft-io` can open them in Indexed Color mode.
## Documented asymmetries and limitations
* **DDS.** The top mip level of a 2D texture: uncompressed pixels described by channel masks
(8, 16, 24 or 32 bits, RGB or luminance, with or without alpha) or BC1/BC2/BC3 (DXT1/DXT3/DXT5),
with the legacy header or the DX10 one. Everything opens as U8; the other mip levels are
ignored. Cubemaps, texture arrays, volume textures, premultiplied DXT2/DXT4 and other formats
(BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing is not provided.
* **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3`
opens as RGB and any other plane count opens the first plane with `DecodeWarning::MorePages`.
Rows follow `ROWORDER` (bottom-up by default, as Siril and DS9 do). Physical values that fit
@@ -71,6 +82,19 @@ the same.
kept as text (`HIERARCH` for long names). Tile-compressed (fpack) files are refused with
`CodecError::Unsupported`; Bayer mosaics open undebayered.
* **DPX.** SMPTE 268M frames in either byte order (`SDPX`, `XPDS`): the first image element,
descriptor 6 (luma), 50 (RGB) or 51 (RGBA), unsigned and unencoded, at 8, 10, 12 or 16 bits.
10-bit data may be packed (0) or filled three to a word (1, method A; 2, method B), 12-bit data
packed (0) or one to a 16-bit word (1, method A). 10- and 12-bit code values open as U16
`round(c × 65535 / (2^b − 1))`, so a frame written back at its depth keeps them exactly. No
colour transform is applied: `DecodeWarning::DpxEncoding` names the transfer characteristic and
colorimetric specification, so a log frame is reported as log. More elements than the first
give `DecodeWarning::MoreElements`; the image orientation is applied on import. Writing is
big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for
U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma.
Other descriptors, signed or run-length-encoded data and other bit depths return
`CodecError::Unsupported` naming the field. Cineon (`.cin`) is not supported.
* **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused
with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
and develops them, and `photocraft-io` routes them there.
+1 -1
View File
@@ -16,7 +16,7 @@ cargo-fuzz = true
libfuzzer-sys = "0.4"
photocraft-codecs = { path = "..", features = ["heif"] }
# Called directly too: the codec turns its panics into errors (see decode_heif.rs).
heic-rs = "=0.1.1"
heic-rs = "=0.1.4"
[profile.release]
debug = 1
+284
View File
@@ -0,0 +1,284 @@
//! DirectDraw Surface (DDS) reader: the top mip level of a 2D texture, either uncompressed
//! (8 to 32 bits per pixel described by channel masks, RGB or luminance) or block-compressed
//! BC1/BC2/BC3 (DXT1/DXT3/DXT5), with or without the DX10 header. Read-only. Cubemaps,
//! texture arrays and volume textures are refused rather than flattened to one face or slice.
use crate::SampleType;
use crate::error::CodecError;
use crate::format::Format;
use crate::image::{ChannelLayout, Image};
use crate::options::Limits;
/// `DDS ` plus the 124-byte DDS_HEADER.
const HEADER: usize = 128;
/// DDS_HEADER_DXT10, present when the FourCC is `DX10`.
const DX10_HEADER: usize = 20;
const DDSD_DEPTH: u32 = 0x80_0000;
const DDPF_ALPHAPIXELS: u32 = 0x1;
const DDPF_FOURCC: u32 = 0x4;
const DDPF_RGB: u32 = 0x40;
const DDPF_LUMINANCE: u32 = 0x2_0000;
const DDSCAPS2_CUBEMAP: u32 = 0x200;
const DDSCAPS2_VOLUME: u32 = 0x20_0000;
const DIMENSION_TEXTURE2D: u32 = 3;
const MISC_TEXTURECUBE: u32 = 0x4;
fn bad(reason: impl std::fmt::Display) -> CodecError {
CodecError::malformed(Format::Dds, reason)
}
fn unsupported(reason: impl Into<String>) -> CodecError {
CodecError::unsupported(Format::Dds, reason)
}
fn le32(b: &[u8], at: usize) -> Result<u32, CodecError> {
let bytes = b.get(at..at + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).ok_or_else(|| bad("truncated header"))?;
Ok(u32::from_le_bytes(bytes))
}
/// A FourCC as text when it is printable, else as the number legacy writers store there.
fn fourcc_name(fourcc: u32) -> String {
let bytes = fourcc.to_le_bytes();
if bytes.iter().all(|c| c.is_ascii_graphic() || *c == b' ') { bytes.iter().map(|&c| char::from(c)).collect() } else { fourcc.to_string() }
}
/// One channel of an uncompressed pixel: where it sits and its largest value.
#[derive(Clone, Copy)]
struct Field {
shift: u32,
max: u32,
}
impl Field {
/// `None` for an empty mask; an error for one with gaps or wider than the pixel.
fn new(mask: u32, bits: u32) -> Result<Option<Field>, CodecError> {
if mask == 0 {
return Ok(None);
}
let shift = mask.trailing_zeros();
let max = mask >> shift;
if max & max.wrapping_add(1) != 0 || (bits < 32 && mask >> bits != 0) {
return Err(bad(format!("channel mask {mask:#x} is not a contiguous field of a {bits}-bit pixel")));
}
Ok(Some(Field { shift, max }))
}
fn get(self, pixel: u32) -> u8 {
let v = u64::from((pixel >> self.shift) & self.max);
let max = u64::from(self.max);
((v * 255 + max / 2) / max) as u8
}
}
enum Encoding {
/// Uncompressed: bytes per pixel, then red (or luminance), green, blue and alpha.
Masks {
bytes: usize,
gray: bool,
fields: [Option<Field>; 4],
},
Bc1,
Bc2,
Bc3,
}
impl Encoding {
fn masks(bits: u32, masks: [u32; 4], gray: bool) -> Result<Encoding, CodecError> {
if !matches!(bits, 8 | 16 | 24 | 32) {
return Err(bad(format!("{bits} bits per pixel is not 8, 16, 24 or 32")));
}
let mut fields = [None; 4];
for (field, mask) in fields.iter_mut().zip(masks) {
*field = Field::new(mask, bits)?;
}
let colour = if gray { fields[0].is_some() } else { fields[..3].iter().any(Option::is_some) };
if !colour {
return Err(bad("the pixel format has no colour channel masks"));
}
Ok(Encoding::Masks { bytes: bits as usize / 8, gray, fields })
}
fn layout(&self) -> ChannelLayout {
match self {
Encoding::Masks { gray: true, fields, .. } if fields[3].is_some() => ChannelLayout::GrayA,
Encoding::Masks { gray: true, .. } => ChannelLayout::Gray,
Encoding::Masks { fields, .. } if fields[3].is_none() => ChannelLayout::Rgb,
_ => ChannelLayout::Rgba,
}
}
/// Bytes of the top mip level.
fn level_size(&self, width: u32, height: u32) -> Option<usize> {
let (w, h) = (usize::try_from(width).ok()?, usize::try_from(height).ok()?);
let block = match self {
Encoding::Masks { bytes, .. } => return w.checked_mul(*bytes)?.checked_mul(h),
Encoding::Bc1 => 8,
Encoding::Bc2 | Encoding::Bc3 => 16,
};
w.div_ceil(4).checked_mul(h.div_ceil(4))?.checked_mul(block)
}
}
/// The legacy pixel format: a FourCC, or channel masks.
fn legacy_encoding(b: &[u8]) -> Result<Encoding, CodecError> {
let (flags, fourcc, bits) = (le32(b, 80)?, le32(b, 84)?, le32(b, 88)?);
let alpha = if flags & DDPF_ALPHAPIXELS != 0 { le32(b, 104)? } else { 0 };
if flags & DDPF_FOURCC != 0 {
return match &fourcc.to_le_bytes() {
b"DXT1" => Ok(Encoding::Bc1),
b"DXT3" => Ok(Encoding::Bc2),
b"DXT5" => Ok(Encoding::Bc3),
b"DXT2" | b"DXT4" => Err(unsupported("premultiplied-alpha DXT2/DXT4 textures are not supported")),
_ => Err(unsupported(format!("compression `{}` is not supported (DXT1, DXT3 and DXT5 are)", fourcc_name(fourcc)))),
};
}
if flags & DDPF_RGB != 0 {
return Encoding::masks(bits, [le32(b, 92)?, le32(b, 96)?, le32(b, 100)?, alpha], false);
}
if flags & DDPF_LUMINANCE != 0 {
return Encoding::masks(bits, [le32(b, 92)?, 0, 0, alpha], true);
}
Err(unsupported(format!("pixel format flags {flags:#x} are not RGB, luminance or a supported compression")))
}
/// The DX10 header's DXGI format, refusing anything but a single 2D texture.
fn dx10_encoding(b: &[u8]) -> Result<Encoding, CodecError> {
let (dxgi, dimension, misc, array) = (le32(b, HEADER)?, le32(b, HEADER + 4)?, le32(b, HEADER + 8)?, le32(b, HEADER + 12)?);
if misc & MISC_TEXTURECUBE != 0 {
return Err(unsupported("cubemaps are not supported"));
}
if dimension != DIMENSION_TEXTURE2D {
return Err(unsupported(format!("only 2D textures are supported (resource dimension {dimension})")));
}
if array > 1 {
return Err(unsupported(format!("texture arrays are not supported ({array} textures)")));
}
const RGBA: [u32; 4] = [0xff, 0xff00, 0xff_0000, 0xff00_0000];
const BGRA: [u32; 4] = [0xff_0000, 0xff00, 0xff, 0xff00_0000];
const BGRX: [u32; 4] = [0xff_0000, 0xff00, 0xff, 0];
match dxgi {
27..=29 => Encoding::masks(32, RGBA, false),
87 | 90 | 91 => Encoding::masks(32, BGRA, false),
88 | 92 | 93 => Encoding::masks(32, BGRX, false),
70..=72 => Ok(Encoding::Bc1),
73..=75 => Ok(Encoding::Bc2),
76..=78 => Ok(Encoding::Bc3),
_ => Err(unsupported(format!("DXGI format {dxgi} is not supported (BC1, BC2, BC3 and 8-bit RGBA or BGRA are)"))),
}
}
pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result<Image, CodecError> {
if !b.starts_with(b"DDS ") {
return Err(bad("missing the `DDS ` signature"));
}
if le32(b, 4)? != 124 {
return Err(bad("header size is not 124"));
}
let (flags, height, width, depth, caps2) = (le32(b, 8)?, le32(b, 12)?, le32(b, 16)?, le32(b, 24)?, le32(b, 112)?);
if caps2 & DDSCAPS2_CUBEMAP != 0 {
return Err(unsupported("cubemaps are not supported"));
}
if caps2 & DDSCAPS2_VOLUME != 0 || (flags & DDSD_DEPTH != 0 && depth > 1) {
return Err(unsupported("volume textures are not supported"));
}
let dx10 = le32(b, 80)? & DDPF_FOURCC != 0 && &le32(b, 84)?.to_le_bytes() == b"DX10";
let (encoding, start) = if dx10 { (dx10_encoding(b)?, HEADER + DX10_HEADER) } else { (legacy_encoding(b)?, HEADER) };
let layout = encoding.layout();
limits.check(width, height, layout, SampleType::U8)?;
let size = encoding.level_size(width, height).ok_or_else(|| bad("pixel data size overflow"))?;
let data = b.get(start..).and_then(|d| d.get(..size)).ok_or_else(|| bad(format!("truncated pixel data: {size} bytes needed")))?;
let (w, h) = (width as usize, height as usize);
let channels = layout.channels();
let total = w.checked_mul(h).and_then(|n| n.checked_mul(channels)).ok_or_else(|| bad("pixel size overflow"))?;
let mut pixels = vec![0u8; total];
match encoding {
Encoding::Masks { bytes, gray, fields } => {
for (src, dst) in data.chunks_exact(bytes).zip(pixels.chunks_exact_mut(channels)) {
let pixel = src.iter().rev().fold(0u32, |v, &byte| v << 8 | u32::from(byte));
let channel = |i: usize| fields[i].map_or(0, |f| f.get(pixel));
let values = if gray { [channel(0), channel(3), 0, 0] } else { [channel(0), channel(1), channel(2), channel(3)] };
dst.copy_from_slice(&values[..channels]);
}
}
Encoding::Bc1 | Encoding::Bc2 | Encoding::Bc3 => {
let block_size = if matches!(encoding, Encoding::Bc1) { 8 } else { 16 };
let blocks_x = w.div_ceil(4);
for (i, block) in data.chunks_exact(block_size).enumerate() {
let texels = match encoding {
Encoding::Bc1 => bc1(block, true),
Encoding::Bc2 => with_alpha(bc1(block.get(8..).unwrap_or_default(), false), bc2_alpha(block)),
_ => with_alpha(bc1(block.get(8..).unwrap_or_default(), false), bc3_alpha(block)),
};
let (bx, by) = ((i % blocks_x) * 4, (i / blocks_x) * 4);
for (t, texel) in texels.iter().enumerate() {
let (x, y) = (bx + t % 4, by + t / 4);
if x < w && y < h {
let at = (y * w + x) * 4;
if let Some(dst) = pixels.get_mut(at..at + 4) {
dst.copy_from_slice(texel);
}
}
}
}
}
}
Image::from_u8(width, height, layout, pixels)
}
fn rgb565(c: u16) -> [u8; 3] {
let scale = |v: u16, max: u16| ((u32::from(v) * 255 + u32::from(max) / 2) / u32::from(max)) as u8;
[scale(c >> 11, 31), scale((c >> 5) & 63, 63), scale(c & 31, 31)]
}
/// A BC1 colour block. BC2 and BC3 colour blocks always have four colours; only BC1's
/// `c0 <= c1` order selects three colours and transparent black.
fn bc1(block: &[u8], one_bit_alpha: bool) -> [[u8; 4]; 16] {
let byte = |i: usize| block.get(i).copied().unwrap_or(0);
let (c0, c1) = (u16::from_le_bytes([byte(0), byte(1)]), u16::from_le_bytes([byte(2), byte(3)]));
let (a, b) = (rgb565(c0), rgb565(c1));
let mix = |wa: u32, wb: u32| {
let d = wa + wb;
let at = |i: usize| ((wa * u32::from(a[i]) + wb * u32::from(b[i]) + d / 2) / d) as u8;
[at(0), at(1), at(2), 255]
};
let palette = if c0 > c1 || !one_bit_alpha {
[[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(2, 1), mix(1, 2)]
} else {
[[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(1, 1), [0; 4]]
};
let indices = u32::from_le_bytes([byte(4), byte(5), byte(6), byte(7)]);
std::array::from_fn(|t| palette[(indices >> (2 * t)) as usize & 3])
}
/// BC2: four explicit bits of alpha per texel.
fn bc2_alpha(block: &[u8]) -> [u8; 16] {
let bits = u64::from_le_bytes(std::array::from_fn(|i| block.get(i).copied().unwrap_or(0)));
std::array::from_fn(|t| ((bits >> (4 * t)) & 0xf) as u8 * 17)
}
/// BC3: two alpha endpoints and a 3-bit index per texel into eight values, or into six plus
/// 0 and 255 when `a0 <= a1`.
fn bc3_alpha(block: &[u8]) -> [u8; 16] {
let byte = |i: usize| block.get(i).copied().unwrap_or(0);
let (a0, a1) = (u32::from(byte(0)), u32::from(byte(1)));
let mut palette = [a0, a1, 0, 0, 0, 0, 0, 255];
if a0 > a1 {
for (k, v) in palette.iter_mut().enumerate().skip(2) {
let k = k as u32;
*v = ((8 - k) * a0 + (k - 1) * a1 + 3) / 7;
}
} else {
for (k, v) in palette.iter_mut().enumerate().take(6).skip(2) {
let k = k as u32;
*v = ((6 - k) * a0 + (k - 1) * a1 + 2) / 5;
}
}
let bits = u64::from_le_bytes([byte(2), byte(3), byte(4), byte(5), byte(6), byte(7), 0, 0]);
std::array::from_fn(|t| palette[((bits >> (3 * t)) & 7) as usize] as u8)
}
fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] {
for (texel, a) in texels.iter_mut().zip(alpha) {
texel[3] = a;
}
texels
}
Loaded 100 of 717 files, more files were not shown because too many files have changed in this diff. Show more