Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 ffd811d679 feat(save): File › Save keeps a JPEG's quality and a WebP's lossy/lossless encoding (#2399)
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 04:13:51 -04:00
6a852f8852 fix(ui): correct the clipping-mask indicator orientation (#3037)
* fix(ui): correct the clipping-mask indicator orientation

* Drop the before/after screenshots from the repository

Docs screenshots stay out of the tree; the PR description keeps them.

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 02:10:18 -04:00
Dan HiornsandClaude Opus 5.5 cb1c06fc57 GPU memory budget: read installed RAM on Windows (#3189)
physical_memory() had no Windows implementation, so the wgpu
compositor's budget capped layer textures at a quarter of an assumed
16 GB on every Windows machine: too little on a large one (4 GB of a
62 GB PC's 8 GB Memory Usage allowance) and too much on an 8 GB laptop
whose integrated GPU shares that RAM. Read it with GlobalMemoryStatusEx
through winsafe, the safe binding the desktop app already uses (same
pinned version, kernel feature only).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 01:43:06 -04:00
f14817a5d2 Animated GIF: every frame loads into video layers (#572) (#2999)
* feat(codecs): decode every frame of an animated GIF (#572)

decode_gif_frames returns each frame composited onto the logical screen
(disposal methods applied) with its delay in milliseconds. decode still
keeps the first frame only. A damaged later frame keeps the frames before
it and marks the first with DecodeWarning::Truncated; dimensions, the
total decoded bytes and a frame cap are checked against the limits.

* feat(io): import_gif_frames hands each GIF frame over as a document (#572)

A frame at a time becomes a single-layer document, so a caller can
flatten and drop it instead of holding every frame twice.

* feat(engine): animated GIFs load every frame into video layers (#572)

New Video Layer from File, Replace Footage, Reload Frame and Video Frames
to Layers take all frames of a GIF instead of the first. The timeline rate
comes from the GIF's delays (the most common one, delays under 20 ms shown
at 100 ms as browsers do); an explicit fps still wins.

* docs: animated GIF frames in video layers (#572)

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 01:26:37 -04:00
skylar taylor-barrick 61b6276c78 Security: harden document exports and automation inputs (#2269)
* chore: clear strict Clippy simplifications

* security: harden web exports and generated image assets

* security: reject redirected directory bundle writes

* security: quote paths in droplet shell launchers

* security: use private ephemeral print spool files

* security: bound automation file reads and MCP input

* security: bound MCP transport requests before dispatch

* chore: keep current sample checks Clippy clean

* security: preserve safe export roots and print semantics

* security: clear non-sending print state before replay

* security: forget print send overrides between jobs

* chore: keep macOS UI tests and Clippy clean

* Test web export paths portably on Windows
2026-10-11 23:18:52 -06:00
Sir Starch b4a949225d Support automatic monitor ICC profiles on Linux X11 (#2963)
* Linux X11: detect and apply monitor ICC profiles

* Test Linux monitor ICC profile handling

* Gate display detection test to supported platforms
2026-10-12 01:17:59 -04:00
Sir Starch e8c7656150 Use each XRandR output's ICC profile in the screen picker (#2962)
* Screen picker: use each XRandR output's ICC profile

* Test X11 screen picker ICC validation
2026-10-12 01:09:03 -04:00
Sir Starch 82c58fabab Use Fontconfig for Linux font discovery (#2330) (#2961)
* Fix #2330: discover installed fonts through Fontconfig

* Fix wasm cfg for Fontconfig fallback

* Add Linux font discovery regression tests

* Reject Fontconfig matches without CJK coverage
2026-10-12 01:05:56 -04:00
5e0a6d404d Tools: Shift key switches tools by default, as in Photoshop (#3525) (#3528)
* Tools: Shift key switches tools by default, as in Photoshop (#3525)

Use Shift Key for Tool Switch is on by default in Photoshop: T keeps the
current tool, Shift+T cycles. Saved settings keep their stored value.

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

* Tests: the M-key marquee cycling test sends Shift+M, the new default (#3296)

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 00:54:41 -04:00
FinnNkandClaude Opus 5.5 962e0a2f98 feat(ui): Photoshop's New Guide: units and colour (#3553)
* feat(engine): accept Photoshop units for guide positions

view.newGuide and view.moveGuide take the position as before (a number
of pixels) or as text with a unit, as Photoshop's New Guide dialog does:
"50%", "2.5 cm", "1in", "12pt", "3pc", "100px". Percent is of the
document's width for a vertical guide and its height for a horizontal
one; points and picas follow Units & Rulers › Point Size. Fractional
positions are kept. Text that isn't a length, NaN and positions beyond
1,000,000 px are refused without recording a history step.

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

* feat(engine): guides can have their own colour

Photoshop's New Guide sets a colour per guide; PhotoCraft guides only had
the Preferences guide colour. Guides now keep optional colours, keyed by
orientation and position so code that drops or reorders guide positions
(crop, artboards, guide layouts) can never move a colour onto another
guide: an unmatched guide just uses the preference colour.

view.newGuide takes an optional "color": a name from New Guide's list
(lightBlue, lightRed, green, mediumBlue, yellow, magenta, cyan, lightGray,
black) or "#rrggbb". Without one the guide uses the preference colour, as
guides dragged from the rulers always have. Moving a guide carries its
colour; deleting and clearing drop it (Clear Guides, Clear Canvas Guides,
Clear Selected Artboard Guides and New Guide Layout's Clear Existing
Guides alike); undo restores it. Guides are drawn in their own colour
(while New Guide Layout previews, the rest show the colour it will set), and .pcraft files keep it (omitted when no guide has
one, so older readers and files are unaffected). PSD export keeps guide
positions only.

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

* feat(ui): Photoshop's New Guide dialog: units and colour

View › Guides › New Guide… was the generic schema dialog, whose position
field was whole pixels only and had no colour. It now has its own
dialog, as in Photoshop:

- Position opens in the ruler unit ("0 cm") and takes any unit
  Photoshop's field takes ("50%", "2.5 cm", "12 pt"); a bare number is
  in the ruler unit. The last guide reopens in whatever the ruler unit
  is then ("5.08 cm").
- Color lists Custom…, Light Blue, Light Red, Green, Medium Blue,
  Yellow, Magenta, Cyan (the default), Light Gray and Black. Custom…
  shows a swatch that opens the Color Picker. The guide stores the
  chosen colour itself, so a later change of the preference guide
  colour leaves it alone.
- A position the engine refuses reopens the dialog with what was typed
  and the engine's message, as Photoshop keeps New Guide open.

Light Blue, Light Red, Medium Blue and Light Gray are new to the
catalogues; they were translated for all 16 languages from the existing
colour terms in each and would benefit from native review.

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

* refactor(engine): one unit-suffix table for typed lengths

New Guide's position field (Unit::parse_length) and the Crop tool's size
fields (crop_size::unit_of) each kept the same table of unit suffixes.
Unit::from_suffix is now the one table: px, in or ", cm, mm, pt, pc or
pica, % and their long forms, in any case. The crop fields keep their own
number parsing (they accept arithmetic) and still refuse percent; they now
also accept "pc" for picas.

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

* feat(ui): New Guide and New Guide Layout share one colour list

New Guide Layout (#2930) offered five colours of its own; New Guide offers
Photoshop's nine. Both dialogs now list the same nine (GUIDE_COLORS), in
Photoshop's order and with the same translated names. Cyan (#4affff, the
default guide colour), Magenta (#ff00ff) and Yellow (#ffcc00) take New
Guide Layout's values; Light Blue, Light Red, Green, Medium Blue, Light
Gray and Black keep New Guide's. New Guide Layout's Blue (#378ef0) and
jade Green (#00a86b) give way to Photoshop's Light Blue and Green; a colour
outside the list still shows as Custom.

GUIDE_COLORS drops its unused label column (the dialogs translate the
names), and New Guide Layout reads "#rrggbb" with prefs::parse_hex instead
of its own copy (rulers::parse_guide_color).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 00:46:56 -04:00
F4LCON 973e7776ea History panel: snapshots (camera button, snapshot rows) (#3545)
Part of #2966. The History panel's camera button takes a named snapshot
of the document ("Snapshot N", not a history state). Snapshots are
listed under the opening state with a thumbnail and scroll with the
states; clicking one returns the document to it as one new, undoable
state named after it. A double-click or right-click › Rename… renames
it in place, and right-click › Delete removes it. Snapshots live for the
session only, as in Photoshop.

For agents: history.newSnapshot {name?, from?: fullDocument |
mergedLayers | currentLayer}, history.selectSnapshot, renameSnapshot and
deleteSnapshot, addressed by ids that are never reused, and doc.inspect
reports snapshots.
2026-10-12 00:35:56 -04:00
skylar taylor-barrick 2bf49f10a7 Security: verify AppImage build inputs (#2270)
* security: pin and verify release build inputs

* docs: explain verified offline AppImage inputs

* security: validate executable AppImage tooling offline

* security: preserve read-only verified AppImage caches

* test: cover verified runtime downloads and cache diagnostics

* test: assert verified runtime mode and cache diagnostics

* Fix shellcheck ordering in verified download test

* Use external chmod for cache test setup
2026-10-11 22:33:26 -06:00
Valentin KleinandClaude Opus 5.5 80d646fc17 Brush: draw the smoothing string from the brush to the pointer while drawing (#3479) (#3526)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 00:26:04 -04:00
Scott C Hughes ee05bc833b fix(io): a fresh Metrics type layer writes no empty Txt2 stub (#3514) (#3529)
PSD export always wrote a document-level Txt2 block, and for a fresh document its
document-resources dictionary (/0) was the empty `<< >>` Photoshop never writes. Photopea's
reader fails on it ("Cannot read properties of undefined (reading '_Resources')"), so a PSD
saved with a new type layer stopped opening there.

Write the block only when it carries something: the file already had one (round-trip its pen
positions and extras), or a layer uses the Optical auto-kern mode - the one mode EngineData's
AutoKerning cannot distinguish (#1348). A plain Metrics document now writes no block, as 0.3.0
did.

Tests: text needs_txt2_only_for_optical; io fresh_metrics_text_writes_no_txt2_block.
2026-10-12 00:18:14 -04:00
UnemarsandClaude Opus 5.5 6ecad20132 Content-Aware tools: faster, cancellable at once, same pixels (P21, P22, P49) (#3395)
* Content-Aware tools: faster, cancellable at once, same pixels

#2202 matched Photoshop's Content-Aware Fill but made the perf nightly slower (#2575): P21
Content-Aware Fill 10 MP 5x, P22 Spot Healing 2.4x, P49 cancel on 24 MP 4x and over its 200 ms
budget. This keeps every result bit for bit and takes the time back:

- The solver works over the hole's bounds grown by a patch: match field, costs, votes and the
  per-target tables are kept there (nothing outside changes or is matched), the vote computes
  each target's weight once and writes into a buffer instead of a Vec per pixel.
- Colour adaptation reuses each patch's own moments (target sums kept per EM step, source sums
  per level where they pay off), summed in the same order as before.
- Exact early outs: SSD per pixel instead of per row (callers only ask "better?"), and the onion
  peel's exhaustive search starts from where the filled neighbours took their sources and stops a
  source once it can only lose (first-of-equals kept).
- PatchMatch sweeps a band in wavefronts of blocks when there are fewer bands than threads: each
  cell sees the same neighbours and random numbers (SplitMix64 skip) as in raster order.
- Parallel pyramid, valid-source and target setup; no copy of the window when there are no
  variants (fill_owned / complete_masked_owned); the job reads the window in parallel bands and
  keeps only the selection's bounds for the apply step.
- Cancellation is checked per row everywhere, also in Auto sampling and in its max flow
  (maxflow_with / grid_cut_cancellable): a 24 MP job now stops in tens of milliseconds.
- algo::par: rayon on native, sequential on wasm.

Proof: the new `content_aware_bench` example hashes every tool's output (Fill with each sampling
and adaptation option, Edit > Fill, Delete and Fill, Spot Healing x3, Patch, Content-Aware Move,
8/16/32-bit, Gray/CMYK/Lab, and the benchmarks' 10 and 24 MP fills): 117 hashes identical to
main, also with 1, 2 and 4 threads. New tests compare the onion peel with a plain scan, full and
clipped patch distances and moments, the valid-source runs with the summed-area test, thread
counts 1/3/16, in-place and copied fills, and cancellation of Auto sampling and max flow.

perf_scenarios --cpu, Ryzen 7 5800X shared with other work (load 4-7), 3 interleaved rounds:
P21 p50 295-306 -> 167-171 ms (max 342-362 -> 180-184); P22 p50 67-68 -> 38-40 ms (max 69-71 ->
40-41); P49 max 378-389 -> 54-77 ms (p50 94-110 -> 47). retouch_bench: Content-Aware Move
-47..-64 %, Spot Healing Content-Aware -40..-61 %.

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

* Content-Aware: faster Spot Healing dabs than main with #2805, same pixels

Against main with #2805 the branch was slower for small Spot Healing dabs (size 50:
+25-30 %, single-threaded +60 %): its onion peel walked all 49 offsets of every candidate patch,
checking each against the known pixels, and had no bound at the hole's edge.

- Onion peel: the target's known pixels are gathered once (#2805), the search starts from the
  filled neighbours' shifted sources or every 16th source at the hole's edge (#2805), the limit
  a candidate must stay under is a precomputed sum (no division per pixel), and candidates are
  summed four at a time in step: their sums are independent, so the processor overlaps them.
  Each is still given up only against the best from before the four, which can only improve.
- Vote: each target's weight and match centre are taken once; a pixel's patches are visited
  over the clipped offset range, with the channel count and the colour adaptation fixed at
  compile time. Same sums, same order.
- Valid sources: sliding counts per row and stacked runs per column instead of a 7-row check
  per pixel; the k-th source is found through per-row and per-64-column counts, so no level
  but the coarsest lists them all.
- `integral` stays out of line: inlined into `synthesize` it made Create Texture slower.

Same results: `content_aware_bench -- hash` (112 cases) and `-- hash-big` (the benchmarks' 10 and
24 MP fills) equal main's, also with 1 and 3 threads; the onion peel tests (#2805's and ours)
compare with the plain scan. `content_aware_bench bench spot` runs retouch_bench's Spot Healing
dabs; with `CAF_SPOT_TYPE` (comma-separated) other types, with page faults per dab.

Ryzen 7 5800X, shared (load 5-20), interleaved with main (3af4c46a), min of rounds,
retouch_bench, all threads / one thread:
Spot Healing Content-Aware 50: 49 -> 31 / 204 -> 117 ms; 100: 62 -> 35 / 164 -> 98 ms;
200: 118 -> 67 / 250 -> 158 ms; 300: 212 -> 119 / 411 -> 285 ms. Content-Aware Move -47..-62 %
(one thread -23..-45 %). Rows that don't run this code stay within noise, except Proximity
Match 200 after the Content-Aware dabs in the same process (+8..23 %): it takes ~5800 page
faults there against ~300 on main, because main's heavier dabs left more of the heap faulted
in; alone, or after Content-Aware dabs in `bench spot`, it is equal.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 00:11:10 -04:00
b14037083d Brush and Pencil: hold ~ to erase, and Clear mode (#3439) (#3465)
* Brush and Pencil: hold ~ to erase

Fixes #3439. Holding ~ (the backtick key) with the Brush or Pencil makes
the strokes started while it is held erase, with that tool's own brush,
as in Photoshop; releasing it paints again and the tool never changes.
The key is a Tools › Temporary binding (tools.temporary.erase), so it
can be rebound or removed in Keyboard Shortcuts.

* Brush and Pencil: Clear painting mode, used by ~

Photoshop's Clear joins the Brush's and Pencil's Mode menus, after
Dissolve: chosen there, every stroke erases with that tool's brush until
another mode is picked. Holding ~ now puts the stroke in Clear mode too,
and the Mode menu shows Clear while the key is held. History names the
tool rather than the Eraser. Agents get it as "mode": "clear" on
paint.stroke and paint.pencil.

* Keep the Smoothing doc comment on smoothing_field

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 00:02:42 -04:00
MrCloudy2andClaude Opus 5.5 7e5d2ce6da Warp presets: Photoshop's Bezier control grids instead of approximations (#3444)
The 15 warp presets (Edit > Transform > Warp, smart object warps, Warp
Text) were our own closed-form approximations, up to 70 px off
Photoshop's: Arc kept its centre fixed and pulled the ends in where
Photoshop keeps the inner edge's corners and raises the middle, Flag
waved the wrong way at a third of the height, Squeeze shrank instead of
pinching.

Measured on Photoshop 27.11 (scripted renders of a grid image whose
colours encode each pixel's position, 102 cases: every preset at bend
±50 and ±100, both distortions, vertical orientation, square, wide and
tall boxes), every preset is a single bicubic Bezier patch (fits within
0.5 px). Its control grid:
- Arc: each row a circular arc of b·π around one centre, the inner edge
  keeping its chord; Arch, Bulge, Arc Lower/Upper and Shell built from
  the same arcs. Circular in pixels on any box.
- Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist: control
  points moved linearly with the bend, in box units.
- Vertical distortion scales the top and bottom rows about their
  midpoints and blends the rows between; horizontal distortion scales
  each column. Vertical orientation swaps the axes and the two
  distortions.
All 102 cases land within 1 px (median) of Photoshop. On the corpus's
Photoshop-made warped text, warp-arc-50 now spans 340x85 at (-3, 3)
against Photoshop's 350x88 at (-7, 3), where it was 278x74 at (28, 62).

Presets also convert to custom meshes exactly now (to_mesh splits the
patch instead of fitting). Tests that pinned the old shapes now assert
Photoshop's; presets_match_photoshop checks measured points.

Still open: Warp Text warps over our line box, while Photoshop uses the
type layer's stored `bounds`; with those the five corpus warp files land
within a few px (arc, flag, bulge with distortion, twist, vertical rise).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 23:56:17 -04:00
David MannandClaude Opus 5.5 bea81c2f3d Save: show save progress as Saving NN% in the status bar and tab title (#3389) (#3485)
Background saves only ever reported 0 % ("Encoding", then "Writing"), because
photocraft_io::export produced all the bytes in one call: the status bar showed an
indeterminate bar and no percentage. Now:

- photocraft_io::export_with(doc, name, opts, &Interrupt) reports encoding progress
  (export keeps its signature and calls it with Interrupt::NONE, same bytes): PSD/PSB
  per layer record and per composite band, layered TIFF the same plus its encoder
  stages, .pcraft after its previews, flat formats once the composite is rendered.
  Cancelling stops the conversion early and returns IoError::Cancelled.
- photocraft_format::atomic_write_progress writes in 8 MiB chunks and reports the
  fraction written.
- The desktop save maps encoding to 0..85 % and writing to 85..99 %, 100 % once the
  file is flushed and renamed.
- The status bar reads "Saving 45%" for a save (file name on hover), and the
  document's tab title gets " - Saving 45%" in both tab strips, as in Photoshop.
  Percentages round down so 100 % only shows when done.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 23:49:53 -04:00
MrCloudy2andClaude Opus 5.5 3794ad471f Color Balance: Photoshop's per-channel levels and gamma curves (#3443)
Color Balance added a share of each slider weighted by the pixel's
lightness, then (Preserve Luminosity) rescaled to the old luma. On the
corpus's Photoshop-made file (photoshop/adjustments/rgb8/color-balance.psd,
all three tones moved, Preserve Luminosity on) that was up to 43 levels
off on 99% of the pixels.

Measured on Photoshop 27.11 (scripted: a gray ramp and 16 pure colours
through each slider at ±25, ±50 and ±100, with Preserve Luminosity on
and off, plus two and three tones and two sliders at once), every
slider is a levels-and-gamma curve on each channel:
- Preserve Luminosity off: midtones is gamma 2^-a on the slider's own
  channel; shadows gamma 2^(-a/2), after a black point at 0.39·|a| when
  lowered; highlights gamma 2^(-a/2), after a white point at 1 - 0.39·a
  when raised.
- Preserve Luminosity on: midtones gamma 2^(-a/2) on its channel and
  2^(a/2) on the other two; shadows and highlights go by the differences
  between a tone's sliders: a black point at 0.39·(max - own) and a
  white point at 1 - 0.39·(own - min) per channel.
- Tones apply highlights, shadows, then midtones.
All 88 measured cases match within 1 level, except midtones +100 below
about 28 of 255 (Photoshop flattens the steepest gamma near black, by
up to 9 levels). The corpus file is now within 4 levels (mean 1.4, was
20.9).

The GPU builds the same curves into per-channel tables and uses the
Levels/Curves kernel; the old Color Balance shader case is gone.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 23:45:58 -04:00
skylar taylor-barrick d380309338 CI: restore tests, catalogs, and web size gate (#3923)
* ci: restore test and Clippy gates

* ci: keep browser wasm below size gate

* CI: restore plural catalog and MCP resource test gates

Adapted the catalog, parser, and shared-cache test fixes from PhotoCraft PR #3821 by @bommerts. The source-key test, bundled-catalog test, and MCP resource test pass locally.

* CI: expect saved native document name in CLI test

The native save path intentionally writes the output filename into the reopened bundle. Keep the pre-save Run assertion and assert r.pcraft after reopening.

* CI: compare orientation round trips without saved filenames

Native saves encode the output filename in document name. Check each expected name, then compare the remaining document fields and pixels.
2026-10-11 21:37:09 -06:00
MrCloudy2andClaude Opus 5.5 9237a8bdde PSD gradients: read the interpolation method Photoshop writes; add Smooth (#3442)
Gradient fills and gradient effects read their interpolation method from
`gs99`, a key Photoshop doesn't write. Gradient fill layers saved by
Photoshop 27.11 store it as the enum `gradientsInterpolationMethod`
(Perc, Lnr , Smoo, Gcls), so every Perceptual, Linear or Smooth gradient
opened as Classic. It shows once the fill is re-rendered (any edit):
measured against Photoshop's own composite of the saved file, a
black→white Linear fill was up to 74 levels off and Perceptual 31; both
are now within 1. `gs99` stays as a fallback.

Smooth, Photoshop's default method since 2024 (also for Gradient Map),
had no model. Measured on Photoshop 27.11 (scripted gradients on three
colour pairs), it runs straight across each segment in Oklab with its
own easing, the same curve for every pair, and Smoothness doesn't
change it. The curve fits no simple formula, so it is a 17-point
measured table. Two-stop Smooth gradients are within 2 levels (Classic
was 31 to 83 off); with three or four stops within 11 to 25 (Classic 48
to 106), as Smooth also smooths across stops in a way not modelled yet.

Corpus: no file changes status; psd-tools effect-enums.psd (Smoo
gradient effects) goes from 7.10% to 6.13% differing pixels.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 23:33:46 -04:00
David MannandClaude Opus 5.5 39aa849b47 Raw: open monochrome LinearRaw DNGs (1 sample per pixel) (#3497) (#3503)
A one-sample LinearRaw DNG (VueScan film scans, Leica Monochrom) was
rejected as "LinearRaw data with 1 samples per pixel". The DNG decoder
now accepts it and develop renders it as neutral grey: the one sample
fills all three channels, white balance, colour matrices and camera
looks do not apply, and the output is R = G = B in 16-bit ProPhoto.
io skips the preview colour fit for such files.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 23:33:40 -04:00
Valentin KleinandClaude Opus 5.5 c20b81b251 Autosave: best-effort cleanup after commit; sweep orphaned recovery bundles (#3489) (#3502)
save_dir removed unreferenced objects with `remove_file(..)?` after the
manifest commit, so a vanished or locked file made a completed save
(autosave) report failure. The cleanup is now best-effort.

RecoveryStore::new sweeps recovery data nothing could load or clean up:
bundles without a sidecar (crash during a first autosave) and
*.pcsave.tmp files of other processes, only once untouched for 24 hours
so another instance's in-progress save is never touched.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 23:31:23 -04:00
4030096482 perf: Edit > Clear works tile by tile on large selections (#3463) (#3482)
Clear (and the background-colour fill it does on a Background layer)
read the whole selection bounds into one float buffer, looked the
selection up in its tile map once per pixel and wrote every tile back,
single-threaded. On an 8000x6000 layer that took about 2 s (3 s on the
Background). It now edits the encoded tiles in parallel, drops fully
cleared tiles, shares one solid tile for fully covered opaque fills and
copies a tile only when a pixel in it changes. Results match the old
pass exactly; 20-110 ms on the same documents.

Fixes #3463

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 23:29:25 -04:00
siraxeandJJ 80b8deab0f Brush cursor: shaped tips trace their outline (split from #2516) (#3467)
Add the brush-outline half of #2516 on its own, so review and merge stay
small; the last-brush persistence half follows separately.

- paint: new `outline` module traces the tip shape as closed rings in
  radius units -- a computed ellipse for round tips (rotated/flattened),
  marching squares over the bitmap for sampled/stored tips, at the full
  tip or the 50 % alpha contour. Traced rings stay finite and bounded for
  hostile sizes; tested for the half-coverage contour, blank tips and
  hostile input.
- ui-egui: new `brush_cursor` picks the traced outline when the tip has a
  shape and falls back to the plain circle otherwise; `tool_cursor` gained
  the `Outline` shape (bitmap rasterisation by segment splatting, painter
  fallback on non-Windows).
- The Normal Brush Tip circle now goes through `half_coverage_radius`
  (#2744) so outline and circle agree on the real 50 % contour.

Review fixes from #2516:
- Merged main; docs/scorecard.md is main's version and was verified with
  `cargo xtask scorecard --check` (regeneration changes nothing).
- Dropped the scratch benchmark `crates/paint/examples/tmp_outline_bench.rs`
  and the two `#[ignore = "temp bench"]` tests in tool_cursor/tests.rs.

Co-authored-by: JJ <[email protected]>
2026-10-11 23:23:29 -04:00
Fabian GhenofandClaude Opus 5.5 17855ab3d1 Commands check an explicit "layer" instead of whichever layer is active (#3466)
A command whose params document "layer":id acts on that layer, but its
availability check looked at the active layer. With another layer selected,
`layer.smartObjects.replaceContents {"layer": N, "path": …}` failed with "the
active layer is a Group layer, not a smart object", although layer N is a
smart object; likewise Rasterize, Export Contents, Merge Down and the rest.
Scripts, recorded actions and MCP agents hit this whenever the selection
differs from the layer they name.

Two allow-lists covered part of it: #466 (smart filters, Edit Contents,
Convert to Layers) and #2457 (hide, show, rename with nothing selected). The
#466 check now applies to every command that documents "layer":id (210
today; the old list is a subset): the precondition runs with the named layer
as the active one, then the active layer is restored. A name that isn't a
layer of the active document falls back to the usual check, so bad ids still
fail as before. It moves from smart_cmds to Session, as it isn't specific to
smart objects any more.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 23:17:45 -04:00
Yousuf 'Jay' Philips afac33f8d7 Fix GIMP hole heading name and location (#3517) 2026-10-11 23:12:04 -04:00
DomiLuebbenandClaude Sonnet 5.5 5d8e5c138b Copy takes every selected layer, Paste recreates them (#2478) (#3394)
* fix(clipboard): Copy takes every selected layer, Paste recreates them (#2478)

With several layers selected and no pixel selection, Edit > Copy only
copied the active layer's pixels, so Paste made one layer. Photoshop
copies all selected layers and pastes them back as layers.

Copy now puts the selected layers (those no selected group already
carries, bottom-to-top) on the session clipboard as whole layers, which
Paste already knew how to recreate (paste_layers). The OS clipboard still
gets the merged pixels. A pixel selection keeps the old behaviour (the
active layer's selected pixels), and the Background in the set pastes as
an ordinary, unlocked layer, as Duplicate Layer makes it.

Tests: several layers round-trip in one undo step, a pixel selection
still copies one layer, and the Background case.

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

* test(clipboard): several copied layers paste into another document (#2478)

Copy two layers in one document, paste them into a 16-bit and a 32-bit
document: both arrive in order, converted to the target depth with their
own pixels, as one undo step, and the source document is untouched.

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

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-11 23:08:12 -04:00
prekabrekiandprekabreki 9829d7ea37 Duplicate Layer from the menu opens a dialog for the copy's name and destination document (#3348) (#3440)
Co-authored-by: prekabreki <[email protected]>
2026-10-11 23:02:59 -04:00
f0322f74e8 fix(selection): scope Color Range to the active selection (#3313) (#3366)
* fix(selection): keep Color Range inside active selection and reject out-of-bounds samples

* fix(ui): scope Color Range dialog and canvas previews to the active selection

* test(selection): cover Color Range scoping, sampling, invert, Add and undo

* test(ui): ensure Color Range preview and pipette honor existing selection

* test(selection): avoid Debug bound when asserting prepared-query error

* test(selection): validate malformed Color Range sample orders without selection interference

* Color Range: format, and scope the OK undo test's prior selection (#3313)

The existing ok_applies_one_undo_step test picked a prior selection that
excluded the red it then expected Color Range to select; with Color Range
scoped to the active selection it now selects the red inside it.

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-11 22:52:08 -04:00
kotulskyandkotulsky 51c671c5a0 fix(selection): load shape and path thumbnails with Ctrl-click (#3449)
Load Work Path, saved path and layer path thumbnails as selections on
Ctrl/Cmd-click. Support Shift to add, Alt to subtract and Shift+Alt to
intersect, without changing the selected path or treating the click as
Save Path. Clicking the row name keeps its existing selection behavior.

Render uncached shape transparency instead of selecting the entire canvas.
Add regression tests for thumbnail hit testing, active-layer preservation,
selection combinations, undo/redo and 8/16/32-bit uncached shapes.

Co-authored-by: kotulsky <[email protected]>
2026-10-11 22:51:42 -04:00
Mr-Pearce 8f102b0222 feat(api): add file path to document.inspect (#3398) 2026-10-11 22:34:03 -04:00
piero-83 23417a60b6 fix(engine): create new layer masks at the document depth (#3359)
* fix(engine): use document depth for new pixel masks

* test(engine): check mask depth and selection coverage

* test(engine): preserve mask edits across native and PSD round trips
2026-10-11 22:30:26 -04:00
Thomas Han fb36a3fdc4 fix(presets): never overwrite preset or actions files that failed to load (#3346)
* fix(presets): never overwrite a brush group file that failed to load

A group file skipped at load (corrupt, newer version, missing tips) was replaced on the next sync with only the new presets. Remember skipped files and leave them unchanged with a warning.

* fix(presets): never overwrite an actions.json that failed to load

A corrupt, oversized or newer-version actions.json loaded as an empty list, and the next action change rewrote it with only the new actions. Remember the skip and leave the file unchanged with a warning.
2026-10-11 22:25:31 -04:00
Valentin KleinandClaude Opus 5.5 60ef9d836f Format: reopening a .pcraft shares deduplicated tiles again (#3315) (#3339)
Saves store each distinct tile once (a solid fill shares one Arc<Tile>), but
the loader fetched, decompressed and charged max_total_bytes once per tile
reference and gave every coordinate its own copy. A solid canvas above
~65536² (8-bit) failed to reopen with "bundle expands beyond 17179869184
bytes", and smaller ones lost their sharing (a solid 30000² 32-bit document
went from about 1 MB to about 14 GB in memory).

The loader now keeps the decoded tiles of the current load by sample type and
content hash: each distinct tile is decoded, hash-checked and charged against
the budget once, and every further reference gets the same copy-on-write Arc.
The budget still bounds distinct decoded bytes; extra references cost a map
lookup and an Arc clone each, bounded by max_manifest_bytes.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 22:21:45 -04:00
alexanderalber 980cf05be4 Group effects follow their children's effects, not just their pixels (#3274) (#3326)
A group's effect region and the empty-tile check were computed from
layer_bounds, which for a group is the union of its children's pixels.
A child's outside stroke reaching past that region was cut off on the
canvas (GPU) and in tiled CPU renders, and the group's drop shadow
followed the cut shape.

New photocraft_compose::effect_source_bounds: for a group, the children
grown by their own effect reach. Used by compose::effect_region,
compose::empty_in and gpu::bounds::effect_region.

generated with Claude Code
2026-10-11 22:20:23 -04:00
538c5020b2 New Document: a real Recent category of recently created documents (#3131) (#3135)
File › New › Recent was a hard-coded pair of presets plus Clipboard. Each
document created with the dialog now records its settings (size,
resolution, mode, depth, background, display units) through the new
document.recent.add command: newest first, equal entries collapsed, at most
20, persisted with the presets (a damaged entry is dropped on load, the rest
kept). The Recent tab lists Clipboard, those documents, then the defaults
they don't cover; a click restores every setting, a double-click creates,
and the grid scrolls past three rows. document.recent.list exposes the list
to agents; creation stays in file.new. Closes scorecard row UI-217-12.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 22:17:17 -04:00
KnottyDyesandClaude Opus 5.5 48c1721470 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]>
2026-10-11 22:10:12 -04:00
myangaa1 27222cf4b7 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.
2026-10-11 22:05:50 -04:00
0e2c34af3e 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]>
2026-10-11 22:02:04 -04:00
Valentin KleinandClaude Opus 5.5 638b5cc4e6 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]>
2026-10-11 21:40:37 -04:00
7fd53f0e3f Puppet Warp: the mesh follows the object outline (#3105) (#3117)
The mesh was every square grid cell touching the layer's opaque pixels:
a staircase up to a cell outside the shape, and shapes closer than about
a cell shared cells, so pinning one dragged the other along.

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

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

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 21:40:06 -04:00
9a9d23d258 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]>
2026-10-11 21:27:08 -04:00
Nigel Ihza Firdausy 6f6d2ea467 fix(format): save the target file name in native bundle manifests (#3312) (#3319) 2026-10-11 21:26:13 -04:00
Valentin KleinandClaude Opus 5.5 6e9ba6511d 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]>
2026-10-11 21:22:11 -04:00
ee9c9ef159 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]>
2026-10-11 21:16:28 -04:00
Valentin KleinandClaude Opus 5.5 59820ecafb 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]>
2026-10-11 21:16:07 -04:00
Sir Starch 04e7e7d7f8 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.
2026-10-11 21:14:28 -04:00
aba9b2b4ef 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]>
2026-10-11 21:05:58 -04:00
1051265f75 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]>
2026-10-11 21:03:22 -04:00
b0e555c60f 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]>
2026-10-11 21:01:26 -04:00
Vikram Bhaskaran 2af2cec6ea Edit > Copy takes a targeted layer mask, so masks travel between layers and documents (#2928) (#3267) 2026-10-11 20:50:49 -04:00
6de37ce7f0 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]>
2026-10-11 20:34:58 -04:00
6d08ae6400 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]>
2026-10-11 20:29:36 -04:00
Valentin KleinandClaude Opus 5.5 c450e755ae PNG export: Indexed Color keeps the document resolution (#3047) (#3271)
The palette PNG encoder never wrote a pHYs chunk, so an Indexed Color
document saved as PNG reopened at 72 ppi while the RGB path kept its
resolution. encode_png_indexed now takes an optional dpi and the flat
exporter passes the document resolution under the same embed_metadata
rule as the ordinary PNG encoder. Save for Web PNG-8 is unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 20:12:44 -04:00
872d3df2c5 Layers panel: a mask dragged onto the Trash asks to apply or delete it (#3113) (#3118)
* 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]>

* 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]>
2026-10-11 20:11:43 -04:00
Gert Pellin - GPE 5a907b110e test(paint): pin the retouch dab footprint to the Full Size ring (#1761) (#3258)
A dab never paints past radius + 0.5 px from its centre, at any hardness:
the half pixel is the anti-aliased edge of dab_coverage. A hard tip does
reach the ring; softer tips fade out inside it, which is the falloff
working as intended.
2026-10-11 20:02:35 -04:00
Dan HiornsandClaude Opus 5.5 599aea0086 Liquify: the brush fades to nothing at its edge at every Density (#2606) (#3241)
A dab's strength was (1 - density) + density * (1 - t^2)^2, so at the
brush rim it stopped at 1 - Density instead of 0: half strength at the
default Density 50, a hard-edged disc at 0. Forward Warp then moved the
pixels just inside the brush and left those just outside, tearing the
image along every dab's circle; with a dab every fifth of the radius
the tears stacked into the sawtooth edges and stripes in #2606. Density
was also backwards: 0 gave the strongest, flattest brush.

`falloff` now blends (1 - t^2)^3 (Density 0: the effect in the centre,
feathered) toward (1 - t^4)^2 (Density 100: most of the brush), both 1
at the centre and 0 with a flat slope at the rim, as Photoshop's
Density reads. Every Liquify brush tool and Freeze/Thaw use it.

Replaying saved Liquify strokes (the command, smart filters) now gives
the smooth result for the same strokes.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 20:01:39 -04:00
f1059f1885 Shortcuts: layout-independent brush keys, Photoshop's German keys plus a fallback for all layouts (#3109) (#3112)
* 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]>

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

* Drop shortcuts::layout_symbols left by the merge: layout_keys::apply replaces it

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-11 19:55:11 -04:00
14733b5223 Moving type keeps its rendering: Artboards to Files no longer drops PSD text (#3218)
translate_layer re-rendered a type layer on every whole-pixel move. For type
from a PSD that replaced Photoshop's rendering with PhotoCraft's: paragraphs
re-wrapped, and text in a font that isn't installed vanished. Artboards to
Files moves each board to the origin this way, so a Steam store template's
"How to use" artboard lost its "Hello!" title. The Move tool, nudges, layer
comps and moving artboards went through the same path.

A whole-pixel move now shifts the rendered pixels, as smart objects and the
Move tool's live preview already do, and moves the TySh position with
photocraft_text::psd::translate_tysh: only the transform's translation
bytes change, so the rest of Photoshop's type data stays byte for byte. Type
with nothing rendered yet is still rendered.

On that template, 17 of 20 artboards now match Photoshop's own composite
exactly (the "How to use" board went from a mean difference of 4.5 to 0.02);
the remaining differences are shape layers (same cause, separate path) and
pre-existing 1-level rounding on two smart objects.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 19:46:29 -04:00
4ab61338ca Layers panel: Shift+Plus/Minus cycle blend modes; effects dropped on the Trash are deleted (#3102) (#3106)
* 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]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 19:45:42 -04:00
886372812d automation: doc.place reads a file through the read root (#2810) (#3215)
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.

Co-authored-by: switch87 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 19:33:29 -04:00
42ee0d9675 Content-Aware Scale: transform box with live preview (#3096) (#3103)
* Content-Aware Scale: transform box with live preview (#3096)

Edit > Content-Aware Scale opened a parameter dialog. Photoshop has no
dialog: it puts a scale-only transform box on the layer (or the
selection's bounds). The menu now opens that box.

- The box reuses Free Transform. Corner and edge drags scale and an
  inside drag moves; dragging outside does not rotate, Cmd does not
  skew or distort, and the Warp switch and the Transform modes are off.
- The options bar shows X/Y and W/H, then Amount, Protect (an alpha
  channel) and Protect skin tones.
- The live preview runs the command's own carving
  (ContentAwareScalePreview) on a 384 px copy on a worker thread. A
  run for a box that has changed since is cancelled after 150 ms, and
  the latest result is drawn through the box.
- Enter runs edit.contentAwareScale with the box's size and its new
  x/y params as one history step; Esc cancels.
- With params the command still runs directly.

Scorecard TOOL-214-7: done.

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

* Content-Aware Move's transform box: set the new content_aware field

#3095 (Transform On Drop) builds a TransformSession by hand; this PR adds
content_aware to it.

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-11 19:33:15 -04:00
Dan HiornsandClaude Opus 5.5 c18ee8c88b Tests: one GPU harness at a time in every GPU test binary (WARP crash) (#3212)
On Windows, GPU UI test binaries crashed with STATUS_ACCESS_VIOLATION
whenever two of their tests ran at once: arbitrary_rotation_preview 3
runs in 4 on main, and color_managed_canvas, crop_default_mode,
drag_preview_canvas and full_res_preview at random. egui_kittest
prefers a CPU adapter, which on Windows is WARP over DX12, and a dump
puts the fault inside d3d10warp.dll with two WARP devices rendering on
parallel test threads. Bare egui harnesses with no PhotoCraft code
crash the same way, and Vulkan or GL never does. CI's Windows runners
have no GPU, so they run on WARP too.

Three binaries already held a per-binary lock for the same class of
driver bug (Mesa llvmpipe and RADV, #194; libvulkan with NVIDIA and
lavapipe). That lock now lives once in photocraft-testkit
(`gpu_lock`, with the reasons) and every test in the twelve GPU test
binaries without it takes it on its first line. Under nextest each
test is its own process and the lock is free.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 19:27:26 -04:00
imSlicedBread 92baf43103 Preserve whitespace in OpenRaster layer names (#3207) 2026-10-11 19:17:06 -04:00
aa1febaffb Keep fitted Camera Raw colour during tuning and crop Nikon D5100 borders (#3201)
* raw: fit non-DNG colour to the camera's embedded JPEG

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

---------

Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: optimalbytee <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 19:12:19 -04:00
d706acdde9 Numeric fields: typed units, scrubby labels, double-click reset (#3099) (#3100)
* Numeric fields: typed units, scrubby labels, double-click reset (#3099)

- Value fields convert a unit typed after a number into the field's unit
  (1in, 2.54cm, 72pt, 1pica/pc, 10px, 50%) at the active document's
  resolution; Image Size and New Document use their own resolution. A
  unit that can't convert, or bad arithmetic, keeps the old value.
- widgets::scrub_label: dragging a label scrubs the value field right
  after it on its row (a step a point, Shift x10, Alt a tenth as fast),
  with a horizontal-resize cursor. Used by options-bar labels, slider
  rows and the Character panel icons. A slider row reports the scrub as
  one drag, so adjustment edits coalesce into one history step.
- Adjustment slider rows with a known default and filter dialog sliders
  reset on double-click, like Color Balance already did.

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

* Scrubby labels: drag-only, not focusable, so Tab skips them

Sense::drag() is focusable in egui 0.36, so every scrubby label became an
extra Tab stop and tab_focus_reveals_the_final_narrow_tool_option could no
longer reach the last tool option.

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-11 19:09:08 -04:00
Valentin KleinandClaude Opus 5.5 661ba806d1 Content-Aware Move: Transform On Drop (#3071) (#3095)
* Content-Aware Move: Transform On Drop (#3071)

The Content-Aware Move tool gains Photoshop's Transform On Drop checkbox. With it on, the drop
puts a transform box on the moved patch (the Free Transform box's handles, limited to move,
scale and rotate); Enter, the check button or a double-click computes the content-aware blend
with the transformed patch, Esc or the cancel button drops the whole move. The document is
untouched until the commit, so the move stays one History step.

Engine: paint.contentAwareMove takes scaleX / scaleY (%, 1..10000, negative flips) and rotate
(degrees, clockwise) about the selection's centre before the offset, validated (Err, not a
panic, for non-finite, out-of-range or off-canvas placements). The content and the selection's
shape are resampled through the inverse map on float samples (any depth); the selection is
transformed with the content. A plain move keeps the old, exact code path.

UI: cam_transform.rs holds the box (UiState.cam_transform, view state); the option is remembered
in ToolOptions and settable with ui.set camTransformOnDrop; ui.inspect reports camTransform.

Scorecard TOOL-213-4 stays partial: no live preview of the blended result yet.

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

* Tests: keep Transform On Drop cases in the Content-Aware Move test, not Patch (#3071)

paint.patch takes no scaleX/scaleY/rotate, so those cases (which only
paint.contentAwareMove rejects) failed patch_fails_gracefully. They are
already covered by content_aware_move_fails_gracefully.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 18:44:10 -04:00
Valentin KleinandClaude Opus 5.5 8584e5dbf8 Actions: Last Filter is recorded as the filter it repeated (#3089) (#3091)
filter.lastFilter was journaled as itself, so a recorded Ctrl+F step
looked up the playing session's last filter: it failed in a new session
("no filter applied yet") and applied whatever filter ran last otherwise.
dispatch now runs Last Filter as the filter it repeats, so that filter is
journaled, recorded, faded and run as a job, as Transform Again records
its transform (#1440).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 18:28:51 -04:00
Scott C Hughes 4e334b479b test(gradient): guard the on-canvas midpoint diamond drag (#3172) (#3512)
The midpoint diamond's hit-test and drag were untested, so a regression there would go
unnoticed. Add a repro that drags the diamond and asserts the midpoint follows (and no stop
is added). The path works on current main; this is coverage, not a behaviour change.
2026-10-11 18:20:26 -04:00
Valentin KleinandClaude Opus 5.5 f0d3156ad6 Freeform Pen: Alt-clicks draw straight segments; Cmd/Ctrl is Direct Selection with the new pens (#3085) (#3087)
* Tools: Freeform Pen and Curvature Pen (#1054)

The Pen flyout (P / Shift+P) now holds Pen, Freeform Pen and Curvature Pen.

Freeform Pen: a drag draws freehand; on release the trace is fitted with
cubic Beziers within the options bar's Curve Fit (default 2 px) by
photocraft_vector::trace::fit_trace (Schneider, "An Algorithm for
Automatically Fitting Digitized Curves", Graphics Gems 1990, the fitting
Make Work Path already uses), keeping turns sharper than 60 degrees as
corners. Releasing on the start point closes the path.

Curvature Pen: each click adds a smooth point and the path runs through
every point (Catmull-Rom style handles from
photocraft_vector::edit::curvature_knots); dragging a point moves it,
double-clicking toggles corner/smooth, clicking the first point closes,
Enter/Esc finish and Backspace removes the last point, as with the Pen.

Both commit through the Pen's path: a shape layer in Shape mode, else the
targeted vector mask, else the work path, as one History step.

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

* Freeform Pen: Alt-clicks draw straight segments; Cmd/Ctrl is Direct Selection with the new pens (#3085)

The Freeform Pen now draws through lasso_ui, as the Lasso does: Alt held at
the release keeps the path open, each Alt-click adds a straight segment,
releasing Alt with the button down goes on freehand, with it up finishes
the path; clicking the start point closes it. freeform_finish fits each
freehand run on its own and joins the runs with straight segments between
corner anchors. Cmd/Ctrl with the Freeform or Curvature Pen is the
temporary Direct Selection, as with the Pen (#790); it finishes a path in
progress first.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 18:16:39 -04:00
Scott C Hughes 3455234537 fix(crop): the crop box's own Undo/Redo (#3145) (#3510)
While a crop is pending, Edit > Undo/Redo now steps the *box*, not the document, as
Photoshop does. The frame's history lives on CropState; crop_ui::is_enabled/intercept route
the commands for every caller (menu, shortcut, control channel, MCP), and committing or
cancelling a crop clears it.

The ratio-field input is PR #3175. The handle-drag jitter is left as-is on main (whose
live crop-handle snapping, snap_ui::live_crop_handle, deliberately redesigns that path).

Test: edit_undo_steps_the_crop_box_not_the_document. cargo test -p photocraft-ui-egui --lib crop: 101 pass.
2026-10-11 18:10:38 -04:00
optimalbyteeandoptimalbytee da3f5ffd54 Optimise computed brush coverage and native raster row writes (#3098)
* Speed up raster writes with contiguous sample rows

* Specialise coverage rasterisation for computed brush tips

---------

Co-authored-by: optimalbytee <[email protected]>
2026-10-11 18:08:44 -04:00
0142b24fcb Artboards: a board moved past the canvas origin grows the canvas left/up (#3070) (#3073)
fit_canvas only grew the canvas to the boards' right/bottom edges, so a
board moved left of or above the origin (Move tool drag, layer.translate,
layer.artboard.set with negative x/y) stayed partly off-canvas. It now
shifts the whole document (boards, contents, guides, paths...) right/down
by the overhang via image_cmds::translate_doc and grows the canvas, inside
the move's undo step, like Photoshop's artboard documents.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 18:05:58 -04:00
2b2bb45870 Color Picker: no bigger than Photoshop's (532 × 376 points, was 682 × 437), buttons sized to their labels (#3500)
* Color Picker: no bigger than Photoshop's (532 x 376 points, was 682 x 437)

A 200-point colour field, 42-point component fields, buttons at their
width rather than stretched, smaller new/current swatches and tighter
spacing. Every control stays; the 16-point window padding stays.

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

* Color Picker: actions as wide as the longest label, not the column (133 points, was 160)

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

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 18:03:29 -04:00
4fe6dc6764 Layers: the New Layer dialog (Layer › New › Layer…, ⇧⌘N) (#3104)
* Layers: clip groups follow New Layer, Arrange, Reorder and Delete (#2203)

Adding, moving or deleting layers never touched Layer::clipped, so a
new layer above a clipping base split its group and became the base of
the layers clipped above it, and moving or deleting a base left its
clipped layers clipping to whatever ended up below them.

The new clip_group module applies Photoshop's rules, measured on #2203:
a layer added or moved joins the clip group it lands in; a clipped
layer moved stays clipped while it still sits on a clip group; the
layers clipped to a base that moves away or is deleted are released;
and a clipped run left with nothing below it is released. They are
applied by layer.new.layer (new clipToPrevious param, Photoshop's Use
Previous Layer to Create Clipping Mask), layer.arrange.*, layer.moveTo
(Layers panel drags, batches and ⌥-drag copies) and layer.delete.

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

* Layers: the New Layer dialog (Layer › New › Layer…, ⇧⌘N)

Layer › New › Layer… made a layer straight away. It now opens
Photoshop's New Layer dialog: Name (focused and selected), Use Previous
Layer to Create Clipping Mask, Color, Mode, Opacity, and Fill with
<mode>-neutral color, greyed out with "(No neutral color exists for
<mode> mode.)" for modes that have none. OK makes the layer in one
history step.

layer.new.layer gains mode, opacity, color and fillNeutral params
(new engine module new_layer.rs) beside clipToPrevious. The neutral
colors (white for the darkening modes and Divide, black for the
lightening and difference modes, 50% gray for the light modes) are
checked by compositing at 8, 16 and 32 bits.

As in Photoshop, the Layers panel's New Layer button still makes a
layer at once and ⌥-click opens the dialog; commands with params
(automation, Actions, MCP) never show it.

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-11 18:01:12 -04:00
b26256faee Type: triple-click selects a line, quadruple a paragraph, five clicks all text (#3493) (#3496)
While editing type, a run of clicks at one spot now selects like Photoshop:
two clicks the word, three the laid-out line (a column in vertical type),
four the paragraph up to its hard return, five all text; the next quick
click starts over with a caret. egui stops counting at three, so the Type
tool keeps its own count per layer (next_click_count) and the ranges come
from a pure helper over the text and its layout (click_selection).

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

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 17:55:18 -04:00
Valentin KleinandClaude Opus 5.5 d7e2f171f3 Actions: a command that runs other commands is recorded once (#3067) (#3072)
* Actions: a command that runs other commands is recorded once (#3067)

A journaled command that called Session::execute journaled the inner
calls as well as its own entry, so a recorded action replayed them twice
(Straighten Layer: rotate + crop, then Straighten again). dispatch now
remembers the journal length when a command starts; when a replayable
command succeeds its entry replaces the entries of the commands it ran.
Edit > Transform > Again is not replayable and keeps its concrete transform.

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

* Actions: Again on a Copy records its steps; taken-back steps are dropped (#3067)

- edit.transform.againCopy is not replayable, like Transform Again (#1440):
  it records layer.duplicate + edit.transform, which play in a session
  that never ran a transform. With one layer selected the duplicate names
  no layer id, so it plays on any document.
- compound, Again on a Copy and Frame from Layers take back what they ran
  when they fail; they now also drop those commands from the journal, so
  recording does not replay an undone edit.
- Recording pauses while a replayable command runs: a script that plays an
  action no longer flushes its earlier steps into the recording and loses
  its own entry.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 17:53:25 -04:00
d523405627 scorecard: FILE-216-5 save-path tests are in the tree (#3476)
Every existing save path (layered TIFF, print PDF, ACO/ASE, .shc, Smart
Filters in PSD, atomic-save failure) already has a save-and-read-back
test; SVG and Photoshop PDF have no writer yet (FILE-215-10).

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 17:48:52 -04:00
cc772a03c1 fix(export): quick-export every selected layer as a separate PNG (#3462)
Enable Layers context-menu Quick Export as PNG for a multi-layer selection. Capture each selected source before showing one destination prompt and write separate numbered PNG siblings with sanitized layer names, without silently replacing existing sibling files.

Add actual PNG encoding and cancellation regressions, preserve the captured selection when the dialog is open, and update context-menu enablement tests.

Co-authored-by: kotulsky <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 17:48:03 -04:00
Valentin KleinandClaude Opus 5.5 8f7cf009bb Type Mask: Shift/Alt at commit add to or subtract from the selection; no Layers row for the working text (#3036) (#3069)
* Tools: Horizontal and Vertical Type Mask (#1055)

The Type flyout (T, Shift+T cycle) gains Vertical Type Mask and Horizontal
Type Mask, in Photoshop's order. They type like the Type tools, with the
canvas under Quick Mask's colour and the glyphs clear while editing. On
commit the glyph coverage replaces the selection; no layer is left behind
and the whole session is one "Type Mask" history step. Escape always
cancels and leaves the selection unchanged.

The commit runs a new engine command, type.createSelection: a type layer's
glyphs (optionally removing that layer, the tools' hidden working text) or
type.create's keys directly, with new/add/subtract/intersect and an
anti-aliasing override. type.create's layer building is shared through
type_cmds::new_text_layer.

Original dashed-T icons (ATTRIBUTION.md), tool label translations in every
catalog, README tool list and the gaps/parity docs updated.

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

* Type Mask: Shift/Alt at commit combine with the selection; no Layers row for the working text (#3036)

Committing a Type Mask with Shift held (click outside, Cmd+Shift+Enter or
the check button) adds the glyphs to the selection, Alt subtracts them and
Shift+Alt intersects, as in Photoshop; without modifiers it still replaces.
The Layers panel no longer lists the hidden working text layer while a
Type Mask session is open (layer_tree_ui::panel_rows).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 17:43:56 -04:00
Timothy DalyandClaude Opus 5.5 5e5a0f6d2d Layers: clip groups follow New Layer, Arrange, Reorder and Delete (#2203) (#3090)
Adding, moving or deleting layers never touched Layer::clipped, so a
new layer above a clipping base split its group and became the base of
the layers clipped above it, and moving or deleting a base left its
clipped layers clipping to whatever ended up below them.

The new clip_group module applies Photoshop's rules, measured on #2203:
a layer added or moved joins the clip group it lands in; a clipped
layer moved stays clipped while it still sits on a clip group; the
layers clipped to a base that moves away or is deleted are released;
and a clipped run left with nothing below it is released. They are
applied by layer.new.layer (new clipToPrevious param, Photoshop's Use
Previous Layer to Create Clipping Mask), layer.arrange.*, layer.moveTo
(Layers panel drags, batches and ⌥-drag copies) and layer.delete.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 17:43:30 -04:00
kotulskyandkotulsky 7c2e8c3d95 fix(actions): fill the resizable panel with the step list (#3460)
Remove the 320-point list height cap and nested dock scrolling. Reserve space for the footer while filling the available panel height.

Add a panel-height regression test and check recording autoscroll against the visible footer instead of a fixed coordinate.

Co-authored-by: kotulsky <[email protected]>
2026-10-11 17:41:04 -04:00
buivanvu94 17167e27d6 fix(transform): identity transform makes feathered selection edges translucent (#3079) (#3159) 2026-10-11 17:31:25 -04:00
Valentin KleinandClaude Opus 5.5 911f69d7b5 Artboard tool: '+' buttons add a board beside the selected one; ⇧ keeps proportions (#3033) (#3046)
* Tools: Artboard tool (#2858)

Photoshop's Artboard tool in the Move flyout (V, ⇧V cycles Move ↔ Artboard):
dragging on an empty spot draws a new board (`layer.new.artboard`), clicking a
board selects it, dragging it moves it with its contents, and the selected
board's edge and corner handles resize it (`layer.artboard.set`,
moveContents: false). Each gesture is one undo step. Options bar: Size preset,
W/H, Make Portrait, Make Landscape, Add New Artboard. Original toolbar glyph
(assets/icons/artboard.svg, ATTRIBUTION.md row).

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

* Artboard tool: '+' buttons add a board beside the selected one; ⇧ keeps proportions (#3033)

With the Artboard tool, the selected board shows a "+" button beside each
side. A click adds an empty board of the same size (background and preset
copied) on that side, the 100 px artboard gap away and past any board in
the way; ⌥-click adds a copy with its contents instead. A board left of or
above the canvas origin shifts the whole document right/down and grows the
canvas, so it stays visible. Both go through the new engine command
`layer.artboard.addBeside` {layer?, side, duplicate} (one undo step).

⇧ while dragging a corner handle keeps the board's aspect ratio: the
opposite corner stays and the board scales by the larger axis factor.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 17:30:35 -04:00
72bf3e13b6 i18n(cs): translate the dynamic labels the Czech catalog was missing (#3454)
* i18n(cs): translate dynamic labels the literal scanner can't see

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

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

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

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

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

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

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

Changes from an independent review pass over the added block:

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

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

* contributors: add my own people.toml entry

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

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

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

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

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

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

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

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

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

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

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

* Localize Czech status messages and notice wrappers

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

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

* Localize Czech UI labels and hardware information

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

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

* Localize Czech composed labels and distinguish Navigator zoom

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

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

* Fix Czech UI localization review follow-ups

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

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

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

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

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

---------

Co-authored-by: Losminek <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 17:23:27 -04:00
buivanvu94 680518b425 fix(image): arbitrary canvas rotation moves position-locked children in groups (#3078) (#3155) 2026-10-11 17:21:19 -04:00
Valentin KleinandClaude Opus 5.5 55c1847f89 Paths: Stroke Path with any brush-based tool and Simulate Pressure (#2978) (#3039)
* Paths: Stroke Path with any brush-based tool and Simulate Pressure (#2978)

path.stroke now takes every brush-based tool of Photoshop's Stroke Path
dialog: besides the Brush, Pencil and Eraser it renders itself, the
Background Eraser, Clone and Pattern Stamp, Healing and Spot Healing
Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color
Replacement and Mixer Brush run their paint.* command along each
subpath, in one history step, with only path.stroke journalled.

simulatePressure resamples each subpath and ramps the pressure from 0 at
its start to full at the middle and back to 0 at its end, so a brush
whose size follows pen pressure tapers at both ends.

The Stroke Path dialog (Pen/Path Selection right-click menu, the Paths
panel row menu, Alt-click on the panel's Stroke button) asks for the
tool and Simulate Pressure, as in Photoshop. Scorecard TOOL-214-11 done.

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

* Automation: classify path.stroke's params as a document vector path (#2978)

The new Stroke Path params doc says the tools run "along the path", which
registry_filesystem_path_params_are_classified reads as a possible host
file path. path.stroke strokes a document path and forwards only to
paint.* commands that take no files, so it joins the vector-path list.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 17:18:39 -04:00
6a4c48aea7 fix(warp): Perspective Warp moves only the selected part of a linked mask (#3083) (#3148)
With a selection, Perspective Warp moved only the selected pixels but warped
the whole linked layer mask, so unselected pixels changed visibility. The mask
now follows the same split as Free Transform (#205): the selected region of a
linked mask moves with the pixels and the rest stays put.


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

Co-authored-by: Leel Wasanatha <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 17:16:55 -04:00
David MannandClaude Opus 5.5 9e378277e8 Crop tool: switching to another tool commits the pending crop (#3368) (#3451)
Leaving the Crop tool kept a drawn frame pending, so picking another tool
from the toolbar or by its key left the image uncropped. As in Photoshop,
the per-frame crop sync now commits a drawn frame (as Enter would) once the
current tool is no longer Crop; the untouched default frame is still just
dropped. The pen's eraser end and a gesture in progress keep the frame
pending; held tools (Space, Cmd) never change the current tool.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 17:16:18 -04:00
Diego Chagas 18f787f04d SVG: say when text had no font to use instead of opening an empty drawing (#3168)
With no font installed at all (a bare CI image, a minimal container) usvg drops every <text>
while parsing, so nothing reaches the conversion and the file opened as "nothing to draw". The
parser now notes when a drawing has text and the font database has no faces, and the import
warns that the text needs a font and was dropped. Fixes the FreeBSD svg test, which has been red
since the reader landed.
2026-10-11 17:09:21 -04:00
prekabrekiandprekabreki e762c784fb Keep transform controls with moved selected pixels (#3402) (#3436)
Co-authored-by: prekabreki <[email protected]>
2026-10-11 17:09:15 -04:00
Sir Starch d9ecd60fb3 automation: deny edit.installedProfiles, it scans the OS profile folders (#3763)
edit.installedProfiles lists ICC profiles from the OS and user profile
directories, so automation must not run it, the same as the other
profile commands that read ambient paths. Without a policy the registry
check in workspace::tests fails, which has kept CI red on main since the
command landed.
2026-10-11 17:08:47 -04:00
Sir Starch 40d7e6a1d8 fix(history): read pixels from locked history states (#3076) (#3186) 2026-10-11 17:06:15 -04:00
prekabrekiandprekabreki f9e94aa535 Keep the Color panel selected when switching themes (#3371) (#3433)
* Keep the Color panel selected when switching themes (#3371)

* Scope Camera Raw color checks to its settings panel (#3371)

---------

Co-authored-by: prekabreki <[email protected]>
2026-10-11 17:02:38 -04:00
Sir Starch 2bbe6c3fdc fix(filters): process untouched Reveal All masks (#3082) (#3156) 2026-10-11 16:59:55 -04:00
David MannandClaude Opus 5.5 cee1033112 Properties: a new adjustment or fill layer shows its settings, not its mask (#3369) (#3429)
Creating an adjustment or fill layer still targets its mask (#2166), so
painting and Invert edit the mask, but Properties now shows the layer's
own settings, as in Photoshop. A new UiState.properties_layer remembers
the layer; clicking its mask thumbnail, targeting the pixels or selecting
another layer clears it and Properties shows the mask controls again.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 16:55:33 -04:00
cc75ed52c1 fix(cli): skip recorded batch view and save steps (#3058) (#3143)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:54:24 -04:00
Valentin KleinandClaude Opus 5.5 3674ed62bd Single Row/Column Marquee: History names the step after the tool (#3031) (#3035)
select.rect takes an optional `line` ("row"|"column"); the canvas sends it
for the Single Row / Single Column Marquee, so History records
"Single Row Marquee" / "Single Column Marquee" instead of
"Rectangular Marquee". An unknown `line` is an error.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:52:17 -04:00
Sir Starch 06dcdc1267 fix(mode): restore indexed pixels after clearing transparency (#3080) (#3153) 2026-10-11 16:50:58 -04:00
a97cfd103e Fix Camera Raw preview color management (#3427)
* Fix Camera Raw preview color management

* Apply canvas color management to refined previews

* Camera Raw detail preview: clippy and rustfmt

allow too_many_arguments on DetailPreview::paint (the display joins seven
existing params) and as_chunks for the RGBA8 rows (clippy 1.99).

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-11 16:47:56 -04:00
88ffd89e9f fix(crop): unlock Background when extending canvas (#3081) (#3158)
* fix(crop): unlock Background when extending canvas (#3081)

* Crop: fill an extended Background with the background colour

Photoshop's Crop tool keeps the Background when the frame extends past
the canvas and fills the added area with the background colour, as an
opaque Canvas Size extension does. Do that instead of converting the
Background to a normal layer; other layers still get transparent pixels.

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-11 16:46:09 -04:00
Valentin KleinandClaude Opus 5.5 62e0335116 Transform Path: Distort, Perspective and Warp apply to paths (#3027) (#3032)
* Transform: Distort, Perspective and Warp keep shape layers live (#3009)

Edit › Transform › Distort / Perspective and Warp on a shape layer let the
user edit the box or mesh and then refused at commit ("needs a rasterized
shape"). Photoshop maps the shape's path and keeps it a shape.

- photocraft-vector: new `map` module. `map_points` sends every anchor and
  control point through a map (Distort / Perspective: exact for straight
  segments, exact end tangents for curves). `map_path` follows a curved map
  (Warp), halving each segment until the mapped control points follow the
  mapped curve within a tolerance (at most 64 pieces a segment, bounded in
  total); straight segments whose image is straight stay straight.
- engine: `transform_layer` and `warp_layer` map a shape's path instead of
  refusing; a live rectangle/ellipse/polygon/line becomes a plain path. A
  linked vector mask now follows Distort / Perspective / Warp too. A map that
  sends a point to infinity is an error, not a corrupt path.

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

* Transform Path: Distort, Perspective and Warp apply to paths (#3027)

path.transform accepted only an affine matrix, so the Free Transform
Path commit refused any box pulled out of a parallelogram ("Distort and
Perspective can't be applied to a path"), and Warp was not offered on a
path at all. path.transform now also takes a `quad` (with an optional
`rect` frame, default the path's control bounds) and a warp (`style`,
`mesh` or `warp`, as edit.transform.warp), validated (finite numbers,
magnitude at most 1e6, non-degenerate quad) and mapped with the
vector::map helpers from #3025. A parallelogram quad stays affine, so a
live shape stays live. The UI commit sends the quad or warp instead of
refusing, and Transform Path offers Warp; each is one undo step.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:43:13 -04:00
rafi612 014dd5f25d fix(ui): drop doubled options-bar borders reintroduced in UI.9 (#2226) (#3425) 2026-10-11 16:40:36 -04:00
f155374ac3 fix(type): navigate adjacent line with large leading (#3066) (#3141)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:38:14 -04:00
76c02862c6 Toolbox: make the colour swap arrow clearer (#3416)
* Toolbox: bolder Switch Colors arrow with filled heads

* Toolbox: match the reference Switch Colors glyph

A bold diagonal curve from the icon's top-right to its bottom-left with
filled heads, measured from the reference glyph, replacing the quarter
arc. Geometry extracted to swap_arrow.rs with unit tests.

* Toolbox: correct the swap glyph reference and arrow directions

* Fix formatting and Clippy checks for swap glyph

* swap_arrow: drop the test's env-var PNG dump

Tests shouldn't read env vars to write files; the render assertions stay.

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-11 16:32:18 -04:00
Sir Starch b66ebb84d2 fix(type): count normalized insert text caret (#3074) (#3138) 2026-10-11 16:31:51 -04:00
c234865b17 fix(type): respect Lock All on text edits (#3092) (#3132)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:26:10 -04:00
7aba2bd305 Type: scan system fonts off the UI thread (#3021) (#3030)
* Type: scan system fonts off the UI thread (#3021)

The first `photocraft_text::shared()` call scans every installed font, and
nothing made that call before the Type tool's options bar drew its font
menu, so the scan ran inside a UI frame (over 60 s on some Macs, #2518).

- photocraft-text: `warm_shared()` starts the scan on a background thread;
  `try_shared()` returns the engine without blocking (None while the scan
  runs, started there if nobody warmed it; built in place on the web, in
  tests, if the thread can't start or the worker died). `shared()` keeps
  its blocking behaviour for engine commands.
- The desktop app warms the fonts at start-up.
- Font menus that draw every frame (options bar, Character, Glyphs and the
  Character/Paragraph Styles family menus) show a disabled "Loading fonts…"
  box and repaint until the scan is done. "Loading fonts…" is in all 16
  catalogs.

Complements #2644 (CoreText discovery on macOS): this changes where the
scan runs, not how fonts are discovered.

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

* Type: axis sliders wait for the background font scan too

#2996 (landed on main) added axis_sliders, which called the blocking
photocraft_text::shared() every frame; use try_shared() so the Character
section doesn't stall while the scan runs.

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-11 16:25:57 -04:00
buivanvu94 3e45d769fa fix(ui): focus and select hex field when color picker opens (#3375) (#3408) 2026-10-11 16:21:57 -04:00
Sir Starch a893113502 fix(gradient): respect Lock All on Gradient Fills (#3093) (#3120) 2026-10-11 16:19:40 -04:00
Valentin KleinandClaude Opus 5.5 8458cf7095 Transform: Distort, Perspective and Warp keep shape layers live (#3009) (#3025)
Edit › Transform › Distort / Perspective and Warp on a shape layer let the
user edit the box or mesh and then refused at commit ("needs a rasterized
shape"). Photoshop maps the shape's path and keeps it a shape.

- photocraft-vector: new `map` module. `map_points` sends every anchor and
  control point through a map (Distort / Perspective: exact for straight
  segments, exact end tangents for curves). `map_path` follows a curved map
  (Warp), halving each segment until the mapped control points follow the
  mapped curve within a tolerance (at most 64 pieces a segment, bounded in
  total); straight segments whose image is straight stay straight.
- engine: `transform_layer` and `warp_layer` map a shape's path instead of
  refusing; a live rectangle/ellipse/polygon/line becomes a plain path. A
  linked vector mask now follows Distort / Perspective / Warp too. A map that
  sends a point to infinity is an error, not a corrupt path.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:16:04 -04:00
Sir Starch f435eb0b55 fix(paint): respect Lock All on layer masks (#3094) (#3111) 2026-10-11 16:15:08 -04:00
b3993e89b3 Linux: say so when no file dialog can open (#3354); read ldconfig on non-English systems (#3397)
* feat(linux): say so when no file dialog can open (#3354)

File › Open / Save / Export did nothing on a minimal Wayland session
(labwc, Void) and said nothing: rfd loads libdbus-1 for the XDG file
chooser portal and falls back to zenity, and when neither works it only
logs and answers "nothing picked", which the app took for Cancel.

A background probe now looks once for the pieces (libdbus in the linker
cache, the portal's FileChooser interface on the session bus, zenity on
PATH). When a dialog comes back empty and none of them is there, the
shell answers with the new FileDialogAnswer::Unavailable and the status
bar tells the user what to install. The probe only explains an empty
answer; it never blocks a dialog, and anything it can't tell counts as
present.

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

* fix(linux): read the ldconfig cache on non-English systems

The startup library check and the new file dialog probe parse
`ldconfig -p`, but accepted the listing only when its first line
contained "libs found in cache". On a German system the header is
"5522 Bibliotheken im Cache ... gefunden", so the cache was treated as
unreadable and the check silently fell back to a directory search.

Run ldconfig with LC_ALL=C, and accept any listing that starts with a
count. Test: a translated header is a valid listing.

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

* file_dialog_probe: no dead-code errors off Linux

Findings, problem and on_path are only used by the Linux probe (and the
tests); the empty non-Linux start() was never called. Windows/macOS
clippy -D warnings failed on them.

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-11 16:14:47 -04:00
5f55a29d2b Color: honour the EXIF sRGB tag on open; wire Ignore EXIF Profile Tag (#204) (#3026)
A file with no embedded ICC profile whose EXIF ColorSpace tag says sRGB
(what cameras write) now opens as sRGB, as in Photoshop: it is tagged
sRGB before the Color Settings policy runs, so it is kept, reported as a
mismatch, or converted like any embedded profile. Before, it was read as
untagged and took the working space, so with a wide working space such
as Display P3 its colours were reinterpreted.

Preferences › File Handling › Ignore EXIF Profile Tag (off by default)
restores the untagged reading, and is now shown in the dialog.

photocraft_codecs::exif_color_space reads the tag from the Exif sub-IFD,
bounds-checked; anything malformed reads as no tag. Settings that do
nothing: 43 → 42 of 150.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 16:07:03 -04:00
56b192453e i18n: translate the Select and Mask dialog labels (#3014) (#3015)
Smart Radius, Shift Edge, Decontaminate, Output and the Refine Edge
title had no catalog rows, so the Select and Mask dialog stayed partly
English in every language; Greek and Polish also missed Modify's
Apply At Canvas Bounds. A new test checks every generated select.*
dialog (params, choices, title) in all registered languages.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 16:01:04 -04:00
57cba5ef8e Window: a window saved while minimized reopens at the minimum size, not 160 x 64 (#3380)
On Windows, eframe saves a minimized window's size (160 pt wide, raised to
64 pt tall), and the minimum size isn't applied to a restored window, so
PhotoCraft reopened as a 160 x 64 strip on every launch. A window_builder
hook grows a restored window to the minimum size, keeping its centre.

Co-authored-by: Katt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 16:00:11 -04:00
b1900ace28 Installed ICC profiles in the profile menus, and Mirror in Print (#3011)
* Installed ICC profiles in the profile menus, and Mirror in Print

- photocraft_engine::installed_profiles: scans the OS colour profile
  folders (Windows, macOS, Linux), bounded and cached; new
  edit.installedProfiles command.
- Print's Printer Profile and the Proof Setup, Convert to Profile and
  Assign Profile dialogs list the installed profiles after the
  built-ins; a profile given by path that isn't listed shows by name.
- file.print emulsionDown mirrors the page (PDF and preview), with a
  Functions › Emulsion Down (mirror) checkbox.
- Print dialog: cm/mm/in, Height and Width fields, Print Resolution.

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

* Print dialog: translate Emulsion Down, Functions, Print Resolution in every catalog; cargo 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]>
2026-10-11 15:55:11 -04:00
bbb05cabfb fix(shapes): options bar Fill/Stroke edit the selected shape (#3347) (#3370)
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 15:54:38 -04:00
539bf7ba14 Color panel: HSB on sRGB values, like Photoshop (#3323) (#3327)
The Color panel converted between the foreground and its hue, saturation and
brightness with egui's `Hsva`, which works in linear light. So a 50% grey
(128) read as Brightness 22%, and the colour field's left edge halfway down
was drawn as 188, not 128. Photoshop's HSB, and the Color Picker dialog's,
work on the sRGB-encoded values.

`srgb_hsva` / `hsva_srgb` now use the Color Picker's `rgb_to_hsv` /
`hsv_to_rgb`, and the field, hue strip, HSB slider tracks and swatch draw
through a new `hsva_color32` instead of egui's linear `Color32::from(Hsva)`.
`Hsva` only holds the numbers now.

Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 15:45:50 -04:00
Valentin KleinandClaude Opus 5.5 d9445c7d1d Free Transform: ⌘-drag of a corner scales editable type instead of distorting it (#3010) (#3012)
Distort and Perspective are greyed for editable type (#2701) and the engine
refuses them at commit, but the box's ⌘ / ⌘⌥⇧ corner drags didn't look at the
layer, so the box distorted and the commit then failed. On a box over live type
those drags now act as a plain corner drag (⌥ and ⇧ keep their meaning).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 15:42:43 -04:00
4ecf8ea6b9 feat(ui): Motion Blur and other filter angles get the angle dial (#3152) (#3407)
Move Drop Shadow's angle dial out of layer_style.rs into a shared
widgets::angle_dial_row that takes the param's range and uses the
dialogs' value_field. The field keeps Motion Blur's -360..360, and it
takes the dialog's opening focus. The filter dialog uses the dial for
direction angles that count counter-clockwise from three o'clock:
Motion Blur, Emboss, Oil Paint, Lighting Effects, Relight and Global
Light. Rotation amounts (Twirl, Rotate Arbitrary, Crop, Lens Correction,
Blur Gallery) and Flame's up-zero angle keep their sliders.

Arrow keys on the focused dial wrap round only when the range is the
dial's own -180..180 (Drop Shadow); on a wider range they step on, so
Motion Blur's 270° goes to 271°, not -89°.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 15:38:26 -04:00
67747dfd02 Type: Warp on live type takes the warp styles and stays editable, no prompt (#3004) (#3008)
* Type: ask to rasterize before Warp on live type (#3004)

Edit › Transform › Warp (the menu, or the warp toggle of an open transform
box) on editable type opened Warp mode, let the user shape the mesh, then
failed on commit with "Warp needs rasterized type". It now opens Photoshop's
"This type layer must be rasterized before proceeding…" prompt, the one the
pixel tools already use. OK applies an open transform box, rasterizes the
type (its own history state) and opens Warp on the pixels; Cancel does
nothing. The prompt's continuation is a menu command from an allow-list,
run with the egui context right after the confirm (control channel, OK
button) or on the next frame.

Distort and Perspective stay greyed for type (#2701), Puppet Warp and
Perspective Warp stay greyed for non-pixel layers, as in Photoshop.

Scorecard TYPE-218-9 -> done.

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

* Type: Warp on live type takes the warp styles, no rasterize prompt (#3004)

Photoshop 2026 keeps Edit › Transform › Warp enabled on live type and asks
nothing: the mesh can't be dragged, only the options bar's warp styles
(Warp Text's) apply, and the type stays editable. The earlier version of
this change asked to rasterize first; that is gone (and with it the prompt
continuation, which nothing else used).

Warp mode on a type layer now starts from the layer's own Warp Text style
over the unwarped type, ignores mesh drags and splits (Split, Grid and the
Custom style are greyed/hidden, the split menu items are greyed), and the
commit runs edit.transform.warp, which on type sets the Warp Text: one
history step, recordable, the layer stays Text. None takes a warp off. A
box scaled or rotated before toggling Warp is applied first.

Scorecard TYPE-218-9 note updated: Photoshop doesn't prompt for Warp;
Distort and Perspective stay greyed (#2701).

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-11 15:35:47 -04:00
Valentin KleinandClaude Opus 5.5 bd3be48785 Pen: Alt breaks a handle while drawing, and close-drag pulls out the first anchor's handles (#3306) (#3314)
Alt during a drag unlinks the handle pair (only the outgoing one keeps following),
Alt-press on the last anchor's outgoing handle turns that handle alone, and pressing
the first anchor now closes on release, so a drag pulls out that anchor's handles
(a stand-in knot folded into the first one on commit). A plain click still closes
without touching the first anchor's handles. Still one History step per path.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 15:29:33 -04:00
KnottyDyes 2d6ff3f6cc Inspect: report each layer's vector mask (#2980)
`hasMask` in document inspection covers only raster masks, so a layer
with a vector mask looked unmasked to an agent. Layers, groups included,
now also report `hasVectorMask` and `vectorMaskEnabled` (null when there
is no vector mask). `hasMask` keeps its meaning.
2026-10-11 15:21:03 -04:00
28874bbfad Select and Mask: Output To New Document, with or without a layer mask (#2998) (#3007)
* Select and Mask: Output To New Document, with or without a layer mask (#2998)

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

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

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

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-11 15:16:45 -04:00
8a72978549 File Handling: wire Image Previews to embed a PSD thumbnail (#204) (#2971)
* File Handling: wire Image Previews to embed a PSD thumbnail (#204)

Preferences > File Handling > Image Previews is live for PSD/PSB saves. With
Always Save (the default) and Ask When Saving, the file carries Photoshop's
thumbnail image resource (1036: the 28-byte kJpegRGB header and a JFIF stream,
at most 160 px on the long side); Never Save writes none. The thumbnail is the
reduced composite converted to sRGB through the document's profile with
photocraft-cms (8/16/32-bit; Gray, CMYK and Lab become RGB), over white where
transparent. Ask When Saving has no Save As checkbox yet, so it saves the
preview, as Photoshop's checkbox starts on.

The preference reaches Save, Save As, Save a Copy and automation saves in the
app (ExportSettings.image_previews), and the engine's headless saves
(file.saveACopy, batch, Layers/Comps/Artboards to Files) through SaveOpts.
photocraft_io::ExportOptions gains image_previews (default on).

Settings that do nothing: 32 of 153 (#204).

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

* File Handling: rustfmt the Image Previews save path

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-11 15:14:49 -04:00
f92f13490b Fix/3144 taskbar maximized (#3308)
* Window frame: trim a caption-less maximize clear of the taskbar (#3144)

* Credit axlecoffee in the About window

* 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-11 15:08:18 -04:00
58406c38da Cycle Color Lookup LUTs with keyboard and wheel (#3304)
* Preview Color Lookup LUT selection while cycling

* Test Color Lookup keyboard and wheel cycling

* rustfmt; drop a useless into_iter clippy flags

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-11 14:57:43 -04:00
Valentin KleinandClaude Opus 5.5 b54fdfd94f Tests: panic_hunt reruns a timed-out command before calling it hung (#2994) (#2997)
The first type.create starts the shared text engine, which scans every
system font once (3.5-5 s under load). panic_hunt billed that to
type.create against its 4 s deadline and failed intermittently. A real
hang reproduces, one-time setup does not: a timeout now counts only if a
rerun on a fresh session times out too.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 14:54:14 -04:00
Valentin KleinandClaude Opus 5.5 139df7ae85 Type: variable font axis sliders; optical size follows the type size (#2985) (#2996)
The Character section (Properties and Window > Character) shows one slider
per axis of the style's variable font below the font style (Weight, Width,
Slant, Italic, or the font's own tag), setting type.setStyle variations; a
drag is one history step. Optical size has no slider: the layout now sets
opsz to the type size unless the style sets it, as CSS font-optical-sizing
auto. Picking a font style or weight resets the wght axis to it, so an
earlier slider value no longer overrides the style the dropdown shows.

Scorecard TYPE-218-4 -> done.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 14:46:26 -04:00
Dan HiornsandClaude Opus 5.5 bb5d26e052 Free Transform: right-click W or H to pick their unit (#3235) (#3301)
As in Photoshop, right-clicking the W or H field of the Free Transform
(and Free Transform Path) options bar opens a menu of units: Pixels,
Inches, Centimeters, Millimeters, Points, Picas and Percent, the
current one checked. W and H then show the box's size in that unit at
the document's resolution (1200 px at 300 ppi is 10.16 cm), and a
value typed in it scales the box to that length; Percent, the default,
is the scale as before. The pick is remembered with the dialog
settings (`dialogs["edit.freeTransform"].sizeUnit`).

The menu is the rulers' unit menu (`rulers::unit_menu`), the
conversion is the Properties panel's `LenUnit`, and the size limits and
the link toggle work on the scale as before. Length fields are wider
(84 pt) so "2116.67 mm" fits; Percent keeps its 66 pt.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 14:40:14 -04:00
76c7d723f4 Move tool: double-click type on the canvas to edit it (#1132) (#3300)
Since Photoshop CC 2019 a double-click on a type layer with the Move tool starts
editing its text without switching tools by hand. PhotoCraft only offered this
from the Layers panel thumbnail. A double-click with the selected Move tool now
picks the topmost visible type layer under the pointer (the Type tool's own hit
test), makes it active and opens an edit session with the caret on the glyph
there, switching to the Type tool. Elsewhere the double-click does nothing, ⌘
held over another tool doesn't count, and an open transform box keeps its
double-click (commit).

The type tests' harness steps a quarter second per frame, past egui's
double-click delay, so the new test uses a 60 fps harness.

Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 14:28:05 -04:00
Valentin KleinandClaude Opus 5.5 65e8d5f020 GPU canvas: price the display textures before building them (#2934) (#3299)
The GPU canvas built a full-resolution, mipmapped texture set covering the
whole declared canvas (DocTextures::new) with only per-texture side limits,
so a document's size alone decided how much GPU memory was committed: a
300000x300000 canvas asks for hundreds of gigabytes.

canvas_texture_bytes prices the set in closed form (every tile and mip
level) and DocTextures::new refuses one over canvas_budget (a quarter of
RAM, at least MIN_GPU_BUDGET) without creating anything. refresh reports
"over-budget" and ensure_gpu then draws the document on the reduced CPU
canvas, so a supported large document still displays.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 14:16:27 -04:00
myangaa1 e5dbcab335 Open several files in name order, keeping it for background opens (#3248) (#3293)
Files opened together (File > Open with several files, paths on the
command line, the OS's "open these documents") appeared in random tab
order: each one opens in the background and its tab was added when it
finished decoding, so small files came first. The paths were also used
in the order the picker returned them.

`open_paths` now sorts the paths naturally (`File_2` before `File_10`,
case-insensitively) and opens them as a set: every background open
carries its position (`OpenOrder`), and when it finishes its document is
moved after the members before it that have already opened, so the set
reads in order however long each file takes. Members opened at once
(preset files) take their place immediately. The web build sorts the
picked files' contents the same way.

Tests: `natural_cmp`, a set opened synchronously, and a set whose
background opens finish out of order, mixed with a tab dropped at a
slot and an open from a forgotten set.
2026-10-11 13:51:46 -04:00
Gert Pellin - GPEandswitch87 e26442657f ui: Dust & Scratches previews at full resolution, as it applies (#3265) (#3287)
A 1/4 proxy averages away the specks the filter exists to remove, so the
preview showed an untouched image, at a scale that upscaled back to blocky
— while `radius` stayed in document pixels and covered k times the area it
covers in the result. OK always applied at full resolution, so only the
interactive feedback lied; add the filter to FULL_RESOLUTION_PREVIEW and
place its regression test on that path.

Co-authored-by: switch87 <[email protected]>
2026-10-11 13:39:11 -04:00
Valentin KleinandClaude Opus 5.5 740dd3019f Rulers: stay level and fully ticked on a rotated view; guides turn with the image (#3233) (#3282)
* Rulers: stay level and fully ticked on a rotated view; guides turn with the image (#3233, #3277)

The rulers measured along the rotated document axes, so at most angles the
ticks were squeezed into a few pixels (none at 90°) and the rest of the
ruler was empty. They now measure with the view minus its rotation: same
zoom, flip and pixel aspect, same document point under the canvas centre.

Guides were drawn as screen-level lines through the screen position of
(pos, 0), so on a rotated view they no longer marked their document
coordinate while hit-testing still used it. They are now drawn along the
document line across the clip rect.

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

* Rulers: anchor a rotated view's rulers at the document origin (#3233)

Photoshop 2026 keeps the rulers level at zoom scale on a rotated view, with 0
at the screen position of the document origin (where the rotated image's
top-left corner lands), not at the unrotated frame through the canvas centre.
ruler_xform now re-centres the level mapping so the origin projects where the
rotated view draws it; tick spacing still follows zoom, flip and pixel aspect.

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

* Rulers: label distances before the origin without a minus sign (#3233)

Photoshop's rulers print the distance from the origin, so ticks left of /
above it read "1000 500 0 500 1000", not "-1000 -500 0". The label is now
the absolute value (extracted into `ruler_label`), for every view.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 13:27:41 -04:00
Gert Pellin - GPEandswitch87 0144cb283d ui: keep the Color panel knob in the corner it was dragged to (#3228) (#3275)
The SV field re-derives Hsva from the colour every frame, and black reads
back as s = 0 — so a drag that bottoms out into black drew the knob at the
bottom-left one frame later, although the colour it picked was right.

Remember what the field was showing (hue *and* saturation) and restore both
only when the colour cannot carry them: both for black, the hue alone for a
grey, whose saturation is genuinely zero.

Co-authored-by: switch87 <[email protected]>
2026-10-11 13:16:02 -04:00
a35af4e279 Add FITS read/write (astronomy images) (#2956)
* Add FITS read/write codec

FITS (the astronomy format) opens and saves as a flat image: the primary
HDU or first IMAGE extension; gray, or RGB when NAXIS3 = 3; BITPIX 8/16/
32/64/-32/-64 with BSCALE/BZERO/BLANK; rows follow ROWORDER (bottom-up by
default). Camera U16 data stays exact; float data is normalised to 0..1.
Header cards round-trip as text metadata (HIERARCH for long names).
Tile-compressed (fpack) files are refused as unsupported.

Wired into File > Open, Save As and the batch commands as .fits/.fit/.fts.

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

* Test FITS open and save through photocraft-io

Gray 16-bit, RGB 8-bit and RGB 32-bit float documents round-trip exactly,
and header cards travel as the document's text metadata.

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

* FITS: hostile-header regression test (absurd axes, bad BITPIX, no END, HDU chains, overflowing sizes)

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-11 13:15:22 -04:00
f8af27d261 Prevent white flash when opening the screen color picker (#3256)
* Avoid white flash when opening screen color picker

* Normalize screen picker source line endings

* rustfmt; doc comment on viewport_builder

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-11 13:04:55 -04:00
Gert Pellin - GPEandswitch87 308ef63294 raw: charge the lossless-JPEG intermediate against max_alloc (#2925) (#3194)
Co-authored-by: switch87 <[email protected]>
2026-10-11 12:52:41 -04:00
9441484d16 Quick Mask: an empty start and Select All stay apart (#2743) (#2922)
* Keep Quick Mask's empty start and Select All apart (#2743)

Quick Mask stored "no selection" as an all-white mask, the same as Select
All. With Color Indicates Selected Areas that white was coloured over the
whole canvas, and leaving Quick Mask turned any all-white mask (a real
Select All too) into no selection.

The document now records that Quick Mask was entered with no selection.
The empty mask is white for Masked Areas and black for Selected Areas, so
neither shows colour; switching the option on an untouched empty mask
keeps it empty. On exit only that untouched white start becomes no
selection, so Select All survives the round trip. The flag is saved with
the document.

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

* Quick Mask tests: pass the active layer to channel_view::render (new main signature)

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

---------

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-11 12:51:18 -04:00
b814fd5241 Zoom and Hand tools: right-click the canvas for the view menu (#3242) (#3255)
Right-clicking the image with the Zoom or Hand tool opens Zoom In, Zoom Out,
Fit on Screen, Fill Screen, 100%, 200% and Print Size. Zoom In/Out step about
the right-clicked point (or centre it with Zoom Clicked Point to Center); the
other rows run their View menu command. Outside the image the pasteboard menu
still wins. The canvas keeps the view a menu row sets instead of writing its
frame copy back over it. Agents get the same menu through ui.pointer and
ui.context.choose.

Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 12:50:01 -04:00
a7f26522ff i18n(pt-br): translate 782 missing UI, filter, and tool strings (#3254)
Co-authored-by: AlastorIRL <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 12:42:10 -04:00
Brennan StehlingandClaude Opus 5.5 2b19987bf1 Menus: a submenu stays open while the pointer heads for it (#1218) (#3251)
In the in-window menu bar (Windows, Linux, --in-window-menus), moving from a
submenu's row toward one of its lower items crosses the rows below. egui keeps
the submenu open only while the pointer moves in a straight line at it: a
bowed path or a pause on the way closed it, and the crossed rows lit up as if
chosen.

As in macOS and Photoshop menus, the submenu now stays open while the pointer
is inside the triangle from where it left the row to the submenu's near edge,
until it rests on another row for 0.3 s or moves away. Its row stays lit and
the crossed rows don't light up; when the submenu lets go, the row under the
pointer takes the highlight. Clicks, keys and leaving the triangle behave as
before.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 12:34:12 -04:00
Tanish Mankuandjerrykimo da5abd10f9 Native brush outline and crosshair cursors on macOS, X11 and Wayland (#1343) (#3243)
* Native brush outline and crosshair cursors on macOS, X11 and Wayland (#1343)

The brush outline and crosshairs were OS cursor bitmaps only on Windows; on
macOS and Linux the app painted them, so they trailed the pointer by the
render, present and compositor latency and jumped to catch up on slow frames.
tool_cursor now hands the OS the bitmap on every desktop root viewport.

macOS takes NSCursor images in logical points, so the bitmap scales with the
UI zoom only there; Windows, X11 and Wayland take physical pixels. Off Windows
bitmaps stay within 255 px (X servers and hardware cursor planes), and a
larger tip falls back to the painted outline plus a small OS crosshair.

The Pencil's grid-snapped square and the pipette stay painted, and the Hand
bitmap stays Windows-only (the other platforms draw their own hands).

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

* Cursor test: the Hand is a bitmap only on Windows; elsewhere the OS draws Grab

---------

Co-authored-by: jerrykimo <[email protected]>
2026-10-11 12:21:40 -04:00
Myosh1 3f0970923d Add built-in rainbow gradient presets (#2916)
Add a "Rainbows" group to the built-in Gradients panel with five presets: Rainbow,
Pastel Rainbow, Neon Rainbow, Oil Slick and Rainbow Fade. The three hue ramps sample 13
evenly spaced hues (red 0 deg to magenta 300 deg) so the transitions are smooth rather
than the seven coarse ROYGBIV points; Rainbow Fade reuses the classic ramp with
transparent ends. All colours are original (clean-room).

Covered by a new engine test, presets::tests::rainbow_gradient_presets.
2026-10-11 12:18:19 -04:00
28622aabfc Move dock tabs between groups individually (#3239)
* Report which dock tab leaves its strip

* Carry dragged dock tab identity

* Expose dock panel name normalization

* Move individual tabs across native dock groups

* Document cross-group dock tab drops

* Test individual tab drops between dock groups

* Resolve a dropped tab by its original index; rustfmt

The strip reports visible indices, so a reordered group moved the wrong
panel. Adds a regression 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-11 12:10:27 -04:00
David MannandClaude Opus 5.5 66c0b59a23 Layers: Combine Shapes merges the selected shape layers (#2400) (#2906)
Layer › Combine Shapes › Unite / Subtract Front Shape / Intersect Shape
Areas / Exclude Overlapping Shapes only changed the operations between the
components of the active layer. With two single-shape layers selected (the
report's case) the active layer had one component, so nothing happened.

With two or more shape layers selected and no `layer`/`subpath` param, the
commands now merge them into the top-most layer in one history step: the
bottom layer's components keep their operations and every component of the
layers above takes the chosen one, in stacking order, then the other layers
are removed. Merge Shape Components merges them the same way (each layer
combined) and bakes the result. Selected non-shape layers are left alone,
locked layers refuse the merge, and one selected layer behaves as before.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 12:05:57 -04:00
Valentin KleinandClaude Opus 5.5 c1bd296dcf Select and Mask: Refine Edge Brush (#2972) (#2989)
* Select and Mask: live preview in Photoshop's view modes (#1754)

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 12:00:25 -04:00
3360f6e442 Speed up Tilt-Shift with existing per-level halo trimming (#2880)
Co-authored-by: optimalbytee <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 11:58:04 -04:00
Dylan M. Taylor 19f427aa00 ci: refactor workflows to use storytold/craft-common-actions (#3432)
* ci: refactor workflows to use reusable artcraft-common-actions

* ci: point reusable workflows to storytold/craft-common-actions

* ci: pin storytold/craft-common-actions to commit 27a978f and pass secrets explicitly

* ci: use @main (unpinned) for craft-common-actions with explanation comment
2026-10-11 11:50:48 -04:00
myangaa1 d10ded55d6 Clip the checker under rounded colour swatches to their corners (#3208) (#3363)
The colour swatches (Layer Style's Stroke, Color Overlay and the rest,
and every `color_swatch` / `color_swatch_button`) painted a square
checkerboard under a rounded fill, so the checker's light cells showed
at the four corners, white specks on the dark themes.

The checker under a rounded swatch is now a repeating two-by-two tile
textured onto a rect with the swatch's corner radius: one shared texture
per context, nearest-filtered so it never blurs, scaled so a cell stays
4 pt. `checker` itself is unchanged for the square previews.
2026-10-11 11:48:13 -04:00
5025fd2895 Color Sampler tool (#1046) (#2753)
* Color Sampler tool (#1046)

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 11:43:09 -04:00
d22a9fac6e fix(ui): Global Light opens with stale values and overwrites document setting (#3217)
Populate Layer › Layer Style › Global Light dialog fields from the
active document's shared global lighting rather than schema defaults or
remembered values from other documents. This ensures opening Global Light
and confirming unchanged preserves the active document's lighting.

Fixes #3054.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 11:36:23 -04:00
Valentin KleinandClaude Opus 5.5 7fd5715dbd Type menu: Anti-Alias › None first and checked; checks follow the edited layer (#2983) (#2984)
- menu_catalog: add Type › Anti-Alias › None before Sharp, as in Photoshop.
  It used to come from the engine list, appended after Windows and built
  without a checked state, so a layer set to None showed no check.
- view_cmds::type_checked reads type_tool::target_text (the edited layer,
  else the active or a selected type layer), as the options bar and the
  Type commands do, not only the active layer.
- Anti-alias and orientation items stay check items (Some(false)) with no
  type layer, so the native menu doesn't rebuild.
- Parity floor 629 -> 630; docs/parity-checklist.md regenerated.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 11:35:38 -04:00
Valentin KleinandClaude Opus 5.5 fd351918e5 PSD: cap the layer tree for psd_to_document; layered TIFF checks group depth (#2940) (#2975)
The file-open path rejects PSDs nested past MAX_GROUP_DEPTH before
conversion, but the public psd_to_document built file.layer_tree() to the
file's full depth (~16000 levels) before Ctx::build capped it, and layered
TIFF reached it with no preflight at all.

- photocraft-psd: PsdFile::layer_tree_max_depth(n) builds the tree
  iteratively and drops the children of groups nested n deep, so walking
  or dropping it recursively is bounded. layer_tree() is the uncapped case.
- psd_to_document uses it with MAX_GROUP_DEPTH, the same cap Ctx::build
  and apply_link_groups apply, so imports are unchanged.
- Layered TIFF rejects nesting past the cap with the same error as a PSD
  (shared psd_import::check_group_depth).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 11:20:32 -04:00
buivanvu94 6a2aaebe99 fix(ui): unchanged Custom Proof Setup replaces active document proof profile (#3213)
Populate View › Proof Setup dialog fields from the active document's
current proofing condition rather than schema defaults or remembered
values from other documents. This ensures opening Custom Proof Setup and
confirming unchanged preserves the active proof profile and condition.

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

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

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 11:09:38 -04:00
c0110fb4d2 fix(engine): allow targeting layer mask and vector mask via channel.target (#2458) (#2705)
* fix(engine): allow targeting layer mask and vector mask via channel.target (#2458)

* fix(ui): apply channel target actions before syncing mask targets in channels panel

---------

Co-authored-by: huudinh1401 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 11:06:57 -04:00
deb419eeb2 fix: macOS Monterey crash on Intel Iris Pro (disable wgpu indirect validation) (#3171)
* GPU startup: skip wgpu indirect-call validation shaders (#3107)

wgpu compiles its indirect-call validation compute shaders inside
request_device. On Intel Iris Pro (Metal GPUFamily 1, Monterey) the Metal
compiler hits an internal error on them, which wgpu-core reports as
Device(Lost), so the window device can't be created in any mode, including
--safe-gpu and PHOTOCRAFT_CPU_CANVAS. PhotoCraft issues no indirect draws or
dispatches, so drop the flag; WGPU_VALIDATION_INDIRECT_CALL=1 still re-enables it.

* gpu_startup: skip indirect-call validation only on Intel Macs

Keep wgpu's default validation on Apple silicon, Windows and Linux; only Intel Macs,
where Metal's compiler crashed on the validation shaders (#3107), skip them.

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-11 11:00:01 -04:00
a1de9ce4c8 fix(ui): respect native desktop scale under Auto UI scaling (#1278) (#3165)
* fix(ui): respect native desktop scale under Auto UI scaling (#1278)

* UI scale: drop the now-unused monitor size, update docs and scorecard

Merge main; Auto no longer reads the monitor size, so remove the parameter and its
computation, simplify the test, and update ui-design.md and UI-217-9 to match.

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-11 10:53:55 -04:00
AyanandBrandon Thomas acce8e0e99 Five exact-output speedups: dense Extrude 160s to 0.39s (#2685)
* Rasterize Extrude blocks and scatter Wind streaks once

* Prepare Spin Blur rotations and fixed-channel sampling

* Share Lens Correction interpolation across color and alpha

* Make wide prepress traps independent of radius

* Add full-operation timing for five imaging algorithms

* Record 24 full-operation benchmarks and exact-output checks

---------

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

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

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 10:48:26 -04:00
d67e28c118 fix(zoom): Alt-click with the Zoom tool on desktops that take Alt+click (#3137) (#3164)
Desktops that move windows with Alt+drag grab every Alt+left press, so a
Zoom tool Alt-click never arrived and nothing happened. Watch the Zoom
tool for that grab (alt_grab) so the status bar explains it, and let a
latched Alt (Window > Modifier Keys) zoom out: the click, cursor and zoom
rectangle now honour it.


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

Co-authored-by: Leel Wasanatha <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 10:47:37 -04:00
Elvis33LE 2c3d972f18 Speed up large content-aware scale operations (#2596) 2026-10-11 10:46:43 -04:00
LessaandBrandon Thomas 53d09f5850 Preserve HDR range in float adjustments and stack statistics (#2592)
* Preserve HDR range in float adjustments and stack statistics

* Cover HDR exposure round trips and Levels record order

* Report the backend used by GPU parity tests

* Compare float PSD oracles at native depth and preserve signed Exposure

* Retain merged alpha metadata when decoding float references

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 10:44:55 -04:00
Valentin KleinandClaude Opus 5.5 989644cabd Type tool: right-click menu while editing text (#2968) (#2969)
* Type: Edit › Paste, Copy and Select › All work on the text being typed (#2893)

While editing type, Edit › Cut already worked on the selected characters (#1355), but the other
menu rows still ran their pixel commands: Edit › Paste tried to paste an image (and did nothing
with text on the clipboard), Edit › Copy copied rendered pixels, and Select › All selected the
canvas. Route them through the type editor like the keyboard: Paste asks the window for the
clipboard text, which arrives as a Paste event and replaces the selection; Copy copies the
selected characters; Select › All selects every character. Copy is enabled for a non-empty
character selection, Paste and Select › All while typing.

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

* Type tool: right-click menu while editing text (#2968)

A right-click inside an on-canvas type edit opened nothing: the Type tool
had no canvas menu, and the inline type editor is not an egui TextEdit, so
the text-field menu doesn't reach it. It now opens Photoshop's type edit
menu (canvas_tool_menu.rs TYPE_EDIT_MENU), built from existing commands:
Undo, Cut, Copy, Paste and Select All (dispatched as the Edit/Select menu
items, which act on the text being edited), Check Spelling..., Find and
Replace Text..., Faux Bold/Italic (type.setStyle on the edit's selection,
checked), Rasterize Type, Create Work Path, Convert to Shape (these and the
paragraph/point conversions end the edit first), Horizontal/Vertical and
the anti-alias modes (checked), Convert to Paragraph/Point Text and
Warp Text.... The right-click keeps the text selection. While the menu is
open the keyboard belongs to it: Esc closes only the menu instead of
committing the edit. Agents open it through ui.pointer as for the other
canvas menus.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 10:44:36 -04:00
0f8b370621 i18n(es): native-style review of the Spanish catalog (#3154)
Aligns the Spanish UI with the terminology a Spanish-speaking Photoshop user already knows,
fixes mistranslations and makes the register consistent. 351 rows changed out of 2,457.

- Dodge/Burn were swapped (tools, Color Dodge/Burn, Paint.NET variants).
- "Constrain proportions" read "Construir proporciones" (build) instead of "Restringir".
- Feather and Fade shared one label, as did Artboard and Canvas.
- Photoshop terms: Mesa de trabajo, Motivo, Ajuste preestablecido, Métodos abreviados,
  Cuadrícula, Sector, Deformación de posición libre, Tampón de clonar, Calar, menú Vista…
- Formal "usted" throughout (the catalog mixed tú and usted).

docs/localization-es.md records the terminology so later additions stay consistent.


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

Co-authored-by: Diego Ospina <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 10:43:15 -04:00
Valentin KleinandClaude Opus 5.5 0fb4bbf1db PSD: don't inflate an over-budget ZIP merged image at parse time (#2926) (#2967)
validate_planes bounded its zlib loop by the uncapped declared size, so a
PSD/PSB declaring more than MAX_DECODED_BYTES of merged image could make
PsdFile::from_bytes decompress far past the decode budget before any
error. decode_planes refuses such a layout with LimitExceeded anyway, so
the stream is now kept verbatim without inflating it, like an unknown
compression method. A layered file whose merged composite is too large
still opens (the importer only warns when the composite can't be decoded).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 10:40:31 -04:00
buivanvu94 1fdb0378a3 fix(ui): respect explicit dropdown defaults in schema dialogs (#3056) (#3149) 2026-10-11 10:36:15 -04:00
Brennan StehlingandClaude Opus 5.5 16bdcbd54e Window menu: list the open documents, the active one checked (#2991) (#3147)
Photoshop's Window menu ends with the open documents; PhotoCraft's had no
list in either menu bar (#1264 got the tab overflow menu only). Window now
ends with a separator and one row per document in tab order, labelled with
its name and checked when active; choosing one brings it forward. The ids
are window.document.<index>, so the rows also work over ui.menu.invoke.

- In-window menu bar: the rows come from menus::menu_items, after Window's
  last item. Names are shown as they are, never translated.
- macOS menu bar: the rows follow Bring All to Front, where a Mac app lists
  its windows. Opening or closing a document rebuilds only the Window menu:
  AppKit adds its own items there (window tiling, the window list), so muda
  can't insert rows by position, and the other menus stay as they are.
  Switching documents moves the check in place. The menu state hash now
  includes the documents' names, so a Save As rename shows up.

Tested in a Tart guest with three generated files (.psd, .png, .tif) in
both menu bars: on main neither lists them; with this change both do, and
choosing a row switches the document and moves the check.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 10:31:26 -04:00
kayrockscreenprintingandClaude Opus 5.5 1f742629bb Dialogs: keep the live dialog in front of older ones (#3140)
Only the last dialog takes input, but egui only lets the modal drawn on top
take input, and it raises an area that is pressed or wasn't drawn the frame
before. With several stacked dialogs (three files opened together, each with
an Embedded Profile Mismatch prompt) an older prompt could end up in front:
greyed out, with the live one blocked behind it, so nothing could answer
either and the app had to be quit. Raise the live dialog when another one
is above it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 10:25:29 -04:00
d45b3718ce New Document: presets follow Units & Rulers › Preset Resolutions (#204) (#3126)
Preferences › Units & Rulers › New Document Preset Resolutions did nothing:
Print Resolution was hidden as unread, and the File › New presets always used
300 / 72 ppi. As in Photoshop, print and photo presets now keep their size in
inches at the Print Resolution (Letter at 200 ppi is 1700 x 2200 px), and
screen presets keep their pixels at the Screen Resolution. The Clipboard
preset keeps the copied pixels. Values that are not positive and finite (a
hand-edited preferences file) leave the preset as listed, and sizes clamp to
the file.new limit.

Print Resolution is now shown in the Preferences dialog; settings that do
nothing: 43 -> 42 of 150.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 10:15:40 -04:00
Valentin KleinandClaude Opus 5.5 7b88189e2d Type: Edit › Paste, Copy and Select › All work on the text being typed (#2893) (#2960)
While editing type, Edit › Cut already worked on the selected characters (#1355), but the other
menu rows still ran their pixel commands: Edit › Paste tried to paste an image (and did nothing
with text on the clipboard), Edit › Copy copied rendered pixels, and Select › All selected the
canvas. Route them through the type editor like the keyboard: Paste asks the window for the
clipboard text, which arrives as a Paste event and replaces the selection; Copy copies the
selected characters; Select › All selects every character. Copy is enabled for a non-empty
character selection, Paste and Select › All while typing.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 10:09:30 -04:00
ade5799165 Mirror the shell for right-to-left UI languages (DIST-220-4) (#3114)
* Mirror the shell for right-to-left UI languages (DIST-220-4)

Arabic, Hebrew, Persian and Urdu read right to left, but the window's
chrome was hard-wired left to right, so an RTL catalog (#2505) would
put Arabic labels into a left-to-right layout.

The reading direction now follows the language code
(i18n::dir_for_code: ar, ckb, dv, fa, he, ps, sd, ug, ur, yi), so a
catalog needs no extra setting and the LANGUAGES rows are unchanged.
In a right-to-left language the shell mirrors:

- the menu bar starts at the right with File; menus open under their
  title's right edge, and the arrow keys step through them in screen
  order
- the toolbox docks on the right, the panel dock and its rail on the
  left; tool flyouts open towards the canvas and their corner marks
  and the header chevrons follow
- the title, options and status bars read from the right, with the
  workspace controls and the Fit/progress group at the left end; the
  title and window-drag gap use the free span between them

New module direction.rs holds the helpers (start_panel/end_panel,
row/trailing layouts, horizontal_centered, the popup alignment). Left
to right, every change lays out as before.

No RTL catalog ships yet, so PHOTOCRAFT_UI_DIRECTION=rtl forces the
mirrored layout for review (read once, native only). Panel interiors,
dialogs, menu rows, submenus and document tabs stay left to right, and
egui still draws UI labels without Arabic shaping or bidi reordering;
docs/localization.md and the parity docs say so.

Adds Lucide's chevron-left (the status bar's info menu arrow points
left in RTL; Timeline's Previous frame button asked for it too and drew
the fallback square).

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

* Localization docs: egui shapes Arabic words; what's missing is a font and bidi word order

egui 0.36 shapes right-to-left runs with HarfRust, so letters join within
a word. The gaps are an Arabic fallback font in the UI and paragraph bidi
(words are placed left to right; emilk/egui#8576, #8577).

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

---------

Co-authored-by: MathMattTech <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 10:06:18 -04:00
Valentin KleinandClaude Opus 5.5 1a54384d80 Rulers: remember View › Rulers for new documents; guides as f64 (#2918) (#2954)
View › Rulers (⌘R) now writes unitsAndRulers.showRulers, which is applied
at launch and whenever the preference changes, so new and opened documents
show the rulers as last set, as in Photoshop. The Preferences dialog doesn't
list it: the View menu sets it.

Guides are Vec<f64> (doc, New Guide/Move Guide, guide layouts, artboard and
canvas geometry, snapping, PSD resource 1032), so a guide placed in ruler
units or read from a PSD's 1/32 px positions keeps its exact place.

Scorecard UI-217-20 done (part of #217).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 10:05:13 -04:00
2d2e4993a1 fix(ui): reduced suffix spacing width in the value field to prevent label overflow (#3040) (#3084)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 09:59:53 -04:00
Tanish Manku 75101ddce2 8-bit documents: soft, low-flow brush strokes are dithered instead of banded (#3237)
Brush phase 6c. A stroke composites in float, then each pixel is rounded to 8 bits, so a soft,
low-flow stroke's gentle gradient collapses into flat bands (the audit counted 35 levels against
119 at 16-bit). The pixels the stroke changes now get the same deterministic per-pixel offset of
under half a level on every channel before quantisation:
- fractional values round up or down in proportion, so bands become fine grain;
- a value already on an 8-bit level rounds back to itself, so exact colours never change, and
  pixels the stroke doesn't touch are left alone;
- the offset depends only on the pixel's position, so the live preview, the commit and a replay
  agree.
16-bit and float targets are unchanged.
2026-10-11 06:58:47 -07:00
52e130d481 Fix canvas context menus closing immediately on trackpad taps (#3029)
* Fix canvas context menus closing immediately on trackpad taps

Ignore outside-click dismissal during each menu's opening frame.
Share the guard across canvas tool and layer menus, including control-channel openings.
Add regression coverage for quick taps, repeated reopening, dismissal, and selection/history preservation.

* Share popup dismissal guard with the brush picker

* Merge main: pass the context to main's new control-channel Free Transform menu opener

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-11 09:52:26 -04:00
30bd8a5598 Fix Shape Stroke controls, popup interactions and paint colours (#3019)
Co-authored-by: optimalbytee <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 09:46:44 -04:00
3cbd727b85 Tests: the marquee ellipse check walks several dash periods of the curve (#3018)
* Tests: the marquee ellipse check walks several dash periods of the curve

The old check sampled ~12 positions along a 28 px arc of a 4-on/4-off ants
pattern: a margin of one sample, so renderer, antialiasing or dash phase
decided pass or fail. Walk the true ellipse around each edge midpoint over
±0.3 rad instead, with the ±2 physical px band hugging the curve, so the
check sees the dashes wherever the phase puts them (#253 follow-up).

* 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-11 09:40:44 -04:00
585131440a Pixel grid: fade the line colour across mid-tones instead of flipping at one luma (#2990)
* Pixel grid: fade the line colour across mid-tones instead of switching at one luma

The grid line was a full white line over pixels at or below luma 0.55 and a weaker
black one above it. On an image whose tones sit near that cut, neighbouring cells
landed on either side and the grid showed bright, dark and different-strength lines
in patches that traced the 0.55 contour.

The line is now a white layer and a black layer weighted by a smoothstep over luma
0.35 to 0.75, so mid-tones get a faint line that varies smoothly and dark and light
pixels keep the old white and black lines. The GPU shader and the CPU path
(pixel_grid.rs) use the same band; the CPU share is rounded to eighths so equal-colour
runs still merge into long segments.

Tests: unit tests for the fade and mid-tone evenness, and a GPU render test that
fails on the old shader (grid line 30 levels off its pixel at grey 0x87).

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

* Pixel grid: test the line over opaque white too (the #2461 review case)

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

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 09:35:34 -04:00
Harrison O'Connor-Hoover 7a6058e6b4 control: allow requests that operate an open Camera Raw dialog (#2988)
Preserve profile, competing-dialog, crop, discard and authorization gates. Add control-handler regressions for RAW preview, commit and cancel.

Prepared with Codex assistance.
2026-10-11 09:26:46 -04:00
Valentin KleinandClaude Opus 5.5 885b754414 Text fields: right-click edit menu (#2921) (#2952)
* Text fields: right-click edit menu (#2921)

Right-clicking in any text field (layer rename, a number field being
typed into, dialog and panel fields) opens Undo, Cut, Copy, Paste,
Delete and Select All at the pointer, acting on that field. A
right-click inside the selection keeps it, Esc closes only the menu,
and clicking the menu doesn't take the field's focus (the layer rename
would commit). The right-click never reaches the canvas or tool menus.

An egui plugin (text_field_menu.rs) opens and closes the menu for
every TextEdit; the app draws it before any field, so a chosen item is
handed to the field as its keyboard input in the same pass. Paste asks
the window for the clipboard text like Cmd+V in a field. No spelling
suggestions: there is no spell checker (UI-217-8 stays partial).

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

* Text field menu: correct the spell-checker note (#2921)

PhotoCraft has a spell checker (edit.checkSpelling with a suggest
action); it just isn't wired into the text-field context menu yet.

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

* Context-menu parity doc: same spell-checker correction (#2921)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 09:25:19 -04:00
1234ba4c6f Type: wire Show Font Names in English (#204) (#2981)
Preferences > Type > Show Font Names in English (default on) now drives the
font menus (Type options bar, Character panel, style and Glyphs panels). With
it off, a family whose name table carries a non-English family name (e.g.
"MS Gothic" -> "MS ゴシック", "Microsoft YaHei" -> "微软雅黑") is listed by
that native name, sorted by the shown label, and the search matches either
name. Choosing it still applies the English family, so documents and PSD
output never change with the preference.

- text: FontDb::localized_family_names() reads each family's first face
  (typographic family name, else family name) and a pure localized_name()
  picks the first non-English record.
- ui-egui: sync_font_names() publishes the labels to the egui context once a
  frame (cached per font generation); family_picker_in shows them.
- prefs: the row is no longer hidden; scorecard: dead settings 43 -> 42.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 09:21:55 -04:00
Valentin KleinandClaude Opus 5.5 7c96e9e120 Layer Style: contour picker and Contour Editor (#2912) (#2949)
Every effect with a contour (Drop/Inner Shadow, Outer/Inner Glow, Satin,
Bevel & Emboss Gloss Contour and Contour element) now shows a contour
thumbnail with a preset picker and an Anti-aliased checkbox. Clicking the
thumbnail opens the Contour Editor: add/drag/delete points (the shared
point-curve editor), Corner per point, Input/Output, Preset, and Load…/Save…
of .shc contour files, read and written from Adobe's public File Formats
Specification (Contours). Edits go to the dialog's pending params, so the
preview follows and OK applies them in the one layer.layerStyle.replace.

- doc: Contour::Custom gains per-point corner flags (serde default).
- compose: contour_curve_lut splits the spline at corner points; CPU and
  GPU effects use it.
- engine: the 12 standard presets with our own control points (older names
  still accepted); `contour` params also take a curve object
  {name, points, corners}; antiAlias params; Satin contour; the bevel
  Contour element edits in place; contours_from_shc / contour_to_shc.
- io: PSD contour points read and write the Cnty (continuity) flag.
- psd: shc.rs parser/writer.
- scorecard FILE-215-15 missing -> partial.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 09:21:18 -04:00
6601bbc3e7 Layers: Pattern Fill dialog for new and edited pattern fill layers (#2908) (#2979)
* Layers: Pattern Fill dialog for new and edited pattern fill layers (#2908)

Layer > New Fill Layer > Pattern... created the layer with default
settings and no dialog, and double-clicking a Pattern Fill thumbnail only
opened Properties, which has no pattern controls, so a pattern fill could
not be adjusted after creation.

Both now open Photoshop's Pattern Fill dialog: the pattern swatch picker,
Snap to Origin, Angle, Scale and Link with Layer, previewed live on the
canvas from a document snapshot. OK runs one engine command, so it is one
History step; Cancel changes nothing (a new layer is not created).

- engine: new `layer.fill.pattern.set` (pattern_fill_cmds) edits a Pattern
  Fill's pattern, scale, angle, link and phase in one undo step, validating
  every param; `preview` applies it or `layer.newFillLayer.pattern` to a
  copy of the document for the dialog. `new_fill_layer`'s edit is shared
  as `insert_fill_layer` so the preview is exactly what OK commits.
- ui: pattern_fill_ui (dialog body, OK, canvas preview), routed from the
  menu item (no params) and Layer Content Options; explicit params still
  run the engine directly. Catalog entries for the new strings.

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

* Pattern Fill test: pick the swatch picker's Patterns button (main added a Patterns dock tab)

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 09:16:18 -04:00
60339cf675 app.open: open directory .pcraft through the automation read root (#2490) (#2581)
* app.open: open directory .pcraft through the automation read root (#2490)

The native on-disk format is a directory, but the automation read path
serves regular files only, so agents could not open the files the app
itself writes. The format crate's storage seam is now public (ByteSource
+ load_bytes_source); the workspace opens the .pcraft subdirectory through
the held capability and loads through it. app.open tries the directory
form for .pcraft paths and falls back to the bytes path for the
single-file (zip) form; every other format is unchanged.

* app.open .pcraft: review fixes (#2581)

read_entry now bounds the read itself (the stat length is only a buffer
hint) and both directory sources reject non-regular entries, so a crafted
bundle with a FIFO entry can no longer block the open and stream unbounded.
The desktop opener runs under the crash guard like its import sibling, the
zip fallback is structural (Ok(None) instead of an error-message match),
a directory-form document no longer arms a save-back target the file-only
automation writer could never replace, and the open status is translated
(Opened {name}, 16 locales).

* read_entry: saturating max+1 so a usize::MAX budget can't overflow

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-11 09:12:59 -04:00
10fb9233d5 Tools: wire Enable Flick Panning for the Hand tool (#204) (#2964)
* Tools: wire Enable Flick Panning for the Hand tool (#204)

Preferences > Tools > Enable Flick Panning (default on) was never read.
Like Photoshop, a Hand drag (the selected Hand, the Space-held Hand or a
middle-button drag) released while the pointer is still moving now keeps
the view gliding that way and slows smoothly to a stop; a click, new drag
or key press stops it. Off, the view stops where the drag ended.

flick_pan.rs holds the pure state: the release velocity from the last
80 ms of drag steps, an exponential friction step (tau 0.18 s, so a fast
flick rests in under a second), a start/stop speed threshold, and clamps
for huge, NaN or infinite numbers. The canvas drives it per frame with
request_repaint only while gliding; with Overscroll off the glide is
clamped like the scrollbars and stops at the pan limit.

Settings that do nothing: 43 -> 42 of 149 (docs/scorecard.md).

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

* Preferences: show Enable Flick Panning now that the Hand tool reads it (#204)

Remove tools.enableFlickPanning from prefs::HIDDEN_UNTIL_IMPLEMENTED so the
prefs_usage test passes and the dialog shows it; regenerate the scorecard
(settings that do nothing: 36 of 153) and mark flick panning done in
docs/ui-parity.md.

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-11 09:03:11 -04:00
cb9513c718 Channels: keep RGB off while an alpha or mask is shown, and select from that channel. (#2555)
* Channels: keep RGB off while an alpha or mask is shown, and select from that channel.

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

* Channels: rustfmt the RGB-off view check.

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

* channel view: pass the active layer to render in main's new tests

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

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 09:03:05 -04:00
026432815c Preferences: wire Tools › Show Transformation Values (#204) (#2953)
* Preferences: wire Tools › Show Transformation Values (#204)

The size and angle readouts beside the pointer (marquee W/H, Crop frame
W/H and angle, Crop Straighten angle) now go through
canvas::draw_value_readout, which draws them only while
tools.showTransformationValues is on, as in Photoshop. The brush HUD
isn't a transformation value and keeps drawing.

The preference leaves HIDDEN_UNTIL_IMPLEMENTED, so Preferences › Tools
shows it (its label is already translated). Settings that do nothing:
43 → 42 of 149.

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

* Contributors: add my display name

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-11 08:57:49 -04:00
55a617cd66 Place: wire Always Create Smart Objects When Placing (#204) (#2543)
* feat(engine): Always Create Smart Objects When Placing gates the Place layer kind (#204)

* scorecard: regenerate after wiring Always Create Smart Objects When Placing

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-11 08:51:33 -04:00
a0ff3a01f2 Smart Objects: linked smart objects follow their files while open (#2510)
* Smart Objects: linked smart objects follow their files while open

As Photoshop documents ("Linked Smart Objects update automatically when
the Photoshop document is open, provided the source file has changed"),
a linked smart object whose file another app saves is re-rendered
without Update Modified Content. The desktop app stamps the active
document's linked files (size + modification time) on a worker thread
every two seconds and when its window comes back to the front; each
changed file is one Update Modified Content undo step, independent of
the selected layer. Stamps are kept per document; a file that can't be
read yet leaves no step and is tried again; files changed while the
document was closed still wait for Update Modified Content.
layer.smartObjects.updateChanged runs one look (refused over the
control channel like the other layer.smartObjects commands).

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

* Smart Objects: a linked file that can't be read is tried again once it changes, not on every look

A linked file the automatic update couldn't read (still being written, or a
format smart objects don't read) kept its old stamp, so every two-second look
saw it as changed and read it again on the UI thread. It now keeps its new
stamp and is tried again once it changes again.

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

---------

Co-authored-by: Katt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 08:42:52 -04:00
David MannandClaude Opus 5.5 f781cb70fc Layers: right-click an effect row for the layer style menu (#2846) (#2950)
Right-clicking the "Effects" row or an effect row under a layer did nothing.
Like Photoshop, it now opens the layer style menu for that layer: Blending
Options, Copy/Paste/Clear Layer Style, Global Light, Create Layer, Hide All
Effects and Scale Effects. Items run the existing commands with menu-item
params (dialogs open as from Layer > Layer Style), selecting the right-clicked
layer first. The item loop of the layer row menu is shared.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 08:37:34 -04:00
509b596805 Dropdowns: type a letter to jump to an item, Enter to choose (#1481) (#2948)
As in Photoshop's pop-up menus, typing a letter in an open dropdown list
jumps to the next option starting with it (the same letter again cycles
through them), letters typed within a second match a longer prefix, and
Enter closes the list on the option shown. Matching is case-insensitive
on the translated labels (Unicode lowercase, no byte slicing). While a
dropdown list is open it owns the keyboard like an open menu, so its
letters no longer reach the tool shortcuts.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 08:33:29 -04:00
Ahmed Hosny 3e3e580217 MCP bridge: reconnect before idle sockets die, retry reads, actionable timeouts (#2487) (#2507)
* MCP bridge: reconnect before idle sockets die, retry reads, actionable timeouts (#2487)

The app closes idle control connections after 30s (IO_TIMEOUT) while the
bridge kept one persistent connection, so the first call after a pause
failed with "operation may have completed". The bridge now drops and
reconnects before sending once the connection has been idle 25s, so a
request is never written to a socket the app may have dropped.

The default call timeout drops from 60s to 55s, strictly below the app's
60s reply deadline, so the bridge's own error — naming the method and the
likely causes — is the one callers see instead of a race between the
app's deadline reply and the caller's own cutoff.

Idempotent read methods (ui.inspect and friends) get one automatic retry
on a transport failure; edits keep #1007 semantics and are never resent.
A timed-out call is never retried (it would only double the wait).

* MCP bridge: caller-vouched read retries, per-failure wording, docs contract (#2487 review)

The retry decision moves from a hand-maintained method list — most of whose
entries were engine command ids that never travel as control-protocol
methods, so they could never match — to the call sites that know a call is
a read: call never resends (#1007), call_read retries once on a transport
failure, and the MCP server opts its pure-read tools in. Failures are
worded by what they prove: a connection that could never be established is
"the request was not sent", not "may have completed"; a read timeout says
a read may be retried safely instead of demanding a state inspection. The
55s client cap, the read-retry exception, and the idle reconnect are now
documented in the control protocol and the security book.
2026-10-11 08:29:35 -04:00
ecbd3d78be Toolbar: Pro themes draw tool glyphs at two thirds of the button (#2665) (#2932)
The Pro toolbar drew each tool glyph at 16 px in a 30 px button (the
generic icon-button proportion), which reads small and thin next to
Photoshop's toolbar. Pro themes now draw tool glyphs at 20 px; the other
themes keep the icon-button size.


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

Co-authored-by: ngrafton2006 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 08:28:38 -04:00
UnemarsandClaude Opus 5.5 0b4b1e4286 Image Size: Photoshop's resampling, measured (#2451)
* Image Size: Photoshop's resampling, measured

Photoshop 25.4's Image Size was measured as a black box (synthetic 16-bit
images in, exact RAW out); every method but Preserve Details' enlargement is
linear, so each was taken apart from its impulse responses.

- Bicubic is the Keys cubic at a = -0.75, not Catmull-Rom.
- Bicubic Smoother (stretched x1.15, a = -0.625) and Bicubic Sharper
  (x1.05, a = -1), with a growing with the reduction, and Bicubic Automatic.
- Reducing widens the filter by 1/s; below 1/4 the image is first averaged
  down to four times the target. Bilinear is never widened and averages
  whole pixels below 1/2. Positions are rounded to 1/128 pixel and stop at
  the edge pixel's centre.
- Two passes, the smaller scale first, clipped and stored between them; the
  bicubic filter is designed for the height's scale and serves both axes.
  Bicubic Automatic splits a mixed resize into Sharper, then Smoother.
- Image Size and Fit Image take automatic, bicubicSmoother, bicubicSharper
  and bicubicAutomatic; the dialog lists Photoshop's methods and opens on
  the interpolation preference.

On 225 probes the mean error falls from 240-550 to under 0.5 levels of
32768; 8-bit results are identical to Photoshop's for 99 % of values.

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

* Format: one rustfmt change main needs

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 08:25:21 -04:00
Valentin KleinandClaude Opus 5.5 7107c80042 perf: pasting a layer recomposites only where it draws (#2901) (#2917)
* perf: Duplicate Layer recomposites only where the copy draws (#2883)

Duplicating a layer reported no damage, so the canvas recomposited the
whole document after every Duplicate Layer (P8/P9). The copy itself was
already cheap (tiles are shared Arcs). Report the copy's change bounds
instead, through the step_damage walk extended to bounded additions:
self-contained added layers (not an adjustment, not a new clipping base,
no video or excluded channels) contribute their change bounds; anything
else still recomposites everything.

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

* perf: pasting a layer recomposites only where it draws (#2901)

Paste (and Paste in Place, Paste Into, Paste Outside) inserted the
pasted layer through Session::edit, which reports no damage, so every
paste recomposited the whole document (P10/P11). Capture the document
before the paste and report the new layer's change bounds with
note_insert, as Duplicate Layer does (#2884). Anything that isn't
self-contained (a new clipping base, video, excluded channels) still
recomposites everything.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 08:24:51 -04:00
ec28fdd03d Add Photoshop-style guide layout dialog and live preview (#2930)
* feat(ui): add guide layout dialog with live preview

* test(ui): restore path helper used by save dialog assertions

* chore(i18n): avoid conflicts with appended catalog entries

* chore: avoid append conflict for contributor credit

* Merge main: one people.toml entry for Skyfuller1303, drop the duplicate joined helper

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-11 08:23:03 -04:00
Valentin KleinandClaude Opus 5.5 42e4af6416 Tools: Frame tool (#2914) (#2915)
The Frame tool (K, its own toolbar slot after the Crop group) draws a
rectangular or elliptical frame; the options bar picks the shape, Shift
constrains to a square or circle and Alt draws from the centre. A frame
drawn over the selected pixel layer's content takes that layer in;
otherwise it is an empty frame.

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

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 08:20:15 -04:00
Valentin KleinandClaude Opus 5.5 614d06892c perf: marquee selections on large documents are built row by row (#2888) (#2907)
The Elliptical Marquee tested and wrote every pixel one at a time
(Surface::write_pixel), single-threaded, with no shared full tiles. The
add/subtract/intersect modes read both selections over the whole canvas
into f32 buffers. On 6000x4000 an ellipse took ~0.6-1.1 s and a mode
combination ~0.3-0.5 s.

ellipse_surface builds the shape per row: a run of fully selected pixels
(bounded analytically, checked with the exact f32 tests at both ends)
and edge pixels that get the original per-pixel coverage test. Tiles are
built in parallel, and fully selected tiles share one tile.
combine_surfaces maps two GRAY8 selections tile by tile through a
256x256 byte table, sharing tiles that come through unchanged. Both
produce exactly the old bytes (equivalence test against the old
per-pixel code).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 08:11:17 -04:00
984bc5c715 scorecard: twenty rows describe work that is already in the tree (#2894)
* scorecard: eight Open bugs rows describe work that is already in the tree

AGENTS.md sends contributors to the scorecard's missing and partial rows when
picking work, so a stale row costs someone a session: they sit down to write a
test that is already there. These eight ask for work that exists and is covered
by passing tests.

#205 / #207, mask and channel transforms:
- BUG-205-4 ("no raster-mask test"): linked_raster_mask_moves_with_the_pixels.
- BUG-205-5 ("the other two not implemented"): all three cases are covered, by
  linked_mask_with_a_selection_moves_only_the_selected_region,
  unlinked_mask_stays_put and targeted_unlinked_mask_transforms_alone.
- BUG-207-4 ("transform() has no target handling"): edit.transform takes target
  (pixels, mask, quickMask or {channel: i}) and documents it in its params doc;
  targeted_alpha_channel_transforms_alone covers the alpha channel.

#206, kerning — the whole section:
- BUG-206-1: CharStyle has both `kerning: Kerning` and `kern: f32`.
- BUG-206-2 / BUG-206-3: io/tests/psd_kerning.rs round-trips manual, Off and
  Optical kerning, and reads the AutoKerning flag and Kerning value of every
  character back out of the written TySh.
- BUG-206-4: Alt+Left/Right at a collapsed caret calls kern_pair, and the
  Character panel field parses Metrics / Optical / a manual value; both tested.
- BUG-206-5 stays partial, not done: the round trips are covered, but no oracle
  corpus file is dedicated to kerning values, which that row also asks for.

FILE-216-5 keeps its missing status (no save-path tests for layered TIFF, PDF,
SVG, ACO/ASE, .shc or Smart Filters), but its note claimed there is no
atomic-save failure-injection test; format/src/atomic/tests.rs has two, and
BUG-203-6 already records them as done.

docs/scorecard.md regenerated with cargo xtask scorecard; Open bugs moves from
22/2/8 to 29/2/1.

* scorecard: the Type section's kerning and Thai rows are out of date too

TYPE-218-6 ("Optical renders as Metrics; Alt+arrow moves by word") is done:
Alt+Left/Right at a collapsed caret calls kern_pair, optical kerning is computed
from the glyph outlines in text/src/optical.rs and applied by layout.rs when the
run's mode is Optical, and both modes round-trip through PSD. Covered by
type_tool_alt_arrows_kern_the_pair, optical_matches_photoshop,
optical_kerning_tightens_open_pairs, txt2_carries_optical_kerning and
io/tests/psd_kerning.rs. The row points at #206, whose own rows are in the same
state.

TYPE-218-3 goes to partial, not done: Thai is covered (THAI_FAMILIES in fonts.rs
and thai_in_latin_font_falls_back_to_installed_thai_font, #1909), but Khmer and
Indic have no fallback list and there are still no per-script golden tests.

Unchanged because they read correctly: TYPE-218-1 (TextShape is still Point and
Box), TYPE-218-2 (no direction UI), TYPE-218-4 (the axes in the shell are the
brush's), TYPE-218-8 (no triple or quadruple click) and TYPE-218-9.

* scorecard: Tools and Distribution rows are out of date too

TOOL-213-7 ("Live stroke only for Brush and Eraser"): canvas.rs strokes_live
covers Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn and Sponge,
and live_retouch_command adds the Clone Stamp, Healing Brush and History Brush.

TOOL-214-8 ("ruler only feeds straighten"): image.rotation.arbitrary opens
seeded with the measured ruler angle and the direction that straightens it;
arbitrary_rotation_starts_at_the_ruler_angle checks 5.7106 deg within 1e-3 and
the direction for three lines.

TOOL-214-5 goes to partial, not done ("No context menu in canvas.rs or
transform_tool.rs"): TRANSFORM_MENU does open on a right click during a
transform, with Free Transform, Scale, Rotate, Skew, Distort, Perspective and
Warp, and three tests cover it — but the row also asks for Flip and
Rotate 90/180, which are not there.

DIST-220-2 ("release.yml only x86_64 and i686 msvc"): the Windows matrix has
{ arch: arm64, target: aarch64-pc-windows-msvc }, and windows-arm64.yml
installs that MSI on a Windows 11 ARM64 runner, checks the binaries are ARM64
and runs the CLI natively. Signing takes the same path as x64 (DIST-2).

Unchanged because they read correctly: the whole Automation section (ABI_VERSION
is still 1 with Kind::Filter only, there is no ONNX dependency, no such xtask
check, and agent_tasks.rs really does run ten scripted tasks), TOOL-213-12,
TOOL-214-4, TOOL-214-6, DIST-220-1 and DIST-220-3.

* scorecard: Workspace and UI and File compatibility rows are out of date too

UI-217-9 ("prefs.rs UiScale unused"): prefs::UiScale offers Auto plus 75 to 300
percent, display_scale resolves it and sync_display_scale applies it with
ctx.set_zoom_factor; being a preference, it is remembered. The zoom_factor
assert the note cites is in a canvas-zoom test, checking that zooming the
document does not scale the interface.

FILE-216-1 ("No corpus step in ci.yml; in progress in #190"): ci.yml has a
corpus job on every PR and on main, pinned, sha256-verified and cached by the
pins. #190 is merged.

UI-217-22 and UI-217-23 go to partial, not done:
- layer.stampVisible merges the visible layers into a new layer and leaves the
  originals, and layer.stampDown exists, but neither has a menu entry and
  Alt+Merge is still missing.
- layer.copyToDocument copies layers into another document keeping masks,
  styles and blending (#589), but there is still no copy/paste layer clipboard.

UI-217-11 keeps missing; only its note changes, from "five themes" to the eight
ThemeKind has. No Soft dark/Soft light, previews or outlined knobs.

Unchanged because they read correctly: UI-217-20 (guides are still Vec<f32> and
there is no rulers preference), FILE-215-6 (SmartObject.transform is still an
Affine), FILE-215-19 (no JPEG Options dialog), and the rest of both sections.

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 08:10:23 -04:00
a874b149f2 Themes: Photoshop's light-gray and light Pro brightness levels (#2661) (#2892)
Photoshop has four interface brightness levels; PhotoCraft had the two
darkest (Pro, Pro Medium Gray). Add the other two with the Pro layout:

- Pro (Light Gray), id proLightGray: #B8B8B8 panels.
- Pro (Light), id proLight: #F0F0F0 panels.

They are light themes: Preferences > Interface lists them under Light
Theme, Window > Theme has an entry for each, and the legacy
interface.theme preference selects and migrates them. ThemeKind::is_pro
replaces the hard-coded Pro | ProMedium checks so the dock keeps the Pro
tab layout on every Pro level. Labels are translated in every catalog.

Tests check the panel greys, ids and names, the darkest-to-lightest
order, and WCAG contrast of text, icons and accent text on every surface.


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

Co-authored-by: ngrafton2006 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 08:09:05 -04:00
Valentin KleinandClaude Opus 5.5 e7c5df0d69 perf: keep resident GPU tiles when a brush dab grows a layer (#2900) (#2902)
A dab that painted past a layer's stored tiles changed its resident page
region, which recreated the texture and converted and uploaded every tile
of the layer in that page cell again. Copy the overlap from the old
texture on the GPU instead (in its own submission, ahead of the queued
tile writes) and keep its tile map, so only new or changed tiles upload.

32-bit float RGB/gray tiles also convert to RGBA16F straight from their
samples (same bytes as the general path) instead of decoding into a float
buffer and converting pixel by pixel.

P16 (32-bit, 20 layers) brush dab + refresh, median of 6 alternating runs:
p50 8.9 -> 3.1 ms, p95 27.2 -> 7.5 ms. P13 is unchanged (~1.6 / ~4.5 ms).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 08:01:03 -04:00
David MannandClaude Opus 5.5 167ea87ef1 Status bar: buttons sit inside the bar with centred labels (#2823) (#2876)
The Fit button was a full-size 30 pt button in the 30 pt Studio bar and
the zoom field a 24 pt box in the 24 pt Pro bar, so both touched or
overflowed the bar's edges. Inside every value field the theme's 24 pt
interact height and 4 pt button padding made the number taller than its
slot, so it sat low and, in a narrow box, spilled out of the left edge.

The status bar's controls now take a compact height (22 pt in Studio,
18 pt in Pro) from status_bar_heights, leaving even margins, through
new compact_value_field and compact_button widgets. A value field's
number slot is at least one text row tall and the number widget is
sized to it, so the digits sit 1 pt above the box's centre as intended.

With the bar at its true height the canvas shifted by 1 pt, which showed
that the live lasso ants were not on pixel centres; they are now snapped
like the marquee's.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 08:00:18 -04:00
Valentin KleinandClaude Opus 5.5 3e90c81e3c perf: Duplicate Layer recomposites only where the copy draws (#2883) (#2884)
Duplicating a layer reported no damage, so the canvas recomposited the
whole document after every Duplicate Layer (P8/P9). The copy itself was
already cheap (tiles are shared Arcs). Report the copy's change bounds
instead, through the step_damage walk extended to bounded additions:
self-contained added layers (not an adjustment, not a new clipping base,
no video or excluded channels) contribute their change bounds; anything
else still recomposites everything.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 07:56:05 -04:00
SkyfullerandBrandon Thomas 51f0a287b1 Refine gradient stop controls in Properties and Gradient Editor (#2859)
* feat(ui): refine gradient fill properties

* feat(ui): use combined stops in Gradient Editor

* test(ui): restore cross-platform save path helper

* Refine Properties with a shared gradient ramp

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 07:54:47 -04:00
Valentin KleinandClaude Opus 5.5 9b3c6359ae perf: Select > Modify > Feather/Smooth on 24 MP within budget (#2873) (#2882)
The blur itself was already parallel (9-15 ms on 24 MP); most of the
command was serial glue around it. Modify copied the mask into a padded
buffer and back out sample by sample even with no padding, and combine
built a full-canvas byte buffer, scanned it, then copied it into tiles
on one thread.

- modify: use the mask as is when the selection is clear of the canvas
  edge (pad 0); pad and crop row by row otherwise.
- combine / mask_to_surface: fill selection tiles in parallel straight
  from the mask, a row at a time; fully selected tiles share one
  copy-on-write tile.

Bit-identical: the per-sample oracles (combine, now with whole tiles,
and the old pad/crop) are compared in tests.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 07:48:51 -04:00
c732ecd5b0 Dropdowns: press, drag onto an option and release to choose it (#2735) (#2854)
A dropdown needed two clicks: egui's ComboBox opens its list on a whole
click and its rows only take clicks, so press-drag-release (as the menus
do since #775) chose nothing. press_menu::PressCombo gives combo boxes the
gesture without replacing egui's widget:

- after the box, a primary press on it opens the closed list (egui counts
  a popup opened in a pass as open, so it is drawn from the next frame);
- a row released on by that press-drag is chosen and closes the list;
- on the click that ends the press (pointer still on the box), egui would
  toggle the list shut, so it is closed just before the box and egui's
  toggle reopens it, drawn that frame: a plain click still leaves the list
  open for a second click.

widgets::dropdown (and its hovered/wheel variants), dropdown_with_tooltips,
the font family picker, the kerning presets, the Gradient Map presets, the
shape stroke choices and the credits sort all use it.

Co-authored-by: lloyd <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 07:46:46 -04:00
Valentin KleinandClaude Opus 5.5 a030d19437 perf: repeated Select Subject answers from the last mask (#2865) (#2870)
Select Subject recomputed superpixels, saliency, GrabCut and the
full-resolution re-cut on every call (P29: 260-370 ms on 24 MP here,
693 ms p95 in the baseline). The session now keeps the last result,
keyed by the pixels it came from: `same_pixels` (tile pointers) against
a weakly held document, plus the sampled target (composite or layer).
Selection-only edits such as Deselect keep it valid; the mode
(replace/add/subtract/intersect) is still applied each time. One entry
per session, dropped on pixel edits and when its document closes.

Repeat: 7-10 ms p95 (was 270-370 ms). First run unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 07:44:00 -04:00
David MannandClaude Opus 5.5 79e8ad35a6 Text fields: drag-selecting in a single-line field follows the pointer's x (#1946) (#2821)
egui's TextEdit picks the character under the pointer with
Galley::cursor_from_pos, which returns the start of the text when the
pointer is more than 5 points above the first row and the end when it is
below the last one, for single-line fields too. So dragging up selected
everything left of the anchor and dragging down everything right of it.

A small egui plugin, installed by PhotocraftApp::setup_context, keeps the
pointer's y on the text row of the focused single-line field while the
primary button pressed in it is held, before egui sees the input. The x is
untouched, so the selection follows it, as in Photoshop and native fields.
It covers every single-line TextEdit, number fields being typed into
included; a multi-line field (taller than two widget heights) keeps its
vertical drag.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-11 07:41:27 -04:00
1d425ce8f9 Actions: check boxes switch single steps on and off (#2855) (#2866)
Photoshop's Actions panel has a check column: an unchecked step is skipped
by playback, Batch and droplets. PhotoCraft could reorder and delete single
steps but always ran every step.

- Action gains `disabled` (0-based step indices, omitted from actions.json
  when empty), kept in step with actions.move and actions.delete.
- New command actions.toggle {action, step?, enabled?}: one step, or every
  step like the action's own check; bad indices and types are errors.
- actions.play, the desktop playback path and the steps handed to Batch and
  Create Droplet skip unchecked steps; actions.get reports `disabled`.
- Panel: a check box per step, dimmed label when off, "2/3 steps" count.

Part of #219 (AUTO-219-2).

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 07:39:35 -04:00
Valentin KleinandClaude Opus 5.5 017d1aea1c Tools: Perspective Crop (#1043) (#2864)
New Perspective Crop tool in the Crop flyout (C): drag a box or click four
corners, drag corners independently onto a skewed object, perspective grid
(Show Grid), Enter commits, Esc cancels. W/H/Resolution come from the Crop
tool's fields (Front Image, Clear), or the quad's mean side lengths.

Engine: image.perspectiveCrop {corners, width?, height?, resolution?,
interpolation?} maps the quad onto an upright canvas through the
quad-to-rectangle homography (Heckbert's square-to-quad map, inverted):
every raster layer, mask, alpha channel and the Quick Mask, one undo step.
The Background stays opaque over the background colour; type, shape, fill
and smart object layers are rasterized first; vector geometry follows the
nearest affine map. Non-convex, self-crossing, counter-clockwise or
degenerate quads are refused with an error.

algo: warp_surface_clipped bounds the per-tile warp by the new canvas even
when a layer reaches past the map's horizon; convex_quad, quad_size,
quad_to_rect.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 07:28:11 -04:00
2e0c426692 Tools: Artboard tool (#2858) (#2862)
Photoshop's Artboard tool in the Move flyout (V, ⇧V cycles Move ↔ Artboard):
dragging on an empty spot draws a new board (`layer.new.artboard`), clicking a
board selects it, dragging it moves it with its contents, and the selected
board's edge and corner handles resize it (`layer.artboard.set`,
moveContents: false). Each gesture is one undo step. Options bar: Size preset,
W/H, Make Portrait, Make Landscape, Add New Artboard. Original toolbar glyph
(assets/icons/artboard.svg, ATTRIBUTION.md row).

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 07:12:53 -04:00
1f64daeba4 Speed up large feathered mask refreshes (#2417)
* Speed up large feathered mask refreshes

* Format preference label for workspace check

* gpu test: allow float noise on a one-code vector-mask parity step

The combined mask is cached as U16 now; one pixel lands exactly one 8-bit
code apart (1.0000075/255), within the <=1/255 parity rule.

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-11 07:09:04 -04:00
5866cd2cf6 Tools: Horizontal and Vertical Type Mask (#1055) (#2861)
The Type flyout (T, Shift+T cycle) gains Vertical Type Mask and Horizontal
Type Mask, in Photoshop's order. They type like the Type tools, with the
canvas under Quick Mask's colour and the glyphs clear while editing. On
commit the glyph coverage replaces the selection; no layer is left behind
and the whole session is one "Type Mask" history step. Escape always
cancels and leaves the selection unchanged.

The commit runs a new engine command, type.createSelection: a type layer's
glyphs (optionally removing that layer, the tools' hidden working text) or
type.create's keys directly, with new/add/subtract/intersect and an
anti-aliasing override. type.create's layer building is shared through
type_cmds::new_text_layer.

Original dashed-T icons (ATTRIBUTION.md), tool label translations in every
catalog, README tool list and the gaps/parity docs updated.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 07:06:10 -04:00
c436aa7294 Tools: Freeform Pen and Curvature Pen (#1054) (#2856)
The Pen flyout (P / Shift+P) now holds Pen, Freeform Pen and Curvature Pen.

Freeform Pen: a drag draws freehand; on release the trace is fitted with
cubic Beziers within the options bar's Curve Fit (default 2 px) by
photocraft_vector::trace::fit_trace (Schneider, "An Algorithm for
Automatically Fitting Digitized Curves", Graphics Gems 1990, the fitting
Make Work Path already uses), keeping turns sharper than 60 degrees as
corners. Releasing on the start point closes the path.

Curvature Pen: each click adds a smooth point and the path runs through
every point (Catmull-Rom style handles from
photocraft_vector::edit::curvature_knots); dragging a point moves it,
double-clicking toggles corner/smooth, clicking the first point closes,
Enter/Esc finish and Backspace removes the last point, as with the Pen.

Both commit through the Pen's path: a shape layer in Shape mode, else the
targeted vector mask, else the work path, as one History step.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 06:56:44 -04:00
3af4c46a18 View: Pattern Preview tiles the document around the canvas (#1067) (#2803)
View > Pattern Preview only toggled a flag that nothing read. With it on, the
canvas now draws the document repeated around itself as a seamless tile, like
Photoshop, on the GPU canvas and the CPU fallback:

- pattern_preview.rs works out which copies cover the visible area (at most
  31 per axis, centred on the view).
- GPU: each copy is another paint callback over the same document texture,
  offset by whole document sizes in the shader (ViewParams::offset, the spare
  view-uniform slots), so nothing is composited again and seams are exact.
- CPU: the same texture drawn again with a shifted view mapping.
- The drop shadow is off while tiling; selection, guides and other overlays
  stay on the document itself.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 06:51:50 -04:00
bacc825d33 Move tool: drag a marquee from an empty spot to select the layers it touches (#2844) (#2852)
With Auto-Select on (or Cmd held with it off), a Move-tool drag that starts where
no layer has pixels (an empty spot, or the pasteboard) now draws a marquee instead
of moving the previously selected layer. Releasing selects every layer whose
content touches it (Auto-Select: Group selects their outermost groups); Shift adds.

New engine command layer.selectInRect; the UI side lives in move_box.rs.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 06:47:53 -04:00
20dab123fb Free Transform: oriented rotate cursor and live transform readout (#1767) (#2809)
The rotate zone drew its own fixed arc with hard-coded black and white,
and the angle was bare white text that vanished over light images. W/H
and the move offset were not shown at all.

The rotate zone now uses the Crop tool's turn cursor (theme tokens),
bent round the nearest corner through the view, so it follows a turned
or flipped view. While dragging, the shared rounded readout beside the
pointer shows the angle (document-relative, one decimal), W/H in
percent while scaling, or the offset while moving, like Photoshop's
Show Transformation Values. live_readout() exposes the value for tests.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 06:40:41 -04:00
Valentin KleinandClaude Opus 5.5 7609834df8 Channels: Edit › Transform works on a targeted alpha channel (#2835) (#2845)
edit.transform without a "target" (or "layer") param now follows the
Channels panel: a targeted alpha channel or the Quick Mask transforms
instead of the layer. Edit › Transform › Rotate 180°/90° and Flip
Horizontal/Vertical are routed to the panel target like the paint and
filter commands, so they turn a targeted alpha channel, the Quick Mask or
an unlinked layer mask within its frame (one undo step). The Background
no longer greys the transforms when a channel is targeted.

Flips scorecard row BUG-207-4.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 06:38:36 -04:00
d79fc5e8ef Free Transform: right-click menu with the transform modes, rotate and flip (#2832) (#2840)
The Free Transform context menu listed only the modes. It now follows
Photoshop's order: the modes, then Content-Aware Scale and Puppet Warp
(greyed while a box is open), Rotate 180°, Rotate 90° CW and CCW, and Flip
Horizontal and Vertical.

The Rotate and Flip items, whether chosen from this menu, Edit › Transform,
a shortcut or the control channel, now turn or mirror the live box about its
reference point without committing it (transform_tool::intercept). Before,
they ran the engine preset on the layer under the open box. They are greyed
while warping. Distort and Perspective are greyed over a box on editable
type, matching #2701. A control-channel right-click during a transform opens
the same menu as the canvas.

Scorecard TOOL-214-5 is now done.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 06:30:33 -04:00
830541ded2 Options bar: Reset Tool and Reset All Tools (#2825) (#2830)
* Options bar: Reset Tool and Reset All Tools (#2825)

Right-click the tool icon at the far left of the options bar for Reset
Tool (the current tool's options back to their defaults) and Reset All
Tools (asks "Reset all tools to their default settings?", then resets
every tool's options), as in Photoshop. Both are shell commands,
tool.reset and tool.resetAll, so the control channel can run them;
tool.resetAll opens the confirmation as a dialog, or skips it with
{"confirmed": true}.

ToolOptions is one struct, so tool_reset::reset_options maps each tool
to its fields; fields shared between tools (Tolerance, Anti-alias,
Feather, Strength, ...) reset with any tool that shows them. Selection
tools also go back to New Selection, and painting tools get the default
brush (colours kept).

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

* Reset Tool: cover Color Replacement, the anchor point tools and the crop custom ratio

Tools and options that landed on main since the branch was cut.

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-11 06:26:26 -04:00
203b5796b4 Tools: remember tool options between launches (#2814) (#2822)
* Tools: remember tool options between launches (#2814)

The options-bar settings in ToolOptions (Move > Auto-Select, marquee
Feather, tolerance, gradient style, crop ratio, eyedropper sample, ...)
only went through serde for Tool Presets, so every launch started from
the defaults. Photoshop keeps each tool's options between sessions.

Preferences gain `toolOptions` (shell-owned JSON, like brushPicker): the
fields that differ from their defaults, saved once the pointer is up and
restored at launch field by field. An unknown key is ignored; a field
that doesn't read back (wrong type, a number out of f32 range) keeps its
default with a log warning.

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

* Merge upstream/main into fix/2814-persist-tool-options; regenerate docs/scorecard.md

The branch adds a preference, so the prefs audit count in docs/scorecard.md changes
(CI's xtask scorecard staleness test).

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-11 06:20:17 -04:00
mattyandBrandon Thomas ac19db1046 TGA: targa options asks for 24 or 32 bits/pixel, same as in Photoshop (#2760)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 06:17:53 -04:00
Valentin KleinandClaude Opus 5.5 273ff014e3 Layers: double-click a shape layer's thumbnail to pick its fill colour (#2812) (#2820)
Like Photoshop, double-clicking a solid-colour shape layer's thumbnail (or
Layer > Layer Content Options...) opens the Color Picker on the shape's fill.
The canvas previews the pick, Cancel changes nothing and OK commits one
undoable shape.edit with the new fill (keeping the fill's alpha). Gradient,
pattern or no-fill shapes keep opening their Properties controls.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 06:12:57 -04:00
Valentin KleinandClaude Opus 5.5 1608b7b309 Spot Healing: Content-Aware dab no longer scans every patch in full (#2771) (#2805)
A Content-Aware Spot Healing dab spent ~70 % of its time in inpaint's
onion_peel: at the coarsest pyramid level every hole pixel computed the
full 7x7 SSD against every valid source patch. The pyramid and the
sampling window scale with the dab, so this cost ~200-450 ms whatever
the brush size.

The scan is still exhaustive and picks exactly the same source (first
minimum in source order): the target's known offsets and samples are
gathered once, the filled neighbours' sources (shifted, as in PatchMatch
propagation; every 16th source at the hole's edge) give an upper bound,
and each candidate's sum is abandoned once it can no longer win. Sums
keep their order and only grow, so the result is bit-identical.

PatchMatch: the vote weight exp(-cost/2s^2) is computed once per target
instead of once per covered pixel, and dist has a fast path (row slices,
same summation order) for patches fully inside a level with no void.

retouch_bench (6016x6016 8-bit, best of 3 rounds x 5 dabs, loaded
machine): 50 px 178 -> 63 ms, 100 px 190 -> 75 ms, 200 px 226 -> 118 ms,
300 px 239 -> 186 ms; 4.5x fewer instructions. Healed pixels of all
three Spot Healing types are bit-identical at 8 and 16 bit.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 06:08:21 -04:00
Valentin KleinandClaude Opus 5.5 b7b3117aea Droplets: carry the actions their Play Action steps call (#2794) (#2800)
* Batch: a failed step inside a called action fails the file and the script (#2779)

Scripts (File › Scripts › Browse, Script Events) ignored the `failed` entry that
`actions.play` returns when a step inside the called action fails, so they reported
ok and kept going. Batch and droplets ran each file in a scratch session without
the actions list, so any nested action call failed with "no action named". The
scratch session now gets the caller's actions and playback stack (an action that
batches itself is caught as recursive), and both runners check nested_failure.

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

* Droplets: carry the actions their Play Action steps call (#2794)

createDroplet saved only the chosen action's own steps, so a droplet whose
action plays another action failed on every file when run outside the
creating session (photocraft-cli droplet and the .command shim run it in a
fresh Session with no actions).

createDroplet now embeds the called actions, followed transitively with a
seen-set and the 16-level playback cap, under "calledActions" (not
"actions", which the CLI reads as an action set). Index references become
names. runDroplet passes them to file.automate.batch, whose per-file
sessions then play from them. Droplets without the key behave as before.

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

* Droplets test: build Actions from JSON so the test survives new Action fields

#2866 adds Action.disabled; a struct literal here would stop compiling once both land.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 06:06:20 -04:00
Valentin KleinandClaude Opus 5.5 009bef76e7 CLI: batch plays an action from an Actions set with the whole set loaded (#2786) (#2792)
`batch --actions` ran each file in a fresh headless session with no
actions, so a step that plays another action (`actions.play`) failed for
every file, and the Actions panel's own actions.json could not be passed
at all. `--actions` now also takes an Actions set; `--action <name>`
picks the action to play and every per-file session gets the whole set.
A failed step inside a called action fails the file instead of saving it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 06:01:07 -04:00
80519e1a17 Type: ⇧⌘> / ⇧⌘< change the type size (and work on Nordic/German layouts) (#2402)
* feat(type): Cmd+Shift+> / < change the type size, Alt for 10 pt steps

Photoshop's type size shortcuts: the selected characters while typing,
else the whole active type layer, in the points the layer shows. The
period and comma keys match by position so the shortcuts also work on
Nordic and German layouts, where Shift+. types a colon.

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

* test(type): bad size steps fall back or clamp

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-11 05:56:28 -04:00
aa509ca966 UI fonts: lazy fallback for Thai, Arabic, Indic and other scripts (#2275) (#2758)
Layer names (and any other UI text) in scripts the bundled Inter lacks
showed missing-glyph boxes even when the canvas found a fallback font.
The lazy UI loader only knew CJK plus a short hard-coded list of Thai
font paths, so any other script, or a Thai font at another path, never
got a fallback.

photocraft_text::scripts classifies 20 scripts and picks candidate files
by name (Noto, Windows, macOS and common Linux families) from a listing
of the platform font directories, made once per process on first need.
The UI loader tries a script once when one of its characters is missing,
reads at most a few candidates, and registers the first face whose cmap
really maps the character. Latin text loads nothing; the web build has
no candidates and does nothing.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 05:55:39 -04:00
2189a1610e fix(crop): stabilize resizing and apply ratio changes immediately (#3206)
Compute snapped handles from press-time geometry to avoid feedback from
Auto Center Preview. Paint the image and crop frame with the same camera.

Apply ratio and size options immediately as centred rectangles contained
in the current frame. Respect saved ratios, keep Swap and X consistent,
and let Enter apply output sizes without an initial resize.

Use container padding for the options bar's text allowance so right-aligned
crop actions do not introduce horizontal overflow.

Add regression coverage for handle snapping, rendered frames, preset/Enter
and Swap/X interactions, and options-bar scrolling at multiple UI scales.
Enable public GitHub profile name syncing for TheTeslak's contributor credit.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 05:54:09 -04:00
d96bb50161 Add Anchor Point and Delete Anchor Point tools (#1044) (#2756)
Paths could be reshaped after drawing since #865, but the number of
anchors could not change. Two new engine commands do that, and two tools
in the Pen flyout drive them on the canvas.

- photocraft_vector::edit::add_anchor splits the segment at t with de
  Casteljau, so a curve keeps its shape and gets a smooth anchor; a
  straight segment gets a corner on the line. delete_anchor joins the
  neighbours keeping their handles, drops an emptied subpath and refuses
  to remove a path's only anchor.
- path.addAnchor / path.deleteAnchor take the same targets as the other
  point edits (work path, saved path by name, shape layer path, vector
  mask) and record one history step each.
- Tool::AddAnchorPoint / DeleteAnchorPoint join the P group after the Pen.
  They act on Direct Selection's paths, highlight the segment or anchor
  under the pointer, show a +/- cursor badge, swap with Alt and turn into
  Direct Selection with Cmd/Ctrl. An edit clears a Direct Selection on the
  same path so it never points at a shifted anchor.
- Icons derived from Lucide pen-tool (ATTRIBUTION.md), hints and tool
  names in all 16 catalogs, README tool list, roadmap.

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-11 05:48:07 -04:00
303b7849e8 raw: fit non-DNG colour to the camera's embedded JPEG (#2135)
* raw: fit non-DNG colour to the camera's embedded JPEG

* raw look: clippy (as_chunks) and rustfmt 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-11 05:45:46 -04:00
1d67cae1ba Keep custom crop ratio fields editable (#3209)
* Keep custom crop ratio input fields

* Persist custom crop ratio values

* Wire custom ratio edits to persistent option state

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 05:41:44 -04:00
59da288daa fix(type): preserve formatting for trailing empty paragraphs (#3198)
* fix(type): preserve formatting for trailing empty paragraphs

Represent an explicitly styled terminal empty paragraph with a zero-byte paragraph run, keep it through normalization, style only the caret's paragraph, and transfer that style when text is inserted at EOF. Cover line geometry, typing, and PSD text serialization.

* fix(type): retain imported terminal paragraph marker style

* 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-11 05:36:52 -04:00
11817fcb12 Test Move layer edges across bit depths and view modes (#2738)
* Test Move layer edges across bit depths and view modes

* Layer Edges tests: set ViewXform's new aspect 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-11 05:36:25 -04:00
468b8f2792 Crop tool: New / Delete Crop Preset saves and removes user presets (#1919) (#2723)
* Crop tool: New / Delete Crop Preset saves and removes user presets (#1919)

The Crop options-bar dropdown ends with New Crop Preset... and Delete Crop
Preset..., as in Photoshop. A crop preset is a "crop" tool preset
(tool.presets.*, persisted with the preferences), so it also shows in the
Tool Presets panel. New saves W x H x Resolution (W, H, resolution and
unit) or a ratio under a name made from the values; it is greyed out with
nothing to save. Delete lists the saved crop presets for a multi-select;
built-in size presets stay. The dropdown shows the preset the fields hold.

Engine: tool preset names are checked (1-255 chars) and the store capped;
saved tool presets load entry by entry so a damaged one is skipped; a
multi-name delete is all or nothing. ui.dialog.open gains newCropPreset /
deleteCropPreset.

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

* Regenerate the scorecard after rebasing onto main

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-11 05:31:19 -04:00
8a9f4722d2 fix(shapes): preserve transformed outline on unchanged live geometry edits (#3197)
* fix(shapes): preserve transformed outline on unchanged live geometry edit

A nonuniform scale stores an anisotropic outline but live rounded rectangles only store scalar corner radii. Avoid regenerating the path when all live values are unchanged, preserving transformed corners and PSD vector source. Cover no-op edits, real radius edits and undo at all depths.

* 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-11 05:23:55 -04:00
073e1cc1d2 Add the Color Replacement tool to the Brush toolbar group (#1045) (#2719)
The engine already had paint.colorReplacement, but the desktop UI had no
tool for it: no Tool entry, no toolbar slot, no options bar and no stroke
routing, so only agents and the CLI could use it.

Tool::ColorReplacement joins the Brush (B) flyout after the Mixer Brush,
so B / Shift+B cycle to it and ui.set / ui.inspect name it. Its options bar
has Mode, Sampling (Continuous / Once / Background Swatch), Limits,
Tolerance and Anti-alias, all mapped to the command's params, with the
foreground as the replacement colour. Strokes are drawn live through
LiveDab, which can now preview the stroke's finishing dab, so the preview
at the last move matches the commit. The command refuses a mask, channel
or Quick Mask target instead of silently recolouring the layer. New icon
derived from Lucide's brush; label translated in all 16 catalogs.

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-11 05:20:00 -04:00
Nigel Ihza Firdausy d8217b3476 fix(image): keep smart-filter masks aligned through Image Size, Canvas Size and Crop (#3195) 2026-10-11 05:18:52 -04:00
094d0776d1 Improve zoomed Liquify preview detail (#2715)
* Improve zoomed Liquify preview detail

* Record Liquify quick benchmark results

* Keep the roadmap as on main; record the Liquify numbers in target-app-parity.md

The branch carried an old copy of the roadmap's parity tables, which have moved
to docs/target-app-parity.md on 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-11 05:03:10 -04:00
09d079278e fix(video): retain original footage for Restore Frame and Show Altered Video (#3192)
* fix(video): retain original footage for restore and comparison (#3059, #3060)

* Clamp original-frame inserts to the original stack; 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-11 05:02:29 -04:00
Reynold HuandBrandon Thomas 67e3d9be9f feat(ui): Font Preview Size drives the font menu samples (#204) (#2668)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 04:52:17 -04:00
6cfed3a7dc fix(masks): bake feathered coverage when applying pixel masks (#3044) (#3191)
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 04:49:03 -04:00
e46f119e03 i18n test: scan past small test modules; translate what it was missing (#2660)
every_tl_literal_is_translated cut each source file at its first `#[cfg(test)] mod`, so a small
test module early in a file hid every tl!("…") after it. panels.rs has one around line 540,
which left most of the toolbar options, the Layers panel and the Properties panel unchecked:
112 literals were never scanned.

The scan now removes each test module on its own (brace counting; a #[cfg(test)] on a single
item still doesn't count), with a unit test for the helper.

The strings it now finds are translated: 69 in Russian (most of the Layers panel and toolbar
tooltips), and the crop, lasso, zoom and fill hints in cs, de, es, fr, id, it, ja, ko, pl, pt-br,
uk, zh-hans and zh-hant. Translations reuse each catalog's existing terms; a native review is
welcome.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 04:39:45 -04:00
01833cfac2 fix(actions): avoid replaying Browse script edits twice (#3190)
* fix(actions): avoid replaying Browse scripts twice (#3057)

* 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-11 04:38:07 -04:00
21326ded43 fix(vector): preserve scaled live-line thickness during endpoint edits (#3184)
* fix(vector): keep scaled live-line weight after editing endpoints (#3063)

* 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-11 04:31:47 -04:00
Reynold HuandBrandon Thomas 93f2ff835a feat(ui): Beep When Done plays the platform beep for finished jobs (#204) (#2658)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 04:29:59 -04:00
a9aabcdfde fix(vector): pivot explicit path transforms on target layer (#3062) (#3183)
* fix(vector): pivot explicit path transforms on target layer (#3062)

* 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-11 04:23:08 -04:00
233237e9dc Reject bad params in the control channel's synthetic input methods (#2449) (#2656)
ui.pointer, ui.click/ui.move, ui.key and ui.screenshot read every value
with as_f64/as_bool/as_str and fell back to a default on any other type,
and an unknown button or event kind fell through to a left click or a
move. A typo such as "button":"ritgh" ran a different action and still
replied ok.

Each method now validates its whole request before acting, like ui.set:
unknown fields (top-level, under modifiers, or in an event), wrong JSON
types, unknown button/kind/tool values (the error lists the accepted
ones) and out-of-range pressure, tilt and rotation return an error that
names the parameter, and nothing is queued or applied. Null fields still
mean omitted, and valid calls behave as before.

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-11 04:18:18 -04:00
f6dd0aa6f3 fix(io): preserve group fill opacity in OpenRaster exports (#3181)
* fix(io): preserve group fill opacity when exporting OpenRaster (#3048)

* 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-11 04:16:30 -04:00
458870dadc Auto-Blend Stack Images: fuse the merged layer per pyramid level by region energy, with halo control (#2044)
* Auto-Align Layers: register focus and exposure brackets by intensity when there is nothing to match

Feature matching fails outright on layers that nearly coincide (a focus bracket: sub-pixel shifts, up to a few percent of scale from focus breathing, much of every frame defocused). `photocraft_algo::direct` registers the target to the reference coarse-to-fine on a Gaussian pyramid of the luminance, the cost the mean-removed RMS of the warped difference over the pixels both cover, by a bounded Nelder-Mead search with a per-level schedule; the pose is a similarity about the centre, affine or projective per the projection, and resolution independent, so the search stops short of full resolution. `edit.autoAlignLayers` gets `registration`: auto (features, then intensity when nothing matches), features, intensity; layers register outward from the reference, each to the previous one aligned.

Closes #2064.

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

* Intensity registration: reject a no-fit, search on coarsened planes

Review follow-up on #2065:
- `direct::cost` also returns the correlation of the reference with the
  warped target over the overlap; `Registration.correlation` carries it.
  Unlike the mean-removed RMS it is exposure independent, so it tells a
  bracket (near 1) from unrelated pictures (near 0).
- `register_direct` fails with the existing "couldn't find enough matching
  detail" error when a layer's best pose overlaps its neighbour by less than
  a quarter of the plane or correlates below 0.5, instead of placing it at
  whatever pose the search box ended at.
- The luminance planes are built at the search scale (`luma_alpha` with the
  reduction factor, ≤ 2048 px across) and the warp of each registered layer
  runs there too; the pose is resolution independent and its matrix at full
  resolution places the layer. Nothing full-resolution is held.
- The reference index is checked once with `get`; no unclamped indexing.
- Tests: correlation at an exposure step, for unrelated textures and a flat
  plane; the engine rejects unrelated layers and a mostly empty one.

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

* Auto-Blend Stack Images: fuse the merged layer per pyramid level by region energy, with halo control

The merged layer of Edit › Auto-Blend Layers › Stack Images was the per-pixel masks blended with a multiresolution spline, so a wrong pick in the mask reached every scale. `pyramid::fuse_stack` fuses the layers on the Laplacian pyramid instead: per coefficient the layer with the largest region energy of its luminance band wins (Adelson et al. 1984's multifocus composite with Wang & Chang 2011's rules), the residual follows local deviation + entropy, and with halo control the levels coarser than the guide are weighed by the guide's energy so a bright object's defocused glow stays out. `haloControl` (0..8, default 2) on `edit.autoBlendLayers`; the masks and the panorama method are unchanged.

Closes #2043.

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

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 04:13:11 -04:00
5c9d9116bc fix(layers): select Shift ranges in visible panel row order (#3041) (#3180)
* fix(layers): select Shift ranges in visible panel row order (#3041)

* 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-11 04:12:27 -04:00
Lyraedan f11380d5bd Double-clicking a mask thumbnail in the Layers panel now opens that mask's settings (#2654)
* Double-click a layer mask thumbnail to open its settings

Double-clicking a mask thumbnail in the Layers panel now opens that mask's settings in the Properties panel, matching Photoshop. When the Preferences ▸ Tools ▸ "Double Click Layer Mask Launches Select and Mask" option is on (the current default) a pixel mask still launches Select and Mask, exactly as before; with it off, double-clicking targets the mask and opens its Properties (Density/Feather). Vector masks now behave consistently too: double-clicking one targets it and opens a new Vector Mask Properties section.

* Fixed syntax issue from merge

* Fixed missing closed brace in ui tests
2026-10-11 04:11:35 -04:00
ragtuxandClaude Fable 5.1 ec60f43b5b Auto-Align Layers: register focus and exposure brackets by intensity when there is nothing to match (#2065)
* Auto-Align Layers: register focus and exposure brackets by intensity when there is nothing to match

Feature matching fails outright on layers that nearly coincide (a focus bracket: sub-pixel shifts, up to a few percent of scale from focus breathing, much of every frame defocused). `photocraft_algo::direct` registers the target to the reference coarse-to-fine on a Gaussian pyramid of the luminance, the cost the mean-removed RMS of the warped difference over the pixels both cover, by a bounded Nelder-Mead search with a per-level schedule; the pose is a similarity about the centre, affine or projective per the projection, and resolution independent, so the search stops short of full resolution. `edit.autoAlignLayers` gets `registration`: auto (features, then intensity when nothing matches), features, intensity; layers register outward from the reference, each to the previous one aligned.

Closes #2064.

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

* Intensity registration: reject a no-fit, search on coarsened planes

Review follow-up on #2065:
- `direct::cost` also returns the correlation of the reference with the
  warped target over the overlap; `Registration.correlation` carries it.
  Unlike the mean-removed RMS it is exposure independent, so it tells a
  bracket (near 1) from unrelated pictures (near 0).
- `register_direct` fails with the existing "couldn't find enough matching
  detail" error when a layer's best pose overlaps its neighbour by less than
  a quarter of the plane or correlates below 0.5, instead of placing it at
  whatever pose the search box ended at.
- The luminance planes are built at the search scale (`luma_alpha` with the
  reduction factor, ≤ 2048 px across) and the warp of each registered layer
  runs there too; the pose is resolution independent and its matrix at full
  resolution places the layer. Nothing full-resolution is held.
- The reference index is checked once with `get`; no unclamped indexing.
- Tests: correlation at an exposure step, for unrelated textures and a flat
  plane; the engine rejects unrelated layers and a mostly empty one.

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

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-11 04:02:26 -04:00
Nigel Ihza Firdausy 37929405df fix(history): restore empty layer target on undo and redo (#3042) (#3179) 2026-10-11 04:02:23 -04:00
Reynold HuandBrandon Thomas fa2fe1c90f feat(ui): the brush HUD honors Vary Round Brush Hardness and Brush Preview Color (#204) (#2640)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 03:59:04 -04:00
64ec252dbe fix(layers): remap linked-layer groups when copying between documents (#3178)
* fix(layers): remap link groups when copying across documents (#3045)

* 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-11 03:48:33 -04:00
cf83e05821 fix(psd): Improve interoperability and decoding (#1955)
* fix(psd): Align global tagged blocks to four bytes

Use four-byte defaults for global blocks and streamed layer information.
Read global payloads without full copies while preserving block order,
explicit padding and the first layer candidate. Keep two-byte layer
alignment; test canonical padding and lossless PSD/PSB parsing.

* fix(psd): Preserve transparent composite colours and precision

* fix(psd): Check ZIP integrity and bound layer previews

* fix(io): Use document CMYK profiles for PSD fill pixels

Generated fill pixels used the default CMYK separation even when the PSD
embedded a different profile. Scope layer export to the document colour
space so cached pixels and the composite use the same profile.

Check native cached samples against an explicit ICC transform at 8 and
16 bits.

* io: refuse an empty RGB composite before splitting it into planes

chunks_exact(0) panics; an empty decoded composite now returns an error.

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-11 03:46:52 -04:00
cfd12263af Rulers: keep tick marks and labels out of the origin corner (#2617)
* Rulers: clip tick marks and labels outside the origin corner

* Rulers test: set ViewXform's new aspect 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-11 03:46:29 -04:00
Nigel Ihza Firdausy 3188866b25 fix(masks): retain off-canvas alpha in From Transparency (#3043) (#3177) 2026-10-11 03:43:18 -04:00
2785699464 fix(type): use the correct line at soft-wrap caret boundaries (#3174)
* fix(type): use the next visual line at shared wrap boundaries

At soft-wrap and forced-break character offsets, resolve the caret's visual line from the last matching line rather than the preceding one. Make line-edge navigation share that choice and add regression tests for wrapped, forced and paragraph breaks.

* test(type): use actual break characters in caret boundary regressions

* fix(type): preserve the visual line when hit testing a shared wrap offset

Keep caret index preference on the following line, but use pointer geometry for click-hit line identity at an adjacent line's end.

* Update the caret round-trip test for wrap-boundary lines; cargo fmt

The existing round-trip helper expected the first line containing a
shared soft-wrap offset; the caret now reports the following line, where
it is drawn and where hit testing lands.

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-11 03:37:27 -04:00
Reynold HuandBrandon Thomas de58a0a778 feat(ui): the Type units preference drives the type metric units (#204) (#2587)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 03:37:06 -04:00
334ae5779c Enhanced Controls: wire Zoom with Trackpad Pinch (#204) (#2577)
* feat(ui): Zoom with Trackpad Pinch gates the canvas pinch (#204)

* Navigator: pass the pinch preference to wheel_nav::read (new caller on 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-11 03:26:07 -04:00
ee3839955b fix(type): exclude text inside hidden groups from automatic hit testing (#3173)
* fix(type): skip text in hidden ancestor groups during automatic hit testing

Honor the effective visibility of every ancestor when locating a type layer by point. Explicit layer targeting remains available, with a regression test for nested hidden groups.

* 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-11 03:17:46 -04:00
Fabian GhenofandClaude Opus 5.5 c54b3c4a02 Render Video: animated GIFs keep their transparency (#3061) (#3162)
Animated GIF export quantised every frame with no transparent index, so a
transparent document came out opaque black. Each frame now does what Save for
Web's GIF does: pixels under 50% opacity get a transparent index, the rest are
drawn opaque, and the palette comes from the visible pixels with one entry
kept for transparency.

The encoder also left every frame in place (disposal 1). A frame with a
transparent index is now cleared before the next one (disposal 2); otherwise
earlier frames would show through it once transparency works.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-11 03:12:26 -04:00
LuniiyaandBrandon Thomas e44d96e1d4 Favorite themes from the appearance button's right-click menu (#2445)
* feat(ui): favorite themes from the appearance button's context menu

Hovering a theme in Preferences > Interface shows a star; clicking it saves
the theme to interface.favoriteThemes (filled accent star once favorited).
Right-clicking the title-bar appearance button opens a menu with Appearance
Settings, then the favorite light themes and the favorite dark themes.

* Regenerate the scorecard for the favorite themes pref

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-11 03:09:47 -04:00
f9c5486ffe fix(shortcuts): reject malformed bindings and correctly display Plus (#3006)
* fix(shortcuts): reject malformed key bindings and format Plus

* 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-11 03:05:09 -04:00
Ahmed Hosny c25630b5fa app.save: encode on the save worker, not the UI thread (#2488) (#2517)
* app.save: encode on the save worker, not the UI thread (#2488)

The automation save ran export+write inline inside the control drain,
freezing the window and every queued control request while a large
document encoded — and overrunning the 60s reply deadline. It now uses
the same #2017 background path as File > Save when background jobs are
on: the encode and the capability-scoped write run on a worker, the
document is locked, and the control reply waits on the job (wait: false
answers {job, pending} at once). Tests and the web keep the inline path.

The save job's result now carries the path alongside the warnings, so
waiting callers get the same {path, warnings} reply both ways.

* app.save: never fire saveDocument hooks from automation saves; review fixes (#2488)

Automation saves complete in the job tick, long after run_waiting lifted
its synchronous event suppression, so the background branch fired the
user's saveDocument script bindings and Image Assets — the exact hooks
docs/control-protocol.md promises never run for automation. SaveJob now
carries an automation flag and records through saved_automation, which
skips document_saved on every path; the inline fallback records through
it too, so both modes behave the same. A regression test installs a
saveDocument binding and asserts it fires for an interactive save and
not for app.save.

The desktop's two save workers collapse into one save_through parameterized
by the write step (drift between interactive and automation saves), the
Saved status/notices are translated (Saved {name}, 16 locales), the pending
reply carries warnings like file.save's, and the success payload is built
at one site.
2026-10-11 02:54:14 -04:00
Nigel Ihza Firdausy d5bab13674 fix(transform): commit Free Transform on an outside click (#2909)
* fix(transform): accept a free transform on an outside click (#2851)

* fix(transform): accept outside clicks in the rotation zone (#2851)
2026-10-11 02:51:59 -04:00
Nigel Ihza Firdausy 03d45a80ee fix(brush): preserve pen pressure when joining Shift-click strokes (#2827) (#2890) 2026-10-11 02:46:01 -04:00
Tanish Manku c6a525cc99 Releasing a brush stroke no longer freezes: the commit takes the pixels the live stroke already drew (P32: 136 ms → 0.8 ms) (#2131)
* Releasing a brush stroke no longer freezes: the commit takes the pixels the live stroke already drew (P32: 136 ms → 0.8 ms)

Brush phase 4. On release the canvas dropped its live stroke and ran `paint.stroke` over every
point again (the whole stroke re-rendered), then re-composited the stroke's bounding box, although
the canvas already showed exactly those pixels. Long strokes froze for 100–250 ms at mouse-up
(#2068).

- `Session::offer_live_stroke` hands the finished live stroke to the next command. A
  `paint.stroke` / `paint.pencil` whose command, document revision, params, seed and points match
  it exactly takes its pixels instead of rendering (result `"fromLive": true`). Anything else
  ignores the offer and renders as before. It still runs through the normal dispatch
  (authorization, journal, one history step), and a replay renders identical pixels.
- The canvas feeds the release frame's last points to the live stroke first, then remembers the
  stroke's per-step damage for one revision (`CommittedStroke`): each canvas cache re-composites
  only the steps it hadn't shown, usually nothing, instead of the bounding box.
- P32 (brush release on a long stroke) becomes measurable: perf_scenarios times a 600-point
  200 px soft stroke on 24 MP. 136 ms → 0.83 ms on the GPU canvas, 257 ms → 0.71 ms on the CPU
  canvas (RTX 5090, release). Budget 16 ms, not enforced until a baseline run.
- Scorecard: TOOL-213-10 (⌃⌥-drag resize with readout) is done; TOOL-213-7 (live strokes for every
  brush-based retouch tool) is partial (Spot Healing, Mixer Brush, Background Eraser and Pattern
  Stamp still aren't live).

* Mask view test: a commit that takes the live stroke uploads nothing it already showed
2026-10-11 02:42:12 -04:00
ea4579ea11 fix(canvas): update Move transform-control cursors on first hover (#2887)
* fix(canvas): show Move transform control cursors before first interaction (#2871)

* Fix the Move transform-controls cursor test after merging main

ViewXform gained an aspect field on main, and the rotation-band probe sat
within the top-centre handle's grab radius; probe between handles instead.

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-11 02:39:28 -04:00
1ccdac4c94 Show camera data in File Info (#2424) (#2692)
File Info only read the editable XMP description fields, so the camera
settings stored in the file's EXIF block (JPEG APP1, PSD resource 1058)
never reached the dialog or file.fileInfo.

file.fileInfo now returns a read-only "camera" object (make, model, date
taken, exposure, f-stop, ISO, focal length, 35 mm focal length, lens),
formatted for display (1/250 s, f/8, ISO 400, 33.1 mm). It reads the
existing photocraft_algo::exif parser, which gains DateTimeOriginal, and
falls back to the XMP tiff:/exif:/aux: properties when there is no EXIF
block. The dialog shows the values in a Camera Data section; labels are
translated in all 16 catalogs.

The EXIF reader now uses checked offset arithmetic and requires a tag's
whole value to lie inside the payload, so hostile counts and offsets
cannot overflow on 32-bit targets (wasm) or read past the data.

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-11 02:26:29 -04:00
598 changed files with 67192 additions and 6512 deletions

No files matched your search

+7 -42
View File
@@ -19,47 +19,12 @@ concurrency:
jobs:
lint:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
verified-appimage-inputs:
name: Verified AppImage inputs
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
- name: AppImage AppRun desktop integration
run: bash packaging/linux/apprun-test.sh
- name: PowerShell
shell: pwsh
run: |
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
$errors = $null
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
./packaging/windows/check-icons.ps1
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/photocraft.wxs packaging/windows/photocraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
desktop-file-validate packaging/linux/ai.storyteller.photocraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.photocraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.photocraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.photocraft.metainfo.xml"
python3 - <<'EOF'
import yaml
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.photocraft.yml"))
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml"))
for m in (src, bundle):
assert m["id"] == "ai.storyteller.photocraft" and m["command"] == "photocraft", m["id"]
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
print("Flatpak manifests ok")
EOF
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: bash packaging/linux/verified-download-test.sh
+17 -473
View File
@@ -1,9 +1,3 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "PhotoCraft v<version>". See docs/releasing.md.
#
# All jobs run in the `release` environment (restricted to the `release` branch), which holds
# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
# warning, never a failed build.
name: Release
on:
@@ -17,477 +11,27 @@ on:
default: ""
permissions:
contents: read
contents: write
concurrency:
group: release-${{ github.ref }}
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
PHOTOCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
# craft-fonts (https://github.com/storytold/craft-fonts): shared font assets every release
# embeds (today the Japanese fonts). An optional build input for everyone else; see
# docs/development.md › Fonts. Bump deliberately; every desktop build job checks out this commit (not web).
CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695
jobs:
version:
name: version
runs-on: ubuntu-latest
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
- id: v
name: Resolve version
run: |
. packaging/env.sh
v="${INPUT_VERSION#v}"
v="${v:-$VERSION}"
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
exit 1
fi
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
{
echo "version=$v"
echo "date=$(date -u +%Y-%m-%d)"
echo "prerelease=$pre"
} >>"$GITHUB_OUTPUT"
echo "### PhotoCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
name: macOS universal
needs: version
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
environment: release
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
- uses: Swatinem/rust-cache@v2
with:
key: release-macos-universal
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
- name: Import signing certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: packaging/macos/import-cert.sh
- name: Build, sign, notarize
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/macos/package.sh --arch universal
# Unpacks the CLI zip and checks the binary users get: Developer ID, hardened runtime,
# timestamp, and Gatekeeper's notarization lookup (codesign --verify, spctl). Also the DMG's
# stapled ticket. Strict when the signing secrets are present, warnings otherwise.
- name: Verify signatures and notarization
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/verify.sh --arch universal
- uses: actions/upload-artifact@v4
with:
name: release-macos
path: dist/release/*
if-no-files-found: error
- name: Remove temporary keychain
if: always()
run: |
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
environment: release
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: release-windows-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
shell: bash
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
- name: Install WiX
shell: pwsh
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build, sign, package
shell: pwsh
env:
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 }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-${{ matrix.arch }}
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/linux/package.sh
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/photocraft$|metainfo|applications'
ldd target/release/photocraft
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.photocraft.yml')); assert m['id'] == 'ai.storyteller.photocraft'"
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
# Update information (#349) is embedded, and the .zsync for delta updates sits beside it.
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/release/*
if-no-files-found: error
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
runs-on: ${{ matrix.runner }}
# No `environment: release`: this job signs nothing, so it gets no release secrets.
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install flatpak-builder
run: |
sudo apt-get update
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
# `appstreamcli compose`, which renders the scalable icon and fails without it.
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/photocraft-$PHOTOCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@v4
with:
name: release-flatpak-${{ matrix.arch }}
path: dist/release/*.flatpak
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
# .github/workflows/freebsd.yml.
runs-on: ubuntu-latest
timeout-minutes: 180
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "PHOTOCRAFT_VERSION PHOTOCRAFT_BUILD_DATE PHOTOCRAFT_BUILD_SHA INPUT_VERSION"
# The FreeBSD CI packages, plus bash for the packaging script.
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/photocraft-target CARGO_BUILD_JOBS=4
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$PHOTOCRAFT_VERSION"; fi
# craft-fonts was checked out next to the sources and synced into the VM with them.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
bash packaging/freebsd/package.sh
tar -tzf dist/release/photocraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/photocraft$|/bin/photocraft-cli$|/share/applications/ai\.storyteller\.photocraft\.desktop$|/share/doc/photocraft/LICENSE-MIT$'
- uses: actions/upload-artifact@v4
with:
name: release-freebsd-x86_64
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
key: release-web
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
| tar -xz -C "$HOME/.cargo/bin" trunk
trunk --version
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
# No craft-fonts here: the wasm32 build never embeds it (crates/text/build.rs), since even
# one Japanese font takes the wasm over packaging/web/package.sh's 24 MiB gate.
- name: Build and zip
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
with:
name: release-web
path: dist/release/*
if-no-files-found: error
linux-riscv64:
name: Linux riscv64
needs: version
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
# CLI is smoke-tested under QEMU. Same recipe as GridCraft's linux-riscv64 job.
runs-on: ubuntu-24.04
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-riscv64
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
- name: Install cross-compilation dependencies
run: |
. /etc/os-release
sudo dpkg --add-architecture riscv64
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
fi
if [ -f /etc/apt/sources.list ]; then
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
fi
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
done | sudo tee /etc/apt/sources.list.d/riscv64.list
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
libxi-dev:riscv64 libgl1-mesa-dev:riscv64 \
desktop-file-utils appstream
- name: Build and package (tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: "1"
CROSS_ARCH: riscv64
CROSS_TARGET: riscv64gc-unknown-linux-gnu
CROSS_COMPILE: riscv64-linux-gnu-
EMULATOR: qemu-riscv64
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
run: packaging/linux/package.sh --formats tar
- uses: actions/upload-artifact@v4
with:
name: release-linux-riscv64
path: dist/release/*.tar.gz
if-no-files-found: error
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
env:
VERSION: ${{ needs.version.outputs.version }}
PRERELEASE: ${{ needs.version.outputs.prerelease }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- uses: actions/download-artifact@v4
with:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Checksums
working-directory: artifacts
run: |
sha256sum -- * | tee SHA256SUMS.txt
- name: Create or update the draft release
working-directory: artifacts
run: |
tag="v$VERSION"
title="PhotoCraft v$VERSION"
pre=()
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [ "$draft" != "true" ]; then
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
exit 1
fi
echo "Updating draft $tag: replacing its assets"
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
gh release delete-asset "$tag" "$asset" --yes
done
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
echo "Creating draft $tag"
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: photocraft
app-title: PhotoCraft
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
+6 -68
View File
@@ -22,72 +22,10 @@ concurrency:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: |
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
with:
name: photocraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
with:
name: photocraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'PhotoCraft'
foreach ($exe in 'photocraft.exe', 'photocraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'photocraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "photocraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok photocraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'photocraft.exe')) { throw 'uninstall left photocraft.exe behind' }
Write-Output 'ok uninstalled'
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: photocraft
app-dir: PhotoCraft
+12 -2
View File
@@ -18,17 +18,27 @@ next to them.
| `crates/ui-egui/src/i18n/pl.tsv` | Polish 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/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) |
| `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`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (110 files) | 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-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/*.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/{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 |
| `assets/icons/convert-point.svg` | Convert Point tool glyph: an angle with a square anchor on its apex, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `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/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) |
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/pen-add-anchor.svg`, `assets/icons/pen-delete-anchor.svg` | Add Anchor Point and Delete Anchor Point tool glyphs: Lucide `pen-tool` plus a + or − mark | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/painting-symmetry.svg` | Painting symmetry glyph: Lucide `heart` with a central axis | Lucide Icons and Contributors; adapted by @shorbank | Derived from <https://github.com/lucide-icons/lucide> `icons/heart.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/symmetry-{vertical,horizontal,dual,diagonal}.svg` | Painting symmetry axis glyphs | @shorbank | Original work; supplied SVG line geometry centred on a common square canvas, stroke adjusted to match the painting-symmetry glyph | MIT OR Apache-2.0, [`assets/icons/LICENSE-symmetry-axes.txt`](assets/icons/LICENSE-symmetry-axes.txt) |
| `assets/icons/lasso-magnetic.svg` | Magnetic Lasso tool glyph, adapted from "Magnetic Lasso Tool" (redrawn on the 24 px toolbar grid) | Lil' Seal (Noun Project); adapted by PhotoCraft contributors | <https://thenounproject.com/icon/magnetic-lasso-tool-177513/> | CC BY 3.0, [`assets/icons/LICENSE-noun-magnetic-lasso.txt`](assets/icons/LICENSE-noun-magnetic-lasso.txt) |
Generated
+3
View File
@@ -4199,6 +4199,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"open",
"photocraft-automation",
"photocraft-cms",
"photocraft-codecs",
"photocraft-color",
"photocraft-compose",
@@ -4372,6 +4373,7 @@ dependencies = [
"roxmltree",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.21",
"wat",
]
@@ -4597,6 +4599,7 @@ dependencies = [
"serde_json",
"sys-locale",
"toml",
"winsafe",
]
[[package]]
+11 -2
View File
@@ -81,8 +81,8 @@ 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 shell, and "s" for
# other crates except pixel processing, which keeps opt-level 3 for filters and compositing. panic
# 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.
[profile.wasm-release]
@@ -100,6 +100,15 @@ opt-level = "s"
[profile.wasm-release.package.photocraft-ui-egui]
opt-level = "z"
# 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]
opt-level = "z"
[profile.wasm-release.package.photocraft-io]
opt-level = "z"
[profile.wasm-release.package.photocraft-web]
opt-level = "z"
[profile.wasm-release.package.photocraft-raster]
opt-level = 3
[profile.wasm-release.package.photocraft-color]
+4 -4
View File
@@ -133,7 +133,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
<br>
<sub>A badge made of shape layers: a gradient-filled lotus, a star and a dotted ring.<br><i>Water Lilies</i>, Claude Monet, 1906</sub>
<h3>Pixel-perfect vectors</h3>
Rectangle, Ellipse, Triangle, Polygon, Line and the Pen tool, with resolution-independent shape layers, gradient fills, and dashed, aligned strokes.
Rectangle, Ellipse, Triangle, Polygon, Line and the Pen, Freeform Pen and Curvature Pen tools, with resolution-independent shape layers, gradient fills, and dashed, aligned strokes.
<br><br>
Combine shapes (unite, subtract, intersect, exclude), keep paths in the Paths panel, use them as vector masks, or stroke and fill them. 116 of 116 shape layers in our PSD corpus match Photoshop's pixels.
</td>
@@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
<table>
<tr>
<td width="33%" valign="top">
<h4>🧰 55 tools</h4>
Move · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Horizontal and Vertical Type · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
<h4>🧰 67 tools</h4>
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
</td>
<td width="33%" valign="top">
<h4>🖌️ A real brush engine</h4>
@@ -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 and Radiance HDR, 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>, 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>
+5 -4
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-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: full re-measure against Photoshop 2026 27.11.0) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (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)
@@ -19,7 +19,7 @@ About 80% of the work parallelizes.
| | Value | Kind |
|---|---|---|
| **Feature breadth** | **~76%** | partly measured: menus 628/628, tools 53/68, panels 30/35, formats 13+4 of 31 |
| **Feature breadth** | **~76%** | partly measured: menus 628/628, tools 56/68, panels 30/35, formats 13+4 of 31 |
| **Ready for real work** | **~45%** (40–50%) | estimated: weighted by dimension below |
| Mainstream practitioner | ~43% (38–48%) | estimated: weekly areas of a typical pro, with discounts ([method](docs/target-app-parity.md#mainstream-practitioner-43)) |
| Essentials user | ~61% (55–67%) | estimated: core features only ([method](docs/target-app-parity.md#essentials-user-61)) |
@@ -95,7 +95,7 @@ because engine messages are still English. Detail: [localization-parity](docs/lo
| Simplified Chinese | `zh-hans` | 100% | partial |
| Spanish | `es` | 100% | partial |
| Hindi | `hi` | 0% | none |
| Arabic | `ar` | 0% (no RTL UI) | none |
| Arabic | `ar` | 0% (RTL shell only) | none |
| French | `fr` | 100% | partial |
| Portuguese (Brazil) | `pt-br` | 100% | partial |
| Indonesian | `id` | 100% | partial |
@@ -114,7 +114,7 @@ Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dut
| 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 |
| 5 | The 15 missing tools | 60–100 h |
| 5 | The 12 missing tools | 60–100 h |
Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
@@ -135,6 +135,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 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 |
| 2026-10-10 | minor | Alpha gate result (passes; stays alpha) in the stage explanation |
+124 -12
View File
@@ -7,6 +7,7 @@ use std::io::Write;
use std::path::{Path, PathBuf};
use photocraft_automation::{AuthorizedWorkspace, Headless, PhotocraftMcp, files, security};
use photocraft_engine::actions_cmds::Action;
use photocraft_io::ExportOptions;
use serde_json::{Value, json};
@@ -14,20 +15,25 @@ pub const USAGE: &str = "\
photocraft-cli: headless Photocraft
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers]
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
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.
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]
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>]
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> --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers]
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,
{\"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 --out folder that is the
--in folder is refused, as the results would replace the originals; --in-place allows it.
(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
when the set holds more than one) with the whole set loaded, so its steps can play other actions
of the set. An --out folder that is the --in folder is refused, as the results would replace
the originals; --in-place allows it.
photocraft-cli droplet <file.pcdroplet> <file-or-dir>… [--out <dir>]
Run a droplet (File › Automate › Create Droplet) on images and folders.
photocraft-cli commands [--json] [--filter <text>]
@@ -39,7 +45,7 @@ USAGE:
[--automation-read-root <dir>] [--automation-write-root <dir>]
Keep one headless session open and answer JSON lines ({\"id\",\"method\",\"params\"}) on stdio,
or on 127.0.0.1:<port>. Methods: engine.execute, jobs.list/cancel, engine.commands,
doc.open/new/save/inspect/render/select/close, session.list, batch, methods
doc.open/new/place/save/exportLayers/exportDataSets/importDataSets/inspect/render/select/close, session.list, batch, methods
(docs/control-protocol.md#headless-server).
photocraft-cli <subcommand> --help (or -h) prints this text. A flag the subcommand doesn't take is
@@ -61,10 +67,20 @@ struct Subcommand {
}
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand { name: "convert", values: &["--format", "--quality"], bare: &["--tiff-layers"], run: convert },
Subcommand { name: "convert", values: &["--format", "--quality", "--tga-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"], bare: &["--tiff-layers"], run: run_cmds },
Subcommand { name: "batch", values: &["--actions", "--in", "--out", "--format", "--quality"], bare: &["--in-place", "--tiff-layers"], run: batch },
Subcommand {
name: "run",
values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits"],
bare: &["--tiff-layers"],
run: run_cmds,
},
Subcommand {
name: "batch",
values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits"],
bare: &["--in-place", "--tiff-layers"],
run: batch,
},
Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet },
Subcommand { name: "commands", values: &["--filter"], bare: &["--json"], run: |a, out, _| commands(a, out) },
Subcommand {
@@ -194,6 +210,9 @@ fn export_opts(a: &Args) -> Result<ExportOptions, String> {
o.encode.webp_quality = q;
o.encode.webp_lossless = false;
}
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"))?);
}
Ok(o)
}
@@ -330,7 +349,39 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
/// list or wrapped in `{"actions": […]}`, `{"steps": […]}` or a droplet (#489).
pub fn parse_actions(text: &str) -> Result<Vec<(String, Value)>, String> {
let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?;
photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(&v), "batch --actions").map_err(|e| e.to_string())
steps_of(&v)
}
fn steps_of(v: &Value) -> Result<Steps, String> {
photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(v), "batch --actions").map_err(|e| e.to_string())
}
/// Engine commands to run in order: `(id, params)`.
pub type Steps = Vec<(String, Value)>;
/// The steps `batch` plays on each file, and the actions they can call. An Actions set (the
/// Actions panel's `actions.json`: `{"actions": [{"name", "steps"}…]}`, or the bare list) plays
/// the action `name` picks (optional when the set holds one) with the whole set loaded, as
/// Photoshop's Batch does; anything else is one action's steps ([`parse_actions`]) (#2786).
pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec<Action>), String> {
let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?;
let list = v.get("actions").unwrap_or(&v);
let is_set = list.as_array().is_some_and(|a| !a.is_empty() && a.iter().all(|x| x.get("name").is_some_and(Value::is_string)));
if !is_set {
if name.is_some() {
return Err("--action picks an action from an Actions set ({\"actions\": [{\"name\", \"steps\"}…]}), but this file holds one action's steps".into());
}
return Ok((steps_of(&v)?, Vec::new()));
}
let set: Vec<Action> = serde_json::from_value(list.clone()).map_err(|e| format!("Actions set: {e}"))?;
let names = || set.iter().map(|a| format!("`{}`", a.name)).collect::<Vec<_>>().join(", ");
let action = match (name, set.as_slice()) {
(Some(n), _) => set.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set (it holds {})", names()))?,
(None, [only]) => only,
(None, _) => return Err(format!("the Actions set holds {} actions: pick one with --action <name> ({})", set.len(), names())),
};
let play = vec![("actions.play".to_string(), json!({"action": action.name}))];
Ok((play, set))
}
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
@@ -352,7 +403,7 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
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 = parse_actions(&text)?;
let (actions, set) = load_actions(&text, a.get("--action"))?;
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).
@@ -378,9 +429,19 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let r = (|| -> Result<Vec<String>, String> {
written.check(&target_text)?;
let mut h = Headless::trusted_local();
// Steps that play another action find it in the set; the session's playback stack
// stops an action that calls itself.
h.session.actions.list = set.clone();
h.open(input).map_err(|e| e.to_string())?;
for (id, p) in &actions {
h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?;
if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) {
continue;
}
let r = h.command_run(id, p.clone()).map_err(|e| format!("`{id}`: {e}"))?;
// A failed step inside a called action makes this file an error, not a saved result.
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(id, &r) {
return Err(format!("`{id}`: {e}"));
}
}
let r = h.save(None, Some(&target), Some(&ext), &opts).map_err(|e| e.to_string())?;
Ok(serde_json::from_value(r["warnings"].clone()).unwrap_or_default())
@@ -589,3 +650,54 @@ mod missing_font_warning_tests {
let _ = std::fs::remove_dir_all(&folder);
}
}
#[cfg(test)]
mod batch_action_set_tests {
use super::*;
fn invoke(args: &[&str]) -> (i32, String, String) {
let (mut output, mut errors) = (Vec::new(), Vec::new());
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let code = run(&args, &mut output, &mut errors);
(code, String::from_utf8(output).unwrap(), String::from_utf8(errors).unwrap())
}
/// A batched action that plays another action of its set finds it in every file's session; a
/// failure inside the called action fails the file, and a self-call stops instead of looping.
#[test]
fn batch_plays_an_action_that_calls_another_action_of_its_set() {
let dir = std::env::temp_dir().join(format!("photocraft-cli-action-set-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
std::fs::create_dir_all(dir.join("in")).unwrap();
for name in ["a", "b"] {
let (code, _, stderr) = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path(&format!("in/{name}.png"))]);
assert_eq!(code, 0, "{stderr}");
}
let set = json!({"version": 1, "actions": [
{"name": "Outer", "steps": [["actions.play", {"action": "Rotate"}]]},
{"name": "Rotate", "steps": [["image.imageRotation.90cw", {}]]},
{"name": "Calls bad", "steps": [["actions.play", {"action": "Bad"}]]},
{"name": "Bad", "steps": [["layer.delete", {"layer": 999_999}]]},
{"name": "Loop", "steps": [["actions.play", {"action": "Loop"}]]},
]});
std::fs::write(dir.join("actions.json"), set.to_string()).unwrap();
let batch = |action: &str| invoke(&["batch", "--actions", &path("actions.json"), "--action", action, "--in", &path("in"), "--out", &path(action)]);
let (code, stdout, stderr) = batch("Outer");
assert_eq!(code, 0, "{stdout}{stderr}");
for name in ["a", "b"] {
let (code, info, stderr) = invoke(&["info", &path(&format!("Outer/{name}.png")), "--compact"]);
assert_eq!(code, 0, "{stderr}");
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "rotated by the called action");
}
for action in ["Calls bad", "Loop"] {
let (code, stdout, stderr) = batch(action);
assert_ne!(code, 0, "{stdout}");
assert_eq!(stderr.matches("FAIL").count(), 2, "{stderr}");
assert!(!dir.join(action).join("a.png").exists(), "a file whose called action failed is not saved");
}
let _ = std::fs::remove_dir_all(&dir);
}
}
+60 -1
View File
@@ -189,9 +189,11 @@ fn run_commands_and_save() {
let lines: Vec<Value> = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[2]["command"], "document.inspect");
assert_eq!(lines[2]["result"]["name"], "Run");
let (info, _) = ok(bin().arg("info").arg(&out_pc));
let v: Value = serde_json::from_str(&info).unwrap();
assert_eq!(v["name"], "Run");
// Saving a native document gives the reopened bundle its output filename.
assert_eq!(v["name"], "r.pcraft");
let names: Vec<&str> = v["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect();
assert_eq!(names, ["Ink", "Background"]);
// Continue from the saved file and export PNG.
@@ -264,6 +266,37 @@ fn batch_applies_actions_to_directory() {
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn batch_skips_recorded_view_and_save_steps() {
let d = tmp("batch-shell-steps");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
let source = input.join("a.png");
write_png(&source, 12, 8, 3);
let original = std::fs::read(&source).unwrap();
let recorded_save = d.join("recorded-save.psd");
let mut cases = Vec::new();
for (name, id) in [("zoom-in", "view.zoomIn"), ("zoom-out", "view.zoomOut"), ("fit", "view.fitOnScreen"), ("actual-pixels", "view.actualPixels")] {
cases.push((name, json!([[id, {}], ["image.imageRotation.90cw", {}]]).to_string()));
}
cases.push(("save", json!([["file.save", {}], ["image.imageRotation.90cw", {}]]).to_string()));
cases.push(("save-as", json!([["file.saveAs", {"path": recorded_save.to_string_lossy()}], ["image.imageRotation.90cw", {}]]).to_string()));
for (name, actions) in cases {
let o = batch_with(&d, &input, name, &actions, &[]);
let (stdout, stderr) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
assert!(o.status.success(), "{name}: {stdout}\n{stderr}");
assert!(stdout.contains("1 succeeded, 0 failed"), "{name}: {stdout}");
let output = d.join(name).join("a.png");
let (info, _) = ok(bin().arg("info").arg(&output));
let info: Value = serde_json::from_str(&info).unwrap();
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(8), Some(12)), "{name}: {info}");
assert_eq!(std::fs::read(&source).unwrap(), original, "{name} changed its input");
assert!(!recorded_save.exists(), "{name} wrote the recorded Save As path");
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn parse_actions_formats() {
let a = photocraft_cli::parse_actions(r#"[{"command":"a","params":{"x":1}},{"id":"b"}]"#).unwrap();
@@ -378,6 +411,32 @@ fn quality_outside_1_to_100_is_refused() {
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
}
/// `--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]
fn tga_bits_choose_the_targa_depth() {
let d = tmp("tga-bits");
let a = d.join("a.png");
write_png(&a, 8, 4, 3);
for (bits, want) in [(None, 32), (Some("24"), 24), (Some("32"), 32)] {
let out = d.join("a.tga");
let mut convert = bin();
convert.arg("convert").arg(&a).arg(&out);
if let Some(b) = bits {
convert.args(["--tga-bits", b]);
}
ok(&mut convert);
assert_eq!(std::fs::read(&out).unwrap()[16], want, "--tga-bits {bits:?}");
}
for bad in ["16", "0", "abc", ""] {
let out = d.join("bad.tga");
let o = bin().arg("convert").arg(&a).arg(&out).args(["--tga-bits", bad]).output().unwrap();
assert_eq!(o.status.code(), Some(1), "--tga-bits {bad}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --tga-bits `{bad}`: expected 24 or 32")), "--tga-bits {bad}");
assert!(!out.exists(), "--tga-bits {bad} wrote a file");
}
}
/// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before
/// anything is written, unless `--in-place` asks for it.
#[test]
+7 -2
View File
@@ -125,14 +125,19 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() {
let (a, b) = (d.join("a.pcraft"), d.join("b.pcraft"));
ok(bin().arg("convert").arg(&jpg).arg(&a));
ok(bin().arg("convert").arg(&a).arg(&b));
// Everything but the file's own path must match.
// Everything but the file's own path and saved document name must match.
let info = |p: &Path| {
let mut v = serde_json::from_str::<Value>(&ok(bin().arg("info").arg(p))).unwrap();
v.as_object_mut().unwrap().remove("file");
v.as_object_mut().unwrap().remove("path");
v
};
let (ia, ib) = (info(&a), info(&b));
let (mut ia, mut ib) = (info(&a), info(&b));
assert_eq!((ia["width"].as_u64(), ia["height"].as_u64()), (Some(16), Some(32)));
assert_eq!(ia["name"], "a.pcraft");
assert_eq!(ib["name"], "b.pcraft");
ia.as_object_mut().unwrap().remove("name");
ib.as_object_mut().unwrap().remove("name");
assert_eq!(ia, ib);
let (pa, pb) = (d.join("a.png"), d.join("b.png"));
ok(bin().arg("convert").arg(&a).arg(&pa));
+4 -2
View File
@@ -18,8 +18,8 @@ type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// 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", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco",
"ase", "af", "afdesign", "afphoto", "afpub",
"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",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
const CANVAS_ID: &str = "photocraft_canvas";
@@ -350,6 +350,8 @@ fn services(inbox: Inbox) -> Services {
}
opts.tiff_layers = settings.tiff_layers;
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;
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 {
+1
View File
@@ -64,6 +64,7 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
[target.'cfg(target_os = "linux")'.dependencies]
photocraft-cms = { workspace = true }
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] }
# Wayland desktop color selection through the existing XDG portal transport.
zbus = "=5.19.0"
+129
View File
@@ -0,0 +1,129 @@
//! Why a File › Open / Save dialog may never appear on Linux (#3354).
//!
//! `rfd` talks to the XDG file-chooser portal through `libdbus-1`, which it loads at runtime, and
//! falls back to `zenity`. When neither works (a minimal window-manager session without a portal
//! backend) it logs the failure and answers "nothing picked", which the app took for Cancel: the
//! menu item did nothing and said nothing. [`start`] looks for the pieces once, in the background,
//! and [`known_problem`] returns what to install when a dialog came back empty and none of them
//! is there.
//!
//! The probe only ever explains an empty answer; it never stops a dialog from opening. Anything
//! it can't tell counts as present, so a working system never sees the message.
use std::sync::OnceLock;
/// What the probe found. `None` means it couldn't tell, which counts as present.
// Only Linux probes; the types and helpers stay testable on every platform.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Findings {
/// `libdbus-1.so.3` is in the linker cache (`rfd` loads it for the portal).
pub libdbus: Option<bool>,
/// The session bus has the portal's `FileChooser` interface (a portal and a backend for it).
pub file_chooser_portal: Option<bool>,
/// `zenity` is on `PATH` (`rfd`'s fallback).
pub zenity: bool,
}
/// The message for `findings`, or `None` when some file dialog should work.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub fn problem(findings: Findings) -> Option<String> {
let portal = findings.libdbus != Some(false) && findings.file_chooser_portal != Some(false);
if portal || findings.zenity {
return None;
}
let cause = if findings.libdbus == Some(false) { "libdbus is missing" } else { "no desktop portal offers a file chooser" };
Some(format!(
"No file dialog can open: {cause}. Install xdg-desktop-portal with a backend (xdg-desktop-portal-gtk, -kde or -gnome) and libdbus, or zenity. Meanwhile, drop a file onto the window or paste a copied image."
))
}
static RESULT: OnceLock<Option<String>> = OnceLock::new();
/// The problem found by [`start`], once it has finished.
pub fn known_problem() -> Option<String> {
RESULT.get().cloned().flatten()
}
/// Whether `program` is an executable file in one of the `path` directories.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn on_path(program: &str, path: &std::ffi::OsStr) -> bool {
std::env::split_paths(path).any(|dir| dir.join(program).is_file())
}
/// Look for the file dialog backends on a helper thread (the portal call can take a moment).
#[cfg(target_os = "linux")]
pub fn start() {
let spawned = std::thread::Builder::new().name("file dialog probe".into()).spawn(|| {
let findings = Findings {
libdbus: crate::linux_libs::cached("libdbus-1.so.3"),
file_chooser_portal: file_chooser_portal(),
zenity: std::env::var_os("PATH").is_some_and(|path| on_path("zenity", &path)),
};
log::info!("file dialog backends: {findings:?}");
let _ = RESULT.set(problem(findings));
});
if let Err(e) = spawned {
log::warn!("couldn't start the file dialog probe: {e}");
}
}
/// Whether the portal's `FileChooser` interface is on the session bus: `Some(false)` only when the
/// bus or the interface is definitely absent, `None` for anything inconclusive (a slow reply).
#[cfg(target_os = "linux")]
fn file_chooser_portal() -> Option<bool> {
let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build()) else {
return Some(false);
};
let proxy = zbus::blocking::proxy::Builder::<zbus::blocking::Proxy<'_>>::new(&conn)
.destination("org.freedesktop.portal.Desktop")
.and_then(|b| b.path("/org/freedesktop/portal/desktop"))
.and_then(|b| b.interface("org.freedesktop.portal.FileChooser"))
.and_then(|b| b.cache_properties(zbus::proxy::CacheProperties::No).build())
.ok()?;
match proxy.get_property::<u32>("version") {
Ok(_) => Some(true),
Err(zbus::Error::MethodError(name, _, _)) => {
let name = name.as_str();
let absent = ["ServiceUnknown", "UnknownObject", "UnknownInterface", "UnknownProperty"].iter().any(|n| name.ends_with(n));
absent.then_some(false)
}
Err(_) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn found(libdbus: Option<bool>, file_chooser_portal: Option<bool>, zenity: bool) -> Findings {
Findings { libdbus, file_chooser_portal, zenity }
}
#[test]
fn a_working_backend_is_not_reported() {
assert_eq!(problem(found(Some(true), Some(true), false)), None, "portal");
assert_eq!(problem(found(Some(false), Some(false), true)), None, "zenity alone");
assert_eq!(problem(found(None, None, false)), None, "unknown counts as present");
assert_eq!(problem(found(Some(true), None, false)), None, "slow portal reply");
}
#[test]
fn nothing_to_show_a_dialog_with_is_explained() {
let no_portal = problem(found(Some(true), Some(false), false)).unwrap();
assert!(no_portal.contains("no desktop portal offers a file chooser") && no_portal.contains("xdg-desktop-portal"), "{no_portal}");
let no_dbus = problem(found(Some(false), Some(true), false)).unwrap();
assert!(no_dbus.contains("libdbus is missing"), "{no_dbus}");
}
#[test]
fn programs_are_found_on_the_given_path() {
let dir = std::env::temp_dir().join(format!("photocraft-probe-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("zenity"), b"").unwrap();
let path = std::env::join_paths([std::path::Path::new("/nonexistent"), dir.as_path()]).unwrap();
assert!(on_path("zenity", &path));
assert!(!on_path("kdialog", &path));
std::fs::remove_dir_all(&dir).unwrap();
}
}
+35
View File
@@ -267,6 +267,14 @@ pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
}
/// wgpu compiles indirect-call validation shaders inside `request_device`; Metal's compiler
/// crashes on them on old Intel Macs (Iris Pro, GPUFamily 1 on Monterey), so no mode could start
/// (#3107). Only Intel Macs skip them: PhotoCraft issues no indirect draws or dispatches, but every
/// other GPU keeps wgpu's default. `WGPU_VALIDATION_INDIRECT_CALL=1` still wins.
fn skips_indirect_validation(os: Os, intel: bool) -> bool {
os == Os::Mac && intel
}
/// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter
/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the
/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
@@ -275,6 +283,9 @@ pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel
if let Some(b) = backends(plan, os) {
create.instance_descriptor.backends = b;
}
if skips_indirect_validation(os, cfg!(target_arch = "x86_64")) {
create.instance_descriptor.flags = create.instance_descriptor.flags.difference(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
}
if os == Os::Windows {
let exe = std::env::current_exe().ok();
let env = std::env::var("WGPU_DX12_COMPILER").ok();
@@ -797,4 +808,28 @@ mod tests {
assert!(safe, "{:?}", create.instance_descriptor.backend_options.dx12.shader_compiler);
}
}
#[test]
fn intel_macs_skip_the_indirect_validation_shaders() {
// Metal's compiler crashed on them on Intel Iris Pro, losing the device in every mode (#3107).
assert!(skips_indirect_validation(Os::Mac, true));
// Apple silicon, Windows and Linux keep wgpu's validation.
for (os, intel) in [(Os::Mac, false), (Os::Windows, true), (Os::Other, true), (Os::Other, false)] {
assert!(!skips_indirect_validation(os, intel), "{os:?} intel={intel}");
}
for (os, safe_gpu) in [(Os::Windows, false), (Os::Other, true), (Os::Other, false)] {
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
let egui_wgpu::WgpuSetup::CreateNew(before) = &setup else { panic!("a new instance") };
let before = before.instance_descriptor.flags;
configure(&mut setup, &plan(Auto, None, None, safe_gpu, os), os, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
assert_eq!(create.instance_descriptor.flags, before, "{os:?}: flags untouched");
}
if cfg!(target_arch = "x86_64") && std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
configure(&mut setup, &plan(Auto, None, None, false, Os::Mac), Os::Mac, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
}
}
}
+16 -3
View File
@@ -111,9 +111,10 @@ pub fn ldconfig_has(output: &str, soname: &str, arch_tag: Option<&str>) -> bool
})
}
/// A real `ldconfig -p` listing starts with "N libs found in cache".
/// A real `ldconfig -p` listing starts with "N libs found in cache" (translated on a system whose
/// locale `LC_ALL=C` doesn't reach): a count, then the cache's name.
fn ldconfig_output_is_valid(output: &str) -> bool {
output.lines().next().is_some_and(|l| l.contains("libs found in cache"))
output.lines().next().is_some_and(|l| l.split_whitespace().next().is_some_and(|n| n.parse::<u32>().is_ok()))
}
/// Where we look for libraries, and what we found there.
@@ -245,7 +246,9 @@ fn search_dirs(ld_library_path: Option<&str>) -> Vec<PathBuf> {
/// `ldconfig -p`, from the usual locations (`/sbin` isn't on a normal user's PATH on Debian).
fn read_ldconfig() -> Option<String> {
for cmd in ["/sbin/ldconfig", "/usr/sbin/ldconfig", "ldconfig"] {
if let Ok(out) = std::process::Command::new(cmd).arg("-p").output()
// The listing is parsed, so it must be the untranslated one: a German system prints
// "5522 Bibliotheken im Cache", which `ldconfig_output_is_valid` took for no cache at all.
if let Ok(out) = std::process::Command::new(cmd).arg("-p").env("LC_ALL", "C").output()
&& out.status.success()
{
let text = String::from_utf8_lossy(&out.stdout).into_owned();
@@ -262,6 +265,12 @@ fn system_probe() -> Probe {
Probe { ldconfig: read_ldconfig(), dirs: search_dirs(std::env::var("LD_LIBRARY_PATH").ok().as_deref()) }
}
/// Whether the linker cache lists `soname`; `None` when the cache can't be read, which proves
/// nothing (musl, NixOS, some containers).
pub fn cached(soname: &str) -> Option<bool> {
read_ldconfig().map(|text| ldconfig_has(&text, soname, ldconfig_arch_tag()))
}
/// Are all the libraries `session` needs found on this system? (For choosing a display server:
/// libraries the check can't find count as missing.)
pub fn available(session: DisplaySession) -> bool {
@@ -325,6 +334,10 @@ mod tests {
fn ldconfig_parsing_matches_name_and_architecture() {
assert!(ldconfig_output_is_valid(CACHE));
assert!(!ldconfig_output_is_valid("ldconfig: command not found"));
// A translated listing is still a listing (it used to be rejected, so the check was
// silently skipped on non-English systems).
assert!(ldconfig_output_is_valid("5522 Bibliotheken im Cache »/etc/ld.so.cache« gefunden\n\tlibz.so.1 (libc6,x86-64) => /usr/lib/libz.so.1\n"));
assert!(!ldconfig_output_is_valid("Error: kein Cache\n"));
assert!(ldconfig_has(CACHE, "libxkbcommon.so.0", Some("x86-64")));
// Only the 32-bit build is installed: doesn't count for a 64-bit app.
assert!(!ldconfig_has(CACHE, "libxkbcommon-x11.so.0", Some("x86-64")));
+42 -5
View File
@@ -5,13 +5,15 @@
//! - Item ids are command ids; a command in two menus (Keyboard Shortcuts is in Edit and Window)
//! gets `id#2` for its second copy, as muda ids must be unique.
//! - A structure change (a new Open Recent file, a language switch) rebuilds the menu; anything
//! else (labels, enabled, checked) is updated in place.
//! else (labels, enabled, checked) is updated in place. Opening or closing a document rebuilds
//! only the Window menu, whose list of documents changed: AppKit adds its own items to that
//! menu (window tiling, the window list), so muda can't insert rows there by position.
//! - A chosen item is a click or a key equivalent: AppKit's current event says which. Key
//! equivalents go back to egui as the key press that was made (the event's modifiers, not the
//! item's: AppKit may match ⌘R to a ⇧⌘R item), so PhotoCraft's key rules still apply.
//! - Hide is a custom item calling `NSApplication hide:`, as the predefined one always takes ⌘H.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::mpsc::{Receiver, channel};
use muda::accelerator::{Accelerator, Code, Modifiers};
@@ -49,6 +51,8 @@ pub struct MacMenu {
entries: HashMap<String, Entry>,
rx: Receiver<Chosen>,
structure: u64,
/// The Window menu, the muda ids of its items, and its [`native_menu::window_key`].
window: Option<(Submenu, Vec<String>, u64)>,
}
impl MacMenu {
@@ -67,7 +71,7 @@ impl MacMenu {
// Wake egui, so the item runs now rather than on the next mouse move.
repaint.request_repaint();
}));
MacMenu { menu: None, entries: HashMap::new(), rx, structure: 0 }
MacMenu { menu: None, entries: HashMap::new(), rx, structure: 0, window: None }
}
fn build(&mut self, bar: &MenuBar) {
@@ -75,12 +79,15 @@ impl MacMenu {
old.remove_for_nsapp();
}
self.entries.clear();
self.window = None;
let menu = Menu::new();
let mut special = Vec::new();
for m in &bar.menus {
let sub = Submenu::new(escape(&m.title), true);
self.append(&sub, &m.children);
let (sub, ids) = self.submenu(m);
let _ = menu.append(&sub);
if m.role == MenuRole::Window {
self.window = Some((sub.clone(), ids, native_menu::window_key(bar)));
}
if matches!(m.role, MenuRole::Window | MenuRole::Help) {
special.push((m.role, sub));
}
@@ -97,6 +104,33 @@ impl MacMenu {
self.structure = native_menu::structure_key(bar);
}
/// A top-level menu, and the muda ids of the items it added.
fn submenu(&mut self, m: &native_menu::Menu) -> (Submenu, Vec<String>) {
let before: HashSet<String> = self.entries.keys().cloned().collect();
let sub = Submenu::new(escape(&m.title), true);
self.append(&sub, &m.children);
let ids = self.entries.keys().filter(|id| !before.contains(*id)).cloned().collect();
(sub, ids)
}
/// Replace the Window menu (a document opened or closed), leaving the other menus as they are.
fn rebuild_window(&mut self, bar: &MenuBar) {
let (Some(menu), Some((old, ids, _))) = (self.menu.clone(), self.window.take()) else { return self.build(bar) };
let Some((at, spec)) = bar.menus.iter().enumerate().find(|(_, m)| m.role == MenuRole::Window) else { return self.build(bar) };
if menu.remove(&old).is_err() {
return self.build(bar);
}
for id in ids {
self.entries.remove(&id);
}
let (sub, ids) = self.submenu(spec);
if menu.insert(&sub, at).is_err() {
return self.build(bar);
}
sub.set_as_windows_menu_for_nsapp();
self.window = Some((sub, ids, native_menu::window_key(bar)));
}
fn append(&mut self, sub: &Submenu, nodes: &[Node]) {
for n in nodes {
match n {
@@ -160,6 +194,9 @@ impl Backend for MacMenu {
self.build(bar);
return;
}
if self.window.as_ref().map(|w| w.2) != Some(native_menu::window_key(bar)) {
self.rebuild_window(bar);
}
let items: HashMap<&str, &native_menu::Item> = bar.items().into_iter().map(|it| (it.id.as_str(), it)).collect();
for e in self.entries.values_mut() {
let Some(it) = items.get(e.command.as_str()) else { continue };
+54 -1
View File
@@ -29,6 +29,7 @@ mod caps_lock;
mod control_server;
mod crash_guard;
mod cursor;
mod file_dialog_probe;
mod gpu_startup;
#[cfg(target_os = "macos")]
mod mac_menu;
@@ -89,10 +90,25 @@ 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)),
..Default::default()
}
}
/// 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
/// as a 160 × 64 strip on every launch.
fn at_least_min_size(mut builder: egui::ViewportBuilder) -> egui::ViewportBuilder {
let (Some(size), Some(min)) = (builder.inner_size, builder.min_inner_size) else { return builder };
let grown = size.max(min);
if grown != size {
builder.position = builder.position.map(|pos| pos - (grown - size) / 2.0);
builder.inner_size = Some(grown);
}
builder
}
/// Parse a `--control` / `PHOTOCRAFT_CONTROL_PORT` value. An unparseable port is an error that
/// names the value (issue #701): silently running with no control server leaves a launcher or
/// agent unable to tell a typo from a successful grant.
@@ -139,6 +155,8 @@ fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
crash_guard::install_hook();
// The system font scan can take seconds (minutes on some Macs): run it off the UI thread.
photocraft_ui_egui::type_tool::warm_fonts();
let mut control_port: Option<u16> = None;
let mut control_arg_errors: Vec<String> = Vec::new();
if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) {
@@ -212,6 +230,8 @@ fn main() -> eframe::Result {
// The display server to open the window on: Xwayland for a pen on Wayland, which gives this
// app no pen input (#639).
#[cfg(target_os = "linux")]
let native_session = linux_libs::session_from_env(|name| std::env::var(name).ok());
#[cfg(target_os = "linux")]
let session = tablet::display_session();
// Preferences › Performance › Linux display server = X11: Xwayland on a Wayland session too,
// where native file drops work (winit 0.30 has none on Wayland, #386). Only when Xwayland can
@@ -279,6 +299,9 @@ fn main() -> eframe::Result {
// Read the displays' ICC profiles while the window opens (colour-managed canvas; `None`
// where the platform has no reader).
#[cfg(target_os = "linux")]
let monitor = monitor_profile::detect_async_for_session(native_session);
#[cfg(not(target_os = "linux"))]
let monitor = monitor_profile::detect_async();
// Brush presets load in the background; the app attaches them when they arrive.
let presets = services::presets_dir().map(photocraft_engine::preset_store::open_dir_async);
@@ -342,6 +365,7 @@ fn main() -> eframe::Result {
#[cfg(target_os = "linux")]
{
services.is_wayland = display == Some(tablet::DisplayKind::Wayland);
file_dialog_probe::start();
if services.is_wayland {
let var = |name| std::env::var(name).ok();
services.xwayland_command =
@@ -360,7 +384,14 @@ fn main() -> eframe::Result {
// the right profile; a slower reading is applied when it arrives, and the shell reads
// again when the displays may have changed.
if let Some(rx) = monitor {
app.services.read_displays = Some(std::sync::Arc::new(monitor_profile::detect_async));
#[cfg(target_os = "linux")]
{
app.services.read_displays = Some(std::sync::Arc::new(move || monitor_profile::detect_async_for_session(native_session)));
}
#[cfg(not(target_os = "linux"))]
{
app.services.read_displays = Some(std::sync::Arc::new(monitor_profile::detect_async));
}
match rx.recv_timeout(std::time::Duration::from_secs(2)) {
Ok(r) => photocraft_ui_egui::monitor_status::apply(&mut app, r),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => photocraft_ui_egui::monitor_status::pending(&mut app, rx),
@@ -571,6 +602,28 @@ mod tests {
assert_ne!(o.viewport.clamp_size_to_monitor_size, Some(false));
}
#[test]
fn a_window_saved_while_minimized_reopens_at_the_minimum_size() {
let hook = super::native_options(super::CUSTOM_TITLEBAR).window_builder.expect("a window builder hook");
// Saved minimized on Windows, then centred on a 1920 × 1080 monitor by eframe.
let saved = super::native_options(super::CUSTOM_TITLEBAR).viewport.with_inner_size([160.0, 64.0]).with_position([880.0, 508.0]);
let opened = hook(saved);
assert_eq!(opened.inner_size, Some(egui::vec2(760.0, 480.0)));
assert_eq!(opened.position, Some(egui::pos2(580.0, 300.0)), "still centred");
// Too narrow only: only the width grows.
let opened = super::at_least_min_size(super::native_options(false).viewport.with_inner_size([500.0, 900.0]));
assert_eq!(opened.inner_size, Some(egui::vec2(760.0, 900.0)));
// A window at or above the minimum, or with no size, is left alone.
for size in [[760.0, 480.0], [1100.0, 760.0]] {
let builder = super::native_options(false).viewport.with_inner_size(size).with_position([10.0, 20.0]);
let opened = super::at_least_min_size(builder);
assert_eq!((opened.inner_size, opened.position), (Some(egui::Vec2::from(size)), Some(egui::pos2(10.0, 20.0))));
}
let mut builder = super::native_options(false).viewport;
builder.inner_size = None;
assert_eq!(super::at_least_min_size(builder).inner_size, None);
}
#[test]
fn the_system_title_bar_preference_keeps_the_window_decorations() {
// #1271, #1316: Preferences › Interface › System Title Bar gives the window back its
+246 -6
View File
@@ -6,7 +6,12 @@
//! AppleScriptObjC) so the app needs no `unsafe` FFI. A read takes about 0.4 s on a background
//! thread; the shell starts one at launch and again when the displays may have changed
//! (`photocraft_ui_egui::monitor_status`), and each window then uses the profile of the display
//! it is on. Other platforms report no displays (sRGB, or the profile chosen in Color Settings).
//! it is on. Linux X11: RandR output names and CRTC frames are matched to colord display devices
//! by the exact `XRANDR_name` metadata; cloned outputs get a profile only when every output has
//! the same ICC bytes. Native Wayland, including Xwayland windows opened from a Wayland session,
//! reports no display profiles: eframe/winit has no safe way to set the app surface's Wayland
//! image description, so applying the output ICC in PhotoCraft could double-transform it.
//! Other platforms report no displays (sRGB, or the profile chosen in Color Settings).
use std::sync::mpsc::Receiver;
@@ -16,15 +21,33 @@ use photocraft_engine::display_color::Display;
pub type Detection = Result<Vec<Display>, String>;
/// Starts reading the displays in the background; `None` when the platform has no reader.
#[cfg(not(target_os = "linux"))]
pub fn detect_async() -> Option<Receiver<Detection>> {
if !cfg!(target_os = "macos") {
return None;
#[cfg(target_os = "macos")]
{
Some(spawn_reader(detect))
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
/// Linux display profile reads are supported only for an X11 session. In particular, an app
/// window opened through Xwayland still belongs to the Wayland desktop and must not apply a
/// compositor-managed output profile a second time.
#[cfg(target_os = "linux")]
pub fn detect_async_for_session(session: crate::linux_libs::DisplaySession) -> Option<Receiver<Detection>> {
(session == crate::linux_libs::DisplaySession::X11).then(|| spawn_reader(detect))
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn spawn_reader(detect: fn() -> Detection) -> Receiver<Detection> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(detect());
});
Some(rx)
rx
}
/// One line per screen, primary (menu-bar) screen first: id, frame (x, y, width, height in
@@ -110,9 +133,225 @@ fn run_helper(cmd: &mut std::process::Command, timeout: std::time::Duration) ->
Ok(String::from_utf8_lossy(&out).into_owned())
}
#[cfg(not(target_os = "macos"))]
#[cfg(target_os = "linux")]
fn detect() -> Detection {
Err("this platform doesn't report display profiles".into())
linux::detect()
}
#[cfg(target_os = "linux")]
mod linux {
use std::collections::{BTreeMap, HashMap};
use std::io::Read as _;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use photocraft_engine::display_color::Display;
use x11rb::connection::Connection as _;
use x11rb::protocol::randr;
use zbus::blocking::{connection::Builder, proxy::Proxy};
use zbus::zvariant::OwnedObjectPath;
use super::Detection;
const MAX_ICC_BYTES: usize = 64 * 1024 * 1024;
const COLORD: &str = "org.freedesktop.ColorManager";
const COLORD_ROOT: &str = "/org/freedesktop/ColorManager";
const COLORD_MANAGER: &str = "org.freedesktop.ColorManager";
const COLORD_DEVICE: &str = "org.freedesktop.ColorManager.Device";
const COLORD_PROFILE: &str = "org.freedesktop.ColorManager.Profile";
struct IccProfile {
name: String,
bytes: Arc<Vec<u8>>,
}
pub(super) fn detect() -> Detection {
let (connection, screen) = x11rb::connect(None).map_err(|e| format!("couldn't connect to the X11 display: {e}"))?;
let root = connection.setup().roots.get(screen).ok_or_else(|| "the X11 server didn't provide a screen".to_string())?.root;
let resources = randr::get_screen_resources_current(&connection, root)
.map_err(|e| format!("couldn't request XRandR outputs: {e}"))?
.reply()
.map_err(|e| format!("couldn't read XRandR outputs: {e}"))?;
let mut output_names = HashMap::new();
let mut active_crtcs = BTreeMap::new();
for output in resources.outputs {
let info = match randr::get_output_info(&connection, output, resources.config_timestamp) {
Ok(cookie) => match cookie.reply() {
Ok(info) => info,
Err(e) => {
log::debug!("couldn't read one XRandR output: {e}");
continue;
}
},
Err(e) => {
log::debug!("couldn't request one XRandR output: {e}");
continue;
}
};
if info.connection != randr::Connection::CONNECTED || info.crtc == 0 {
continue;
}
let Ok(name) = String::from_utf8(info.name) else {
log::debug!("skipping an XRandR output with a non-UTF-8 name");
continue;
};
if !name.is_empty() {
output_names.insert(output, name);
active_crtcs.insert(info.crtc, ());
}
}
let profiles = match colord_profiles() {
Ok(profiles) => profiles,
Err(e) => {
log::warn!("couldn't read X11 display profiles from colord: {e}");
HashMap::new()
}
};
let mut displays = Vec::with_capacity(active_crtcs.len());
for crtc in active_crtcs.into_keys() {
let crtc_info = match randr::get_crtc_info(&connection, crtc, resources.config_timestamp) {
Ok(cookie) => match cookie.reply() {
Ok(info) if info.status == randr::SetConfig::SUCCESS && info.width > 0 && info.height > 0 => info,
Ok(_) => continue,
Err(e) => {
log::debug!("couldn't read XRandR CRTC {crtc}: {e}");
continue;
}
},
Err(e) => {
log::debug!("couldn't request XRandR CRTC {crtc}: {e}");
continue;
}
};
let Some(output_names) = crtc_info.outputs.iter().map(|output| output_names.get(output).cloned()).collect::<Option<Vec<_>>>() else { continue };
let Some(primary_name) = output_names.first() else { continue };
let profile = unambiguous_clone_profile(&output_names, &profiles);
displays.push(Display {
id: crtc,
name: primary_name.clone(),
frame: [f64::from(crtc_info.x), f64::from(crtc_info.y), f64::from(crtc_info.width), f64::from(crtc_info.height)],
profile_name: profile.as_ref().map(|p| p.name.clone()),
icc: profile.map(|p| p.bytes),
});
}
if displays.is_empty() {
return Err("X11 reported no active RandR outputs".into());
}
Ok(displays)
}
fn colord_profiles() -> Result<HashMap<String, Vec<IccProfile>>, String> {
let connection = Builder::system()
.map_err(|e| format!("couldn't connect to the system bus: {e}"))?
.method_timeout(Duration::from_secs(2))
.build()
.map_err(|e| format!("couldn't connect to the system bus: {e}"))?;
let manager = Proxy::new(&connection, COLORD, COLORD_ROOT, COLORD_MANAGER).map_err(|e| format!("couldn't access colord: {e}"))?;
let devices: Vec<OwnedObjectPath> =
manager.call("GetDevicesByKind", &("display",)).map_err(|e| format!("colord couldn't list display devices: {e}"))?;
let mut profiles: HashMap<String, Vec<IccProfile>> = HashMap::new();
for device_path in devices {
let device = Proxy::new(&connection, COLORD, &device_path, COLORD_DEVICE).map_err(|e| format!("couldn't access a colord display device: {e}"))?;
let metadata: HashMap<String, String> = device.get_property("Metadata").map_err(|e| format!("couldn't read colord display metadata: {e}"))?;
let Some(output_name) = metadata.get("XRANDR_name").filter(|name| !name.is_empty()) else { continue };
// `*` is colord's default profile qualifier. A display without one is kept, with no
// ICC, so the engine reports the normal sRGB fallback.
let profile_path: OwnedObjectPath = match device.call("GetProfileForQualifiers", &(vec!["*"],)) {
Ok(path) => path,
Err(_) => continue,
};
let profile = Proxy::new(&connection, COLORD, &profile_path, COLORD_PROFILE).map_err(|e| format!("couldn't access a colord ICC profile: {e}"))?;
let filename: String = match profile.get_property::<String>("Filename") {
Ok(filename) if !filename.is_empty() => filename,
_ => continue,
};
let Some(profile) = read_icc(Path::new(&filename)) else {
log::warn!("couldn't read a valid ICC profile assigned to X11 output {output_name}");
continue;
};
profiles.entry(output_name.clone()).or_default().push(profile);
}
Ok(profiles)
}
fn read_icc(path: &Path) -> Option<IccProfile> {
let mut bytes = Vec::new();
std::fs::File::open(path).ok()?.take((MAX_ICC_BYTES + 1) as u64).read_to_end(&mut bytes).ok()?;
let bytes = checked_icc_bytes(bytes)?;
let name = path.file_name()?.to_str()?.to_owned();
Some(IccProfile { name, bytes: Arc::new(bytes) })
}
fn checked_icc_bytes(mut bytes: Vec<u8>) -> Option<Vec<u8>> {
if bytes.len() > MAX_ICC_BYTES || bytes.len() < 132 || bytes.get(36..40) != Some(b"acsp") {
return None;
}
let declared = u32::from_be_bytes(bytes.get(0..4)?.try_into().ok()?) as usize;
if declared < 132 || declared > bytes.len() {
return None;
}
bytes.truncate(declared);
let profile = photocraft_cms::Profile::parse(&bytes).ok()?;
(profile.color_space == photocraft_cms::ColorSpace::Rgb).then_some(bytes)
}
fn unambiguous_clone_profile(output_names: &[String], profiles: &HashMap<String, Vec<IccProfile>>) -> Option<IccProfile> {
let mut selected: Option<&IccProfile> = None;
for output in output_names {
let candidates = profiles.get(output)?;
let first = candidates.first()?;
if candidates.iter().any(|candidate| candidate.bytes != first.bytes) {
return None;
}
if selected.is_some_and(|profile| profile.bytes != first.bytes) {
return None;
}
selected = Some(first);
}
selected.map(|profile| IccProfile { name: profile.name.clone(), bytes: profile.bytes.clone() })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn checked_icc_bytes_require_a_bounded_parseable_rgb_profile() {
let rgb = photocraft_cms::Builtin::Srgb.profile().to_bytes();
let bytes = rgb.as_ref().clone();
assert_eq!(checked_icc_bytes(bytes.clone()), Some(bytes));
let cmyk = photocraft_cms::Builtin::CoatedCmyk.profile().to_bytes();
assert!(checked_icc_bytes(cmyk.as_ref().clone()).is_none());
assert!(checked_icc_bytes(vec![0; 132]).is_none());
assert!(checked_icc_bytes(vec![0; MAX_ICC_BYTES + 1]).is_none());
}
#[test]
fn cloned_outputs_use_only_one_unambiguous_profile() {
let srgb = photocraft_cms::Builtin::Srgb.profile().to_bytes();
let p3 = photocraft_cms::Builtin::DisplayP3.profile().to_bytes();
let profile = |name: &str, bytes: std::sync::Arc<Vec<u8>>| IccProfile { name: name.into(), bytes };
let outputs = ["DP-1".to_owned(), "HDMI-1".to_owned()];
let matching =
HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())]), ("HDMI-1".to_owned(), vec![profile("sRGB", srgb.clone())])]);
assert_eq!(unambiguous_clone_profile(&outputs, &matching).map(|p| p.name), Some("sRGB".into()));
let differing =
HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())]), ("HDMI-1".to_owned(), vec![profile("Display P3", p3.clone())])]);
assert!(unambiguous_clone_profile(&outputs, &differing).is_none());
let missing = HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb.clone())])]);
assert!(unambiguous_clone_profile(&outputs, &missing).is_none());
let conflicting = HashMap::from([("DP-1".to_owned(), vec![profile("sRGB", srgb), profile("Display P3", p3)])]);
assert!(unambiguous_clone_profile(&["DP-1".to_owned()], &conflicting).is_none());
}
}
}
/// The helper's reply ([`SCRIPT`]) → displays, frames flipped to a top-left origin with y down
@@ -285,6 +524,7 @@ mod tests {
}
/// On a Mac, every display is read, and the profiles parse as RGB profiles.
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn detected_displays_parse() {
if let Ok(displays) = detect() {
+88 -16
View File
@@ -48,6 +48,66 @@ fn count(width: usize, height: usize) -> Result<usize, String> {
width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_PIXELS).ok_or_else(|| "Screen capture exceeds the 64 MP memory budget".into())
}
#[cfg(target_os = "linux")]
const MAX_ICC_PROFILE_BYTES: usize = 1024 * 1024;
#[cfg(target_os = "linux")]
const ICC_PROFILE_PROPERTY_LONG_LENGTH: u32 = 256 * 1024;
#[cfg(target_os = "linux")]
fn icc_profile(bytes: Vec<u8>) -> Option<Vec<u8>> {
if bytes.is_empty() || bytes.len() > MAX_ICC_PROFILE_BYTES {
return None;
}
let profile = match photocraft_cms::Profile::parse(&bytes) {
Ok(profile) => profile,
Err(_) => return None,
};
(profile.color_space == photocraft_cms::ColorSpace::Rgb).then_some(bytes)
}
#[cfg(target_os = "linux")]
fn icc_profile_atom<C: x11rb::connection::RequestConnection>(conn: &C, name: &str) -> Option<u32> {
use x11rb::protocol::xproto::ConnectionExt as _;
let cookie = match conn.intern_atom(true, name.as_bytes()) {
Ok(cookie) => cookie,
Err(_) => return None,
};
let atom = match cookie.reply() {
Ok(reply) => reply.atom,
Err(_) => return None,
};
(atom != 0).then_some(atom)
}
#[cfg(target_os = "linux")]
fn output_icc_profile<C: x11rb::connection::RequestConnection>(conn: &C, output: u32, atom: u32) -> Option<Vec<u8>> {
use x11rb::protocol::{randr::ConnectionExt as _, xproto::AtomEnum};
let cookie = match conn.randr_get_output_property(output, atom, AtomEnum::CARDINAL, 0, ICC_PROFILE_PROPERTY_LONG_LENGTH, false, false) {
Ok(cookie) => cookie,
Err(_) => return None,
};
let reply = match cookie.reply() {
Ok(reply) => reply,
Err(_) => return None,
};
if reply.type_ != u32::from(AtomEnum::CARDINAL) || reply.format != 8 || reply.bytes_after != 0 {
return None;
}
icc_profile(reply.data)
}
#[cfg(target_os = "linux")]
fn root_icc_profile<C: x11rb::connection::RequestConnection>(conn: &C, root: u32, atom: u32) -> Option<Vec<u8>> {
use x11rb::protocol::xproto::{AtomEnum, ConnectionExt as _};
let cookie = match conn.get_property(false, root, atom, AtomEnum::ANY, 0, ICC_PROFILE_PROPERTY_LONG_LENGTH) {
Ok(cookie) => cookie,
Err(_) => return None,
};
let reply = match cookie.reply() {
Ok(reply) => reply,
Err(_) => return None,
};
if reply.format != 8 || reply.bytes_after != 0 {
return None;
}
icc_profile(reply.value)
}
#[cfg(target_os = "linux")]
fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result<Capture, String> {
if wayland {
return portal(stop);
@@ -55,10 +115,7 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
use x11rb::{
connection::Connection,
image::{Image, PixelLayout},
protocol::{
randr::ConnectionExt as _,
xproto::{AtomEnum, ConnectionExt, VisualClass},
},
protocol::{randr::ConnectionExt as _, xproto::VisualClass},
};
let (conn, index) = x11rb::connect(None).map_err(|e| e.to_string())?;
let screen = conn.setup().roots.get(index).ok_or("X11 screen is missing")?;
@@ -71,13 +128,16 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
{
first.primary = true;
}
let regions: Vec<(i16, i16, u16, u16, bool)> =
if monitors.is_empty() { vec![(0, 0, w, h, true)] } else { monitors.iter().map(|m| (m.x, m.y, m.width, m.height, m.primary)).collect() };
let regions: Vec<(i16, i16, u16, u16, bool, Vec<x11rb::protocol::randr::Output>)> = if monitors.is_empty() {
vec![(0, 0, w, h, true, Vec::new())]
} else {
monitors.iter().map(|m| (m.x, m.y, m.width, m.height, m.primary, m.outputs.clone())).collect()
};
if regions.len() > 16 {
return Err("Too many screens for the color picker".into());
}
let mut total = 0usize;
for &(x, y, mw, mh, _) in &regions {
for &(x, y, mw, mh, _, _) in &regions {
if x < 0 || y < 0 || u32::from(x as u16) + u32::from(mw) > u32::from(w) || u32::from(y as u16) + u32::from(mh) > u32::from(h) {
return Err("Invalid X11 display geometry".into());
}
@@ -92,20 +152,20 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
return Err("Screen picking requires an X11 TrueColor visual".into());
}
let layout = PixelLayout::from_visual_type(*visual).map_err(|e| e.to_string())?;
let atom = conn.intern_atom(true, b"_ICC_PROFILE").map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?.atom;
let primary_profile = if atom == 0 {
None
} else {
let reply = conn.get_property(false, screen.root, atom, AtomEnum::ANY, 0, 256 * 1024).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
(reply.bytes_after == 0 && reply.format == 8 && !reply.value.is_empty()).then_some(reply.value)
};
let output_profile_atom = icc_profile_atom(&conn, "_ICC_PROFILE");
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
let byte = |v: u16| ((u32::from(v) + 128) / 257) as u8;
let mut images = Vec::new();
for (x, y, mw, mh, primary) in regions {
for (x, y, mw, mh, primary, outputs) in regions {
if stop.load(std::sync::atomic::Ordering::Relaxed) {
return Ok(Capture::Color(None));
}
let profile = output_profile_atom.and_then(|atom| outputs.iter().find_map(|output| output_icc_profile(&conn, *output, atom))).or_else(|| {
if !primary {
return None;
}
icc_profile_atom(&conn, "_ICC_PROFILE").and_then(|atom| root_icc_profile(&conn, screen.root, atom))
});
let mut rgba = Vec::with_capacity(count(usize::from(mw), usize::from(mh))?.saturating_mul(4));
for row in 0..mh {
for col in 0..mw {
@@ -120,7 +180,7 @@ fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, plac
width: usize::from(mw),
height: usize::from(mh),
rgba,
profile: if primary { primary_profile.clone() } else { None },
profile,
});
}
Ok(Capture::Images(images))
@@ -359,6 +419,18 @@ mod tests {
}
#[cfg(target_os = "linux")]
#[test]
fn x11_screen_capture_accepts_only_bounded_rgb_icc_profiles() {
let rgb = photocraft_cms::Builtin::Srgb.profile().to_bytes();
let bytes = rgb.as_ref().clone();
assert_eq!(icc_profile(bytes.clone()), Some(bytes));
assert!(icc_profile(Vec::new()).is_none());
assert!(icc_profile(vec![0; MAX_ICC_PROFILE_BYTES + 1]).is_none());
let cmyk = photocraft_cms::Builtin::CoatedCmyk.profile().to_bytes();
assert!(icc_profile(cmyk.as_ref().clone()).is_none());
}
#[cfg(target_os = "linux")]
#[test]
#[ignore = "requires a live X11 display; draws only a tiny synthetic fixture"]
fn screen_capture_reads_a_known_x11_fixture_pixel() {
use x11rb::{
+75 -13
View File
@@ -21,8 +21,8 @@ mod recovery_tests;
/// (.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", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco",
"ase", "kys", "af", "afdesign", "afphoto", "afpub",
"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",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -60,6 +60,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("TIFF", &["tif"]),
("Targa", &["tga"]),
("OpenEXR", &["exr"]),
("FITS", &["fits", "fit"]),
];
/// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog
@@ -129,7 +130,11 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
}
};
// Without the thread the reply is dropped unanswered, which the app takes as Cancel.
if let Err(e) = std::thread::Builder::new().name("file dialog".into()).spawn(move || reply.send(block_on(answer))) {
if let Err(e) = std::thread::Builder::new().name("file dialog".into()).spawn(move || {
// An empty answer from a system with no dialog backend isn't a Cancel (#3354).
let answer = block_on(answer).or_else(|| crate::file_dialog_probe::known_problem().map(FileDialogAnswer::Unavailable));
reply.send(answer)
}) {
log::error!("couldn't wait for the file dialog: {e}");
}
}
@@ -186,6 +191,11 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
photocraft_format::atomic_write(path, bytes).map_err(|e| e.to_string())
}
/// [`write_atomic`] reporting the fraction written (a background save's progress, #3389).
fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> Result<(), String> {
photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string())
}
fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions {
let mut opts = photocraft_io::ExportOptions::default();
if let Some(q) = settings.jpeg_quality {
@@ -197,25 +207,47 @@ fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_i
}
opts.tiff_layers = settings.tiff_layers;
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
}
/// A save on a worker thread (`Services::save_file`, #2017): encode, then write atomically.
/// Cancelling while encoding leaves the file on disk untouched; once writing starts the save
/// can't be cancelled.
/// The share of a save's progress bar that encoding fills; writing the file fills the rest. A
/// 24 MP, 5-layer PSD (572 MB) takes about 3 s to encode and 0.5 s or more to write and flush.
const ENCODE_SHARE: f32 = 0.85;
/// A save on a worker thread (`Services::save_file` and `Services::automation_save`, #2017):
/// encode, then write through `write`. Cancelling while encoding leaves the target untouched;
/// once writing starts the save can't be cancelled. The encoder's progress fills the first
/// [`ENCODE_SHARE`] of the job's progress, so the status bar and tab read "Saving NN%" (#3389).
fn save_through(
doc: &Document,
path: &str,
settings: &photocraft_ui_egui::ExportSettings,
ctl: &photocraft_ui_egui::jobs_ui::SaveCtl,
write: impl Fn(&str, &[u8], &dyn Fn(f32)) -> Result<(), String>,
) -> Result<Vec<String>, String> {
ctl.progress(0.0, "Encoding");
let opts = export_options(settings);
let cancelled = || ctl.check().is_err();
let progress = |f: f32| ctl.progress(f * ENCODE_SHARE, "");
let interrupt = photocraft_raster::Interrupt::new(&cancelled, &progress);
let r = crate::crash_guard::guard("Save", || photocraft_io::export_with(doc, path, &opts, &interrupt).map_err(|e| e.to_string()))?;
ctl.commit()?;
ctl.progress(ENCODE_SHARE, "Writing");
// The last bit is the flush and rename after the last chunk: 100% only once they're done.
write(path, &r.bytes, &|f| ctl.progress(ENCODE_SHARE + (0.99 - ENCODE_SHARE) * f, ""))?;
ctl.progress(1.0, "");
Ok(r.warnings)
}
fn save_file(
doc: &Document,
path: &str,
settings: &photocraft_ui_egui::ExportSettings,
ctl: &photocraft_ui_egui::jobs_ui::SaveCtl,
) -> Result<Vec<String>, String> {
ctl.progress(0.0, "Encoding");
let opts = export_options(settings);
let r = crate::crash_guard::guard("Save", || photocraft_io::export(doc, path, &opts).map_err(|e| e.to_string()))?;
ctl.commit()?;
ctl.progress(0.0, "Writing");
write_atomic(Path::new(path), &r.bytes)?;
Ok(r.warnings)
save_through(doc, path, settings, ctl, |p, b, progress| write_atomic_progress(Path::new(p), b, progress))
}
type SharedRecovery = Rc<RefCell<Option<RecoveryStore>>>;
@@ -315,6 +347,18 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
let automation_write = automation.clone().map(|workspace| {
Box::new(move |path: &str, bytes: &[u8]| workspace.write(path, bytes).map_err(|error| error.to_string())) as photocraft_ui_egui::AutomationWriteFn
});
let automation_open_pcraft = automation.clone().map(|workspace| {
Box::new(move |path: &str| {
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path).to_string();
let opened = crate::crash_guard::guard("Open", || workspace.open_pcraft(path).map_err(|e| e.to_string()))?;
Ok(opened.map(|doc| (name, doc)))
}) as photocraft_ui_egui::AutomationOpenPcraftFn
});
let automation_save = automation.clone().map(|workspace| {
std::sync::Arc::new(move |doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings, ctl: &photocraft_ui_egui::jobs_ui::SaveCtl| {
save_through(doc, path, settings, ctl, |p, b, _| workspace.write(p, b).map_err(|e| e.to_string()))
}) as photocraft_ui_egui::SaveFileFn
});
let step: fn(&str, &serde_json::Value) -> photocraft_engine::Result<()> = photocraft_automation::workspace::authorize_desktop_engine_step;
let automation_authorize = automation.is_some().then_some(step);
let automation_command = automation.map(|_| {
@@ -337,6 +381,8 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
write: Some(Box::new(|path: &str, bytes: &[u8]| write_atomic(Path::new(path), bytes))),
automation_read,
automation_write,
automation_open_pcraft,
automation_save,
automation_command,
automation_authorize,
encode_png: Some(Box::new(|w, h, rgba| {
@@ -345,6 +391,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
})),
inbox: None,
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
beep: Some(platform_beep),
clipboard_set_image: Some({
let clip = clip.clone();
Box::new(move |w: u32, h: u32, px: &[u8]| {
@@ -391,6 +438,21 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
}
}
/// Preferences ▸ General ▸ Beep When Done: the platform's default information beep.
fn platform_beep() {
#[cfg(target_os = "windows")]
{
let _ = winsafe::MessageBeep(winsafe::co::MBP::OK);
}
#[cfg(not(target_os = "windows"))]
{
// No beep API among this app's other platforms' dependencies: write the terminal bell
// (audible when launched from a terminal, which is the same cue macOS terminals use).
use std::io::Write as _;
let _ = std::io::stdout().write_all(b"\x07").and_then(|()| std::io::stdout().flush());
}
}
/// Flat-image import via photocraft-codecs (kept for reference/tests; the app uses photocraft-io).
#[allow(dead_code)]
pub fn import_flat(name: &str, bytes: &[u8]) -> Result<Document, String> {
+94 -3
View File
@@ -12,6 +12,14 @@
//! `WS_BORDER` and forces Windows to recalculate the frame. winit puts them back whenever it
//! changes the window flags (maximize, restore, …), so the returned guard checks every frame.
//!
//! Windows sizes a maximize by the frame its styles describe, so a window maximized while its
//! caption is gone is sized without one: its rect covers the whole monitor, taskbar included.
//! The shell reads a caption-less window covering its monitor as a full-screen app and drops the
//! taskbar's backdrop behind it — a black band on stock Windows 11, a glitched unresponsive one
//! under ExplorerPatcher (#3144). The guard therefore also trims a parked maximize's rect back to
//! the work area plus its own frame, clear of the taskbar. winit pins the client area to the work
//! area either way (`WM_NCCALCSIZE`), so the app's pixels do not move.
//!
//! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and
//! winsafe can't wrap a raw handle without it, so the window is found by its handle instead.
@@ -34,8 +42,8 @@ mod platform {
_ => return None,
};
let hwnd = find_window(target)?;
strip(&hwnd);
Some(Box::new(move || strip(&hwnd)))
reapply(&hwnd);
Some(Box::new(move || reapply(&hwnd)))
}
/// The top-level window with the given raw handle. `EnumWindows` hands out real `HWND`s, so
@@ -53,6 +61,13 @@ mod platform {
found
}
/// One pass of the per-frame guard: the styles winit put back come off again, and a parked
/// maximize stays clear of the taskbar.
fn reapply(hwnd: &HWND) {
strip(hwnd);
keep_clear_of_the_taskbar(hwnd);
}
/// Removes `WS_CAPTION`/`WS_BORDER` and has Windows recalculate the frame. A no-op once they
/// are gone, so it is cheap to call every frame.
fn strip(hwnd: &HWND) {
@@ -70,6 +85,51 @@ mod platform {
);
}
/// How far a parked maximize reaches past its monitor on the left, top and right: the resize
/// border (about 8 pt at 100 %, more with DPI). Past that, a maximized window's rect is not
/// parked but being dragged or snapped, and is left alone.
const FRAME: i32 = 32;
/// [`clamped_height`] for this frame's window: trim a maximized window whose rect runs under
/// the taskbar back to the work area plus its frame (#3144). No-op otherwise.
fn keep_clear_of_the_taskbar(hwnd: &HWND) {
if !hwnd.IsZoomed() {
return;
}
let (Ok(r), Ok(mi)) = (hwnd.GetWindowRect(), hwnd.MonitorFromWindow(co::MONITOR::DEFAULTTONEAREST).GetMonitorInfo()) else {
return;
};
let (m, w) = (mi.rcMonitor, mi.rcWork);
let Some(height) = clamped_height((r.left, r.top, r.right, r.bottom), (m.left, m.top, m.right, m.bottom), (w.left, w.top, w.right, w.bottom)) else {
return;
};
let _ = hwnd.SetWindowPos(
HwndPlace::None,
POINT::new(),
SIZE { cx: r.right - r.left, cy: height },
co::SWP::NOMOVE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
}
/// The height to trim a maximize to so its rect stops at the taskbar, or `None` when it
/// already does or `rect` (`left, top, right, bottom`, desktop coordinates) is not parked at
/// its maximize place. `monitor` is the monitor's full rect and `work` its work area (the
/// monitor less the taskbar) in the same coordinates. The parked check is against the full
/// monitor, because that is where Windows puts a maximize; the trim is against the work area.
fn clamped_height(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<i32> {
let (l, t, r, b) = rect;
let (ml, mt, mr, _) = monitor;
let (_, _, _, wb) = work;
let side = ml - l;
if !(0..=FRAME).contains(&side) || !(0..=FRAME).contains(&(mt - t)) || !(0..=FRAME).contains(&(r - mr)) {
return None;
}
if b <= wb + side {
return None;
}
Some(wb + side - t)
}
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
/// `WS_BORDER`). The sizing border (`WS_THICKFRAME`) stays, so edge resizing and Aero snap
/// keep working, as they did with winit 0.27's undecorated windows.
@@ -79,7 +139,7 @@ mod platform {
#[cfg(test)]
mod tests {
use super::borderless;
use super::{borderless, clamped_height};
use winsafe::co;
#[test]
@@ -94,6 +154,37 @@ mod platform {
// Already borderless: unchanged, so the per-frame call does nothing.
assert_eq!(bare.raw(), borderless(bare).raw());
}
#[test]
fn a_maximize_covering_the_taskbar_is_trimmed_to_the_work_area() {
// 1920 × 1080 with a 34 pt taskbar: a caption-less maximize covers the whole
// monitor, taskbar included (#3144). Trimmed so the rect ends at the work area
// plus the frame the window was parked with.
let monitor = (0, 0, 1920, 1080);
let work = (0, 0, 1920, 1046);
assert_eq!(clamped_height((-7, -7, 1927, 1087), monitor, work), Some(1046 + 7 - (-7)));
}
#[test]
fn a_maximize_already_clear_of_the_taskbar_is_left_alone() {
// What Windows sizes a framed maximize to: the work area plus the frame.
assert_eq!(clamped_height((-8, -8, 1928, 1054), (0, 0, 1920, 1080), (0, 0, 1920, 1046)), None);
}
#[test]
fn a_maximize_beside_a_side_taskbar_is_left_alone() {
// Taskbar on the left: the work area starts further right, but the monitor's bottom
// is still clear, so the rect reaches nothing.
assert_eq!(clamped_height((-8, -8, 1928, 1088), (0, 0, 1920, 1080), (48, 0, 1920, 1080)), None);
}
#[test]
fn a_maximize_being_dragged_away_is_left_alone() {
let monitor = (0, 0, 1920, 1080);
let work = (0, 0, 1920, 1046);
assert_eq!(clamped_height((100, 200, 1100, 900), monitor, work), None, "moved");
assert_eq!(clamped_height((-500, -7, 1427, 1087), monitor, work), None, "resized");
}
}
}
+25
View File
@@ -165,6 +165,31 @@ fn main() {
frame(&mut app, raw);
assert_eq!(app.ui.extras.rulers, !rulers, "↩ on Rulers toggled it");
// Window ends with the open documents (#2991). Opening one rebuilds only the Window menu,
// which AppKit still knows as its Window menu; choosing a row brings that document forward.
app.run("file.new", json!({"width": 32, "height": 32})).expect("a second document");
frame(&mut app, egui::RawInput::default());
assert!(Retained::as_ptr(&bar) == Retained::as_ptr(&main_menu()), "a new document rebuilt only the Window menu");
let window = submenu(&main_menu(), "Window");
assert!(nsapp.windowsMenu().is_some_and(|w| Retained::as_ptr(&w) == Retained::as_ptr(&window)), "AppKit knows the new Window menu");
let names: Vec<String> = app.session.documents().iter().map(|d| d.doc.name.clone()).collect();
// The last rows (both documents are "Untitled").
let rows = [window.numberOfItems() - 2, window.numberOfItems() - 1];
let shown: Vec<String> = rows.iter().filter_map(|&i| window.itemAtIndex(i)).map(|it| it.title().to_string()).collect();
assert_eq!(shown, names, "in tab order, after Bring All to Front: {:?}", titles(&window));
let checked = |menu: &NSMenu, at: isize| menu.itemAtIndex(at).is_some_and(|it| it.state() == 1);
assert!(!checked(&window, rows[0]) && checked(&window, rows[1]), "the active document is checked");
dequeue(&other());
window.performActionForItemAtIndex(rows[0]);
let mut raw = egui::RawInput::default();
if let Some(m) = app.services.native_menu.as_mut() {
m.raw_input(&mut raw);
}
frame(&mut app, raw);
assert_eq!(app.session.active_index(), Some(0), "choosing the first row brought it forward");
let window = submenu(&main_menu(), "Window");
assert!(checked(&window, rows[0]) && !checked(&window, rows[1]), "the check moved in place");
println!("mac_menu_appkit: ok");
}
+16
View File
@@ -0,0 +1,16 @@
<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"
>
<!-- PhotoCraft's own Artboard tool glyph (not from an icon set): a board with a tick at each end of its edges. -->
<rect x="7" y="7" width="10" height="10" rx="1" />
<path d="M7 2v2.5M17 2v2.5M7 19.5V22M17 19.5V22" />
<path d="M2 7h2.5M2 17h2.5M19.5 7H22M19.5 17H22" />
</svg>

After

Width:  |  Height:  |  Size: 487 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"
>
<path d="m11 10 3 3" />
<path d="M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z" />
<path d="M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031" />
<path d="M2.5 9A6 6 0 0 1 8 3" />
<path d="m6 1.5 2 1.5-2 1.5" />
<path d="M21.5 15a6 6 0 0 1-5.5 6" />
<path d="m18 19.5-2 1.5 2 1.5" />
</svg>

After

Width:  |  Height:  |  Size: 544 B

+13
View File
@@ -0,0 +1,13 @@
<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"
>
<path d="m15 18-6-6 6-6" />
</svg>

After

Width:  |  Height:  |  Size: 238 B

+16
View File
@@ -0,0 +1,16 @@
<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"
>
<!-- PhotoCraft's own Convert Point glyph: a corner point, an angle with a square anchor on its apex. -->
<rect x="9.75" y="4.75" width="4.5" height="4.5" />
<path d="M10.5 9.25 4 19" />
<path d="M13.5 9.25 20 19" />
</svg>

After

Width:  |  Height:  |  Size: 433 B

+15
View File
@@ -0,0 +1,15 @@
<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"
>
<!-- PhotoCraft's own Frame tool glyph (not from an icon set): an empty placeholder frame, crossed corner to corner. -->
<rect x="3" y="4" width="18" height="16" rx="1" />
<path d="M6.5 7.5l11 9M17.5 7.5l-11 9" />
</svg>

After

Width:  |  Height:  |  Size: 428 B

+18
View File
@@ -0,0 +1,18 @@
<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"
>
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
<path d="m2.3 2.3 7.286 7.286" />
<circle cx="11" cy="11" r="2" />
<path d="M18 3.5h5" />
<path d="M20.5 1v5" />
</svg>

After

Width:  |  Height:  |  Size: 594 B

+21
View File
@@ -0,0 +1,21 @@
<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"
>
<g transform="translate(7 7) scale(0.68)" stroke-width="2.6">
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
<path d="m2.3 2.3 7.286 7.286" />
<circle cx="11" cy="11" r="2" />
</g>
<path d="M3 12C3 7 7 3 12 3" />
<circle cx="3" cy="12" r="1" fill="currentColor" />
<circle cx="12" cy="3" r="1" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 765 B

+17
View File
@@ -0,0 +1,17 @@
<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"
>
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
<path d="m2.3 2.3 7.286 7.286" />
<circle cx="11" cy="11" r="2" />
<path d="M18 3.5h5" />
</svg>

After

Width:  |  Height:  |  Size: 569 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"
>
<g transform="translate(7 7) scale(0.68)" stroke-width="2.6">
<path d="M15.707 21.293a1 1 0 0 1-1.414 0l-1.586-1.586a1 1 0 0 1 0-1.414l5.586-5.586a1 1 0 0 1 1.414 0l1.586 1.586a1 1 0 0 1 0 1.414z" />
<path d="m18 13-1.375-6.874a1 1 0 0 0-.746-.776L3.235 2.028a1 1 0 0 0-1.207 1.207L5.35 15.879a1 1 0 0 0 .776.746L13 18" />
<path d="m2.3 2.3 7.286 7.286" />
<circle cx="11" cy="11" r="2" />
</g>
<path d="M8.5 8.5C6 6 7.5 3 5 3S2 6 4 7.5 5.5 11 3 12" />
</svg>

After

Width:  |  Height:  |  Size: 683 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"
>
<!-- PhotoCraft's own Perspective Crop tool glyph (not from an icon set): a crop frame seen in
perspective, with its grid and two crop-mark ticks. -->
<path d="M8 6h8l4 12H4z" />
<path d="M12 6v12" />
<path d="M6 12h12" />
<path d="M8 2v2" />
<path d="M20 22v-2" />
</svg>

After

Width:  |  Height:  |  Size: 493 B

+15
View File
@@ -0,0 +1,15 @@
<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"
>
<circle cx="12" cy="12" r="7" />
<circle cx="12" cy="12" r="1.4" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
</svg>

After

Width:  |  Height:  |  Size: 326 B

+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
</svg>

After

Width:  |  Height:  |  Size: 596 B

+13
View File
@@ -0,0 +1,13 @@
<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"
>
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
</svg>

After

Width:  |  Height:  |  Size: 588 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"
>
<!-- PhotoCraft's own Vertical Type Mask glyph (not from an icon set): a T drawn as marching
ants beside a downward arrow (the text runs top to bottom). -->
<path d="M2.5 7V4h12v3" stroke-dasharray="2.5 2.5" />
<path d="M8.5 4v16" stroke-dasharray="2.5 2.5" />
<path d="M6 20h5" stroke-dasharray="2.5 2.5" />
<path d="M20 8v12" />
<path d="M17.5 17.5 20 20l2.5-2.5" />
</svg>

After

Width:  |  Height:  |  Size: 596 B

+16
View File
@@ -0,0 +1,16 @@
<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"
>
<!-- PhotoCraft's own Horizontal Type Mask glyph (not from an icon set): a T drawn as marching ants. -->
<path d="M4 7V4h16v3" stroke-dasharray="2.5 2.5" />
<path d="M12 4v16" stroke-dasharray="2.5 2.5" />
<path d="M8.5 20h7" stroke-dasharray="2.5 2.5" />
</svg>

After

Width:  |  Height:  |  Size: 472 B

+1
View File
@@ -18,6 +18,7 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
- 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.
## Implemented decode limits
+15 -7
View File
@@ -7,10 +7,15 @@ Automation is a privilege boundary because requests can cause filesystem access,
- Desktop control binds to `127.0.0.1` and uses one JSON request/reply per line.
- Headless TCP refuses a successfully bound non-loopback address.
- Every desktop-control and headless-TCP connection must authenticate with a 256-bit bearer token before method dispatch.
- Encoded request lines are limited to 1 MiB on desktop and headless TCP and on the headless
JSON-lines stdio server (`photocraft-cli serve`). MCP over stdio has no request-byte cap: the
MCP SDK reads its own lines, and only MCP tool results are size-checked. Active TCP connections
are limited to 16, and headless/MCP batches to 256 steps.
- Encoded request lines are limited to 1 MiB on desktop and headless TCP, the headless
JSON-lines stdio server (`photocraft-cli serve`), and MCP stdio. The MCP line limit is
enforced before rmcp's line buffer; an oversized MCP line closes that stdio session because
its request ID cannot safely be parsed. Active TCP connections are limited to 16, and
headless/MCP batches to 256 steps.
- Capability-scoped automation file opens read in chunks without reserving from the file's
metadata length. A read rejects data beyond the length observed on the opened file, so growth
after the metadata check cannot expand the read. Large PSB files have no fixed automation file
size ceiling; their materialized bytes and decoded document still require available memory.
- JSON-lines replies and encoded MCP tool results are limited to 8 MiB; retained batch replies
have an aggregate budget and stop later steps when exhausted. The headless stdio JSON-lines
transport enforces the same request/reply ceilings as TCP.
@@ -19,7 +24,9 @@ Automation is a privilege boundary because requests can cause filesystem access,
PNGs are limited to 5 MiB before base64 encoding or file writes. Trusted-local CLI rendering
retains its existing behavior.
- The MCP bridge bounds incoming replies and does not retry an operation after an oversized
reply. Screenshots are decoded under separate dimension, pixel, and allocation ceilings before
reply. Edits are never automatically resent after a transport failure (#1007); the server's
pure-read tools opt into a single retry on a fresh connection, which applies nothing.
Screenshots are decoded under separate dimension, pixel, and allocation ceilings before
downscaling; the 5 MiB PNG limit applies to the returned image.
- MCP normally uses stdio and can optionally bridge to loopback control TCP.
- The bridge and UI request paths use timeouts.
@@ -47,8 +54,9 @@ Automation is a privilege boundary because requests can cause filesystem access,
- no general per-client or per-tool capability model beyond filesystem read/write authority;
- no automated bulk apply of the Preferences dialog; agents can change individual safe keys with
`prefs.set`;
- no explicit JSON-depth policy, aggregate document/session-memory accounting, compositor
scratch-space accounting, or command-duration/cancellation budget;
- no application-specific JSON-depth policy (serde_json's parser has its default finite recursion
limit), aggregate document/session-memory accounting, compositor scratch-space accounting,
or command-duration/cancellation budget;
- one thread per accepted TCP connection;
- no structured security audit event stream;
- desktop screenshot capture/encoding and document import/export have no automation-specific
+1 -1
View File
@@ -14,7 +14,7 @@ Parser safety is broader than “does not crash.” A parser must also bound mem
### Native format
`photocraft-format` bounds manifest, blob, and total decompressed bytes; validates ZIP structure and decompressed size; verifies CRCs and content hashes; rejects unsupported versions; and property-tests random bundles/manifests.
`photocraft-format` bounds manifest, blob, and total decompressed bytes; admits the declared canvas (by default at most 300000 pixels per side, the largest canvas `file.new`, Canvas Size and PSB import create, so whatever saves reopens; `LoadOptions::max_canvas_side` / `max_canvas_pixels` let a caller lower it) before decoding anything for it; validates ZIP structure and decompressed size; verifies CRCs and content hashes; rejects unsupported versions; and property-tests random bundles/manifests.
## Review checklist
+16
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.Skyfuller1303]
display_name = "Skyfuller"
sync_github_name = true
[people.AlanRockefeller]
sync_github_name = true
@@ -93,3 +97,15 @@ sync_github_name = true
[people.FinnNk]
sync_github_name = true
[people.TheTeslak]
sync_github_name = true
[people.timothydaly1]
display_name = "SyntaxError"
[people.axlecoffee]
sync_github_name = true
[people.Losminek]
sync_github_name = true
+143
View File
@@ -0,0 +1,143 @@
//! Full filter timing, including tile reads, filtering, writeback and pruning.
//! RAYON_NUM_THREADS=16 cargo run --release -p photocraft-algo --example bench_algorithms -- extrude 6000 4000 u8 3 output.bin
//! Optional final argument after output.bin: rgb (default), gray, cmyk or lab.
//! Run the same example at the base commit and the PR commit, then compare output.bin with cmp.
use photocraft_algo::{FilterParams, apply_in};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use std::io::Write;
use std::time::Instant;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().skip(1).collect();
let case = args.first().map(String::as_str).unwrap_or("extrude");
let parse = |i: usize, fallback: i32| -> Result<i32, Box<dyn std::error::Error>> {
Ok(match args.get(i) {
Some(s) => s.parse()?,
None => fallback,
})
};
let (w, h, repeats) = (parse(1, 6000)?, parse(2, 4000)?, parse(4, 3)?);
if !(1..=10000).contains(&w) || !(1..=10000).contains(&h) || !(1..=9).contains(&repeats) {
return Err("dimensions must be 1..10000, repeats 1..9".into());
}
let depth = match args.get(3).map(String::as_str).unwrap_or("u8") {
"u8" => SampleType::U8,
"u16" => SampleType::U16,
"f32" => SampleType::F32,
_ => return Err("depth must be u8, u16 or f32".into()),
};
let extrude = |size, depth| FilterParams::Extrude {
kind: photocraft_algo::ExtrudeType::Blocks,
size,
depth,
level_based: false,
solid_front: false,
mask_incomplete: false,
seed: 17,
};
let params = match case {
"extrude" => extrude(30.0, 30.0),
"extrude-dense" => extrude(8.0, 128.0),
"extrude-small" => extrude(8.0, 64.0),
"wind" => FilterParams::Wind { method: photocraft_algo::WindMethod::Wind, from_right: false, seed: 17 },
"blast" => FilterParams::Wind { method: photocraft_algo::WindMethod::Blast, from_right: false, seed: 17 },
"blast-right" => FilterParams::Wind { method: photocraft_algo::WindMethod::Blast, from_right: true, seed: 17 },
"spin" => FilterParams::SpinBlur {
pins: vec![photocraft_algo::SpinPin { radius_x: 1.0, radius_y: 1.0, blur_angle: 30.0, angle: 17.0, ..Default::default() }],
},
"spin-default" => FilterParams::SpinBlur { pins: vec![Default::default()] },
"lens" | "lens-ca" | "trap" | "trap-wide" | "trap-default" => FilterParams::Invert,
_ => return Err("unknown case".into()),
};
let mode = match args.get(6).map(String::as_str).unwrap_or("rgb") {
"rgb" => ColorMode::Rgb,
"gray" => ColorMode::Grayscale,
"cmyk" => ColorMode::Cmyk,
"lab" => ColorMode::Lab,
_ => return Err("mode must be rgb, gray, cmyk or lab".into()),
};
if case.starts_with("trap") && mode != ColorMode::Cmyk {
return Err("Trap needs CMYK samples".into());
}
let format = PixelFormat::new(mode, depth, true);
let n = format.channels();
let bounds = Rect::new(-17, -29, w - 17, h - 29);
let mut source = Surface::new(format);
for tile in bounds.tiles() {
let r = tile.rect().intersect(&bounds);
let mut pixels = Vec::with_capacity(r.width() as usize * r.height() as usize * n);
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
let seed = (x as u32).wrapping_mul(0x9e37_79b9) ^ (y as u32).wrapping_mul(0x85eb_ca6b);
for c in 0..n {
let v = seed.rotate_left(c as u32 * 7).wrapping_add(c as u32 * 71) % 65536;
pixels.push(if c + 1 == n && seed.is_multiple_of(5) { 0.0 } else { v as f32 / 65535.0 });
}
}
}
source.write_region(r, &pixels);
}
let run_filter = |area: Rect| match case {
"lens" | "lens-ca" => photocraft_algo::lens::correct(
&source,
area,
&photocraft_algo::lens::LensCorrection {
distortion: 12.0,
angle: 2.5,
vignette_amount: 20.0,
red_cyan: if case == "lens-ca" { 12.0 } else { 0.0 },
blue_yellow: if case == "lens-ca" { -8.0 } else { 0.0 },
..Default::default()
},
),
"trap" | "trap-wide" | "trap-default" => {
let mut data = source.read_region(area);
photocraft_algo::trap::trap(
&mut data,
area.width() as usize,
area.height() as usize,
n,
if case == "trap-wide" {
50
} else if case == "trap-default" {
1
} else {
10
},
);
let mut result = source.clone();
result.write_region(area, &data);
result.prune();
result
}
_ => apply_in(&source, &params, area, bounds, None, bounds),
};
let warm = Rect::new(bounds.x0, bounds.y0, bounds.x0 + w.min(128), bounds.y0 + h.min(128));
drop(run_filter(warm));
let mut times = Vec::new();
for run in 0..repeats {
let start = Instant::now();
let result = run_filter(bounds);
let seconds = start.elapsed().as_secs_f64();
println!("{case} {w}x{h} {depth:?} {mode:?} run={} seconds={seconds:.6}", run + 1);
times.push(seconds);
if run + 1 == repeats
&& let Some(path) = args.get(5)
{
let mut file = std::io::BufWriter::new(std::fs::File::create(path)?);
for tile in bounds.tiles() {
for sample in result.read_region(tile.rect().intersect(&bounds)) {
file.write_all(&sample.to_bits().to_le_bytes())?;
}
}
file.flush()?;
}
}
times.sort_by(f64::total_cmp);
if let Some(median) = times.get(times.len() / 2) {
println!("MEDIAN {case} {w}x{h} {depth:?} {mode:?} seconds={median:.6}");
}
Ok(())
}
+54
View File
@@ -0,0 +1,54 @@
//! Complete Tilt-Shift filtering, including tiled reads and writes.
//! Run alone in release: `bench_tilt_shift 6000 4000 5 1`.
//! Fourth argument models the existing preview proxy scale (e.g. 1500 1000 7 4).
use photocraft_algo::{FilterParams, apply_in};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
fn main() {
let args: Vec<String> = std::env::args().collect();
let number = |i: usize, fallback| args.get(i).and_then(|s| s.parse::<i32>().ok()).unwrap_or(fallback);
let (w, h, reps) = (number(1, 6000).clamp(1, 6000), number(2, 4000).clamp(1, 6000), number(3, 5).clamp(1, 20));
let proxy = number(4, 1).clamp(1, 8) as f32;
let bounds = Rect::new(0, 0, w, h);
for (sample, name) in [(SampleType::U8, "U8"), (SampleType::U16, "U16"), (SampleType::F32, "F32")] {
let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
for y in 0..h {
let row: Vec<f32> = (0..w)
.flat_map(|x| {
let a = if (x + y) % 31 == 0 { 0.0 } else { 0.7 + (x % 7) as f32 / 30.0 };
let hdr = if sample == SampleType::F32 { 3.0 } else { 1.0 };
[hdr * (x % 256) as f32 / 255.0, ((x * 3 + y) % 251) as f32 / 250.0, ((x ^ y) % 256) as f32 / 255.0, a]
})
.collect();
source.write_region(Rect::new(0, y, w, y + 1), &row);
}
let mut cases = vec![("medium", 80.0, 0.5, 0.5, 27.0, 0.1, 0.15)];
if sample == SampleType::U8 {
cases.extend([
("small", 15.0, 0.5, 0.5, 0.0, 0.1, 0.15),
("large", 300.0, 0.5, 0.5, -71.0, 0.03, 0.4),
("wide_focus", 80.0, 0.5, 0.5, 0.0, 0.4, 0.05),
("sharp_transition", 80.0, 0.2, 0.8, 63.0, 0.05, 0.01),
("focused", 80.0, 0.5, 0.5, 0.0, 1.0, 0.15),
("blurred", 80.0, -5.0, -5.0, 27.0, 0.1, 0.15),
]);
}
for (label, blur, center_x, center_y, angle, focus, transition) in cases {
let params = FilterParams::TiltShift { blur: blur / proxy, center_x, center_y, angle, focus, transition };
for _ in 0..2 {
std::hint::black_box(apply_in(&source, &params, bounds, bounds, None, bounds));
}
let mut times = Vec::new();
for _ in 0..reps {
let start = std::time::Instant::now();
let output = apply_in(&source, &params, bounds, bounds, None, bounds);
times.push(start.elapsed().as_secs_f64() * 1000.0);
std::hint::black_box(&output);
drop(output);
}
println!("{}", serde_json::json!({"width": w, "height": h, "depth": name, "case": label, "proxy": proxy, "ms": times}));
}
}
}
+205 -93
View File
@@ -30,7 +30,12 @@
use photocraft_geom::Rect;
use crate::inpaint::{Adapt, CompleteParams, Px, complete_masked};
use std::borrow::Cow;
use photocraft_raster::{Cancelled, Interrupt};
use crate::inpaint::{Adapt, CompleteParams, Px, complete_masked, complete_masked_owned};
use crate::par;
use crate::poisson::membrane_fill_with;
/// Options for [`fill`].
@@ -143,7 +148,8 @@ fn sample(src: Src, x: f32, y: f32, out: &mut [f32]) -> bool {
}
/// Transformed copy of the source: rotation (degrees) and scale about the centre, optional mirror.
fn variant(src: Src, deg: f32, scale: f32, mirror: bool) -> Variant {
/// `Err` when cancelled.
fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result<Variant, Cancelled> {
let Src { w, h, ch, .. } = src;
let (s, c) = (-deg.to_radians()).sin_cos();
// Output size: the rotated, scaled bounding box.
@@ -153,8 +159,12 @@ fn variant(src: Src, deg: f32, scale: f32, mirror: bool) -> Variant {
let (icx, icy) = ((w as f32 - 1.0) / 2.0, (h as f32 - 1.0) / 2.0);
let (ocx, ocy) = ((ow as f32 - 1.0) / 2.0, (oh as f32 - 1.0) / 2.0);
let mut out = Variant { w: ow, h: oh, img: vec![0.0; ow * oh * ch], ok: vec![false; ow * oh] };
let mut px = vec![0.0f32; ch];
for y in 0..oh {
// Every pixel is sampled on its own, so the rows can be done in any order.
let row = |y: usize, irow: &mut [f32], orow: &mut [bool]| {
if ctl.cancelled() {
return;
}
let mut px = vec![0.0f32; ch];
for x in 0..ow {
let (dx, dy) = ((x as f32 - ocx) / scale, (y as f32 - ocy) / scale);
let mut sx = icx + dx * c - dy * s;
@@ -163,13 +173,14 @@ fn variant(src: Src, deg: f32, scale: f32, mirror: bool) -> Variant {
sx = (w as f32 - 1.0) - sx;
}
if sample(src, sx, sy, &mut px) {
let i = y * ow + x;
out.ok[i] = true;
out.img[i * ch..(i + 1) * ch].copy_from_slice(&px);
orow[x] = true;
irow[x * ch..(x + 1) * ch].copy_from_slice(&px);
}
}
}
out
};
par::chunks2_mut(&mut out.img, ow * ch, &mut out.ok, ow, true, row);
ctl.check()?;
Ok(out)
}
/// Fill `hole` in `img`, copying only from pixels where `source` is true (and not in the hole).
@@ -191,81 +202,136 @@ pub fn fill_with(
source: &[bool],
opts: &FillOptions,
ctl: &photocraft_raster::Interrupt,
) -> Result<Vec<f32>, photocraft_raster::Cancelled> {
fill_cow(w, h, ch, Cow::Borrowed(img), hole, source, opts, ctl)
}
/// [`fill_with`] that fills `img` itself rather than a copy of it (a big window need not be held
/// twice). The result differs from `img` only in the hole.
#[allow(clippy::too_many_arguments)]
pub fn fill_owned(
w: usize,
h: usize,
ch: usize,
img: Vec<f32>,
hole: &[bool],
source: &[bool],
opts: &FillOptions,
ctl: &photocraft_raster::Interrupt,
) -> Result<Vec<f32>, photocraft_raster::Cancelled> {
fill_cow(w, h, ch, Cow::Owned(img), hole, source, opts, ctl)
}
#[allow(clippy::too_many_arguments)]
fn fill_cow(
w: usize,
h: usize,
ch: usize,
img: Cow<[f32]>,
hole: &[bool],
source: &[bool],
opts: &FillOptions,
ctl: &photocraft_raster::Interrupt,
) -> Result<Vec<f32>, photocraft_raster::Cancelled> {
assert_eq!(img.len(), w * h * ch);
assert_eq!(hole.len(), w * h);
assert_eq!(source.len(), w * h);
if !hole.iter().any(|h| *h) {
return Ok(img.to_vec());
return Ok(img.into_owned());
}
let params = CompleteParams { seed: opts.seed, adapt: opts.color_adaptation, ..Default::default() };
let gap = 2 * params.patch_radius + 2;
let ok: Vec<bool> = hole.iter().zip(source).map(|(h, s)| !h && *s).collect();
let src = Src { w, h, ch, img, ok: &ok };
let ok: Vec<bool> = par::map(w * h, true, |i| !hole[i] && source[i]);
let src = Src { w, h, ch, img: &img, ok: &ok };
// Variants: the identity (which holds the hole) first, then the adaptations.
let mut extra: Vec<Variant> = Vec::new();
if opts.mirror {
extra.push(variant(src, 0.0, 1.0, true));
extra.push(variant(src, 0.0, 1.0, true, ctl)?);
}
for &deg in &opts.rotations {
extra.push(variant(src, deg, 1.0, false));
extra.push(variant(src, deg, 1.0, false, ctl)?);
}
if opts.scale {
extra.push(variant(src, 0.0, 0.8, false));
extra.push(variant(src, 0.0, 1.25, false));
extra.push(variant(src, 0.0, 0.8, false, ctl)?);
extra.push(variant(src, 0.0, 1.25, false, ctl)?);
}
// Mosaic: identity at the left, variants in a column to its right, gaps that are no image.
let col_w = extra.iter().map(|v| v.w).max().unwrap_or(0);
let col_h: usize = extra.iter().map(|v| v.h + gap).sum();
let mw = if extra.is_empty() { w } else { w + gap + col_w };
let mh = h.max(col_h);
let mut mimg = vec![0.0f32; mw * mh * ch];
let mut roles = vec![Px::Void; mw * mh];
for y in 0..h {
for x in 0..w {
let (s, d) = (y * w + x, y * mw + x);
mimg[d * ch..(d + 1) * ch].copy_from_slice(&img[s * ch..(s + 1) * ch]);
roles[d] = if hole[s] {
Px::Hole
} else if ok[s] {
Px::Source
} else {
Px::Known
};
let role = |s: usize| {
if hole[s] {
Px::Hole
} else if ok[s] {
Px::Source
} else {
Px::Known
}
}
let mut oy = 0;
for v in &extra {
for y in 0..v.h {
for x in 0..v.w {
let (s, d) = (y * v.w + x, (oy + y) * mw + w + gap + x);
mimg[d * ch..(d + 1) * ch].copy_from_slice(&v.img[s * ch..(s + 1) * ch]);
if v.ok[s] {
roles[d] = Px::Source;
}
};
// Mosaic: identity at the left, variants in a column to its right, gaps that are no image.
// Without variants it is the image itself.
let (mw, mh, mosaic, roles): (usize, usize, Option<Vec<f32>>, Vec<Px>) = if extra.is_empty() {
(w, h, None, par::map(w * h, true, role))
} else {
let col_w = extra.iter().map(|v| v.w).max().unwrap_or(0);
let col_h: usize = extra.iter().map(|v| v.h + gap).sum();
let mw = w + gap + col_w;
let mh = h.max(col_h);
let mut mimg = vec![0.0f32; mw * mh * ch];
let mut roles = vec![Px::Void; mw * mh];
for y in 0..h {
for x in 0..w {
let (s, d) = (y * w + x, y * mw + x);
mimg[d * ch..(d + 1) * ch].copy_from_slice(&img[s * ch..(s + 1) * ch]);
roles[d] = role(s);
}
}
oy += v.h + gap;
}
let mut oy = 0;
for v in &extra {
ctl.check()?;
for y in 0..v.h {
for x in 0..v.w {
let (s, d) = (y * v.w + x, (oy + y) * mw + w + gap + x);
mimg[d * ch..(d + 1) * ch].copy_from_slice(&v.img[s * ch..(s + 1) * ch]);
if v.ok[s] {
roles[d] = Px::Source;
}
}
}
oy += v.h + gap;
}
(mw, mh, Some(mimg), roles)
};
drop((extra, ok));
ctl.check()?;
// The completion is nearly all the work.
let cancel = || ctl.cancelled();
let progress = |f: f32| ctl.progress(f * 0.99);
let filled = complete_masked(mw, mh, ch, &mimg, &roles, &params, &photocraft_raster::Interrupt::new(&cancel, &progress))?;
let mut out = img.to_vec();
match filled {
Some(f) => {
for y in 0..h {
for x in 0..w {
if hole[y * w + x] {
let (s, d) = ((y * mw + x) * ch, (y * w + x) * ch);
out[d..d + ch].copy_from_slice(&f[s..s + ch]);
}
}
let inner = photocraft_raster::Interrupt::new(&cancel, &progress);
let out = match mosaic {
// Without variants the image itself is completed (it changes only in the hole).
None => {
let filled: Result<Vec<f32>, Cow<[f32]>> = match img {
Cow::Owned(v) => complete_masked_owned(w, h, ch, v, &roles, &params, &inner)?.map_err(Cow::Owned),
Cow::Borrowed(b) => complete_masked(w, h, ch, b, &roles, &params, &inner)?.ok_or(Cow::Borrowed(b)),
};
match filled {
Ok(f) => f,
Err(img) => membrane_fill_with(w, h, ch, &img, hole, ctl)?,
}
}
None => out = membrane_fill_with(w, h, ch, img, hole, ctl)?,
}
Some(mimg) => match complete_masked(mw, mh, ch, &mimg, &roles, &params, &inner)? {
Some(f) => {
let mut out = img.into_owned();
for y in 0..h {
for x in 0..w {
if hole[y * w + x] {
let (s, d) = ((y * mw + x) * ch, (y * w + x) * ch);
out[d..d + ch].copy_from_slice(&f[s..s + ch]);
}
}
}
out
}
None => membrane_fill_with(w, h, ch, &img, hole, ctl)?,
},
};
ctl.check()?;
ctl.progress(1.0);
Ok(out)
@@ -305,36 +371,50 @@ const AUTO_SIDE: usize = 384;
/// pixels that may be copied from (never the hole). Falls back to everything outside the hole
/// when the area would be too small to copy from.
pub fn auto_sampling(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool]) -> Vec<bool> {
use crate::segment::{FREE, HARD_FG, RgbImage, grid_cut_with};
// Never cancelled; the fallback (everything outside the hole) is unreachable.
auto_sampling_with(w, h, ch, img, hole, &Interrupt::NONE).unwrap_or_else(|_| hole.iter().map(|h| !h).collect())
}
/// [`auto_sampling`] that can be cancelled. `Err(Cancelled)` when `ctl` was cancelled.
pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], ctl: &Interrupt) -> Result<Vec<bool>, Cancelled> {
use crate::segment::{FREE, HARD_FG, RgbImage, grid_cut_cancellable};
assert_eq!(img.len(), w * h * ch);
assert_eq!(hole.len(), w * h);
let everything: Vec<bool> = hole.iter().map(|h| !h).collect();
let everything = || -> Vec<bool> { par::map(hole.len(), true, |i| !hole[i]) };
if w == 0 || h == 0 || ch == 0 || !hole.iter().any(|h| *h) {
return everything;
return Ok(everything());
}
// Work at a reduced scale for big windows (the area is smooth at this scale).
let k = w.max(h).div_ceil(AUTO_SIDE).max(1);
let (sw, sh) = (w.div_ceil(k), h.div_ceil(k));
let nc = ch.min(3);
let mut planes = vec![vec![0.0f32; sw * sh]; nc];
let mut shole = vec![false; sw * sh];
let mut cnt = vec![0.0f32; sw * sh];
for y in 0..h {
for x in 0..w {
let i = (y / k) * sw + x / k;
let p = y * w + x;
if hole[p] {
shole[i] = true;
}
for (c, pl) in planes.iter_mut().enumerate() {
pl[i] += img[p * ch + c];
}
cnt[i] += 1.0;
// Each cell sums its k × k pixels, a row of cells at a time (in the same order as one pass
// over the image would).
// Per cell: the sums of its colour planes, its pixel count and whether it holds the hole.
let mut sums = vec![0.0f32; sw * sh * (nc + 2)];
par::chunks_mut(&mut sums, sw * (nc + 2), true, |sy, row| {
if ctl.cancelled() {
return;
}
}
for pl in &mut planes {
pl.iter_mut().zip(&cnt).for_each(|(v, n)| *v /= n.max(1.0));
}
for y in sy * k..((sy + 1) * k).min(h) {
for x in 0..w {
let cell = &mut row[(x / k) * (nc + 2)..(x / k + 1) * (nc + 2)];
let p = y * w + x;
if hole[p] {
cell[nc + 1] = 1.0;
}
for c in 0..nc {
cell[c] += img[p * ch + c];
}
cell[nc] += 1.0;
}
}
});
ctl.check()?;
let cell = |i: usize| &sums[i * (nc + 2)..(i + 1) * (nc + 2)];
let planes: Vec<Vec<f32>> = (0..nc).map(|c| (0..sw * sh).map(|i| cell(i)[c] / cell(i)[nc].max(1.0)).collect()).collect();
let shole: Vec<bool> = (0..sw * sh).map(|i| cell(i)[nc + 1] > 0.0).collect();
drop(sums);
// Features: local means and local contrast (of the luma-ish average of the colour planes).
let r = 3;
let means: Vec<Vec<f32>> = planes.iter().map(|p| box_mean(sw, sh, p, r)).collect();
@@ -354,8 +434,9 @@ pub fn auto_sampling(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool])
let ring_w = 6.0f32 / k as f32 + 1.0;
let ring: Vec<usize> = (0..sw * sh).filter(|&i| !shole[i] && dist[i] <= ring_w).collect();
if ring.is_empty() {
return everything;
return Ok(everything());
}
ctl.check()?;
// A few cluster centres of the ring's features (k-means, deterministic start).
let kc = 6.min(ring.len());
let mut f = vec![0.0f32; nf];
@@ -410,20 +491,21 @@ pub fn auto_sampling(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool])
// Contrast-sensitive smoothness (the weight of a squared colour difference). The scale of
// "similar" and this weight were fitted to Photoshop's Auto overlays (mean IoU 0.92 on ten
// synthetic and public-domain cases).
let area = grid_cut_with(&rgb, &cost_fg, &cost_bg, &fixed, |d2| 8.0 * (-d2 / (2.0 * 0.05 * 0.05)).exp() + 0.05);
ctl.check()?;
let area =
grid_cut_cancellable(&rgb, &cost_fg, &cost_bg, &fixed, |d2| 8.0 * (-d2 / (2.0 * 0.05 * 0.05)).exp() + 0.05, &|| ctl.cancelled()).ok_or(Cancelled)?;
let mut out = vec![false; w * h];
for y in 0..h {
for x in 0..w {
let i = y * w + x;
out[i] = !hole[i] && area[(y / k) * sw + x / k];
par::chunks_mut(&mut out, w, true, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
*o = !hole[y * w + x] && area[(y / k) * sw + x / k];
}
}
});
// Too little to copy from (fewer source pixels than twice the hole): sample everything.
let n_hole = hole.iter().filter(|h| **h).count();
if out.iter().filter(|o| **o).count() < 2 * n_hole.max(64) {
return everything;
let n_hole = par::count(hole.len(), |i| hole[i]);
if par::count(out.len(), |i| out[i]) < 2 * n_hole.max(64) {
return Ok(everything());
}
out
Ok(out)
}
/// The Patch Tool's and the Content-Aware Move Tool's Color option (0–10) as the colour adaptation
@@ -497,6 +579,36 @@ mod tests {
assert_eq!(out, fill(w, h, 1, &img, &hole, &source, &FillOptions::default()), "same result as the plain fill");
}
#[test]
fn filling_in_place_is_filling_a_copy() {
let (w, h) = (90, 70);
let rgb: Vec<f32> = noise(w, h, 4).iter().enumerate().map(|(i, v)| v * 0.5 + 0.3 * ((i / 3 % w) % 11 < 5) as u8 as f32).collect();
let hole: Vec<bool> = (0..w * h).map(|i| (30..52).contains(&(i % w)) && (25..44).contains(&(i / w))).collect();
let source: Vec<bool> = (0..w * h).map(|i| i % w < 75).collect();
let none = Interrupt::NONE;
for opts in [FillOptions::default(), FillOptions { color_adaptation: Adapt::NONE, mirror: true, ..Default::default() }] {
let copy = fill_with(w, h, 3, &rgb, &hole, &source, &opts, &none).unwrap();
let owned = fill_owned(w, h, 3, rgb.clone(), &hole, &source, &opts, &none).unwrap();
assert_eq!(copy.iter().map(|v| v.to_bits()).collect::<Vec<_>>(), owned.iter().map(|v| v.to_bits()).collect::<Vec<_>>());
// Only the hole changes.
assert!((0..w * h).all(|i| hole[i] || (0..3).all(|c| owned[i * 3 + c].to_bits() == rgb[i * 3 + c].to_bits())));
}
// Nothing to copy from: the membrane, either way.
let nowhere = vec![false; w * h];
let copy = fill_with(w, h, 3, &rgb, &hole, &nowhere, &FillOptions::default(), &none).unwrap();
assert_eq!(copy, fill_owned(w, h, 3, rgb.clone(), &hole, &nowhere, &FillOptions::default(), &none).unwrap());
}
#[test]
fn auto_sampling_can_be_cancelled() {
let (w, h) = (200, 160);
let img: Vec<f32> = noise(w, h, 5).iter().enumerate().map(|(i, v)| v * 0.1 + if (i / 3) % w < 120 { 0.3 } else { 0.6 }).collect();
let hole: Vec<bool> = (0..w * h).map(|i| (80..110).contains(&(i % w)) && (60..90).contains(&(i / w))).collect();
let yes = || true;
assert!(auto_sampling_with(w, h, 3, &img, &hole, &Interrupt::cancel_only(&yes)).is_err());
assert_eq!(auto_sampling_with(w, h, 3, &img, &hole, &Interrupt::NONE).unwrap(), auto_sampling(w, h, 3, &img, &hole));
}
#[test]
fn fills_only_the_hole_with_known_texture() {
let (w, h) = (48, 32);
@@ -671,9 +783,9 @@ mod tests {
let img = vec![0.5f32; 10 * 6];
let ok = vec![true; 60];
let src = Src { w: 10, h: 6, ch: 1, img: &img, ok: &ok };
let v = variant(src, 90.0, 1.0, false);
let v = variant(src, 90.0, 1.0, false, &Interrupt::NONE).unwrap();
assert_eq!((v.w, v.h), (6, 10));
let v = variant(src, 0.0, 0.8, true);
let v = variant(src, 0.0, 0.8, true, &Interrupt::NONE).unwrap();
assert_eq!((v.w, v.h), (8, 5));
assert!(v.ok.iter().filter(|o| **o).count() > 20);
}
+596
View File
@@ -0,0 +1,596 @@
//! Direct (intensity-based) registration for Edit › Auto-Align Layers, for layers that nearly
//! coincide: a focus bracket, an exposure bracket, a burst. Feature matching
//! ([`crate::panorama`]) wants distinctive corners seen from two places; such layers are the same
//! view with a sub-pixel shift, a little scale (focus breathing) and rotation, and much of each
//! layer defocused, so there is often nothing to match and nothing to verify. Here the whole
//! overlap is the evidence: the pose that makes the warped target look like the reference.
//!
//! The target is registered to the reference coarse-to-fine on a Gaussian pyramid of the
//! luminance. The cost of a pose is the RMS of the difference between the reference and the
//! target warped by it, the mean difference removed (so exposure and tone changes don't count),
//! over the pixels both cover. Each level runs a bounded Nelder-Mead search whose simplex starts
//! one pixel of that level wide and stops at a tenth of one, so the coarse levels only hand the
//! next one a start within its pixel and the finest level searched settles sub-pixel. The pose
//! is resolution independent (shifts as fractions of the size, a scale, an angle), so a fit that
//! stops a few levels short of full resolution still applies at full resolution, and the cost
//! at full resolution, the dominant expense, is never paid.
//!
//! [`Pose`] is the transform searched: a similarity (shift, scale, rotation) about the layer's
//! centre, with aspect and shear for an affine fit and two perspective terms for a projective
//! one; [`Motion`] says which of them move. Everything is deterministic.
use crate::panorama::Motion;
use crate::photo_util::{par_map, par_rows2};
use crate::transform::Homography;
/// A layer's transform onto the reference, about the layer's centre: a similarity (shift, scale,
/// rotation); with `aspect` and `shear` an affine transform; with `px` and `py` a projective one.
/// The reference sits at [`Pose::IDENTITY`]; [`Pose::matrix`] gives it as a 3×3 homography.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Pose {
/// Shift, as a fraction of the width.
pub dx: f64,
/// Shift, as a fraction of the height.
pub dy: f64,
pub scale: f64,
/// Rotation in radians.
pub rot: f64,
/// Vertical over horizontal scale (1 = isotropic).
pub aspect: f64,
/// Horizontal shear per unit of height (0 = none).
pub shear: f64,
/// Perspective: the divisor `1 + px·u/w + py·v/h` over the centred pixel `(u, v)`.
pub px: f64,
pub py: f64,
}
impl Pose {
/// The number of parameters (the length of [`Pose::to_vec`]).
pub const N: usize = 8;
pub const IDENTITY: Pose = Pose { dx: 0.0, dy: 0.0, scale: 1.0, rot: 0.0, aspect: 1.0, shear: 0.0, px: 0.0, py: 0.0 };
/// The search box's half-width around the start, per parameter: 10 % of the layer in shift,
/// 10 % in scale, 5° in rotation, 5 % in aspect and shear, 5 % in each perspective term.
const SPAN: [f64; Pose::N] = [0.10, 0.10, 0.10, 0.087_266_462_599_716_47, 0.05, 0.05, 0.05, 0.05];
pub fn to_vec(self) -> [f64; Pose::N] {
[self.dx, self.dy, self.scale, self.rot, self.aspect, self.shear, self.px, self.py]
}
pub fn from_vec(v: &[f64; Pose::N]) -> Pose {
Pose { dx: v[0], dy: v[1], scale: v[2], rot: v[3], aspect: v[4], shear: v[5], px: v[6], py: v[7] }
}
/// Which parameters a search over `motion` may move: shifts, then scale and rotation, then
/// aspect and shear, then the perspective terms.
pub fn free(motion: Motion) -> [bool; Pose::N] {
match motion {
Motion::Translation => [true, true, false, false, false, false, false, false],
Motion::Euclidean => [true, true, false, true, false, false, false, false],
Motion::Similarity => [true, true, true, true, false, false, false, false],
Motion::Homography => [true; Pose::N],
}
}
/// The homography mapping the layer's pixels onto the reference's: the linear part is
/// `scale · R(rot) · [[1, shear], [0, aspect]]` about the centre, the perspective terms act on
/// the centred pixel; without them the last row is exactly `[0, 0, 1]`.
pub fn matrix(&self, w: usize, h: usize) -> Homography {
let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0);
let (c, s) = (self.rot.cos(), self.rot.sin());
let sc = self.scale;
let (a, b, d, e) = (sc * c, sc * (c * self.shear - s * self.aspect), sc * s, sc * (s * self.shear + c * self.aspect));
let (gx, gy) = (self.px / w as f64, self.py / h as f64);
// T(c) · [[a, b, t], [d, e, t'], [gx, gy, 1]] · T(−c), written out.
let (a2, b2, d2, e2) = (a + cx * gx, b + cx * gy, d + cy * gx, e + cy * gy);
let tx = cx + self.dx * w as f64 - (a2 * cx + b2 * cy);
let ty = cy + self.dy * h as f64 - (d2 * cx + e2 * cy);
Homography([a2, b2, tx, d2, e2, ty, gx, gy, 1.0 - gx * cx - gy * cy])
}
}
/// A luminance plane with, where the layer does not cover its whole area, its coverage.
pub struct Plane<'a> {
pub w: usize,
pub h: usize,
pub luma: &'a [f32],
pub valid: Option<&'a [bool]>,
}
/// The outcome of [`register`].
#[derive(Clone, Debug, PartialEq)]
pub struct Registration {
pub pose: Pose,
/// The pose as a homography of the target's pixels onto the reference's.
pub h: Homography,
/// The cost at the pose: the RMS of the mean-removed difference, at the finest level searched.
pub rms: f64,
/// Pixels of the finest level searched that both layers cover at the pose.
pub overlap: usize,
/// The correlation of the reference with the warped target over those pixels (Pearson, −1
/// to 1): how much of the reference the pose explains, whatever the exposure. Near 1 for a
/// bracket of one scene, near 0 for unrelated pictures; 0 without overlap or texture.
pub correlation: f64,
}
/// One pyramid level: the plane, its coverage and its size.
struct Lvl {
luma: Vec<f32>,
valid: Option<Vec<bool>>,
w: usize,
h: usize,
}
const K: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
/// Gaussian REDUCE of a plane and its coverage (5-tap binomial, clamped borders, phase-0
/// subsample); a reduced pixel is covered when more than half its support is.
fn reduce(l: &Lvl) -> Lvl {
let (w, h) = (l.w.div_ceil(2), l.h.div_ceil(2));
let cover: Vec<f32> = l.valid.as_ref().map(|v| v.iter().map(|b| *b as u8 as f32).collect()).unwrap_or_default();
let pass = |src: &[f32]| -> Vec<f32> {
let mut tmp = vec![0.0f32; w * l.h];
for (y, row) in tmp.chunks_mut(w).enumerate() {
let s = &src[y * l.w..(y + 1) * l.w];
for (x, o) in row.iter_mut().enumerate() {
*o = K.iter().enumerate().map(|(k, wt)| wt * s[(2 * x + k).saturating_sub(2).min(l.w - 1)]).sum();
}
}
let mut out = vec![0.0f32; w * h];
for (y, row) in out.chunks_mut(w).enumerate() {
for (k, wt) in K.iter().enumerate() {
let sy = (2 * y + k).saturating_sub(2).min(l.h - 1);
for (o, v) in row.iter_mut().zip(&tmp[sy * w..(sy + 1) * w]) {
*o += wt * v;
}
}
}
out
};
let luma = pass(&l.luma);
let valid = (!cover.is_empty()).then(|| pass(&cover).iter().map(|c| *c > 0.5).collect());
Lvl { luma, valid, w, h }
}
/// The search pyramid of a plane: level 0 is the plane, each next level half its size, while
/// the height exceeds 64 and the width 8.
fn levels(p: &Plane) -> Vec<Lvl> {
let mut out = vec![Lvl { luma: p.luma.to_vec(), valid: p.valid.map(<[bool]>::to_vec), w: p.w, h: p.h }];
while let Some(last) = out.last() {
if last.h <= 64 || last.w <= 8 {
break;
}
let next = reduce(last);
out.push(next);
}
out
}
/// The cubic B-spline (4 taps) at fraction `t`.
#[inline]
fn spline4(t: f64) -> [f64; 4] {
[
((-1.0 / 3.0 * t + 0.8) * t - 0.466_666_67) * t,
((t - 1.8) * t - 0.2) * t + 1.0,
((1.2 - t) * t + 0.8) * t,
((1.0 / 3.0 * t - 0.2) * t - 0.133_333_34) * t,
]
}
/// The source points along output row `y` under `inv`: `(x0, y0, dx, dy)` such that pixel `x`
/// reads `(x0 + dx·x, y0 + dy·x)`; `None` for a projective `inv`, handled pixel by pixel.
#[inline]
fn affine_row(inv: &Homography, y: f64) -> Option<(f64, f64, f64, f64)> {
let m = &inv.0;
(m[6] == 0.0 && m[7] == 0.0 && m[8] == 1.0).then(|| (m[1] * y + m[2], m[4] * y + m[5], m[0], m[3]))
}
/// `src` sampled at `(sx, sy)` with the cubic B-spline, edge-clamped.
#[inline]
fn sample(src: &[f32], w: usize, h: usize, sx: f64, sy: f64) -> f32 {
let (x0, y0) = (sx.floor(), sy.floor());
let wx = spline4(sx - x0);
let wy = spline4(sy - y0);
let (x0, y0) = (x0 as i64, y0 as i64);
let mut acc = 0.0f64;
for (j, wyj) in wy.iter().enumerate() {
let yy = (y0 + j as i64 - 1).clamp(0, h as i64 - 1) as usize;
let mut r = 0.0f64;
for (i, wxi) in wx.iter().enumerate() {
let xx = (x0 + i as i64 - 1).clamp(0, w as i64 - 1) as usize;
r += wxi * f64::from(src[yy * w + xx]);
}
acc += wyj * r;
}
acc as f32
}
/// Whether the target covers the source point (nearest pixel of its coverage).
#[inline]
fn covered(valid: Option<&[bool]>, w: usize, sx: f64, sy: f64) -> bool {
valid.is_none_or(|v| v[(sy + 0.5) as usize * w + (sx + 0.5) as usize])
}
/// The cost of `pose`: the RMS of `reference − target ∘ pose`, the mean removed, over the pixels
/// of the reference that both cover; 1e9 when fewer than 16 do. One row-parallel pass folds Σr,
/// Σt, Σr², Σt², Σrt and the count: rms = √((Σd² − (Σd)²/n) / n) with d = r − t, and the
/// correlation of r and t over the same pixels, also returned (0 when either has no spread).
fn cost(reference: &Lvl, target: &Lvl, pose: &Pose) -> (f64, usize, f64) {
let Some(inv) = pose.matrix(target.w, target.h).inverse() else { return (1e9, 0, 0.0) };
let (xmax, ymax) = ((target.w - 1) as f64, (target.h - 1) as f64);
let rows = par_map(reference.h, |y| {
let (mut sr, mut st, mut sr2, mut st2, mut srt, mut cnt) = (0.0f64, 0.0f64, 0.0f64, 0.0f64, 0.0f64, 0usize);
let yf = y as f64;
let aff = affine_row(&inv, yf);
let row = &reference.luma[y * reference.w..(y + 1) * reference.w];
for (x, &r) in row.iter().enumerate() {
if reference.valid.as_ref().is_some_and(|v| !v[y * reference.w + x]) {
continue;
}
let (sx, sy) = match aff {
Some((x0, y0, dx, dy)) => (x0 + dx * x as f64, y0 + dy * x as f64),
None => inv.apply(x as f64, yf),
};
let inside = sx >= 0.0 && sx <= xmax && sy >= 0.0 && sy <= ymax;
if !inside || !covered(target.valid.as_deref(), target.w, sx, sy) {
continue;
}
let (r, t) = (f64::from(r), f64::from(sample(&target.luma, target.w, target.h, sx, sy)));
sr += r;
st += t;
sr2 += r * r;
st2 += t * t;
srt += r * t;
cnt += 1;
}
(sr, st, sr2, st2, srt, cnt)
});
let (sr, st, sr2, st2, srt, cnt) =
rows.iter().fold((0.0, 0.0, 0.0, 0.0, 0.0, 0), |a, b| (a.0 + b.0, a.1 + b.1, a.2 + b.2, a.3 + b.3, a.4 + b.4, a.5 + b.5));
if cnt < 16 {
return (1e9, cnt, 0.0);
}
let n = cnt as f64;
// Central moments: Σ(r−r̄)², Σ(t−t̄)², Σ(r−r̄)(t−t̄).
let (vr, vt, cov) = ((sr2 - sr * sr / n).max(0.0), (st2 - st * st / n).max(0.0), srt - sr * st / n);
let rms = ((vr + vt - 2.0 * cov) / n).max(0.0).sqrt();
let correlation = if vr > 0.0 && vt > 0.0 { (cov / (vr * vt).sqrt()).clamp(-1.0, 1.0) } else { 0.0 };
(rms, cnt, correlation)
}
/// Full-resolution pixels a point at the layer's edge moves per unit of each parameter (the
/// worst case over the layer): the width and height for the shifts, the half-layer for scale
/// and rotation, the half-height for aspect and shear, a quarter-layer for the perspective terms.
fn param_gain(w: usize, h: usize) -> [f64; Pose::N] {
let (w, h) = (w as f64, h as f64);
let r = w.max(h) / 2.0;
[w, h, r, r, h / 2.0, h / 2.0, w / 4.0, h / 4.0]
}
/// The simplex's first step at a level: one pixel of that level.
const STEP_PX: f64 = 1.0;
/// The simplex's stopping size at a level: a tenth of a pixel of that level.
const TOL_PX: f64 = 0.1;
/// The search's schedule at pyramid level `lvl` (a pixel there is `2^lvl` full-resolution
/// pixels) over the parameters `free`: the first step and the stopping size of the simplex, per
/// parameter, in the parameter's units.
fn level_steps(free: &[usize], lvl: usize, w: usize, h: usize) -> (Vec<f64>, Vec<f64>) {
let gain = param_gain(w, h);
let px = (1u64 << lvl.min(60)) as f64;
(free.iter().map(|&k| STEP_PX * px / gain[k]).collect(), free.iter().map(|&k| TOL_PX * px / gain[k]).collect())
}
/// Nelder-Mead's stopping rule on a simplex sorted by `fv`: the values agree to 1e-4 relative
/// and every vertex lies within `tol[k]` of the best along each axis.
fn converged(simplex: &[Vec<f64>], fv: &[f64], tol: &[f64]) -> bool {
let n = tol.len();
let (Some(first), Some(last), Some(best)) = (fv.first(), fv.get(n), simplex.first()) else { return true };
if (last - first).abs() > 1e-4 * (1.0 + first.abs()) {
return false;
}
simplex[1..].iter().all(|v| (0..n).all(|k| (v[k] - best[k]).abs() <= tol[k]))
}
/// Bounded Nelder-Mead: minimises `f` over the box `[lo, hi]` from `x0`, the first simplex `x0`
/// moved by `step[k]` along each axis, until the vertices agree to 1e-4 relative in `f` and lie
/// within `tol[k]` of the best along every axis, or 200 iterations.
fn nelder_mead<F: Fn(&[f64]) -> f64>(f: &F, x0: &[f64], lo: &[f64], hi: &[f64], step: &[f64], tol: &[f64]) -> Vec<f64> {
let n = x0.len();
if n == 0 {
return Vec::new();
}
let clamp = |v: &mut [f64]| {
for k in 0..n {
v[k] = v[k].clamp(lo[k], hi[k]);
}
};
let mut simplex: Vec<Vec<f64>> = vec![x0.to_vec()];
for k in 0..n {
let mut v = x0.to_vec();
v[k] += step[k];
clamp(&mut v);
simplex.push(v);
}
let mut fv: Vec<f64> = simplex.iter().map(|v| f(v)).collect();
let (alpha, gamma, rho, sigma) = (1.0, 2.0, 0.5, 0.5);
for _ in 0..200 {
let mut idx: Vec<usize> = (0..=n).collect();
idx.sort_by(|&a, &b| fv[a].total_cmp(&fv[b]));
simplex = idx.iter().map(|&i| simplex[i].clone()).collect();
fv = idx.iter().map(|&i| fv[i]).collect();
if converged(&simplex, &fv, tol) {
break;
}
let mut c = vec![0.0; n];
for v in &simplex[..n] {
for k in 0..n {
c[k] += v[k] / n as f64;
}
}
let mut xr: Vec<f64> = (0..n).map(|k| c[k] + alpha * (c[k] - simplex[n][k])).collect();
clamp(&mut xr);
let fr = f(&xr);
if fr < fv[0] {
let mut xe: Vec<f64> = (0..n).map(|k| c[k] + gamma * (xr[k] - c[k])).collect();
clamp(&mut xe);
let fe = f(&xe);
if fe < fr {
(simplex[n], fv[n]) = (xe, fe);
} else {
(simplex[n], fv[n]) = (xr, fr);
}
} else if fr < fv[n - 1] {
(simplex[n], fv[n]) = (xr, fr);
} else {
let mut xc: Vec<f64> = (0..n).map(|k| c[k] + rho * (simplex[n][k] - c[k])).collect();
clamp(&mut xc);
let fc = f(&xc);
if fc < fv[n] {
(simplex[n], fv[n]) = (xc, fc);
} else {
let best = simplex[0].clone();
for (v, fvi) in simplex[1..].iter_mut().zip(&mut fv[1..]) {
for k in 0..n {
v[k] = best[k] + sigma * (v[k] - best[k]);
}
clamp(v);
*fvi = f(v);
}
}
}
}
let best = (0..=n).min_by(|&a, &b| fv[a].total_cmp(&fv[b])).unwrap_or(0);
simplex[best].clone()
}
/// Registers `target` to `reference` (planes of the same size) with the parameters `motion`
/// moves, from `init` (the previous layer's pose, or the identity), stopping `coarsen` levels
/// short of full resolution (at least one level is searched).
pub fn register(reference: &Plane, target: &Plane, motion: Motion, init: Pose, coarsen: usize) -> Registration {
let (w, h) = (target.w, target.h);
let pref = levels(reference);
let ptgt = levels(target);
let n = pref.len().min(ptgt.len());
let free: Vec<usize> = (0..Pose::N).filter(|&k| Pose::free(motion)[k]).collect();
let iv = init.to_vec();
let mut cur = iv;
let lo: Vec<f64> = free.iter().map(|&k| iv[k] - Pose::SPAN[k]).collect();
let hi: Vec<f64> = free.iter().map(|&k| iv[k] + Pose::SPAN[k]).collect();
let finest = coarsen.min(n.saturating_sub(1));
let (mut rms, mut overlap, mut correlation) = (1e9, 0, 0.0);
for lvl in (finest..n).rev() {
let (rl, tl) = (&pref[lvl], &ptgt[lvl]);
let snap = cur;
let f = |xf: &[f64]| -> f64 {
let mut v = snap;
for (k, &idx) in free.iter().enumerate() {
v[idx] = xf[k];
}
cost(rl, tl, &Pose::from_vec(&v)).0
};
let x0: Vec<f64> = free.iter().map(|&k| cur[k]).collect();
let (step, tol) = level_steps(&free, lvl, w, h);
let best = nelder_mead(&f, &x0, &lo, &hi, &step, &tol);
for (k, &idx) in free.iter().enumerate() {
cur[idx] = best[k];
}
(rms, overlap, correlation) = cost(rl, tl, &Pose::from_vec(&cur));
}
let pose = Pose::from_vec(&cur);
Registration { pose, h: pose.matrix(w, h), rms, overlap, correlation }
}
/// `src` (with its coverage) warped by `pose` into a plane of the same size with the cubic
/// B-spline, and the coverage of the result: the points that land inside the source, on pixels
/// it covers.
pub fn warp(src: &Plane, pose: &Pose) -> (Vec<f32>, Vec<bool>) {
let (w, h) = (src.w, src.h);
let mut out = vec![0.0f32; w * h];
let mut valid = vec![false; w * h];
let Some(inv) = pose.matrix(w, h).inverse() else { return (out, valid) };
let (xmax, ymax) = ((w - 1) as f64, (h - 1) as f64);
par_rows2(&mut out, &mut valid, w, |y, orow, vrow| {
let yf = y as f64;
let aff = affine_row(&inv, yf);
for (x, (o, v)) in orow.iter_mut().zip(vrow.iter_mut()).enumerate() {
let (sx, sy) = match aff {
Some((x0, y0, dx, dy)) => (x0 + dx * x as f64, y0 + dy * x as f64),
None => inv.apply(x as f64, yf),
};
let inside = sx >= 0.0 && sx <= xmax && sy >= 0.0 && sy <= ymax;
*v = inside && covered(src.valid, w, sx, sy);
if inside {
*o = sample(src.luma, w, h, sx, sy);
}
}
});
(out, valid)
}
#[cfg(test)]
mod tests {
use super::*;
/// A smooth random texture: a sum of a few sinusoids, so sub-pixel resampling is faithful.
fn texture(w: usize, h: usize, seed: u32) -> Vec<f32> {
let s = seed as f32;
(0..w * h)
.map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let v =
(x * 0.21 + s).sin() * (y * 0.17 - s).cos() + (x * 0.071 + y * 0.093 + s * 0.5).sin() * 0.7 + ((x * 0.33 - y * 0.29) * 1.3 + s).cos() * 0.4;
0.5 + 0.2 * v
})
.collect()
}
fn plane(w: usize, h: usize, luma: &[f32]) -> Plane<'_> {
Plane { w, h, luma, valid: None }
}
#[test]
fn pose_matrix_is_the_identity_at_the_identity_and_shifts_in_pixels() {
assert_eq!(Pose::IDENTITY.matrix(640, 480), Homography::IDENTITY);
let p = Pose { dx: 0.1, dy: -0.05, ..Pose::IDENTITY };
let (x, y) = p.matrix(200, 100).apply(50.0, 20.0);
assert!((x - 70.0).abs() < 1e-9 && (y - 15.0).abs() < 1e-9);
// Scale and rotation are about the centre.
let p = Pose { scale: 1.5, rot: 0.3, ..Pose::IDENTITY };
let (x, y) = p.matrix(200, 100).apply(100.0, 50.0);
assert!((x - 100.0).abs() < 1e-9 && (y - 50.0).abs() < 1e-9);
assert_eq!(Pose::from_vec(&p.to_vec()), p);
assert_eq!(Pose::free(Motion::Translation).iter().filter(|f| **f).count(), 2);
assert_eq!(Pose::free(Motion::Homography), [true; Pose::N]);
}
#[test]
fn warp_by_the_identity_is_exact_and_marks_coverage() {
let (w, h) = (40, 30);
let src = texture(w, h, 1);
let (o, v) = warp(&plane(w, h, &src), &Pose::IDENTITY);
assert!(o.iter().zip(&src).all(|(a, b)| (a - b).abs() < 1e-6));
assert!(v.iter().all(|b| *b));
// A shift of 10 px to the right leaves the left 10 columns uncovered.
let (_, v) = warp(&plane(w, h, &src), &Pose { dx: 10.0 / w as f64, ..Pose::IDENTITY });
assert!((0..h).all(|y| !v[y * w] && !v[y * w + 9] && v[y * w + 10]));
// The source's own coverage carries over.
let valid: Vec<bool> = (0..w * h).map(|i| i % w < 20).collect();
let (_, v) = warp(&Plane { w, h, luma: &src, valid: Some(&valid) }, &Pose::IDENTITY);
assert!(v[5 * w + 10] && !v[5 * w + 30]);
}
#[test]
fn cost_is_zero_at_the_truth_and_the_sentinel_without_overlap() {
let (w, h) = (96, 72);
let src = texture(w, h, 2);
let l = levels(&plane(w, h, &src));
assert!(cost(&l[0], &l[0], &Pose::IDENTITY).0 < 1e-9);
assert_eq!(cost(&l[0], &l[0], &Pose { dx: 2.0, ..Pose::IDENTITY }).0, 1e9);
// The mean is removed: a brighter copy costs nothing.
let bright: Vec<f32> = src.iter().map(|v| v + 0.2).collect();
let lb = levels(&plane(w, h, &bright));
assert!(cost(&l[0], &lb[0], &Pose::IDENTITY).0 < 1e-6);
assert!(cost(&l[0], &lb[0], &Pose { dx: 0.02, ..Pose::IDENTITY }).0 > 1e-3);
}
/// The correlation tells a fit from a non-fit whatever the exposure: a stop brighter copy of
/// the texture correlates fully at the truth (where the mean-removed RMS does not vanish),
/// another texture hardly at all, and a flat plane not at all.
#[test]
fn correlation_is_exposure_independent_and_low_for_unrelated_pictures() {
let (w, h) = (96, 72);
let src = texture(w, h, 2);
let l = levels(&plane(w, h, &src));
let doubled: Vec<f32> = src.iter().map(|v| v * 2.0).collect();
let ld = levels(&plane(w, h, &doubled));
let (rms, _, corr) = cost(&l[0], &ld[0], &Pose::IDENTITY);
assert!(rms > 0.05 && corr > 0.9999, "rms {rms} correlation {corr}");
let other = levels(&plane(w, h, &texture(w, h, 7)));
let (_, _, corr) = cost(&l[0], &other[0], &Pose::IDENTITY);
assert!(corr.abs() < 0.3, "unrelated textures correlate at {corr}");
let flat = levels(&plane(w, h, &vec![0.5; w * h]));
assert_eq!(cost(&l[0], &flat[0], &Pose::IDENTITY).2, 0.0);
let r = register(&plane(w, h, &src), &plane(w, h, &doubled), Motion::Similarity, Pose::IDENTITY, 0);
assert!(r.correlation > 0.999, "{r:?}");
}
#[test]
fn pyramid_stops_above_64_rows() {
let l = levels(&plane(40, 300, &vec![0.25; 40 * 300]));
assert_eq!(l.iter().map(|l| (l.w, l.h)).collect::<Vec<_>>(), vec![(40, 300), (20, 150), (10, 75), (5, 38)]);
assert!(l.iter().all(|l| l.luma.iter().all(|v| (v - 0.25).abs() < 1e-6)));
let valid: Vec<bool> = (0..40 * 300).map(|i| i % 40 < 20).collect();
let l = levels(&Plane { w: 40, h: 300, luma: &vec![0.25; 40 * 300], valid: Some(&valid) });
let v = l[2].valid.as_ref().unwrap();
assert!(v[10 * 10 + 2] && !v[10 * 10 + 8]);
}
#[test]
fn nelder_mead_finds_the_minimum_of_a_bowl() {
let f = |x: &[f64]| (x[0] - 0.3).powi(2) + 2.0 * (x[1] + 0.2).powi(2);
let r = nelder_mead(&f, &[0.0, 0.0], &[-1.0, -1.0], &[1.0, 1.0], &[0.1, 0.1], &[1e-6, 1e-6]);
assert!((r[0] - 0.3).abs() < 1e-4 && (r[1] + 0.2).abs() < 1e-4, "{r:?}");
// The box binds.
let r = nelder_mead(&f, &[0.0, 0.0], &[-1.0, -1.0], &[0.1, 1.0], &[0.1, 0.1], &[1e-6, 1e-6]);
assert!((r[0] - 0.1).abs() < 1e-6, "{r:?}");
assert!(nelder_mead(&f, &[], &[], &[], &[], &[]).is_empty());
let (step, tol) = level_steps(&[0, 2], 3, 8000, 6000);
assert!((step[0] * 8000.0 - 8.0).abs() < 1e-9 && (tol[1] * 4000.0 - 0.8).abs() < 1e-9);
}
/// A layer shifted, scaled and rotated a little registers back to within a tenth of a pixel
/// at every motion model that contains the truth, with or without coarsening.
#[test]
fn registers_a_similarity_to_a_tenth_of_a_pixel() {
let (w, h) = (320, 240);
let reference = texture(w, h, 3);
let truth = Pose { dx: 3.3 / w as f64, dy: -2.1 / h as f64, scale: 1.012, rot: 0.6f64.to_radians(), ..Pose::IDENTITY };
// The pose maps the target's pixels onto the reference's: target(u) = reference(M u).
let m = truth.matrix(w, h);
let target: Vec<f32> = (0..w * h)
.map(|i| {
let (x, y) = m.apply((i % w) as f64, (i / w) as f64);
sample(&reference, w, h, x.clamp(0.0, (w - 1) as f64), y.clamp(0.0, (h - 1) as f64))
})
.collect();
for (motion, coarsen) in [(Motion::Similarity, 0), (Motion::Similarity, 1), (Motion::Homography, 0)] {
let r = register(&plane(w, h, &reference), &plane(w, h, &target), motion, Pose::IDENTITY, coarsen);
let (tx, ty) = truth.matrix(w, h).apply(0.0, 0.0);
let (rx, ry) = r.h.apply(0.0, 0.0);
let corner = ((tx - rx).hypot(ty - ry)).max({
let (tx, ty) = truth.matrix(w, h).apply(w as f64, h as f64);
let (rx, ry) = r.h.apply(w as f64, h as f64);
(tx - rx).hypot(ty - ry)
});
assert!(corner < 0.1 * (1 << coarsen) as f64 + 0.05, "{motion:?} coarsen {coarsen}: corners off by {corner} px, {:?}", r.pose);
let searched = (w >> coarsen) * (h >> coarsen);
assert!(r.rms < 0.01 && r.overlap > searched / 2, "{motion:?}: rms {} overlap {} of {searched}", r.rms, r.overlap);
}
// Translation only: the shift is found, the rest stays put.
let t = Pose { dx: 4.0 / w as f64, dy: 3.0 / h as f64, ..Pose::IDENTITY };
let target: Vec<f32> = (0..w * h)
.map(|i| sample(&reference, w, h, ((i % w) as f64 + 4.0).clamp(0.0, (w - 1) as f64), ((i / w) as f64 + 3.0).clamp(0.0, (h - 1) as f64)))
.collect();
let r = register(&plane(w, h, &reference), &plane(w, h, &target), Motion::Translation, Pose::IDENTITY, 0);
assert!(((r.pose.dx - t.dx) * w as f64).abs() < 0.1 && ((r.pose.dy - t.dy) * h as f64).abs() < 0.1, "{:?}", r.pose);
assert_eq!((r.pose.scale, r.pose.rot), (1.0, 0.0));
}
/// Only the covered part counts: a target whose right half is garbage still registers on
/// its left half when its coverage says so.
#[test]
fn coverage_keeps_uncovered_pixels_out_of_the_fit() {
let (w, h) = (320, 240);
let reference = texture(w, h, 4);
let mut target: Vec<f32> = (0..w * h).map(|i| sample(&reference, w, h, ((i % w) as f64 + 2.5).clamp(0.0, (w - 1) as f64), (i / w) as f64)).collect();
let valid: Vec<bool> = (0..w * h).map(|i| i % w < w / 2).collect();
for (i, v) in target.iter_mut().enumerate() {
if !valid[i] {
*v = ((i * 7919) % 1000) as f32 / 1000.0;
}
}
let r = register(&plane(w, h, &reference), &Plane { w, h, luma: &target, valid: Some(&valid) }, Motion::Translation, Pose::IDENTITY, 0);
assert!((r.pose.dx * w as f64 - 2.5).abs() < 0.1, "{:?}", r.pose);
assert!(r.overlap < w * h / 2 + w && r.overlap > w * h / 3);
}
}
+71 -10
View File
@@ -17,6 +17,8 @@ pub struct CameraInfo {
pub focal_length: Option<f64>,
/// 35 mm-equivalent focal length in millimetres.
pub focal_length_35mm: Option<f64>,
/// DateTimeOriginal as stored (`YYYY:MM:DD HH:MM:SS`).
pub date_taken: Option<String>,
}
impl CameraInfo {
@@ -35,11 +37,11 @@ struct Tiff<'a> {
impl Tiff<'_> {
fn u16(&self, o: usize) -> Option<u16> {
let s = self.b.get(o..o + 2)?;
let s = self.b.get(o..o.checked_add(2)?)?;
Some(if self.le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) })
}
fn u32(&self, o: usize) -> Option<u32> {
let s = self.b.get(o..o + 4)?;
let s = self.b.get(o..o.checked_add(4)?)?;
let a = [s[0], s[1], s[2], s[3]];
Some(if self.le { u32::from_le_bytes(a) } else { u32::from_be_bytes(a) })
}
@@ -48,20 +50,25 @@ impl Tiff<'_> {
let Some(n) = self.u16(off) else { return Vec::new() };
(0..n as usize)
.filter_map(|k| {
let e = off + 2 + k * 12;
Some((self.u16(e)?, self.u16(e + 2)?, self.u32(e + 4)?, e + 8))
// Checked: offsets come from the file, and usize is 32 bits on wasm.
let e = off.checked_add(2)?.checked_add(k * 12)?;
Some((self.u16(e)?, self.u16(e.checked_add(2)?)?, self.u32(e.checked_add(4)?)?, e.checked_add(8)?))
})
.collect()
}
fn data_at(&self, typ: u16, count: u32, field: usize) -> Option<usize> {
let size = match typ {
1 | 2 | 6 | 7 => 1,
1 | 2 | 6 | 7 => 1usize,
3 | 8 => 2,
4 | 9 | 11 => 4,
5 | 10 | 12 => 8,
_ => return None,
} * count as usize;
if size <= 4 { Some(field) } else { self.u32(field).map(|v| v as usize) }
}
.checked_mul(usize::try_from(count).ok()?)?;
let at = if size <= 4 { field } else { usize::try_from(self.u32(field)?).ok()? };
// The whole value must lie inside the payload, not just its first element.
self.b.get(at..at.checked_add(size)?)?;
Some(at)
}
fn number(&self, typ: u16, count: u32, field: usize) -> Option<f64> {
let at = self.data_at(typ, count, field)?;
@@ -69,11 +76,11 @@ impl Tiff<'_> {
3 => self.u16(at).map(f64::from),
4 => self.u32(at).map(f64::from),
5 => {
let (n, d) = (self.u32(at)?, self.u32(at + 4)?);
let (n, d) = (self.u32(at)?, self.u32(at.checked_add(4)?)?);
(d != 0).then(|| n as f64 / d as f64)
}
10 => {
let (n, d) = (self.u32(at)? as i32, self.u32(at + 4)? as i32);
let (n, d) = (self.u32(at)? as i32, self.u32(at.checked_add(4)?)? as i32);
(d != 0).then(|| n as f64 / d as f64)
}
_ => None,
@@ -84,7 +91,7 @@ impl Tiff<'_> {
return None;
}
let at = self.data_at(typ, count, field)?;
let s = self.b.get(at..at + count as usize)?;
let s = self.b.get(at..at.checked_add(usize::try_from(count).ok()?)?)?;
let s = String::from_utf8_lossy(s).trim_end_matches('\0').trim().to_string();
(!s.is_empty()).then_some(s)
}
@@ -118,6 +125,7 @@ pub fn read(exif: &[u8]) -> CameraInfo {
0x920A => info.focal_length = t.number(typ, count, field),
0xA405 => info.focal_length_35mm = t.number(typ, count, field).filter(|v| *v > 0.0),
0xA434 => info.lens = t.text(typ, count, field),
0x9003 => info.date_taken = t.text(typ, count, field),
0x8769 => {
if let Some(p) = t.u32(field) {
queue.push(p as usize);
@@ -161,6 +169,9 @@ pub fn build(info: &CameraInfo) -> Vec<u8> {
if let Some(v) = info.iso {
exif.push((0x8827, 3, 1, (v as u16).to_le_bytes().to_vec()));
}
if let Some(s) = &info.date_taken {
exif.push((0x9003, 2, s.len() as u32 + 1, text(s)));
}
if let Some(v) = info.focal_length {
exif.push((0x920A, 5, 1, rational(v)));
}
@@ -224,6 +235,7 @@ mod tests {
iso: Some(200.0),
focal_length: Some(35.0),
focal_length_35mm: Some(52.0),
date_taken: Some("2024:02:17 12:40:19".into()),
};
let b = build(&info);
let r = read(&b);
@@ -237,4 +249,53 @@ mod tests {
let _ = read(&b[..cut]);
}
}
/// Hostile payloads: huge counts, offsets near `u32::MAX`, self-referencing IFDs and random
/// bytes read as `None` fields instead of panicking or overflowing (usize is 32 bits on wasm).
#[test]
fn hostile_payloads_never_panic() {
let entry = |tag: u16, typ: u16, count: u32, value: u32| {
let mut e = tag.to_le_bytes().to_vec();
e.extend_from_slice(&typ.to_le_bytes());
e.extend_from_slice(&count.to_le_bytes());
e.extend_from_slice(&value.to_le_bytes());
e
};
let mut b = b"II*\0".to_vec();
b.extend_from_slice(&8u32.to_le_bytes());
let entries = [
entry(0x010F, 2, u32::MAX, 8), // Make: count far past the end
entry(0x0110, 2, 16, u32::MAX - 1), // Model: offset at the top of the range
entry(0x829A, 5, u32::MAX, 20), // ExposureTime: count overflows the size
entry(0x829D, 5, 1, u32::MAX - 3), // FNumber: rational straddles u32::MAX
entry(0x9003, 2, 0x4000_0000, 8), // DateTimeOriginal: size overflow on 32 bits
entry(0x8769, 4, 1, 8), // Exif IFD pointing back at IFD0
entry(0x8769, 4, 1, u32::MAX), // ... and past the end
];
b.extend_from_slice(&(entries.len() as u16 + 3).to_le_bytes()); // claims more entries than exist
for e in entries {
b.extend_from_slice(&e);
}
assert_eq!(read(&b), CameraInfo::default());
let mut be = b"MM\0*".to_vec();
be.extend_from_slice(&u32::MAX.to_be_bytes());
assert_eq!(read(&be), CameraInfo::default());
// Deterministic noise behind a valid header, and every truncation of a real payload.
let mut x = 0x2545_F491u32;
for _ in 0..200 {
let mut n = b"II*\0\x08\0\0\0".to_vec();
for _ in 0..96 {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
n.push(x as u8);
}
let _ = read(&n);
}
let good =
build(&CameraInfo { make: Some("Make".into()), date_taken: Some("2024:02:17 12:40:19".into()), exposure_time: Some(0.004), ..Default::default() });
for cut in 0..good.len() {
let _ = read(&good[..cut]);
}
}
}
+34 -7
View File
@@ -8,6 +8,8 @@
use photocraft_geom::Rect;
mod spin_plan;
use crate::fxutil::{MAXC, add_sample_premul, gauss_blur_n, gauss_box_radii, premul_window, smoothstep, unpremul_px, xy};
use crate::image::{Image, premultiply};
use crate::{BlurPath, Ctx, FieldPin, IrisPin, SpinPin};
@@ -16,6 +18,10 @@ use crate::{BlurPath, Ctx, FieldPin, IrisPin, SpinPin};
#[path = "tests_iris.rs"]
mod tests;
#[cfg(test)]
#[path = "tests_tilt_shift.rs"]
mod tilt_shift_tests;
/// Gaussian σ for a gallery blur amount in px.
fn sigma_of(blur: f32) -> f32 {
blur.clamp(0.0, 500.0) * 0.6
@@ -86,7 +92,7 @@ fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, trim_levels: bool
let (level, lw): (&[f32], usize) = if k == 0 {
(&p, ww)
} else {
// Only Iris opts in. Each level needs its own box-cascade reach,
// Iris and Tilt-Shift opt in. Each level needs its own box-cascade reach,
// not the maximum blur's trailing halo. Keep the original top/left:
// moving the running sums' start changes floating-point cancellation.
let trim = trim_levels && win.intersect(&out) == out;
@@ -146,9 +152,10 @@ pub(crate) fn tilt_shift(src: &Image, out: Rect, ctx: &Ctx, blur: f32, centre: (
// Band normal (the band runs along `angle`).
let (s, c) = angle.to_radians().sin_cos();
let (nx, ny) = (s, c);
let smax = sigma_of(blur);
// A NaN amount must not become the upper bound of f32::clamp below.
let smax = sigma_of(blur).max(0.0);
let (f0, f1) = (focus.max(0.0), focus.max(0.0) + transition.max(1e-3));
variable_blur(src, out, ctx, smax, false, |x, y| {
variable_blur(src, out, ctx, smax, true, |x, y| {
let d = ((x - cx) * nx + (y - cy) * ny).abs() / ss;
smax * smoothstep(f0, f1, d)
})
@@ -264,7 +271,18 @@ pub(crate) fn field(src: &Image, out: Rect, ctx: &Ctx, pins: &[FieldPin]) -> Vec
/// Spin Blur: content inside each ellipse is blurred along arcs about its
/// centre (`blur_angle` degrees of rotation), feathered at the rim.
pub(crate) fn spin(src: &Image, out: Rect, ctx: &Ctx, pins: &[SpinPin]) -> Vec<f32> {
let n = src.ch;
match src.ch {
1 => spin_impl::<1>(src, out, ctx, pins, true),
2 => spin_impl::<2>(src, out, ctx, pins, true),
3 => spin_impl::<3>(src, out, ctx, pins, true),
4 => spin_impl::<4>(src, out, ctx, pins, true),
5 => spin_impl::<5>(src, out, ctx, pins, true),
_ => spin_impl::<0>(src, out, ctx, pins, true),
}
}
fn spin_impl<const N: usize>(src: &Image, out: Rect, ctx: &Ctx, pins: &[SpinPin], prepare: bool) -> Vec<f32> {
let n = if N == 0 { src.ch } else { N };
let b = ctx.bounds;
let ss = short_side(b);
let mut res = src.crop(out);
@@ -272,11 +290,12 @@ pub(crate) fn spin(src: &Image, out: Rect, ctx: &Ctx, pins: &[SpinPin]) -> Vec<f
if clip.is_empty() {
return res;
}
let plans = prepare.then(|| spin_plan::prepare(pins)).flatten();
let mut acc = [0.0f32; MAXC];
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
for p in pins {
for (pi, p) in pins.iter().enumerate() {
let (e, u, v) = pin_distance(fx, fy, b, p.x, p.y, p.radius_x, p.radius_y, p.angle, 2.0);
if !e.is_finite() || e >= 1.0 {
continue;
@@ -288,15 +307,20 @@ pub(crate) fn spin(src: &Image, out: Rect, ctx: &Ctx, pins: &[SpinPin]) -> Vec<f
let (cx, cy) = (b.x0 as f32 + p.x * b.width() as f32, b.y0 as f32 + p.y * b.height() as f32);
let angle_deg = if p.angle.is_finite() { p.angle } else { 0.0 };
let (sa, ca) = angle_deg.to_radians().sin_cos();
let rotations = plans.as_ref().and_then(|plans| plans.get(pi)).and_then(|plan| plan.rotations(m));
acc[..n].fill(0.0);
for j in 0..m {
let phi = theta * (j as f32 / (m - 1) as f32 - 0.5);
let (sp, cp) = phi.sin_cos();
let (sp, cp) = rotations.and_then(|table| table.get(j)).copied().unwrap_or_else(|| phi.sin_cos());
// Rotate in the ellipse's normalized space, then map back.
let (ru, rv) = (u * cp - v * sp, u * sp + v * cp);
let (lu, lv) = (ru * p.radius_x * ss, rv * p.radius_y * ss);
let (sx, sy) = (cx + lu * ca - lv * sa, cy + lu * sa + lv * ca);
add_sample_premul(src, sx, sy, clip, ctx.alpha, &mut acc[..n]);
if N == 0 {
add_sample_premul(src, sx, sy, clip, ctx.alpha, &mut acc[..n]);
} else {
spin_plan::sample::<N>(src, sx, sy, clip, ctx.alpha, &mut acc);
}
}
for v in acc.iter_mut().take(n) {
*v /= m as f32;
@@ -376,3 +400,6 @@ pub(crate) fn path(src: &Image, out: Rect, ctx: &Ctx, paths: &[BlurPath]) -> Vec
}
res
}
#[cfg(test)]
mod prepared_spin_tests;
@@ -0,0 +1,59 @@
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
#[test]
fn all_rotation_tables_keep_the_direct_bits() {
for angle in [0.0, 0.1, 15.0, 73.7, 180.0, 360.0, f32::NAN, f32::INFINITY] {
let p = SpinPin { blur_angle: angle, ..Default::default() };
let plans = spin_plan::prepare(&[p]).unwrap();
let theta = angle.clamp(0.0, 360.0).to_radians();
for count in 2..=64 {
for j in 0..count {
let phi = theta * (j as f32 / (count - 1) as f32 - 0.5);
let (a, b) = phi.sin_cos();
let (c, d) = plans[0].rotation(count, j).unwrap();
assert_eq!((a.to_bits(), b.to_bits()), (c.to_bits(), d.to_bits()));
}
}
}
assert!(spin_plan::prepare(&vec![SpinPin::default(); 121]).is_none());
}
#[test]
fn prepared_spin_matches_the_original_kernel() {
let bounds = Rect::new(-17, -11, 58, 45);
for mode in [
ColorMode::Rgb,
ColorMode::Grayscale,
ColorMode::Cmyk,
ColorMode::Lab,
ColorMode::Bitmap,
ColorMode::Duotone,
ColorMode::Indexed,
ColorMode::Multichannel,
] {
for alpha in [false, true] {
let n = PixelFormat::new(mode, SampleType::F32, alpha).channels();
let ctx = Ctx { bounds, mode, alpha };
let mut src = Image::new(bounds, n);
for (i, v) in src.data.iter_mut().enumerate() {
*v = ((i * 71 + i / n * 107) % 65536) as f32 / 65535.0;
}
for (i, px) in src.data.chunks_exact_mut(n).enumerate() {
if i % 11 == 0 {
px[n - 1] = 0.0;
}
}
for angle in [0.0, 0.1, 15.0, 91.0, 360.0, f32::NAN] {
let pins = [
SpinPin { x: 0.2, y: 0.3, radius_x: 0.25, radius_y: 0.5, angle: 31.0, blur_angle: angle },
SpinPin { x: 0.7, y: 0.6, radius_x: 0.8, radius_y: 0.6, angle: -15.0, blur_angle: angle },
];
let out = Rect::new(-19, -8, 55, 49);
let a = spin(&src, out, &ctx, &pins);
let b = spin_impl::<0>(&src, out, &ctx, &pins, false);
let first = a.iter().zip(&b).position(|(a, b)| a.to_bits() != b.to_bits());
assert_eq!(first, None, "{mode:?} alpha {alpha} angle {angle}");
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
//! All possible sample counts share a bounded rotation table per pin.
use crate::SpinPin;
pub(super) struct Plan {
rotations: Vec<(f32, f32)>,
}
impl Plan {
#[cfg(test)]
pub(super) fn rotation(&self, count: usize, sample: usize) -> Option<(f32, f32)> {
self.rotations(count)?.get(sample).copied()
}
pub(super) fn rotations(&self, count: usize) -> Option<&[(f32, f32)]> {
if !(2..=64).contains(&count) {
return None;
}
let start = count.checked_mul(count - 1)?.checked_div(2)?.checked_sub(1)?;
self.rotations.get(start..start.checked_add(count)?)
}
}
pub(super) fn prepare(pins: &[SpinPin]) -> Option<Vec<Plan>> {
// Under 2 MiB, even for 120 overlapping pins. Larger lists keep direct sampling.
if pins.len() > 120 {
return None;
}
let mut plans = Vec::new();
plans.try_reserve_exact(pins.len()).ok()?;
for p in pins {
let theta = p.blur_angle.clamp(0.0, 360.0).to_radians();
let mut rotations = Vec::new();
rotations.try_reserve_exact(2079).ok()?;
for count in 2..=64 {
for j in 0..count {
let phi = theta * (j as f32 / (count - 1) as f32 - 0.5);
rotations.push(phi.sin_cos());
}
}
plans.push(Plan { rotations });
}
Some(plans)
}
/// Fixed channels keep the bilinear accumulators in registers. Tap and multiply order
/// stay identical to add_sample_premul; alpha is still multiplied by the tap weight first.
pub(super) fn sample<const N: usize>(src: &crate::image::Image, x: f32, y: f32, clip: photocraft_geom::Rect, alpha: bool, acc: &mut [f32; 8]) {
if !x.is_finite() || !y.is_finite() || clip.is_empty() {
return;
}
let fx = (x - 0.5).clamp(clip.x0 as f32, (clip.x1 - 1) as f32);
let fy = (y - 0.5).clamp(clip.y0 as f32, (clip.y1 - 1) as f32);
let (x0, y0) = (fx.floor() as i32, fy.floor() as i32);
let (x1, y1) = ((x0 + 1).min(clip.x1 - 1), (y0 + 1).min(clip.y1 - 1));
let (ax, ay) = (fx - x0 as f32, fy - y0 as f32);
let w = src.rect.width() as usize;
let idx = |xx: i32, yy: i32| ((yy - src.rect.y0) as usize * w + (xx - src.rect.x0) as usize) * N;
for (i, k) in [(idx(x0, y0), (1.0 - ax) * (1.0 - ay)), (idx(x1, y0), ax * (1.0 - ay)), (idx(x0, y1), (1.0 - ax) * ay), (idx(x1, y1), ax * ay)] {
if k <= 0.0 {
continue;
}
let Some(px) = src.data.get(i..i + N).and_then(|s| s.first_chunk::<N>()) else {
return;
};
let a = if alpha { px.get(N - 1).copied().unwrap_or(0.0) * k } else { k };
if alpha {
for (sum, value) in acc.iter_mut().zip(px).take(N - 1) {
*sum += value * a;
}
if let Some(sum) = acc.get_mut(N - 1) {
*sum += a;
}
} else {
for (sum, value) in acc.iter_mut().zip(px).take(N) {
*sum += value * a;
}
}
}
}
+1516 -299
View File
File diff suppressed because it is too large. Load diff
+51 -2
View File
@@ -269,6 +269,10 @@ const TILE: i32 = 128;
/// to: samples clamp to it, and pixels mapping outside it take the edge mode. `map` returns where
/// each output pixel centre samples (`None` = outside).
pub fn remap(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(dyn Fn(f64, f64) -> Option<Sample> + Sync)) -> Surface {
remap_impl(src, frame, out_area, edge, map, true)
}
fn remap_impl(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(dyn Fn(f64, f64) -> Option<Sample> + Sync), shared: bool) -> Surface {
let sfmt = src.format();
let needs_alpha = matches!(edge, EdgeMode::Transparency) && !sfmt.alpha;
let ofmt = if needs_alpha { PixelFormat::new(sfmt.mode, sfmt.sample, true) } else { sfmt };
@@ -363,7 +367,14 @@ pub fn remap(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(
let fy = ((s.p[1] - fy0).min(fy1 - s.p[1]) + 0.5).clamp(0.0, 1.0);
(fx * fy) as f32
};
let alpha = if sfmt.alpha { bicubic(n_in - 1, s.p).clamp(0.0, 1.0) } else { 1.0 };
// Color channels and coverage usually sample the same location. Prepare the 16
// tap addresses and weights once; each channel keeps its original summation order.
let center = if shared && (!rgb || (s.pr == s.p && s.pb == s.p)) { sample_channels(&buf, win, frame, n_in, s.p) } else { None };
let sample = |c: usize, p: [f64; 2]| {
let cached = if p == s.p { center.as_ref() } else { None };
cached.and_then(|v| v.get(c)).copied().unwrap_or_else(|| bicubic(c, p))
};
let alpha = if sfmt.alpha { sample(n_in - 1, s.p).clamp(0.0, 1.0) } else { 1.0 };
let mut col = [0.0f32; 8];
for (c, v) in col.iter_mut().enumerate().take(cc) {
let p = if rgb && c == 0 {
@@ -373,7 +384,7 @@ pub fn remap(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(
} else {
s.p
};
let pm = bicubic(c, p);
let pm = sample(c, p);
let straight = if sfmt.alpha { if alpha > 1e-6 { pm / alpha } else { 0.0 } } else { pm };
let g = s.gain;
*v = if subtractive {
@@ -418,6 +429,41 @@ pub fn remap(src: &Surface, frame: Rect, out_area: Rect, edge: EdgeMode, map: &(
out
}
fn sample_channels(buf: &[f32], win: Rect, frame: Rect, n: usize, p: [f64; 2]) -> Option<[f32; 8]> {
match n {
1 => sample_channels_impl::<1>(buf, win, frame, p),
2 => sample_channels_impl::<2>(buf, win, frame, p),
3 => sample_channels_impl::<3>(buf, win, frame, p),
4 => sample_channels_impl::<4>(buf, win, frame, p),
5 => sample_channels_impl::<5>(buf, win, frame, p),
8 => sample_channels_impl::<8>(buf, win, frame, p),
_ => None,
}
}
fn sample_channels_impl<const N: usize>(buf: &[f32], win: Rect, frame: Rect, p: [f64; 2]) -> Option<[f32; 8]> {
if N == 0 || N > 8 || win.is_empty() || frame.is_empty() || !p.iter().all(|v| v.is_finite()) {
return None;
}
let x = (p[0] - 0.5).clamp(frame.x0 as f64, frame.x1 as f64 - 1.0);
let y = (p[1] - 0.5).clamp(frame.y0 as f64, frame.y1 as f64 - 1.0);
let (xi, yi) = (x.floor(), y.floor());
let (wx, wy) = (catmull_rom((x - xi) as f32), catmull_rom((y - yi) as f32));
let mut acc = [0.0f32; 8];
for (j, wyv) in wy.iter().enumerate() {
let yy = ((yi as i32 + j as i32 - 1).clamp(win.y0, win.y1 - 1) - win.y0) as usize;
for (i, wxv) in wx.iter().enumerate() {
let xx = ((xi as i32 + i as i32 - 1).clamp(win.x0, win.x1 - 1) - win.x0) as usize;
let start = yy.checked_mul(win.width() as usize)?.checked_add(xx)?.checked_mul(N)?;
let px = buf.get(start..start.checked_add(N)?)?.first_chunk::<N>()?;
let weight = wxv * wyv;
for (a, v) in acc.iter_mut().zip(px) {
*a += weight * *v;
}
}
}
Some(acc)
}
/// Applies a lens correction to `src` over `frame` (normally the canvas).
pub fn correct(src: &Surface, frame: Rect, lc: &LensCorrection) -> Surface {
let map = LensMap::new(lc, frame);
@@ -522,3 +568,6 @@ mod tests {
assert!(out.pixel(0, 0)[0] < 0.4 && (out.pixel(20, 20)[0] - 0.5).abs() < 0.02, "{:?}", out.pixel(0, 0));
}
}
#[cfg(test)]
mod shared_sample_tests;
@@ -0,0 +1,54 @@
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
#[test]
fn shared_interpolation_matches_original_at_all_depths_and_edges() {
let bounds = Rect::new(-13, -9, 39, 26);
for mode in [
ColorMode::Rgb,
ColorMode::Grayscale,
ColorMode::Cmyk,
ColorMode::Lab,
ColorMode::Bitmap,
ColorMode::Duotone,
ColorMode::Indexed,
ColorMode::Multichannel,
] {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
for alpha in [false, true] {
let fmt = PixelFormat::new(mode, sample, alpha);
let n = fmt.channels();
let mut s = Surface::new(fmt);
let data: Vec<f32> = (0..bounds.width() as usize * bounds.height() as usize * n)
.map(|i| if i % 17 == 0 { 0.0 } else { ((i * 103 + i / n * 71) % 65536) as f32 / 65535.0 })
.collect();
s.write_region(bounds, &data);
for edge in [EdgeMode::Transparency, EdgeMode::Extension, EdgeMode::Color([0.2, 0.5, 0.7, 0.4])] {
for ca in [false, true] {
let map = |x: f64, y: f64| {
if (x as i32 + y as i32) % 13 == 0 {
return None;
}
let p = [x * 0.83 - 3.7, y * 1.14 + 1.3];
Some(Sample { p, pr: if ca { [p[0] - 0.9, p[1] + 0.3] } else { p }, pb: if ca { [p[0] + 0.7, p[1] - 0.4] } else { p }, gain: 1.17 })
};
let out = bounds.inflate(3);
let a = remap_impl(&s, bounds, out, edge, &map, true).read_region(out);
let b = remap_impl(&s, bounds, out, edge, &map, false).read_region(out);
assert_eq!(
a.iter().zip(&b).position(|(a, b)| a.to_bits() != b.to_bits()),
None,
"{mode:?} {sample:?} alpha {alpha} edge {edge:?} ca {ca}"
);
}
}
}
}
}
}
#[test]
fn shared_sampler_rejects_bad_windows_and_nonfinite_points() {
let win = Rect::new(0, 0, 3, 3);
assert!(sample_channels(&[], win, win, 4, [1.0, 1.0]).is_none());
assert!(sample_channels(&[0.0; 36], win, win, 4, [f64::NAN, 1.0]).is_none());
assert!(sample_channels(&[], Rect::EMPTY, win, 4, [1.0, 1.0]).is_none());
}
+2
View File
@@ -26,6 +26,7 @@ pub mod camera_raw;
pub mod color_to_alpha;
pub mod content_aware;
mod denoise;
pub mod direct;
mod distort;
mod distort2;
pub mod erase;
@@ -48,6 +49,7 @@ mod other;
mod other2;
pub mod paint;
pub mod panorama;
mod par;
pub mod perspective;
mod photo_util;
mod pixelate;
+226 -4
View File
@@ -116,7 +116,8 @@ pub struct LiquifyStroke {
/// Brush diameter (px).
#[serde(default = "d_size")]
pub size: f64,
/// Edge feathering, 0 (hard) ..100 (soft).
/// How far the full effect reaches toward the brush edge, 0 (only the centre; feathered)
/// ..100 (most of the brush). It always fades to nothing at the edge ([`falloff`]).
#[serde(default = "d_density")]
pub density: f64,
/// Overall strength, 0..100.
@@ -420,8 +421,7 @@ impl LiquifyField {
if t2 >= 1.0 {
continue;
}
let soft = (1.0 - t2) * (1.0 - t2);
let fall = (1.0 - density) + density * soft;
let fall = falloff(t2, density);
let idx = j * self.w + i;
let k_raw = strength * fall;
if s.tool == LiquifyTool::Freeze || s.tool == LiquifyTool::Thaw {
@@ -526,6 +526,23 @@ impl LiquifyField {
}
}
/// A dab's strength at squared distance `t2` (in brush radii, 0..1) for Density `density` (0..1):
/// 1 at the centre, and down to 0 at the rim with a flat slope at every density, as in
/// Photoshop, where Density sets how far the full effect reaches toward the edge (0: only the
/// centre, the rest feathered; 1: most of the brush) but the effect always fades out at it.
/// A brush that stopped short of 0 at its rim moved the pixels inside it and left those just
/// outside, tearing the image along every dab's circle (#2606).
pub fn falloff(t2: f64, density: f64) -> f64 {
let t2 = t2.clamp(0.0, 1.0);
let density = if density.is_nan() { 0.5 } else { density.clamp(0.0, 1.0) };
// Feathered: (1 - t²)³. Dense: (1 - t⁴)², flat in the middle. Both are 0 with zero slope at
// t = 1, and their slopes stay small enough that a dab every fifth of the radius never folds
// the image over itself.
let feathered = (1.0 - t2).powi(3);
let dense = (1.0 - t2 * t2).powi(2);
feathered + (dense - feathered) * density
}
fn bilinear(d: &[[f32; 2]], w: usize, h: usize, gx: f64, gy: f64) -> [f64; 2] {
if !(gx > -1.0 && gy > -1.0 && gx < w as f64 && gy < h as f64) {
return [0.0, 0.0];
@@ -680,6 +697,8 @@ pub struct ProxyImage {
pub px: Vec<[f32; 4]>,
}
const MAX_FULL_RES_SOURCE_PIXELS: usize = 8 * 1024 * 1024;
impl ProxyImage {
/// Box-downsamples `src` over `bounds` so the longer side is at most `max_side`.
pub fn new(src: &Surface, bounds: Rect, max_side: usize) -> Self {
@@ -760,6 +779,130 @@ impl ProxyImage {
rows.chunks_mut(self.w).enumerate().for_each(|(i, line)| row(y0 + i, line));
}
/// Renders a document-pixel-resolution preview for `region` into the corresponding part of
/// `out`, whose dimensions cover `bounds`. Source reads are limited to the dirty region plus
/// the displacement range that can affect it, so zoomed-in brush updates do not flatten the
/// whole layer. `source_scratch` is reused by the caller between updates.
pub fn render_full_resolution(
src: &Surface,
field: &LiquifyField,
bounds: Rect,
region: Rect,
out: &mut [[u8; 4]],
source_scratch: &mut Vec<[f32; 4]>,
) -> bool {
let bounds = bounds.intersect(&field.bounds);
let region = region.intersect(&bounds);
let (width, height) = (bounds.width() as usize, bounds.height() as usize);
let Some(out_len) = width.checked_mul(height) else { return false };
if region.is_empty() || out.len() != out_len || width == 0 || height == 0 {
return false;
}
// Bilinear field interpolation is a convex combination of its surrounding nodes. The
// extrema of those nodes therefore bound every source sample in this output rectangle.
let (mut min_dx, mut min_dy, mut max_dx, mut max_dy) = (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
let (gx0, gy0) = field.to_grid(f64::from(region.x0) + 0.5, f64::from(region.y0) + 0.5);
let (gx1, gy1) = field.to_grid(f64::from(region.x1) - 0.5, f64::from(region.y1) - 0.5);
let (i0, j0) = (gx0.floor().max(0.0) as usize, gy0.floor().max(0.0) as usize);
let (i1, j1) = (gx1.ceil().max(0.0) as usize, gy1.ceil().max(0.0) as usize);
for j in j0..=j1.min(field.h.saturating_sub(1)) {
for i in i0..=i1.min(field.w.saturating_sub(1)) {
let Some(v) = j.checked_mul(field.w).and_then(|row| row.checked_add(i)).and_then(|idx| field.d.get(idx)) else { continue };
min_dx = min_dx.min(v[0]);
min_dy = min_dy.min(v[1]);
max_dx = max_dx.max(v[0]);
max_dy = max_dy.max(v[1]);
}
}
if !min_dx.is_finite() || !min_dy.is_finite() || !max_dx.is_finite() || !max_dy.is_finite() {
return false;
}
// Source coordinates are pixel centres. Add one sample on each side for bilinear
// interpolation, then clamp to the document just as the regular proxy renderer does.
let x0 = (f64::from(region.x0) + f64::from(min_dx)).floor() - 1.0;
let y0 = (f64::from(region.y0) + f64::from(min_dy)).floor() - 1.0;
let x1 = (f64::from(region.x1 - 1) + f64::from(max_dx)).ceil() + 2.0;
let y1 = (f64::from(region.y1 - 1) + f64::from(max_dy)).ceil() + 2.0;
let canvas = field.bounds;
let src_rect = Rect::new(
x0.max(f64::from(canvas.x0)).min(f64::from(canvas.x1)) as i32,
y0.max(f64::from(canvas.y0)).min(f64::from(canvas.y1)) as i32,
x1.max(f64::from(canvas.x0)).min(f64::from(canvas.x1)) as i32,
y1.max(f64::from(canvas.y0)).min(f64::from(canvas.y1)) as i32,
);
if src_rect.is_empty() {
return false;
}
let (sw, sh) = (src_rect.width() as usize, src_rect.height() as usize);
let Some(source_len) = sw.checked_mul(sh) else { return false };
if source_len > MAX_FULL_RES_SOURCE_PIXELS {
return false;
}
if source_scratch.try_reserve(source_len.saturating_sub(source_scratch.len())).is_err() {
return false;
}
source_scratch.resize(source_len, [0.0; 4]);
src.read_rgba_into(src_rect, source_scratch);
if !src.format().alpha {
for pixel in source_scratch.iter_mut() {
pixel[3] = 1.0;
}
}
for pixel in source_scratch.iter_mut() {
let alpha = pixel[3];
pixel[0] *= alpha;
pixel[1] *= alpha;
pixel[2] *= alpha;
}
let x0 = (region.x0 - bounds.x0) as usize;
let x1 = (region.x1 - bounds.x0) as usize;
let y0 = (region.y0 - bounds.y0) as usize;
let y1 = (region.y1 - bounds.y0) as usize;
let source_at = |x: f64, y: f64| -> [f32; 4] {
let fx = (x - 0.5).clamp(f64::from(canvas.x0), f64::from(canvas.x1 - 1));
let fy = (y - 0.5).clamp(f64::from(canvas.y0), f64::from(canvas.y1 - 1));
let ix = fx.floor();
let iy = fy.floor();
let (tx, ty) = ((fx - ix) as f32, (fy - iy) as f32);
let sx0 = (ix as i32).clamp(src_rect.x0, src_rect.x1 - 1);
let sy0 = (iy as i32).clamp(src_rect.y0, src_rect.y1 - 1);
let sx1 = (sx0 + 1).min(src_rect.x1 - 1);
let sy1 = (sy0 + 1).min(src_rect.y1 - 1);
let at = |sx: i32, sy: i32| -> [f32; 4] {
let ox = (sx - src_rect.x0) as usize;
let oy = (sy - src_rect.y0) as usize;
source_scratch.get(oy * sw + ox).copied().unwrap_or([0.0; 4])
};
let (a, b, c, d) = (at(sx0, sy0), at(sx1, sy0), at(sx0, sy1), at(sx1, sy1));
std::array::from_fn(|channel| (a[channel] * (1.0 - tx) + b[channel] * tx) * (1.0 - ty) + (c[channel] * (1.0 - tx) + d[channel] * tx) * ty)
};
let render_rows = |first_y: usize, rows: &mut [[u8; 4]]| {
for (offset, row) in rows.chunks_mut(width).enumerate() {
let y = bounds.y0 + (first_y + offset) as i32;
let Some(dest) = row.get_mut(x0..x1) else { continue };
for (offset_x, pixel) in dest.iter_mut().enumerate() {
let x = bounds.x0 + (x0 + offset_x) as i32;
let p = [f64::from(x) + 0.5, f64::from(y) + 0.5];
let d = field.sample(p[0], p[1]);
let rgba = source_at(p[0] + d[0], p[1] + d[1]);
*pixel = rgba.map(|v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
}
}
};
let Some(rows) = out.get_mut(y0 * width..y1 * width) else { return false };
#[cfg(not(target_arch = "wasm32"))]
if (x1 - x0).saturating_mul(y1 - y0) > 150_000 {
use rayon::prelude::*;
rows.par_chunks_mut(width).enumerate().for_each(|(offset, line)| render_rows(y0 + offset, line));
return true;
}
rows.chunks_mut(width).enumerate().for_each(|(offset, line)| render_rows(y0 + offset, line));
true
}
/// The proxy pixel rectangle covering document rect `r`.
pub fn proxy_rect(&self, r: Rect) -> [usize; 4] {
let s = self.scale;
@@ -940,6 +1083,57 @@ mod tests {
assert_eq!(f.freeze_at(30.0, 30.0), 1.0);
}
#[test]
fn the_brush_fades_to_nothing_at_its_rim_at_every_density() {
for density in [0.0, 0.25, 0.5, 0.75, 1.0, f64::NAN, -3.0, 7.0] {
assert_eq!(falloff(0.0, density), 1.0, "full strength at the centre ({density})");
assert_eq!(falloff(1.0, density), 0.0, "nothing at the rim ({density})");
// Flat at the rim: the last 2 % of the radius moves at most a couple of percent.
assert!(falloff(0.98f64.powi(2), density) < 0.02, "{density}: {}", falloff(0.98f64.powi(2), density));
let mut prev = 1.0;
for k in 1..=100 {
let v = falloff(k as f64 / 100.0, density);
assert!(v <= prev && v >= 0.0, "{density}: not falling at t² = {k}%");
prev = v;
}
}
// Density widens the strong core: more strength at every radius, as in Photoshop.
for k in 1..100 {
let t2 = k as f64 / 100.0;
assert!(falloff(t2, 1.0) >= falloff(t2, 0.5) && falloff(t2, 0.5) >= falloff(t2, 0.0), "t² = {t2}");
}
assert!(falloff(0.5, 1.0) > 0.5 && falloff(0.5, 0.0) < 0.2);
}
/// #2606: the dab's strength used to stop at 1 - Density at its rim, so a Forward Warp moved
/// everything just inside the brush and nothing just outside, tearing the image along every
/// 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);
for density in [0.0, 50.0, 100.0] {
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 2.0 * r);
s.density = density;
s.points = vec![vec![48.0, 32.0], vec![48.0 + drag, 32.0]];
let f = LiquifyField::from_strokes(bounds(), 1.0, &[s]);
let mut rim = 0;
for j in 0..f.h {
for i in 0..f.w {
let p = f.node_pos(i, j);
let t = (p[0] - 48.0).hypot(p[1] - 32.0) / r;
if (0.97..1.0).contains(&t) {
let d = f.d[j * f.w + i];
assert!(f64::from(d[0].hypot(d[1])) < 0.03 * drag, "density {density}: a node at {t:.3} r moved {d:?}");
rim += 1;
}
}
}
assert!(rim > 20, "{rim} rim nodes");
assert!(f.sample(48.0, 32.0)[0] < -0.9 * drag, "density {density}: the centre follows the brush");
}
}
#[test]
fn no_strokes_is_identity_at_every_depth() {
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
@@ -955,6 +1149,34 @@ mod tests {
}
}
#[test]
fn full_resolution_preview_matches_the_unit_scale_proxy_and_partial_updates() {
let canvas = Rect::new(0, 0, 96, 64);
for sample_type in [SampleType::U8, SampleType::U16, SampleType::F32] {
let source = sample(sample_type);
let mut stroke = LiquifyStroke::new(LiquifyTool::ForwardWarp, 32.0);
stroke.points = vec![vec![38.0, 30.0, 1.0], vec![52.0, 34.0, 0.8]];
let mut field = LiquifyField::from_strokes(canvas, 2.0, &[stroke]);
let proxy = ProxyImage::new(&source, canvas, 96);
assert_eq!(proxy.scale, 1.0);
let mut want = vec![[0; 4]; proxy.w * proxy.h];
proxy.render(&field, [0, 0, proxy.w, proxy.h], &mut want);
let mut got = vec![[0; 4]; proxy.w * proxy.h];
let mut scratch = Vec::new();
assert!(ProxyImage::render_full_resolution(&source, &field, canvas, canvas, &mut got, &mut scratch));
assert_eq!(got, want, "full-resolution render at {sample_type:?}");
let mut more = LiquifyStroke::new(LiquifyTool::Bloat, 16.0);
more.points = vec![vec![43.0, 32.0, 1.0]];
let dirty = field.apply_stroke(&more);
proxy.render(&field, proxy.proxy_rect(dirty), &mut want);
assert!(ProxyImage::render_full_resolution(&source, &field, canvas, dirty, &mut got, &mut scratch));
assert_eq!(got, want, "partial update at {sample_type:?}");
}
}
#[test]
fn forward_warp_displaces_along_the_stroke_and_reconstruct_restores() {
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 40.0);
@@ -990,7 +1212,7 @@ mod tests {
assert!(f.max_displacement() < before * 0.2, "{} vs {before}", f.max_displacement());
// Frozen area doesn't move.
let mut fr = LiquifyStroke::new(LiquifyTool::Freeze, 60.0);
fr.density = 0.0;
fr.density = 100.0;
fr.points = vec![vec![40.0, 32.0]];
let mut g = LiquifyField::new(bounds(), 2.0);
g.apply_stroke(&fr);
+54 -9
View File
@@ -224,8 +224,11 @@ pub fn morph(src: &[f32], w: usize, h: usize, r: usize, grow: bool) -> Vec<f32>
morphology::morph(src, w, h, r, grow)
}
mod brush;
mod morphology;
pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, swept_area};
/// Running max (or min) over a window of radius `r` (edge-clipped), van Herk / Gil–Werman:
/// three comparisons per sample whatever the radius.
fn running_extreme(src: &[f32], out: &mut [f32], r: usize, max: bool, g: &mut Vec<f32>, hb: &mut Vec<f32>) {
@@ -293,10 +296,18 @@ pub fn edge_width(img: &RgbImage, r: usize) -> Vec<f32> {
/// Refines a soft mask buffer `m` (`w × h`) with `img` as guide (required when `radius > 0`).
pub fn refine_buffer(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams) -> Vec<f32> {
refine_buffer_with(img, m, w, h, p, morph)
refine_buffer_with(img, m, w, h, p, None, morph)
}
fn refine_buffer_with<F>(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams, morphology: F) -> Vec<f32>
/// The Refine Edge Brush over one buffer: signed coverage (`w × h`, see [`EdgeBrush`]) and the
/// box radius of its colour estimate.
#[derive(Clone, Copy)]
struct BrushBuf<'a> {
v: &'a [f32],
window: usize,
}
fn refine_buffer_with<F>(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams, brush: Option<BrushBuf>, morphology: F) -> Vec<f32>
where
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy,
{
@@ -324,13 +335,26 @@ where
Some(wd) => wd[i].clamp(1.5, p.radius.max(1.5)),
None => p.radius,
};
let wgt = (reff + 1.0 - dist[i]).clamp(0.0, 1.0);
let mut wgt = (reff + 1.0 - dist[i]).clamp(0.0, 1.0);
// Restored by the brush: the original edge.
if let Some(b) = brush.and_then(|b| b.v.get(i)).filter(|b| **b < 0.0) {
wgt *= 1.0 + b;
}
if wgt > 0.0 {
a[i] = m[i] + (q[i].clamp(0.0, 1.0) - m[i]) * wgt;
}
}
}
}
// Added by the brush: coverage estimated from the image.
if let (Some(b), Some(img)) = (brush.filter(|b| b.v.iter().any(|v| *v > 0.0)), img) {
let est = brush::estimate(img, m, b.v, w, h, b.window);
for ((ai, ei), bi) in a.iter_mut().zip(&est).zip(b.v) {
if *bi > 0.0 {
*ai += (ei - *ai) * bi;
}
}
}
let ss = p.smooth_sigma();
if ss >= 0.3 {
a = gaussian_blur(&a, w, h, ss);
@@ -357,7 +381,21 @@ where
/// 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.
pub fn refine_mask(sampler: &dyn Sampler, mask: &(dyn Fn(Rect) -> Vec<f32> + Sync), content: Rect, canvas: Rect, p: &RefineParams) -> Option<Region> {
refine_mask_with(sampler, mask, content, canvas, p, morph)
refine_mask_with(sampler, mask, content, canvas, p, None, morph)
}
/// [`refine_mask`] with the Refine Edge Brush: where `brush` adds, coverage comes from the image
/// (the area can reach beyond the edge-detection band); where it restores, the original edge
/// stays. The sampler is read wherever the brush adds, whatever the radius.
pub fn refine_mask_brushed(
sampler: &dyn Sampler,
mask: &(dyn Fn(Rect) -> Vec<f32> + Sync),
content: Rect,
canvas: Rect,
p: &RefineParams,
brush: Option<&EdgeBrush>,
) -> Option<Region> {
refine_mask_with(sampler, mask, content, canvas, p, brush, morph)
}
fn refine_mask_with<F>(
@@ -366,6 +404,7 @@ fn refine_mask_with<F>(
content: Rect,
canvas: Rect,
p: &RefineParams,
brush: Option<&EdgeBrush>,
morphology: F,
) -> Option<Region>
where
@@ -375,8 +414,8 @@ where
if content.is_empty() {
return None;
}
let bbox = content.inflate(p.spread()).intersect(&canvas);
let halo = p.halo();
let bbox = content.inflate(p.spread()).union(&brush.map_or(Rect::EMPTY, EdgeBrush::added_bounds)).intersect(&canvas);
let halo = p.halo() + brush.map_or(0, |b| i32::try_from(b.window() * 4).unwrap_or(i32::MAX));
let mut tiles = Vec::new();
let mut y = bbox.y0;
while y < bbox.y1 {
@@ -401,11 +440,17 @@ where
}
out
};
if (first == 0.0 || first == 1.0) && m.iter().all(|v| *v == first) {
let painted = brush.filter(|b| !b.bounds().intersect(&r).is_empty()).map(|b| b.read(r));
let adds = painted.as_ref().is_some_and(|v| v.iter().any(|v| *v > 0.0));
if !adds && (first == 0.0 || first == 1.0) && m.iter().all(|v| *v == first) {
return (*t, vec![q(first); cw * ch]);
}
let img = p.uses_image().then(|| sampler.rgb(r));
let out = refine_buffer_with(img.as_ref(), &m, rw, rh, p, morphology);
let img = (p.uses_image() || adds).then(|| sampler.rgb(r));
let bb = match (&painted, brush) {
(Some(v), Some(b)) => Some(BrushBuf { v, window: b.window() }),
_ => None,
};
let out = refine_buffer_with(img.as_ref(), &m, rw, rh, p, bb, morphology);
(*t, core(&out))
};
#[cfg(not(target_arch = "wasm32"))]
+333
View File
@@ -0,0 +1,333 @@
//! Select and Mask's Refine Edge Brush: strokes that set where edge refinement happens.
//!
//! Painting (`add`) marks an area as unknown: its coverage is estimated from the image, so soft
//! detail such as hair beyond the edge-detection band is picked up. Painting with `add = false`
//! (Photoshop's Alt) removes the refinement there and keeps the original edge. Strokes apply in
//! order; a later one paints over an earlier one.
//!
//! The estimate projects each painted pixel's colour on the line between the mean colours of the
//! known foreground and background around it (normalised convolution over a box twice the brush
//! size), the colour-line model of A. Levin, D. Lischinski and Y. Weiss, "A Closed-Form Solution
//! to Natural Image Matting" (CVPR 2006), with one foreground and one background colour per window.
use photocraft_geom::Rect;
use super::box_mean;
use crate::segment::RgbImage;
/// One Refine Edge Brush stroke, in document pixels.
#[derive(Clone, Debug, PartialEq)]
pub struct EdgeStroke {
/// The stroke's path (a single point paints a dab).
pub points: Vec<(f32, f32)>,
/// Brush diameter in pixels.
pub size: f32,
/// Add to the refinement area (true) or restore the original edge (false).
pub add: bool,
}
/// Brush diameters the strokes may use.
pub const SIZE_RANGE: std::ops::RangeInclusive<f32> = 1.0..=5000.0;
/// The painted area: signed coverage (`+1` refined, `−1` restored, `0` untouched) over `bbox`.
#[derive(Clone, Debug, PartialEq)]
pub struct EdgeBrush {
bbox: Rect,
/// Coverage × 127, row-major over `bbox`.
v: Vec<i8>,
/// Bounds of the added (positive) coverage.
added: Rect,
/// Box radius of the colour estimate (pixels).
window: usize,
}
/// Coverage (0–1) of a round brush of radius `r` swept from `a` to `b`, at pixel centre `p`.
fn capsule(p: (f32, f32), a: (f32, f32), b: (f32, f32), r: f32) -> f32 {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let len2 = dx * dx + dy * dy;
let t = if len2 > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };
let (cx, cy) = (a.0 + t * dx, a.1 + t * dy);
let d = ((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt();
(r + 0.5 - d).clamp(0.0, 1.0)
}
/// The pixels a disc of radius `r` at `p` can touch, as a (possibly empty) rectangle.
fn dab_bounds(p: (f32, f32), r: f32) -> Rect {
let lo = |v: f32| (v - r - 1.0).floor().clamp(i32::MIN as f32 / 2.0, i32::MAX as f32 / 2.0) as i32;
let hi = |v: f32| (v + r + 1.0).ceil().clamp(i32::MIN as f32 / 2.0, i32::MAX as f32 / 2.0) as i32;
Rect::new(lo(p.0), lo(p.1), hi(p.0), hi(p.1))
}
/// Most pixels the strokes of one [`EdgeBrush`] may sweep (Σ length × size + size² per stroke):
/// about eleven times a 24 MP canvas.
pub const MAX_SWEPT_AREA: f64 = (1u64 << 28) as f64;
/// The area `strokes` sweep (see [`MAX_SWEPT_AREA`]); non-finite strokes count as infinite.
pub fn swept_area(strokes: &[EdgeStroke]) -> f64 {
strokes
.iter()
.map(|s| {
let size = f64::from(s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()));
let len: f64 = s.points.windows(2).filter_map(|w| Some(f64::from((w.get(1)?.0 - w.first()?.0).hypot(w.get(1)?.1 - w.first()?.1)))).sum();
if len.is_finite() { len * size + size * size } else { f64::INFINITY }
})
.sum()
}
/// The stroke's path as short segments: points closer than a quarter of the radius to the last
/// kept one are dropped, and long segments are split at most `r` long, so each segment's box
/// stays close to the area it paints.
fn segments(points: &[(f32, f32)], r: f32) -> Vec<((f32, f32), (f32, f32))> {
let Some(&first) = points.first() else { return Vec::new() };
let min_step = (r / 4.0).max(0.5);
let max_step = r.max(2.0);
let mut kept = vec![first];
for p in points.iter().skip(1) {
let last = kept.last().copied().unwrap_or(first);
if (p.0 - last.0).hypot(p.1 - last.1) >= min_step {
kept.push(*p);
}
}
if let (Some(last), Some(end)) = (kept.last().copied(), points.last().copied())
&& last != end
{
kept.push(end);
}
if kept.len() == 1 {
return vec![(first, first)];
}
let mut out = Vec::new();
for w in kept.windows(2) {
let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue };
let pieces = ((b.0 - a.0).hypot(b.1 - a.1) / max_step).ceil().clamp(1.0, 1e6) as usize;
let at = |k: usize| {
let t = k as f32 / pieces as f32;
(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)
};
out.extend((0..pieces).map(|k| (at(k), at(k + 1))));
}
out
}
impl EdgeBrush {
/// Rasterises `strokes` (clipped to `canvas`). `None` when they paint nothing or sweep more
/// than [`MAX_SWEPT_AREA`].
pub fn new(strokes: &[EdgeStroke], canvas: Rect) -> Option<EdgeBrush> {
let usable = |s: &&EdgeStroke| s.size.is_finite() && !s.points.is_empty() && s.points.iter().all(|p| p.0.is_finite() && p.1.is_finite());
if swept_area(strokes) > MAX_SWEPT_AREA {
return None;
}
let strokes: Vec<&EdgeStroke> = strokes.iter().filter(usable).collect();
let stroke_bounds = |s: &EdgeStroke| -> Rect {
let r = s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
s.points.iter().fold(Rect::EMPTY, |acc, p| acc.union(&dab_bounds(*p, r))).intersect(&canvas)
};
let bbox = strokes.iter().fold(Rect::EMPTY, |acc, s| acc.union(&stroke_bounds(s)));
if bbox.is_empty() {
return None;
}
let bw = bbox.width() as usize;
let mut v = vec![0i8; bw.checked_mul(bbox.height() as usize)?];
let mut cov: Vec<f32> = Vec::new();
let mut max_add = 0.0f32;
for s in strokes {
let sb = stroke_bounds(s);
if sb.is_empty() {
continue;
}
let r = s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
if s.add {
max_add = max_add.max(2.0 * r);
}
let sw = sb.width() as usize;
cov.clear();
cov.resize(sw * sb.height() as usize, 0.0);
// The stroke's coverage: the most any of its segments gives (a single dab for one point).
for (a, b) in segments(&s.points, r) {
let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&sb);
for y in seg.y0..seg.y1 {
for x in seg.x0..seg.x1 {
let c = capsule((x as f32 + 0.5, y as f32 + 0.5), a, b, r);
if let Some(slot) = cov.get_mut((y - sb.y0) as usize * sw + (x - sb.x0) as usize)
&& c > *slot
{
*slot = c;
}
}
}
}
let target = if s.add { 1.0 } else { -1.0 };
for y in sb.y0..sb.y1 {
for x in sb.x0..sb.x1 {
let c = cov.get((y - sb.y0) as usize * sw + (x - sb.x0) as usize).copied().unwrap_or(0.0);
if c <= 0.0 {
continue;
}
if let Some(slot) = v.get_mut((y - bbox.y0) as usize * bw + (x - bbox.x0) as usize) {
let cur = f32::from(*slot) / 127.0;
*slot = ((cur + (target - cur) * c) * 127.0).round().clamp(-127.0, 127.0) as i8;
}
}
}
}
if v.iter().all(|c| *c == 0) {
return None;
}
let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
for (row, line) in v.chunks_exact(bw).enumerate() {
let (Some(first), Some(last)) = (line.iter().position(|c| *c > 0), line.iter().rposition(|c| *c > 0)) else { continue };
let y = bbox.y0 + row as i32;
(x0, x1, y0, y1) = (x0.min(bbox.x0 + first as i32), x1.max(bbox.x0 + last as i32 + 1), y0.min(y), y1.max(y + 1));
}
let added = if x0 < x1 { Rect::new(x0, y0, x1, y1) } else { Rect::EMPTY };
Some(EdgeBrush { bbox, v, added, window: max_add.ceil().clamp(4.0, 64.0) as usize })
}
/// Where the strokes paint (added or restored).
pub fn bounds(&self) -> Rect {
self.bbox
}
/// Where the strokes add to the refinement area (empty when they only restore).
pub fn added_bounds(&self) -> Rect {
self.added
}
/// Box radius of the colour estimate (it reads up to four times as far).
pub fn window(&self) -> usize {
self.window
}
/// Signed coverage at document pixel `(x, y)` (zero outside the painted area).
pub fn at(&self, x: i32, y: i32) -> f32 {
if !self.bbox.contains(x, y) {
return 0.0;
}
let i = (y - self.bbox.y0) as usize * self.bbox.width() as usize + (x - self.bbox.x0) as usize;
self.v.get(i).map_or(0.0, |c| f32::from(*c) / 127.0)
}
/// Signed coverage over `r` (row-major).
pub fn read(&self, r: Rect) -> Vec<f32> {
(r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| self.at(x, y))).collect()
}
}
/// Coverage estimated from the image where `brush` (signed, `w × h`) adds: each pixel's colour
/// projected on the line between the mean known foreground and background colours within
/// `window` px (or `4 × window` where there are none that close). Known means outside the painted area and (nearly) fully in or out of `m`. Pixels
/// with no foreground or background nearby, or no contrast between them, keep `m`.
pub(super) fn estimate(img: &RgbImage, m: &[f32], brush: &[f32], w: usize, h: usize, window: usize) -> Vec<f32> {
let n = w * h;
let known = |want_fg: bool| -> Vec<f32> {
(0..n)
.map(|i| {
let (mv, bv) = (m.get(i).copied().unwrap_or(0.0), brush.get(i).copied().unwrap_or(0.0));
let ok = bv <= 0.0 && if want_fg { mv >= 0.98 } else { mv <= 0.02 };
if ok { 1.0 } else { 0.0 }
})
.collect()
};
let (wf, wb) = (known(true), known(false));
// Normalised convolution of the known colours: weight sums and weighted colour sums, over the
// window and (where the window finds none) four times as far.
let means = |wt: &[f32], r: usize| -> (Vec<f32>, Vec<Vec<f32>>) {
let sums = (0..3)
.map(|c| {
let v: Vec<f32> = img.px.iter().zip(wt).map(|(p, k)| p.get(c).copied().unwrap_or(0.0) * k).collect();
box_mean(&v, w, h, r)
})
.collect();
(box_mean(wt, w, h, r), sums)
};
let near = [means(&wf, window), means(&wb, window)];
let far = [means(&wf, window * 4), means(&wb, window * 4)];
// The mean known colour at `i` (0 = foreground, 1 = background).
let mean = |k: usize, i: usize| -> Option<[f32; 3]> {
let pick = |(d, s): &(Vec<f32>, Vec<Vec<f32>>)| -> Option<[f32; 3]> {
let d = d.get(i).copied().filter(|d| *d >= 1e-4)?;
Some([0, 1, 2].map(|c| s.get(c).and_then(|v| v.get(i)).copied().unwrap_or(0.0) / d))
};
near.get(k).and_then(pick).or_else(|| far.get(k).and_then(pick))
};
let mut out = m.to_vec();
for (i, o) in out.iter_mut().enumerate() {
if brush.get(i).copied().unwrap_or(0.0) <= 0.0 {
continue;
}
let (Some(fm), Some(bm), Some(px)) = (mean(0, i), mean(1, i), img.px.get(i)) else { continue };
let mut num = 0.0f32;
let mut den = 0.0f32;
for ((f, b), p) in fm.iter().zip(&bm).zip(px) {
num += (p - b) * (f - b);
den += (f - b) * (f - b);
}
if den > 1e-4 {
*o = (num / den).clamp(0.0, 1.0);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::matting::{RefineParams, refine_mask_brushed, region_reader};
use crate::segment::ImageSampler;
use crate::selection::Region;
/// Strands of the subject's colour (every 4th column from x = 60) beyond the selection's
/// edge at x = 40: brushing over them adds them, Alt-brushing the edge keeps it as it was.
#[test]
fn brush_picks_up_strands_beyond_the_band_and_restores_the_edge() {
let (w, h) = (120usize, 60usize);
let (fg, bg) = ([0.9f32, 0.7, 0.2], [0.1f32, 0.3, 0.8]);
let strand = |x: usize| x < 40 || ((60..84).contains(&x) && x.is_multiple_of(4));
let img = RgbImage::from_fn(w, h, |x, _| if strand(x) { fg } else { bg });
let s = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, w as i32, h as i32);
let reg = Region { bbox: canvas, mask: (0..w * h).map(|i| if i % w < 40 { 255 } else { 0 }).collect() };
let p = RefineParams { radius: 4.0, ..Default::default() };
let refine = |strokes: &[EdgeStroke]| {
let brush = EdgeBrush::new(strokes, canvas);
refine_mask_brushed(&s, &region_reader(&reg), reg.bbox, canvas, &p, brush.as_ref()).expect("refined")
};
let plain = refine(&[]);
assert!(plain.at(64, 30) < 0.01, "the band alone stops short of the strands");
let over_strands = EdgeStroke { points: vec![(70.0, 10.0), (70.0, 50.0)], size: 30.0, add: true };
let brushed = refine(std::slice::from_ref(&over_strands));
assert!(brushed.at(64, 30) > 0.9 && brushed.at(72, 30) > 0.9, "strands added: {}", brushed.at(64, 30));
assert!(brushed.at(66, 30) < 0.1, "the background between them stays out: {}", brushed.at(66, 30));
assert!(brushed.at(20, 30) > 0.99 && brushed.at(100, 30) < 0.01, "unpainted areas keep the refined selection");
// Restoring paints the refinement out: with Shift Edge 0 the edge is the original one.
let soft = RefineParams { radius: 4.0, feather: 0.0, ..Default::default() };
let blurred = RgbImage::from_fn(w, h, |x, _| {
let t = ((x as f32 - 36.0) / 8.0).clamp(0.0, 1.0);
[0, 1, 2].map(|c| fg[c] * (1.0 - t) + bg[c] * t)
});
let sb = ImageSampler { img: &blurred, origin: (0, 0) };
let refined = refine_mask_brushed(&sb, &region_reader(&reg), reg.bbox, canvas, &soft, None).expect("refined");
assert!(refined.at(41, 30) > 0.1, "the band softens the blurred edge");
let restore = [EdgeStroke { points: vec![(40.0, 0.0), (40.0, 60.0)], size: 20.0, add: false }];
let restored = refine_mask_brushed(&sb, &region_reader(&reg), reg.bbox, canvas, &soft, EdgeBrush::new(&restore, canvas).as_ref()).expect("refined");
assert!(restored.at(41, 30) < 0.01 && restored.at(38, 30) > 0.99, "the original edge is back");
}
#[test]
fn strokes_paint_in_order_and_clip_to_the_canvas() {
let canvas = Rect::new(0, 0, 100, 50);
let add = EdgeStroke { points: vec![(10.0, 25.0), (90.0, 25.0)], size: 10.0, add: true };
let b = EdgeBrush::new(std::slice::from_ref(&add), canvas).expect("painted");
assert!(b.at(50, 25) > 0.99 && b.at(50, 40) == 0.0);
assert_eq!(b.window(), 10);
// A later subtract stroke paints over the add.
let sub = EdgeStroke { points: vec![(50.0, 25.0)], size: 20.0, add: false };
let b = EdgeBrush::new(&[add.clone(), sub], canvas).expect("painted");
assert!(b.at(50, 25) < -0.99 && b.at(20, 25) > 0.99);
// Off the canvas, non-finite or empty strokes paint nothing.
let off = EdgeStroke { points: vec![(-500.0, -500.0)], size: 10.0, add: true };
let nan = EdgeStroke { points: vec![(f32::NAN, 3.0)], size: 10.0, add: true };
let empty = EdgeStroke { points: vec![], size: 10.0, add: true };
assert!(EdgeBrush::new(&[off, nan, empty], canvas).is_none());
}
}
+4 -4
View File
@@ -261,8 +261,8 @@ fn tiled_refinement_release_comparison() {
let region = trim_region(Region { bbox: canvas, mask }).unwrap();
let reader = region_reader(&region);
let p = RefineParams { radius: 64.0, shift_edge: 100.0, ..Default::default() };
let old = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, repeated).unwrap();
let new = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, morph).unwrap();
let old = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, None, repeated).unwrap();
let new = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, None, morph).unwrap();
assert_eq!(new.bbox, old.bbox);
assert_eq!(new.mask, old.mask);
let (mut old_ms, mut new_ms) = (Vec::new(), Vec::new());
@@ -270,9 +270,9 @@ fn tiled_refinement_release_comparison() {
let mut measure = |fast: bool| {
let start = Instant::now();
let out = if fast {
refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, morph)
refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, None, morph)
} else {
refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, repeated)
refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, None, repeated)
}
.unwrap();
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
+77
View File
@@ -0,0 +1,77 @@
//! Data-parallel helpers: rayon on native targets, sequential on wasm (where this crate has no
//! threads). Each gives the same result either way, and whatever `par` says.
/// `f(index, chunk)` for each `len`-item chunk of `buf`, in parallel when `par`.
pub(crate) fn chunks_mut<T: Send>(buf: &mut [T], len: usize, par: bool, f: impl Fn(usize, &mut [T]) + Sync + Send) {
if len == 0 {
return;
}
#[cfg(not(target_arch = "wasm32"))]
if par {
use rayon::prelude::*;
buf.par_chunks_mut(len).enumerate().for_each(|(i, c)| f(i, c));
return;
}
let _ = par;
buf.chunks_mut(len).enumerate().for_each(|(i, c)| f(i, c));
}
/// [`chunks_mut`] over two buffers in step: the `i`-th `la`-item chunk of `a` with the `i`-th
/// `lb`-item chunk of `b`.
pub(crate) fn chunks2_mut<A: Send, B: Send>(a: &mut [A], la: usize, b: &mut [B], lb: usize, par: bool, f: impl Fn(usize, &mut [A], &mut [B]) + Sync + Send) {
if la == 0 || lb == 0 {
return;
}
#[cfg(not(target_arch = "wasm32"))]
if par {
use rayon::prelude::*;
a.par_chunks_mut(la).zip(b.par_chunks_mut(lb)).enumerate().for_each(|(i, (ca, cb))| f(i, ca, cb));
return;
}
let _ = par;
a.chunks_mut(la).zip(b.chunks_mut(lb)).enumerate().for_each(|(i, (ca, cb))| f(i, ca, cb));
}
/// `[f(0), f(1), …, f(n - 1)]`, in parallel when `par`.
pub(crate) fn map<T: Send>(n: usize, par: bool, f: impl Fn(usize) -> T + Sync + Send) -> Vec<T> {
#[cfg(not(target_arch = "wasm32"))]
if par {
use rayon::prelude::*;
return (0..n).into_par_iter().map(f).collect();
}
let _ = par;
(0..n).map(f).collect()
}
/// How many of `0..n` satisfy `f`.
pub(crate) fn count(n: usize, f: impl Fn(usize) -> bool + Sync + Send) -> usize {
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
(0..n).into_par_iter().filter(|&i| f(i)).count()
}
#[cfg(target_arch = "wasm32")]
(0..n).filter(|&i| f(i)).count()
}
/// `f(item)` for each of `items`, in parallel when `par`.
pub(crate) fn each<T: Send>(items: Vec<T>, par: bool, f: impl Fn(T) + Sync + Send) {
#[cfg(not(target_arch = "wasm32"))]
if par {
use rayon::prelude::*;
items.into_par_iter().for_each(f);
return;
}
let _ = par;
items.into_iter().for_each(f);
}
/// Threads the parallel helpers can use (1 on wasm).
pub(crate) fn threads() -> usize {
#[cfg(not(target_arch = "wasm32"))]
{
rayon::current_num_threads()
}
#[cfg(target_arch = "wasm32")]
1
}
+645 -105
View File
@@ -1,10 +1,12 @@
//! Edit › Puppet Warp: pins on a triangle mesh over the layer's opaque region, deformed
//! as-rigidly-as-possible.
//!
//! * **Mesh.** The layer's coverage (alpha above a threshold, grown or shrunk by `expansion`) is
//! rasterized onto a coarse block grid; a square grid at the density's spacing keeps every
//! cell that touches covered blocks, split into two triangles (alternating diagonals). A
//! grid-clipped mesh is simple, robust and uniform, which is all ARAP needs.
//! * **Mesh.** The layer's coverage (alpha above a threshold) becomes a signed distance field,
//! offset by `expansion` to grow or shrink the region. A square grid at the density's spacing,
//! split into triangles, is fitted to that outline (2D isosurface stuffing): vertices near the
//! outline snap onto it and the triangles it crosses are cut along it, so the mesh edge follows
//! the shape, and separate shapes get separate meshes. Inside, the mesh stays a uniform grid,
//! which is all ARAP needs.
//! * **Deformation.** The per-triangle ARAP energy of Liu et al. 2008 (the triangle form of
//! Sorkine & Alexa 2007), `E = Σ_t Σ_(i,j)∈t w_ij |(v_i − v_j) − R_t (x_i − x_j)|²` with
//! cotangent weights, minimized by alternating a local step (best rotation per triangle from
@@ -18,6 +20,8 @@
//! * **Rendering.** Triangles are rasterized with [`crate::warp::warp_triangles`] (tile-parallel,
//! premultiplied, bilinear/bicubic), back to front by the depth of their nearest pin.
use std::collections::{HashMap, HashSet};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde::{Deserialize, Serialize};
@@ -125,120 +129,619 @@ pub struct PuppetMesh {
/// Coverage threshold: alpha above this counts as part of the puppet.
const ALPHA_MIN: f32 = 0.05;
/// The outline field's longest side, in blocks: how finely the mesh edge follows the outline.
const FIELD_BLOCKS: f64 = 1024.0;
/// The puppet's region as a signed distance field over a block grid: negative inside the
/// opaque area grown (or shrunk) by the expansion, positive outside, in px.
struct Field {
x0: f64,
y0: f64,
/// Block size (px).
g: f64,
bw: usize,
bh: usize,
/// Signed distance from each block centre to the opaque outline (px, negative inside).
sd: Vec<f32>,
expansion: f64,
}
impl Field {
fn new(src: &Surface, bounds: Rect, expansion: f64) -> Field {
let long = f64::from(bounds.width().max(bounds.height()));
let grow = expansion.max(0.0).ceil();
let g = ((long + 2.0 * grow + 8.0) / FIELD_BLOCKS).ceil().max(1.0) as i32;
let area = bounds.inflate(grow as i32 + 2 * g);
let bw = ((area.width() as i32 + g - 1) / g).max(1) as usize;
let bh = ((area.height() as i32 + g - 1) / g).max(1) as usize;
let mut cov = vec![false; bw * bh];
let fmt = src.format();
let n = fmt.channels();
let block = |bx: usize, by: usize| {
let (x, y) = (area.x0 + bx as i32 * g, area.y0 + by as i32 * g);
Rect::new(x, y, x + g, y + g)
};
if !fmt.alpha {
for (i, c) in cov.iter_mut().enumerate() {
*c = !block(i % bw, i / bw).intersect(&bounds).is_empty();
}
} else {
// One block row per task (rows are independent).
let scan = |by: usize, out: &mut [bool]| {
let r = Rect::new(area.x0, area.y0 + by as i32 * g, area.x0 + bw as i32 * g, area.y0 + (by as i32 + 1) * g).intersect(&bounds);
if r.is_empty() || !src.has_tiles_in(r) {
return;
}
let row = src.read_region(r);
let rw = r.width() as usize;
for (i, p) in row.chunks_exact(n).enumerate() {
if p.last().is_some_and(|&a| a > ALPHA_MIN) {
let x = r.x0 + (i % rw) as i32;
if let Some(o) = out.get_mut(((x - area.x0) / g) as usize) {
*o = true;
}
}
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
cov.par_chunks_mut(bw).enumerate().for_each(|(by, out)| scan(by, out));
}
#[cfg(target_arch = "wasm32")]
cov.chunks_mut(bw).enumerate().for_each(|(by, out)| scan(by, out));
}
let outside = edt(&cov, true, bw, bh);
let inside = edt(&cov, false, bw, bh);
let gf = f64::from(g);
let sd = cov
.iter()
.zip(outside.iter().zip(&inside))
.map(|(&c, (&o, &i))| if c { (-(i.sqrt() - 0.5) * gf) as f32 } else { ((o.sqrt() - 0.5) * gf) as f32 })
.collect();
Field { x0: f64::from(area.x0), y0: f64::from(area.y0), g: gf, bw, bh, sd, expansion }
}
/// The field at a block centre (exact, no interpolation).
fn at(&self, bx: usize, by: usize) -> f64 {
self.sd.get(by * self.bw + bx).map_or(f64::MAX, |&d| f64::from(d) - self.expansion)
}
/// The field at `p` (bilinear between block centres; clamped to the grid, which ends
/// outside the region).
fn value(&self, p: [f64; 2]) -> f64 {
let fx = ((p[0] - self.x0) / self.g - 0.5).clamp(0.0, (self.bw - 1) as f64);
let fy = ((p[1] - self.y0) / self.g - 0.5).clamp(0.0, (self.bh - 1) as f64);
let (ix, iy) = (fx.floor() as usize, fy.floor() as usize);
let (ix1, iy1) = ((ix + 1).min(self.bw - 1), (iy + 1).min(self.bh - 1));
let (tx, ty) = (fx - ix as f64, fy - iy as f64);
let top = self.at(ix, iy) * (1.0 - tx) + self.at(ix1, iy) * tx;
let bottom = self.at(ix, iy1) * (1.0 - tx) + self.at(ix1, iy1) * tx;
top * (1.0 - ty) + bottom * ty
}
/// The outline (where the field crosses zero) as closed loops, traced by marching squares
/// over the block centres. The field's border blocks lie outside, so every loop closes.
fn outline(&self) -> Vec<Vec<[f64; 2]>> {
let (bw, bh) = (self.bw, self.bh);
if bw < 2 || bh < 2 {
return Vec::new();
}
let inside = |x: usize, y: usize| self.at(x, y) < 0.0;
// A crossing is named by its block edge: 2·block for the edge to the right neighbour,
// 2·block + 1 for the edge to the one below.
let id = |x: usize, y: usize, down: bool| 2 * (y * bw + x) + usize::from(down);
let point = |id: usize| {
let b = id / 2;
let (x, y) = (b % bw, b / bw);
let (x2, y2) = if id.is_multiple_of(2) { (x + 1, y) } else { (x, y + 1) };
let (fa, fb) = (self.at(x, y), self.at(x2, y2));
let t = if (fa - fb).abs() > 1e-12 { (fa / (fa - fb)).clamp(0.0, 1.0) } else { 0.5 };
let (dx, dy) = ((x2 - x) as f64, (y2 - y) as f64);
[self.x0 + (x as f64 + 0.5 + dx * t) * self.g, self.y0 + (y as f64 + 0.5 + dy * t) * self.g]
};
let mut links: HashMap<usize, [usize; 2]> = HashMap::new();
let mut link = |a: usize, b: usize| {
for (p, q) in [(a, b), (b, a)] {
let e = links.entry(p).or_insert([usize::MAX; 2]);
if e[0] == usize::MAX {
e[0] = q;
} else {
e[1] = q;
}
}
};
for y in 0..bh - 1 {
for x in 0..bw - 1 {
let c = [inside(x, y), inside(x + 1, y), inside(x + 1, y + 1), inside(x, y + 1)];
if c.iter().all(|&v| v == c[0]) {
continue;
}
// Top, right, bottom, left.
let edges = [id(x, y, false), id(x + 1, y, true), id(x, y + 1, false), id(x, y, true)];
let cross = [c[0] != c[1], c[1] != c[2], c[3] != c[2], c[0] != c[3]];
let ks: Vec<usize> = (0..4).filter(|&k| cross[k]).collect();
if let [a, b] = ks[..] {
link(edges[a], edges[b]);
} else if ks.len() == 4 {
// A saddle: the centre decides which diagonal pair is joined.
let centre = self.at(x, y) + self.at(x + 1, y) + self.at(x + 1, y + 1) + self.at(x, y + 1) < 0.0;
if c[0] == centre {
link(edges[0], edges[1]);
link(edges[2], edges[3]);
} else {
link(edges[3], edges[0]);
link(edges[1], edges[2]);
}
}
}
}
let mut keys: Vec<usize> = links.keys().copied().collect();
keys.sort_unstable();
let mut seen: HashSet<usize> = HashSet::with_capacity(keys.len());
let mut loops = Vec::new();
for &start in &keys {
if seen.contains(&start) {
continue;
}
let mut lp = Vec::new();
let (mut prev, mut cur) = (usize::MAX, start);
loop {
seen.insert(cur);
lp.push(point(cur));
let Some(&[a, b]) = links.get(&cur) else { break };
let next = if a != prev { a } else { b };
if next == usize::MAX || seen.contains(&next) {
break;
}
(prev, cur) = (cur, next);
}
if lp.len() >= 3 {
loops.push(lp);
}
}
loops
}
}
/// Squared Euclidean distance (in blocks) from every block to the nearest block whose `mask`
/// equals `site` (Felzenszwalb & Huttenlocher's separable lower-envelope transform). About
/// 1e20 when there is none.
fn edt(mask: &[bool], site: bool, w: usize, h: usize) -> Vec<f64> {
const FAR: f64 = 1e20;
let mut d: Vec<f64> = mask.iter().map(|&m| if m == site { 0.0 } else { FAR }).collect();
let mut f = vec![0.0; w.max(h)];
let mut out = vec![0.0; w.max(h)];
let mut v = vec![0usize; w.max(h)];
let mut z = vec![0.0; w.max(h) + 1];
for x in 0..w {
for (y, fy) in f[..h].iter_mut().enumerate() {
*fy = d[y * w + x];
}
edt_1d(&f[..h], &mut out[..h], &mut v, &mut z);
for (y, o) in out[..h].iter().enumerate() {
d[y * w + x] = *o;
}
}
for row in d.chunks_mut(w) {
f[..w].copy_from_slice(row);
edt_1d(&f[..w], row, &mut v, &mut z);
}
d
}
/// One-dimensional squared distance transform of the sampled function `f`.
fn edt_1d(f: &[f64], out: &mut [f64], v: &mut [usize], z: &mut [f64]) {
let n = f.len();
if n == 0 {
return;
}
let mut k = 0usize;
v[0] = 0;
z[0] = f64::NEG_INFINITY;
z[1] = f64::INFINITY;
let meet = |q: usize, p: usize| {
let (qf, pf) = (q as f64, p as f64);
((f[q] + qf * qf) - (f[p] + pf * pf)) / (2.0 * (qf - pf))
};
for q in 1..n {
let mut s = meet(q, v[k]);
while k > 0 && s <= z[k] {
k -= 1;
s = meet(q, v[k]);
}
k += 1;
v[k] = q;
z[k] = s;
z[k + 1] = f64::INFINITY;
}
k = 0;
for (q, o) in out.iter_mut().enumerate() {
let qf = q as f64;
while z[k + 1] < qf {
k += 1;
}
let p = v[k];
*o = (qf - p as f64).powi(2) + f[p];
}
}
/// Distance from `p` to the segment `a`–`b`.
fn seg_dist(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l2 = dx * dx + dy * dy;
let t = if l2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
dist2(p, [a[0] + dx * t, a[1] + dy * t]).sqrt()
}
/// Simplifies a closed loop (Douglas–Peucker, within `tol` px), which keeps its corners.
fn simplify(lp: &[[f64; 2]], tol: f64) -> Vec<[f64; 2]> {
let n = lp.len();
if n < 4 {
return lp.to_vec();
}
let far = (1..n).max_by(|&a, &b| dist2(lp[0], lp[a]).total_cmp(&dist2(lp[0], lp[b]))).unwrap_or(n / 2);
let mut keep = vec![false; n];
keep[0] = true;
keep[far] = true;
// Spans (a, b) of the loop; b == n stands for point 0.
let mut stack = vec![(0, far), (far, n)];
while let Some((a, b)) = stack.pop() {
let (pa, pb) = (lp[a], lp[b % n]);
let mut best = (0.0, 0);
for (i, &p) in lp.iter().enumerate().take(b).skip(a + 1) {
let d = seg_dist(p, pa, pb);
if d > best.0 {
best = (d, i);
}
}
if best.0 > tol {
keep[best.1] = true;
stack.push((a, best.1));
stack.push((best.1, b));
}
}
let out: Vec<[f64; 2]> = (0..n).filter(|&i| keep[i]).map(|i| lp[i]).collect();
if out.len() >= 3 {
return out;
}
// A speck thinner than the tolerance: keep a few of its points.
(0..n).step_by((n / 4).max(1)).map(|i| lp[i]).collect()
}
/// Splits the sides of a closed loop longer than `step` into equal parts.
fn densify(lp: &[[f64; 2]], step: f64) -> Vec<[f64; 2]> {
let mut out = Vec::with_capacity(lp.len());
for (k, &a) in lp.iter().enumerate() {
let b = lp[(k + 1) % lp.len()];
let parts = (dist2(a, b).sqrt() / step).ceil().clamp(1.0, 1e4) as usize;
for i in 0..parts {
out.push(lerp(a, b, i as f64 / parts as f64));
}
}
out
}
fn lerp(a: [f64; 2], b: [f64; 2], t: f64) -> [f64; 2] {
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
}
fn orient(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> f64 {
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
}
/// Positive when `d` lies inside the circumcircle of the positively oriented `a`, `b`, `c`.
fn in_circle(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> f64 {
let (ax, ay) = (a[0] - d[0], a[1] - d[1]);
let (bx, by) = (b[0] - d[0], b[1] - d[1]);
let (cx, cy) = (c[0] - d[0], c[1] - d[1]);
(ax * ax + ay * ay) * (bx * cy - cx * by) - (bx * bx + by * by) * (ax * cy - cx * ay) + (cx * cx + cy * cy) * (ax * by - bx * ay)
}
const NONE: usize = usize::MAX;
/// A triangle of the Delaunay triangulation: vertices (positively oriented) and the neighbour
/// across the edge opposite each vertex.
#[derive(Clone, Copy)]
struct DTri {
v: [usize; 3],
n: [usize; 3],
alive: bool,
}
/// Incremental Delaunay triangulation (Bowyer–Watson), started from a triangle around the area.
/// Vertices 0..3 are that triangle's.
struct Delaunay {
pts: Vec<[f64; 2]>,
tris: Vec<DTri>,
stamp: Vec<u32>,
pass: u32,
last: usize,
}
impl Delaunay {
fn new(lo: [f64; 2], hi: [f64; 2]) -> Delaunay {
let (cx, cy) = (0.5 * (lo[0] + hi[0]), 0.5 * (lo[1] + hi[1]));
let d = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1.0) * 4.0;
let mut pts = vec![[cx - 2.0 * d, cy - d], [cx + 2.0 * d, cy - d], [cx, cy + 2.0 * d]];
if orient(pts[0], pts[1], pts[2]) < 0.0 {
pts.swap(1, 2);
}
Delaunay { pts, tris: vec![DTri { v: [0, 1, 2], n: [NONE; 3], alive: true }], stamp: vec![0], pass: 0, last: 0 }
}
/// The triangle holding `p`: a visibility walk from the last one made.
fn locate(&self, p: [f64; 2]) -> Option<usize> {
let mut t = if self.tris.get(self.last).is_some_and(|t| t.alive) { self.last } else { self.tris.iter().position(|t| t.alive)? };
for _ in 0..self.tris.len() + 3 {
let tri = self.tris.get(t)?;
let step = (0..3).find(|&k| orient(self.pts[tri.v[(k + 1) % 3]], self.pts[tri.v[(k + 2) % 3]], p) < 0.0);
match step {
None => return Some(t),
Some(k) if tri.n[k] != NONE => t = tri.n[k],
Some(_) => return None,
}
}
self.tris.iter().position(|tri| tri.alive && (0..3).all(|k| orient(self.pts[tri.v[(k + 1) % 3]], self.pts[tri.v[(k + 2) % 3]], p) >= 0.0))
}
/// Inserts `p`; returns its vertex index (an existing vertex's when `p` coincides with it).
fn insert(&mut self, p: [f64; 2]) -> Option<usize> {
let t0 = self.locate(p)?;
if let Some(&v) = self.tris[t0].v.iter().find(|&&v| dist2(self.pts[v], p) < 1e-12) {
return Some(v);
}
self.pass = self.pass.wrapping_add(1);
let pass = self.pass;
// The cavity: every triangle whose circumcircle holds `p`, grown from `t0`.
let mut bad = vec![t0];
self.stamp[t0] = pass;
let mut i = 0;
while let Some(&t) = bad.get(i) {
i += 1;
for k in 0..3 {
let nb = self.tris[t].n[k];
if nb == NONE || self.stamp[nb] == pass {
continue;
}
let [a, b, c] = self.tris[nb].v.map(|v| self.pts[v]);
if in_circle(a, b, c, p) > 0.0 {
self.stamp[nb] = pass;
bad.push(nb);
}
}
}
let mut rim: Vec<(usize, usize, usize)> = Vec::new();
for &t in &bad {
let tri = self.tris[t];
for k in 0..3 {
let nb = tri.n[k];
if nb == NONE || self.stamp[nb] != pass {
rim.push((tri.v[(k + 1) % 3], tri.v[(k + 2) % 3], nb));
}
}
self.tris[t].alive = false;
}
let pi = self.pts.len();
self.pts.push(p);
let base = self.tris.len();
for (i, &(a, b, o)) in rim.iter().enumerate() {
self.tris.push(DTri { v: [a, b, pi], n: [NONE, NONE, o], alive: true });
self.stamp.push(0);
if let Some(ot) = self.tris.get_mut(o) {
for k in 0..3 {
let (x, y) = (ot.v[(k + 1) % 3], ot.v[(k + 2) % 3]);
if (x == b && y == a) || (x == a && y == b) {
ot.n[k] = base + i;
}
}
}
}
for (i, &(a, b, _)) in rim.iter().enumerate() {
let across_b = rim.iter().position(|r| r.0 == b).map_or(NONE, |j| base + j);
let across_a = rim.iter().position(|r| r.1 == a).map_or(NONE, |j| base + j);
self.tris[base + i].n[0] = across_b;
self.tris[base + i].n[1] = across_a;
}
self.last = base;
Some(pi)
}
fn edges(&self) -> HashSet<(usize, usize)> {
let mut e = HashSet::new();
for t in self.tris.iter().filter(|t| t.alive) {
for k in 0..3 {
let (a, b) = (t.v[k], t.v[(k + 1) % 3]);
e.insert((a.min(b), a.max(b)));
}
}
e
}
}
/// Even-odd point-in-polygon test against closed outline segments, bucketed by row.
struct Inside<'a> {
pts: &'a [[f64; 2]],
segs: &'a [(usize, usize)],
y0: f64,
row: f64,
rows: Vec<Vec<usize>>,
}
impl<'a> Inside<'a> {
fn new(pts: &'a [[f64; 2]], segs: &'a [(usize, usize)], row: f64) -> Self {
let y0 = segs.iter().flat_map(|&(a, b)| [pts[a][1], pts[b][1]]).fold(f64::MAX, f64::min);
let y1 = segs.iter().flat_map(|&(a, b)| [pts[a][1], pts[b][1]]).fold(f64::MIN, f64::max);
let n = if y1 >= y0 { ((y1 - y0) / row).floor().clamp(0.0, 1e5) as usize + 1 } else { 0 };
let mut rows = vec![Vec::new(); n];
for (i, &(a, b)) in segs.iter().enumerate() {
let (lo, hi) = (pts[a][1].min(pts[b][1]), pts[a][1].max(pts[b][1]));
let (r0, r1) = (((lo - y0) / row).floor() as usize, ((hi - y0) / row).floor() as usize);
for r in rows.iter_mut().take(r1 + 1).skip(r0) {
r.push(i);
}
}
Inside { pts, segs, y0, row, rows }
}
fn contains(&self, p: [f64; 2]) -> bool {
if p[1] < self.y0 {
return false;
}
let Some(row) = self.rows.get(((p[1] - self.y0) / self.row).floor() as usize) else { return false };
let mut odd = false;
for &i in row {
let (a, b) = (self.pts[self.segs[i].0], self.pts[self.segs[i].1]);
if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < a[0] + (p[1] - a[1]) / (b[1] - a[1]) * (b[0] - a[0]) {
odd = !odd;
}
}
odd
}
}
/// Most outline points the mesh takes; a busier outline is simplified more coarsely.
const MAX_OUTLINE_POINTS: usize = 20_000;
/// Builds the mesh over the covered part of `src` inside `bounds`.
///
/// The outline of the opaque region grown by `expansion` is traced, simplified (corners kept)
/// and split into sides no longer than the density's spacing; a triangular lattice at that
/// spacing fills the inside, at least half a step from the outline. The mesh is the
/// Delaunay triangulation of those points with the outline's sides recovered (split until
/// each is an edge), minus the triangles outside the outline.
pub fn build_mesh(src: &Surface, bounds: Rect, density: PuppetDensity, expansion: f64) -> PuppetMesh {
if bounds.is_empty() {
return PuppetMesh::default();
}
let long = f64::from(bounds.width().max(bounds.height()));
let e = if expansion.is_finite() { expansion.clamp(-200.0, 200.0) } else { 0.0 };
// The density counts steps across the region the mesh covers (grown by the expansion).
let long = f64::from(bounds.width().max(bounds.height())) + 2.0 * e.max(0.0);
let spacing = (long / density.cells()).max(2.0);
let e = expansion.clamp(-200.0, 200.0);
// Coverage on a block grid (block = a quarter cell or less).
let g = ((spacing / 4.0).floor() as i32).max(1);
let pad = e.max(0.0).ceil() as i32 + g;
let area = bounds.inflate(pad);
let bw = (area.width() as i32 + g - 1) / g;
let bh = (area.height() as i32 + g - 1) / g;
let (bw, bh) = (bw as usize, bh as usize);
let mut cov = vec![false; bw * bh];
let fmt = src.format();
let n = fmt.channels();
if !fmt.alpha {
for by in 0..bh {
for bx in 0..bw {
let r = Rect::new(area.x0 + bx as i32 * g, area.y0 + by as i32 * g, area.x0 + (bx as i32 + 1) * g, area.y0 + (by as i32 + 1) * g);
cov[by * bw + bx] = !r.intersect(&bounds).is_empty();
}
}
} else {
// One block row per task (rows are independent).
let scan = |by: usize, out: &mut [bool]| {
let r = Rect::new(area.x0, area.y0 + by as i32 * g, area.x0 + bw as i32 * g, area.y0 + (by as i32 + 1) * g).intersect(&bounds);
if r.is_empty() || !src.has_tiles_in(r) {
return;
}
let row = src.read_region(r);
let rw = r.width() as usize;
for (i, p) in row.chunks_exact(n).enumerate() {
if p[n - 1] > ALPHA_MIN {
let x = r.x0 + (i % rw) as i32;
out[((x - area.x0) / g) as usize] = true;
}
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
cov.par_chunks_mut(bw).enumerate().for_each(|(by, out)| scan(by, out));
}
#[cfg(target_arch = "wasm32")]
cov.chunks_mut(bw).enumerate().for_each(|(by, out)| scan(by, out));
let field = Field::new(src, bounds, e);
let loops = field.outline();
let empty = PuppetMesh { spacing, ..Default::default() };
if loops.is_empty() {
return empty;
}
// Grow / shrink by the expansion (round stamp in block units).
let eb = (e.abs() / f64::from(g)).round() as i32;
if eb > 0 {
let src_cov = cov.clone();
let grow = e > 0.0;
for by in 0..bh as i32 {
for bx in 0..bw as i32 {
let mut hit = !grow;
'stamp: for dy in -eb..=eb {
for dx in -eb..=eb {
if dx * dx + dy * dy > eb * eb {
continue;
}
let (x, y) = (bx + dx, by + dy);
let v = x >= 0 && y >= 0 && x < bw as i32 && y < bh as i32 && src_cov[y as usize * bw + x as usize];
if grow && v {
hit = true;
break 'stamp;
}
if !grow && !v {
hit = false;
break 'stamp;
}
}
}
cov[by as usize * bw + bx as usize] = hit;
let mut tol = (0.5 * field.g).max(0.5);
let rings = loop {
let rings: Vec<Vec<[f64; 2]>> = loops.iter().map(|l| densify(&simplify(l, tol), spacing)).collect();
if rings.iter().map(Vec::len).sum::<usize>() <= MAX_OUTLINE_POINTS || tol > spacing {
break rings;
}
tol *= 2.0;
};
let (lo, hi) = ([field.x0, field.y0], [field.x0 + field.bw as f64 * field.g, field.y0 + field.bh as f64 * field.g]);
let mut dl = Delaunay::new(lo, hi);
let mut segs: Vec<(usize, usize)> = Vec::new();
for ring in &rings {
let idx: Vec<usize> = ring.iter().filter_map(|&p| dl.insert(p)).collect();
for (k, &a) in idx.iter().enumerate() {
let b = idx[(k + 1) % idx.len()];
if a != b {
segs.push((a, b));
}
}
}
// Grid cells touching covered blocks.
let ox = f64::from(area.x0);
let oy = f64::from(area.y0);
let cw = (f64::from(bw as i32 * g) / spacing).ceil() as usize;
let ch = (f64::from(bh as i32 * g) / spacing).ceil() as usize;
let mut index = vec![usize::MAX; (cw + 1) * (ch + 1)];
let mut mesh = PuppetMesh { spacing, ..Default::default() };
for cj in 0..ch {
for ci in 0..cw {
let bx0 = ((ci as f64 * spacing) / f64::from(g)).floor() as usize;
let by0 = ((cj as f64 * spacing) / f64::from(g)).floor() as usize;
let bx1 = (((ci + 1) as f64 * spacing) / f64::from(g)).ceil().min(bw as f64) as usize;
let by1 = (((cj + 1) as f64 * spacing) / f64::from(g)).ceil().min(bh as f64) as usize;
let any = (by0..by1).any(|y| (bx0..bx1).any(|x| cov[y * bw + x]));
if !any {
// The inside: a triangular lattice, kept half a step clear of the outline.
let rise = spacing * 3f64.sqrt() / 2.0;
let rows = ((hi[1] - lo[1]) / rise).ceil() as usize;
let cols = ((hi[0] - lo[0]) / spacing).ceil() as usize;
for j in 0..=rows {
for i in 0..=cols {
let p = [lo[0] + (i as f64 + 0.5 * (j % 2) as f64) * spacing, lo[1] + j as f64 * rise];
if field.value(p) < -0.5 * spacing {
dl.insert(p);
}
}
}
// Recover the outline's sides: split a side the triangulation lacks at its midpoint.
let mut budget = segs.len() * 2;
for _ in 0..8 {
let edges = dl.edges();
let mut next = Vec::with_capacity(segs.len());
let mut split = false;
for &(a, b) in &segs {
if budget == 0 || edges.contains(&(a.min(b), a.max(b))) {
next.push((a, b));
continue;
}
let mut v = |i: usize, j: usize| {
let k = j * (cw + 1) + i;
if index[k] == usize::MAX {
index[k] = mesh.verts.len();
mesh.verts.push([ox + i as f64 * spacing, oy + j as f64 * spacing]);
match dl.insert(lerp(dl.pts[a], dl.pts[b], 0.5)) {
Some(c) if c != a && c != b => {
next.push((a, c));
next.push((c, b));
budget -= 1;
split = true;
}
index[k]
};
let (a, b, c, d) = (v(ci, cj), v(ci + 1, cj), v(ci + 1, cj + 1), v(ci, cj + 1));
if (ci + cj) % 2 == 0 {
mesh.tris.push([a, b, c]);
mesh.tris.push([a, c, d]);
} else {
mesh.tris.push([a, b, d]);
mesh.tris.push([b, c, d]);
_ => next.push((a, b)),
}
}
segs = next;
if !split {
break;
}
}
let inside = Inside::new(&dl.pts, &segs, spacing);
let mut tris: Vec<[usize; 3]> = Vec::new();
for t in dl.tris.iter().filter(|t| t.alive && t.v.iter().all(|&v| v >= 3)) {
let [a, b, c] = t.v.map(|v| dl.pts[v]);
if orient(a, b, c) > 1e-9 && inside.contains([(a[0] + b[0] + c[0]) / 3.0, (a[1] + b[1] + c[1]) / 3.0]) {
tris.push(t.v);
}
}
if tris.is_empty() {
return empty;
}
// Keep the vertices in use, numbered for a narrow band in the solver's matrix.
let pos = &dl.pts;
let order = band_order(pos, &tris);
let mut remap = vec![usize::MAX; pos.len()];
for (new, &old) in order.iter().enumerate() {
remap[old] = new;
}
PuppetMesh { verts: order.iter().map(|&i| pos[i]).collect(), tris: tris.iter().map(|t| t.map(|i| remap[i])).collect(), spacing }
}
/// The vertices `tris` use, in reverse Cuthill–McKee order (breadth first from the topmost
/// vertex of each piece, lower degrees first), which keeps the solver's banded factor small.
fn band_order(pos: &[[f64; 2]], tris: &[[usize; 3]]) -> Vec<usize> {
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); pos.len()];
for t in tris {
for k in 0..3 {
let (a, b) = (t[k], t[(k + 1) % 3]);
adj[a].push(b);
adj[b].push(a);
}
}
for a in &mut adj {
a.sort_unstable();
a.dedup();
}
let mut starts: Vec<usize> = (0..pos.len()).filter(|&i| !adj[i].is_empty()).collect();
starts.sort_by(|&a, &b| pos[a][1].total_cmp(&pos[b][1]).then(pos[a][0].total_cmp(&pos[b][0])));
let mut seen = vec![false; pos.len()];
let mut order = Vec::with_capacity(starts.len());
for s in starts {
if seen[s] {
continue;
}
seen[s] = true;
let first = order.len();
order.push(s);
let mut i = first;
while let Some(&v) = order.get(i) {
i += 1;
let mut next: Vec<usize> = adj[v].iter().copied().filter(|&w| !seen[w]).collect();
next.sort_by_key(|&w| adj[w].len());
for w in next {
seen[w] = true;
order.push(w);
}
}
}
mesh
order.reverse();
order
}
/// A compressed sparse row matrix (symmetric, used by CG).
@@ -330,9 +833,9 @@ impl Csr {
}
}
/// Banded Cholesky factor `A = L Lᵀ` (grid meshes number their vertices row by row, so the
/// system's bandwidth is about two mesh rows): factored once per pin set, then each global step
/// is two triangular solves.
/// Banded Cholesky factor `A = L Lᵀ` (the mesh numbers its vertices in reverse Cuthill–McKee
/// order, so the system's bandwidth is about one breadth-first front): factored once per pin
/// set, then each global step is two triangular solves.
struct BandChol {
n: usize,
p: usize,
@@ -833,6 +1336,43 @@ mod tests {
assert!(more.tris.len() > m.tris.len() && m.tris.len() > fewer.tris.len());
}
fn in_mesh(m: &PuppetMesh, p: [f64; 2]) -> bool {
m.tris.iter().any(|t| {
let [a, b, c] = t.map(|i| m.verts[i]);
let (d0, d1, d2) = (orient(a, b, p), orient(b, c, p), orient(c, a, p));
(d0 >= 0.0 && d1 >= 0.0 && d2 >= 0.0) || (d0 <= 0.0 && d1 <= 0.0 && d2 <= 0.0)
})
}
#[test]
fn mesh_follows_the_outline() {
// #3105: the mesh was every grid cell touching the shape, a staircase up to a cell
// outside it. It now follows the outline grown by the expansion.
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
for y in 40..160 {
for x in 40..160 {
if (f64::from(x) + 0.5 - 100.0).hypot(f64::from(y) + 0.5 - 100.0) <= 60.0 {
s.write_pixel(x, y, &[0.3, 0.6, 0.9, 1.0]);
}
}
}
let m = build_mesh(&s, s.content_bounds(), PuppetDensity::Normal, 2.0);
let r = |v: &[f64; 2]| (v[0] - 100.0).hypot(v[1] - 100.0);
assert!(m.verts.iter().all(|v| r(v) <= 63.0), "a vertex outside the grown disc");
assert!(m.verts.iter().filter(|v| r(v) > 61.0).count() >= 40, "the outline carries vertices");
let area: f64 = m.tris.iter().map(|t| orient(m.verts[t[0]], m.verts[t[1]], m.verts[t[2]]).abs() / 2.0).sum();
let disc = std::f64::consts::PI * 62.0 * 62.0;
assert!((area / disc - 1.0).abs() < 0.03, "{area} vs {disc}");
// Sharp corners and a concave corner: every opaque pixel is still inside the mesh, even
// without an expansion, and the empty corner of the L is not.
let l = blob(SampleType::U8);
let m = build_mesh(&l, l.content_bounds(), PuppetDensity::Fewer, 0.0);
for (x, y) in (20..36).flat_map(|x| (10..70).map(move |y| (x, y))).chain((36..70).flat_map(|x| (56..70).map(move |y| (x, y)))) {
assert!(in_mesh(&m, [f64::from(x) + 0.5, f64::from(y) + 0.5]), "({x}, {y}) is outside the mesh");
}
assert!(!in_mesh(&m, [40.0, 50.0]) && !in_mesh(&m, [60.0, 20.0]));
}
#[test]
fn no_pins_or_unmoved_pins_are_identity() {
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
+572 -3
View File
@@ -7,8 +7,17 @@
//!
//! Weights come from [`stack_weights`] (focus stacking: per pixel, the sharpest image wins, by
//! the smoothed magnitude of the luminance Laplacian) or [`panorama_weights`] (each pixel goes to
//! the image whose opaque area it is deepest inside). Buffers are interleaved `w × h × ch`
//! normalised floats of any colour model and depth; deterministic.
//! the image whose opaque area it is deepest inside).
//!
//! [`fuse_stack`] is the focus stack's merge: E. H. Adelson, C. H. Anderson, J. R. Bergen,
//! P. J. Burt, J. M. Ogden, *Pyramid methods in image processing*, RCA Engineer 1984 (the
//! multifocus composite: per pyramid coefficient, keep the image with the most band-pass energy,
//! then expand-and-add) with the selection rules of W. Wang, F. Chang, *A Multi-focus Image Fusion
//! Method Based on Laplacian Pyramid*, Journal of Computers 6(12), 2011 (maximum region energy
//! for the band-pass levels, local deviation and entropy for the residual), plus halo control.
//!
//! Buffers are interleaved `w × h × ch` normalised floats of any colour model and depth;
//! deterministic.
/// One pyramid level.
#[derive(Clone, Debug, PartialEq)]
@@ -18,7 +27,7 @@ pub struct Level {
pub px: Vec<f32>,
}
use crate::photo_util::par_rows;
use crate::photo_util::{par_map, par_rows, par_rows2};
const K: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
@@ -169,6 +178,349 @@ pub fn blend(w: usize, h: usize, ch: usize, images: &[&[f32]], weights: &[Vec<f3
collapse(&a, ch).into_iter().map(|v| v.clamp(0.0, 1.0)).collect()
}
/// Focus-stack fusion settings for [`fuse_stack`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct StackFusion {
/// Region-energy window radius for the band-pass levels: 1 = the 3×3 binomial window of
/// Wang & Chang; 0 = the per-node |L| of Adelson et al.
pub energy_radius: usize,
/// Halo control hardness: 0 = off (every level picks its own winner); otherwise the levels
/// coarser than `guide` are the mean of the images weighed by the guide's region energy to
/// this power, capped at [`HALO_MAX`]. Default 2.
pub halo: f32,
/// The guide level for halo control (0 = the finest band-pass level); clamped to the last
/// band-pass level.
pub guide: usize,
/// Pyramid levels, the residual included; `None` = [`stack_levels`].
pub levels: Option<usize>,
}
impl Default for StackFusion {
fn default() -> Self {
StackFusion { energy_radius: 1, halo: 2.0, guide: 2, levels: None }
}
}
/// Largest useful halo hardness: a weight's exponent is clamped to ±60 (a span of 1e52), which
/// hardness 8 uses up over the practical range of energies.
pub const HALO_MAX: f32 = 8.0;
/// Reference energy the halo weights are relative to (a well-textured quarter-resolution band),
/// so that `(RE / ref)^p` stays within f32 up to [`HALO_MAX`].
const HALO_REF: f32 = 1e-4;
/// Floor added to the energy before weighing: below it the images average.
const HALO_FLOOR: f32 = 1e-8;
/// Coverage below which an image has no say at a pixel.
const COVERED: f32 = 0.5;
/// The energy of an image where it does not cover the pixel: below any real energy, so any
/// covering image wins over it.
const NO_ENERGY: f32 = -1.0;
/// Levels for [`fuse_stack`]: as many band-pass levels as keep the residual's short side ≥ 32
/// px, so the residual still has the local structure its deviation / entropy rule measures.
pub fn stack_levels(w: usize, h: usize) -> usize {
let mut n = 1;
let mut s = w.min(h);
while s.div_ceil(2) >= 32 && n < 10 {
s = s.div_ceil(2);
n += 1;
}
n
}
/// The weight a coarse coefficient gets from the guide's region energy `re` at hardness `p`:
/// `((re + floor) / ref)^p`, its exponent clamped to ±60.
fn halo_weight(re: f32, p: f32) -> f32 {
(p * ((re.max(0.0) + HALO_FLOOR) / HALO_REF).ln()).clamp(-60.0, 60.0).exp()
}
/// Binomial window weights of radius `r` (row 2r of Pascal's triangle / 4^r).
fn binomial(r: usize) -> Vec<f32> {
let n = 2 * r + 1;
let mut row = vec![1u64; n];
for i in 1..n {
for j in (1..i).rev() {
row[j] += row[j - 1];
}
}
let s = row.iter().sum::<u64>() as f32;
row.into_iter().map(|v| v as f32 / s).collect()
}
/// Separable weighted window sum of a plane (`wt` of odd length, clamped borders).
fn window_sum(w: usize, h: usize, src: &[f32], wt: &[f32]) -> Vec<f32> {
let r = wt.len() / 2;
if r == 0 || w == 0 || h == 0 {
return src.to_vec();
}
let mut tmp = vec![0.0f32; w * h];
par_rows(&mut tmp, w, 1, |y, row| {
let s = &src[y * w..y * w + w];
for (x, o) in row.iter_mut().enumerate() {
*o = wt.iter().enumerate().map(|(t, k)| k * s[(x + t).saturating_sub(r).min(w - 1)]).sum();
}
});
let mut out = vec![0.0f32; w * h];
par_rows(&mut out, w, 1, |y, row| {
for (t, k) in wt.iter().enumerate() {
let sy = (y + t).saturating_sub(r).min(h - 1);
for (o, v) in row.iter_mut().zip(&tmp[sy * w..sy * w + w]) {
*o += k * v;
}
}
});
out
}
/// Region energy of a band-pass luminance level: the window sum of L² (Wang & Chang eq. 13);
/// `NO_ENERGY` where the image's coverage `cover` is below [`COVERED`].
fn region_energy(l: &Level, cover: &Level, win: &[f32]) -> Vec<f32> {
let e: Vec<f32> = l.px.iter().map(|v| v * v).collect();
let mut re = window_sum(l.w, l.h, &e, win);
for (r, a) in re.iter_mut().zip(&cover.px) {
if *a < COVERED {
*r = NO_ENERGY;
}
}
re
}
/// Local deviation (Wang & Chang eq. 10) and local entropy (eq. 11, 256 gray levels) of a
/// plane over a 5×5 window, clamped borders.
fn deviation_entropy(w: usize, h: usize, y: &[f32]) -> (Vec<f32>, Vec<f32>) {
const R: usize = 2;
const BINS: usize = 256;
let n = (2 * R + 1) * (2 * R + 1);
let inv_n = 1.0 / n as f32;
let (mut dev, mut ent) = (vec![0.0f32; w * h], vec![0.0f32; w * h]);
if w == 0 || h == 0 {
return (dev, ent);
}
par_rows2(&mut dev, &mut ent, w, |i, drow, erow| {
let mut hist = [0u32; BINS];
let mut touched: Vec<usize> = Vec::with_capacity(n);
for j in 0..w {
let (mut s, mut s2) = (0.0f32, 0.0f32);
touched.clear();
for dy in 0..=2 * R {
let yy = (i + dy).saturating_sub(R).min(h - 1);
for dx in 0..=2 * R {
let v = y[yy * w + (j + dx).saturating_sub(R).min(w - 1)];
s += v;
s2 += v * v;
let q = ((v.clamp(0.0, 1.0) * (BINS - 1) as f32 + 0.5) as usize).min(BINS - 1);
if hist[q] == 0 {
touched.push(q);
}
hist[q] += 1;
}
}
let mean = s * inv_n;
drow[j] = (s2 * inv_n - mean * mean).max(0.0);
let mut e = 0.0f32;
for &q in &touched {
let p = hist[q] as f32 * inv_n;
e -= p * p.ln();
hist[q] = 0;
}
erow[j] = e;
}
});
(dev, ent)
}
/// One image's residual (low-pass) level, with its luminance and coverage at that level.
struct Residual {
px: Level,
luma: Vec<f32>,
cover: Vec<f32>,
}
/// Fuse the images' residuals by Wang & Chang's region-information rule (eq. 12), generalised
/// to N images as a Pareto rule: an image is dominated where another covering image is at least
/// as good on both local deviation and local entropy and strictly better on one; the fused value
/// is the mean of the non-dominated images. Images that do not cover the pixel are left out
/// unless none does.
fn fuse_residuals(tops: &[Residual], ch: usize) -> Level {
let Some(t0) = tops.first() else { return Level { w: 0, h: 0, px: Vec::new() } };
let (w, h, n) = (t0.px.w, t0.px.h, tops.len());
let measures: Vec<(Vec<f32>, Vec<f32>)> = par_map(n, |k| deviation_entropy(w, h, &tops[k].luma));
let mut out = vec![0.0f32; w * h * ch];
let mut keep = vec![false; n];
for i in 0..w * h {
let any_cover = tops.iter().any(|t| t.cover[i] >= COVERED);
let eligible = |a: usize| !any_cover || tops[a].cover[i] >= COVERED;
let mut kept = 0usize;
for a in 0..n {
let (da, ea) = (measures[a].0[i], measures[a].1[i]);
let dominated = eligible(a)
&& (0..n).any(|b| {
let (db, eb) = (measures[b].0[i], measures[b].1[i]);
b != a && eligible(b) && db >= da && eb >= ea && (db > da || eb > ea)
});
keep[a] = eligible(a) && !dominated;
kept += keep[a] as usize;
}
let inv = 1.0 / kept.max(1) as f32;
for (t, _) in tops.iter().zip(&keep).filter(|(_, k)| **k) {
for c in 0..ch {
out[i * ch + c] += t.px.px[i * ch + c] * inv;
}
}
}
Level { w, h, px: out }
}
/// Halo control: fold the levels coarser than the guide `g` as `Σ w·L` and `Σ w`, the guide's
/// weights `wgt` REDUCEd to each level's size. `src` is the image's pyramid; `None` scales the
/// first image's levels, already in `acc`, in place.
fn fold_halo(acc: &mut [Level], wsum: &mut Vec<Vec<f32>>, src: Option<&[Level]>, g: usize, mut wgt: Level, ch: usize) {
for li in g + 1..acc.len() {
wgt = reduce(&wgt, 1);
let a = &mut acc[li];
let (aw, wp) = (a.w, &wgt.px);
match src {
Some(src) => {
if let Some(s) = wsum.get_mut(li - g - 1) {
for (s, k) in s.iter_mut().zip(wp) {
*s += k;
}
}
let sp = &src[li].px;
par_rows(&mut a.px, aw, ch, |y, row| {
for (x, px) in row.chunks_mut(ch).enumerate() {
let (i, k) = (y * aw + x, wp[y * aw + x]);
for (c, v) in px.iter_mut().enumerate() {
*v += k * sp[i * ch + c];
}
}
});
}
None => {
par_rows(&mut a.px, aw, ch, |y, row| {
for (x, px) in row.chunks_mut(ch).enumerate() {
let k = wp[y * aw + x];
px.iter_mut().for_each(|v| *v *= k);
}
});
wsum.push(wp.clone());
}
}
}
}
/// Multifocus composite of `images` (interleaved `w × h × ch`; `luma[k]` is image k's luminance,
/// `alpha[k]` its coverage): per Laplacian-pyramid coefficient, the image whose luminance band
/// has the largest region energy wins (Adelson et al. 1984's multifocus composite with the
/// maximum-region-energy rule of Wang & Chang 2011), the residual follows the deviation +
/// entropy rule, and the blending between images happens in the reconstruction itself, so every
/// scale is taken from the image that is sharp at that scale. Ties keep the earlier image, and an
/// image has no say where its coverage is below one half.
///
/// With halo control (`p.halo` > 0) the levels coarser than `p.guide` do not pick winners:
/// beside a bright object, the images focused behind it carry the object's defocused copy spread
/// over the background, strong coarse energy where the image that has the object sharp has
/// none, so a per-level pick collects that glow. Instead those levels are the mean of the images
/// weighed by the guide's region energy to the power `p.halo` (REDUCEd to each level's size, as
/// a multiresolution spline blends with a weight mask), so the coarse structure follows the images
/// the guide found sharp, and where none is the images average.
///
/// Images are folded in one at a time: only the running fused pyramid, one best-energy (or
/// weight-sum) plane per level and the small residuals are held. Output is clamped to 0..=1.
pub fn fuse_stack(w: usize, h: usize, ch: usize, images: &[&[f32]], luma: &[Vec<f32>], alpha: &[Vec<f32>], p: StackFusion) -> Vec<f32> {
// Levels the pyramid can actually have (REDUCE stops at 1 × 1).
let levels = {
let want = p.levels.unwrap_or_else(|| stack_levels(w, h)).max(1);
let (mut n, mut a, mut b) = (1, w, h);
while (a > 1 || b > 1) && n < want {
(a, b, n) = (a.div_ceil(2), b.div_ceil(2), n + 1);
}
n
};
let bands = levels - 1;
let halo = (bands > 0 && p.halo > 0.0).then(|| (p.guide.min(bands - 1), p.halo.min(HALO_MAX)));
// The band-pass levels that pick their own winner: all of them, or up to the guide.
let nsel = halo.map_or(bands, |(g, _)| g + 1);
let win = binomial(p.energy_radius);
let mut acc: Vec<Level> = Vec::new();
// Per selecting level, the winning energy so far.
let mut best: Vec<Vec<f32>> = Vec::new();
// With halo control, per level coarser than the guide, the weight sum so far.
let mut wsum: Vec<Vec<f32>> = Vec::new();
let mut tops: Vec<Residual> = Vec::new();
for ((img, lm), al) in images.iter().zip(luma).zip(alpha) {
let lp = laplacian(w, h, ch, img, levels);
let ll = laplacian(w, h, 1, lm, levels);
let ga = gaussian(w, h, 1, al, levels);
let (Some(top), Some(tl), Some(ta)) = (lp.last(), ll.last(), ga.last()) else { continue };
if lp.len() != levels || ll.len() != levels || ga.len() != levels {
continue;
}
if halo.is_none() {
tops.push(Residual { px: top.clone(), luma: tl.px.clone(), cover: ta.px.clone() });
}
let energies: Vec<Vec<f32>> = (0..nsel).map(|li| region_energy(&ll[li], &ga[li], &win)).collect();
let wgt =
halo.map(|(g, hard)| Level { w: ll[g].w, h: ll[g].h, px: energies[g].iter().map(|&e| if e < 0.0 { 0.0 } else { halo_weight(e, hard) }).collect() });
if acc.is_empty() {
acc = lp;
best = energies;
if let (Some((g, _)), Some(wgt)) = (halo, wgt) {
fold_halo(&mut acc, &mut wsum, None, g, wgt, ch);
}
continue;
}
for li in 0..nsel {
let (lvl, lw) = (&lp[li], lp[li].w);
let take: Vec<bool> = energies[li]
.iter()
.zip(best[li].iter_mut())
.map(|(e, b)| {
if *e > *b {
*b = *e;
true
} else {
false
}
})
.collect();
par_rows(&mut acc[li].px, lw, ch, |y, row| {
for (x, px) in row.chunks_mut(ch).enumerate() {
if take[y * lw + x] {
px.copy_from_slice(&lvl.px[(y * lw + x) * ch..(y * lw + x + 1) * ch]);
}
}
});
}
if let (Some((g, _)), Some(wgt)) = (halo, wgt) {
fold_halo(&mut acc, &mut wsum, Some(&lp), g, wgt, ch);
}
}
if acc.is_empty() {
return vec![0.0; w * h * ch];
}
match halo {
Some((g, _)) => {
// The weighted means: Σ w·L / Σ w.
for li in g + 1..levels {
let (a, s) = (&mut acc[li], &wsum[li - g - 1]);
let aw = a.w;
par_rows(&mut a.px, aw, ch, |y, row| {
for (x, px) in row.chunks_mut(ch).enumerate() {
let k = s[y * aw + x];
px.iter_mut().for_each(|v| *v = if k > 0.0 { *v / k } else { 0.0 });
}
});
}
}
None => {
if let Some(last) = acc.last_mut() {
*last = fuse_residuals(&tops, ch);
}
}
}
collapse(&acc, ch).into_iter().map(|v| v.clamp(0.0, 1.0)).collect()
}
fn box_blur1(w: usize, h: usize, v: &[f32], r: usize) -> Vec<f32> {
let pass = |src: &[f32], horizontal: bool| -> Vec<f32> {
let mut dst = vec![0.0f32; src.len()];
@@ -313,4 +665,221 @@ mod tests {
let seam = (0..w).find(|x| wts[1][10 * w + x] == 1.0).unwrap();
assert!((25..=35).contains(&seam), "{seam}");
}
fn checker_img(w: usize, h: usize, cell: usize, ch: usize) -> Vec<f32> {
let mut px = vec![0.0f32; w * h * ch];
for y in 0..h {
for x in 0..w {
let v = if ((x / cell) + (y / cell)).is_multiple_of(2) { 0.2 } else { 0.8 };
let p = &mut px[(y * w + x) * ch..(y * w + x + 1) * ch];
for (c, o) in p.iter_mut().enumerate() {
*o = match c {
0 => v,
1 => v * 0.9,
2 => v * 0.5,
_ => 1.0,
};
}
}
}
px
}
fn luma_of(w: usize, h: usize, ch: usize, px: &[f32]) -> Vec<f32> {
(0..w * h).map(|i| if ch >= 3 { 0.2126 * px[i * ch] + 0.7152 * px[i * ch + 1] + 0.0722 * px[i * ch + 2] } else { px[i * ch] }).collect()
}
/// Blur every channel with the 5-tap kernel `passes` times (no decimation).
fn blur_img(w: usize, h: usize, ch: usize, px: &[f32], passes: usize) -> Vec<f32> {
let mut planes: Vec<Vec<f32>> = (0..ch).map(|c| (0..w * h).map(|i| px[i * ch + c]).collect()).collect();
for _ in 0..passes {
for p in planes.iter_mut() {
*p = window_sum(w, h, p, &K);
}
}
(0..w * h * ch).map(|i| planes[i % ch][i / ch]).collect()
}
fn rms(a: &[f32], b: &[f32]) -> f32 {
(a.iter().zip(b).map(|(x, y)| ((x - y) * (x - y)) as f64).sum::<f64>() / a.len() as f64).sqrt() as f32
}
#[test]
fn stack_levels_keep_the_residual_above_32_px() {
assert_eq!(stack_levels(64, 64), 2);
assert_eq!(stack_levels(48, 100), 1);
assert_eq!(stack_levels(2070, 1378), 6);
assert_eq!(stack_levels(8280, 5520), 8);
assert_eq!(stack_levels(100_000, 100_000), 10);
}
#[test]
fn binomial_window_and_window_sum() {
assert_eq!(binomial(0), vec![1.0]);
assert_eq!(binomial(1), vec![0.25, 0.5, 0.25]);
assert!(binomial(2).iter().zip(&K).all(|(a, k)| (a - k).abs() < 1e-6));
let flat = vec![0.3f32; 7 * 5];
assert!(window_sum(7, 5, &flat, &binomial(1)).iter().all(|v| (v - 0.3).abs() < 1e-6), "a window sum of unit weight keeps a flat plane");
assert_eq!(window_sum(7, 5, &flat, &binomial(0)), flat);
}
#[test]
fn deviation_and_entropy_measure_local_structure() {
let (w, h) = (20, 20);
let flat = vec![0.5f32; w * h];
let (d, e) = deviation_entropy(w, h, &flat);
assert!(d.iter().all(|v| *v == 0.0) && e.iter().all(|v| *v == 0.0));
let check = luma_of(w, h, 1, &checker_img(w, h, 1, 1));
let (d, e) = deviation_entropy(w, h, &check);
assert!(d[10 * w + 10] > 0.08 && e[10 * w + 10] > 0.6, "{} {}", d[10 * w + 10], e[10 * w + 10]);
}
#[test]
fn fuse_stack_identical_images_are_a_fixed_point() {
let (w, h, ch) = (70, 50, 3);
let im = checker_img(w, h, 7, ch);
let lm = luma_of(w, h, ch, &im);
let ones = vec![1.0f32; w * h];
let three = StackFusion { levels: Some(3), ..Default::default() };
for p in [three, StackFusion { halo: 0.0, ..three }, StackFusion { guide: 1, ..three }, StackFusion { energy_radius: 0, ..three }] {
let out = fuse_stack(w, h, ch, &[&im, &im, &im], &[lm.clone(), lm.clone(), lm.clone()], &[ones.clone(), ones.clone(), ones.clone()], p);
assert!(rms(&out, &im) < 1e-5, "{p:?}: {}", rms(&out, &im));
}
// One image, and none, are fine too.
assert!(rms(&fuse_stack(w, h, ch, &[&im], std::slice::from_ref(&lm), std::slice::from_ref(&ones), three), &im) < 1e-5);
assert_eq!(fuse_stack(w, h, ch, &[], &[], &[], three), vec![0.0; w * h * ch]);
// More levels than the image has are clamped.
let many = StackFusion { levels: Some(40), ..Default::default() };
assert!(rms(&fuse_stack(w, h, ch, &[&im], &[lm], &[ones], many), &im) < 1e-5);
}
#[test]
fn fuse_stack_picks_the_sharp_half_from_each_image() {
let (w, h, ch) = (96, 64, 4);
let sharp = checker_img(w, h, 6, ch);
let soft = blur_img(w, h, ch, &sharp, 6);
// Image A is sharp on the left and blurred on the right, image B the opposite.
let (mut a, mut b) = (sharp.clone(), sharp.clone());
for i in 0..w * h {
for c in 0..ch {
if i % w >= w / 2 {
a[i * ch + c] = soft[i * ch + c];
} else {
b[i * ch + c] = soft[i * ch + c];
}
}
}
let (la, lb) = (luma_of(w, h, ch, &a), luma_of(w, h, ch, &b));
let ones = vec![1.0f32; w * h];
for p in [
StackFusion { halo: 0.0, ..Default::default() },
StackFusion { halo: 0.0, energy_radius: 0, ..Default::default() },
StackFusion { guide: 0, ..Default::default() },
] {
let out = fuse_stack(w, h, ch, &[&a, &b], &[la.clone(), lb.clone()], &[ones.clone(), ones.clone()], p);
let err = rms(&out, &sharp);
assert!(err < 0.3 * rms(&a, &sharp), "{p:?}: fused rms {err}");
assert!(out.iter().skip(3).step_by(ch).all(|a| (a - 1.0).abs() < 1e-4), "alpha stays opaque");
// Away from the seam each side is the sharp image. Without halo control the residual
// (one REDUCE here) averages where the blurred side's entropy beats the checker's, so
// the finer scales are exact and the whole is within a few percent; with halo control
// the guide's energy weighs the residual too.
let (e1, e2) = ((out[(20 * w + 10) * ch] - sharp[(20 * w + 10) * ch]).abs(), (out[(20 * w + 85) * ch + 1] - sharp[(20 * w + 85) * ch + 1]).abs());
let tol = if p.halo > 0.0 { 1e-3 } else { 0.1 };
assert!(e1 < tol && e2 < tol, "{p:?}: {e1} {e2}");
}
// The old full-resolution weights and spline blend do worse on the same pair.
let wts = stack_weights(w, h, &[la, lb], &[ones.clone(), ones]);
let old = blend(w, h, ch, &[&a, &b], &wts, auto_levels(w, h));
let new =
fuse_stack(w, h, ch, &[&a, &b], &[luma_of(w, h, ch, &a), luma_of(w, h, ch, &b)], &[vec![1.0; w * h], vec![1.0; w * h]], StackFusion::default());
assert!(rms(&new, &sharp) <= rms(&old, &sharp), "new {} old {}", rms(&new, &sharp), rms(&old, &sharp));
}
#[test]
fn fuse_stack_ignores_uncovered_pixels() {
let (w, h, ch) = (96, 64, 4);
let sharp = checker_img(w, h, 6, ch);
// Image A: flat gray everywhere. Image B: the sharp checker on the left half, garbage
// (opaque white in the colour channels, alpha 0) on the right.
let a: Vec<f32> = (0..w * h * ch).map(|i| if i % ch == 3 { 1.0 } else { 0.5 }).collect();
let b: Vec<f32> = (0..w * h * ch)
.map(|i| {
if (i / ch) % w < w / 2 {
sharp[i]
} else if i % ch == 3 {
0.0
} else {
1.0
}
})
.collect();
let alpha_b: Vec<f32> = (0..w * h).map(|i| if i % w < w / 2 { 1.0 } else { 0.0 }).collect();
let (la, lb) = (luma_of(w, h, ch, &a), luma_of(w, h, ch, &b));
for p in [StackFusion { halo: 0.0, ..Default::default() }, StackFusion { guide: 0, ..Default::default() }] {
let out = fuse_stack(w, h, ch, &[&a, &b], &[la.clone(), lb.clone()], &[vec![1.0; w * h], alpha_b.clone()], p);
// Left: the checker; right: A's gray, not B's white.
assert!((out[(30 * w + 12) * ch] - sharp[(30 * w + 12) * ch]).abs() < 0.05, "{p:?}");
for x in [60, 70, 90] {
let px = &out[(30 * w + x) * ch..(30 * w + x + 1) * ch];
assert!(px[..3].iter().all(|v| (v - 0.5).abs() < 0.03), "{p:?}: x {x} {px:?}");
}
}
}
/// The halo mechanism in one piece: image A is sharp fine texture; image B is the texture
/// defocused with a broad bright bump on it (coarse structure only, as a defocused copy of a
/// bright object has). Every level picking its own winner takes the bump, since B alone has
/// energy at the coarse levels; with halo control the guide finds A sharp everywhere, so the
/// coarse levels follow A and the bump stays out.
#[test]
fn halo_control_keeps_the_coarse_levels_with_the_guide() {
let (w, h, ch) = (192, 144, 3);
let a = checker_img(w, h, 6, ch); // a 12 px period: the guide (level 2, 4 px) sees it
let mut b = blur_img(w, h, ch, &a, 64); // σ ≈ 8 px: the texture is gone
for y in 0..h {
for x in 0..w {
let d2 = (x as f32 - 96.0).powi(2) + (y as f32 - 72.0).powi(2);
let bump = 0.35 * (-d2 / (2.0 * 20.0f32.powi(2))).exp();
for c in 0..ch {
let v = &mut b[(y * w + x) * ch + c];
*v = (*v + bump).min(1.0);
}
}
}
let ones = vec![1.0f32; w * h];
let fuse = |halo: f32, imgs: [&[f32]; 2]| {
let l = [luma_of(w, h, ch, imgs[0]), luma_of(w, h, ch, imgs[1])];
fuse_stack(w, h, ch, &imgs, &l, &[ones.clone(), ones.clone()], StackFusion { halo, guide: 2, levels: Some(5), ..Default::default() })
};
let off = rms(&fuse(0.0, [&a, &b]), &a);
assert!(off > 0.05, "without halo control the bump gets in: rms {off}");
for halo in [1.0, 2.0, 4.0, 8.0, 100.0] {
let on = rms(&fuse(halo, [&a, &b]), &a);
assert!(on < 0.15 * off, "halo {halo}: rms {on} vs {off} without");
}
// The order of the images makes no difference.
assert!(rms(&fuse(2.0, [&b, &a]), &a) < 0.15 * off);
assert!((halo_weight(HALO_REF - HALO_FLOOR, 3.0) - 1.0).abs() < 1e-5);
assert!(halo_weight(1e-3, 2.0) > halo_weight(1e-4, 2.0));
assert!(halo_weight(0.0, 8.0) > 0.0 && halo_weight(1e3, 8.0).is_finite());
}
#[test]
fn residual_rule_takes_the_image_that_wins_deviation_and_entropy() {
// With a single level the whole image is the residual: the checker (2 px cells, so every
// 5×5 window, the clamped corner ones included, straddles an edge) wins both measures over
// the flat image everywhere, so the fused result is the checker.
let (w, h, ch) = (40, 40, 3);
let a = checker_img(w, h, 2, ch);
let b = vec![0.5f32; w * h * ch];
let ones = vec![1.0f32; w * h];
let one = StackFusion { levels: Some(1), halo: 0.0, ..Default::default() };
let out = fuse_stack(w, h, ch, &[&b, &a], &[luma_of(w, h, ch, &b), luma_of(w, h, ch, &a)], &[ones.clone(), ones.clone()], one);
assert!(rms(&out, &a) < 1e-6);
// Identical measures average (the Pareto rule keeps both).
let out = fuse_stack(w, h, ch, &[&b, &a], &[luma_of(w, h, ch, &a), luma_of(w, h, ch, &a)], &[ones.clone(), ones], one);
let mean: Vec<f32> = a.iter().zip(&b).map(|(x, y)| (x + y) / 2.0).collect();
assert!(rms(&out, &mean) < 1e-6);
}
}
+386 -78
View File
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use crate::image::{premultiply, unpremultiply};
/// Resampling method (Photoshop's Image Size options).
/// Resampling method (Photoshop's Image Size options, and Lanczos).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum Resample {
@@ -14,51 +14,121 @@ pub enum Resample {
Bilinear,
#[default]
Bicubic,
/// Photoshop's Bicubic Smoother, for enlargement.
BicubicSmoother,
/// Photoshop's Bicubic Sharper, for reduction.
BicubicSharper,
/// Photoshop's Bicubic Automatic: Smoother where the image grows, Sharper where it shrinks.
BicubicAutomatic,
Lanczos,
/// Lanczos followed by a light sharpening when enlarging.
/// Lanczos followed by a light sharpening when enlarging; Bicubic Sharper when reducing, as
/// Photoshop's Preserve Details reduces.
PreserveDetails,
}
impl Resample {
fn support(self) -> f64 {
match self {
Resample::Nearest => 0.5,
Resample::Bilinear => 1.0,
Resample::Bicubic => 2.0,
Resample::Lanczos | Resample::PreserveDetails => 3.0,
}
/// Sub-pixel positions Photoshop's filters resolve: a sample's offset is rounded to 1/128 pixel.
const PHASES: f64 = 128.0;
/// The filter of one axis.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Kernel {
Nearest,
/// Linear interpolation, never widened. Below half size, each output pixel is the plain
/// average of the source pixels it covers.
Bilinear,
/// The Keys cubic with parameter `a`, stretched over `stretch` source pixels.
Keys {
a: f64,
stretch: f64,
},
/// Lanczos-3, widened by 1/scale when reducing.
Lanczos,
}
/// Photoshop's bicubic filters, measured on Photoshop 25.4 (the Keys cubic, phase table and
/// stretch fitted to within 1/4000 of every weight). `s` is the scale the filter is designed for.
///
/// - Bicubic: a = −0.75.
/// - Bicubic Smoother: stretched by 1.15; a = −0.625, and −0.625 − 0.5·(1 − s) when reducing.
/// - Bicubic Sharper: stretched by 1.05; a = −1, and −1 − 1.6·(1 − s) when reducing.
///
/// Reducing widens the filter by 1/s, down to ¼; below that [`taps`] first averages the image
/// down to four times the target.
fn keys(method: Resample, s: f64) -> Kernel {
let s = s.clamp(0.25, 1.0);
let (a, stretch) = match method {
Resample::BicubicSmoother => (-0.625 - 0.5 * (1.0 - s), 1.15),
Resample::BicubicSharper => (-1.0 - 1.6 * (1.0 - s), 1.05),
_ => (-0.75, 1.0),
};
Kernel::Keys { a, stretch: stretch / s }
}
fn keys_weight(x: f64, a: f64) -> f64 {
let x = x.abs();
if x < 1.0 {
(a + 2.0) * x * x * x - (a + 3.0) * x * x + 1.0
} else if x < 2.0 {
a * (x * x * x - 5.0 * x * x + 8.0 * x - 4.0)
} else {
0.0
}
fn weight(self, x: f64) -> f64 {
let x = x.abs();
match self {
Resample::Nearest => {
if x <= 0.5 {
1.0
} else {
0.0
}
}
fn lanczos_weight(x: f64) -> f64 {
let x = x.abs();
if x < 1e-9 {
1.0
} else if x < 3.0 {
let px = std::f64::consts::PI * x;
3.0 * px.sin() * (px / 3.0).sin() / (px * px)
} else {
0.0
}
}
/// One resampling pass: scale factors and the filter of each axis.
#[derive(Clone, Copy, Debug)]
struct Pass {
sx: f64,
sy: f64,
kx: Kernel,
ky: Kernel,
}
/// Photoshop's passes for `filter` at `(sx, sy)`.
///
/// A bicubic filter is designed for the height's scale, or the width's when the height stays,
/// and serves both axes; the axis with the smaller scale goes first, and the image is clipped and
/// stored between the passes. Bicubic Automatic with one axis growing and the other shrinking is
/// two Image Sizes: the shrinking axis with Sharper, then the growing one with Smoother, each
/// designed for its own scale. Nearest, Bilinear and Lanczos run both axes in one pass.
fn plan(filter: Resample, sx: f64, sy: f64) -> Vec<Pass> {
let both = |k: Kernel| vec![Pass { sx, sy, kx: k, ky: k }];
match filter {
Resample::Nearest => both(Kernel::Nearest),
Resample::Bilinear => both(Kernel::Bilinear),
Resample::Lanczos => both(Kernel::Lanczos),
Resample::PreserveDetails if sx > 1.0 || sy > 1.0 => both(Kernel::Lanczos),
Resample::PreserveDetails => plan(Resample::BicubicSharper, sx, sy),
Resample::BicubicAutomatic if sx <= 1.0 && sy <= 1.0 => plan(Resample::BicubicSharper, sx, sy),
Resample::BicubicAutomatic if sx >= 1.0 && sy >= 1.0 => plan(Resample::BicubicSmoother, sx, sy),
Resample::BicubicAutomatic => {
let (sharper, smoother) = (Resample::BicubicSharper, Resample::BicubicSmoother);
if sx < 1.0 {
vec![Pass { sx, sy: 1.0, kx: keys(sharper, sx), ky: Kernel::Nearest }, Pass { sx: 1.0, sy, kx: Kernel::Nearest, ky: keys(smoother, sy) }]
} else {
vec![Pass { sx: 1.0, sy, kx: Kernel::Nearest, ky: keys(sharper, sy) }, Pass { sx, sy: 1.0, kx: keys(smoother, sx), ky: Kernel::Nearest }]
}
Resample::Bilinear => (1.0 - x).max(0.0),
Resample::Bicubic => {
// Keys cubic, a = -0.5 (Catmull-Rom).
let a = -0.5;
if x < 1.0 {
(a + 2.0) * x * x * x - (a + 3.0) * x * x + 1.0
} else if x < 2.0 {
a * x * x * x - 5.0 * a * x * x + 8.0 * a * x - 4.0 * a
} else {
0.0
}
}
Resample::Lanczos | Resample::PreserveDetails => {
if x < 1e-9 {
1.0
} else if x < 3.0 {
let px = std::f64::consts::PI * x;
3.0 * px.sin() * (px / 3.0).sin() / (px * px)
} else {
0.0
}
}
Resample::Bicubic | Resample::BicubicSmoother | Resample::BicubicSharper => {
let k = keys(filter, if sy != 1.0 { sy } else { sx });
if sx == 1.0 || sy == 1.0 {
both(k)
} else if sy < sx {
vec![Pass { sx: 1.0, sy, kx: k, ky: k }, Pass { sx, sy: 1.0, kx: k, ky: k }]
} else {
vec![Pass { sx, sy: 1.0, kx: k, ky: k }, Pass { sx: 1.0, sy, kx: k, ky: k }]
}
}
}
@@ -72,36 +142,91 @@ struct Taps {
outside: f32,
}
/// Taps for mapping output coordinate `o` (in `[dst0, dst1)`) to the source
/// via `u = (o + 0.5) / scale - 0.5`. With `edge` (`[first, last]` source index), taps outside it
/// read the nearest sample inside it: the image repeats its edge instead of fading out.
/// The source position of output sample `o`, `(o + 0.5) / scale − 0.5`, kept on the edge
/// samples (`[first, last]`) when there is an edge, and rounded to Photoshop's 1/128 pixel.
fn position(o: i32, scale: f64, edge: Option<(i32, i32)>) -> f64 {
let mut u = (o as f64 + 0.5) / scale - 0.5;
if let Some((a, b)) = edge {
u = u.clamp(a as f64, b.max(a) as f64);
}
let j = u.floor();
j + ((u - j) * PHASES).round() / PHASES
}
/// The source pixels `[floor(o / scale), floor((o + 1) / scale))` output pixel `o` covers.
fn cover(o: i32, scale: f64) -> (i32, i32) {
let a = (o as f64 / scale + 1e-9).floor() as i32;
(a, ((o + 1) as f64 / scale + 1e-9).floor().max(a as f64 + 1.0) as i32)
}
/// Unclamped weights of output sample `o` over source samples `lo..lo + len`.
fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32, Vec<f64>) {
match kernel {
Kernel::Nearest => {
let u = (o as f64 + 0.5) / scale - 0.5;
((u + 0.5).floor() as i32, vec![1.0])
}
Kernel::Bilinear if scale < 0.5 => {
let (a, b) = cover(o, scale);
(a, vec![1.0 / (b - a) as f64; (b - a) as usize])
}
Kernel::Bilinear => {
let u = position(o, scale, edge);
let j = u.floor();
(j as i32, vec![1.0 - (u - j), u - j])
}
Kernel::Keys { a, stretch } if scale < 0.25 => {
// Averaged down to four times the target first (pixels of `inner`), then filtered.
let inner = 4.0 * scale;
let inner_edge = edge.map(|(a, b)| ((a as f64 * inner - 1e-9).ceil() as i32, ((b + 1) as f64 * inner - 1e-9).ceil() as i32 - 1));
let (k0, kw) = weights(o, 0.25, Kernel::Keys { a, stretch }, inner_edge);
let clamp = |k: i32| inner_edge.map_or(k, |(a, b)| k.clamp(a, b.max(a)));
let boxes: Vec<(i32, i32)> = (k0..).zip(&kw).map(|(k, _)| cover(clamp(k), inner)).collect();
let lo = boxes.iter().map(|b| b.0).min().unwrap_or(0);
let hi = boxes.iter().map(|b| b.1).max().unwrap_or(lo);
let mut w = vec![0.0; (hi - lo).max(0) as usize];
for ((a, b), v) in boxes.into_iter().zip(kw) {
for slot in w.get_mut((a - lo) as usize..(b - lo) as usize).into_iter().flatten() {
*slot += v / (b - a) as f64;
}
}
(lo, w)
}
Kernel::Keys { a, stretch } => {
let u = position(o, scale, edge);
let lo = (u - 2.0 * stretch).floor() as i32;
let hi = (u + 2.0 * stretch).ceil() as i32;
let w: Vec<f64> = (lo..=hi).map(|i| keys_weight((u - i as f64) / stretch, a)).collect();
let sum: f64 = w.iter().sum();
(lo, w.into_iter().map(|v| v / sum).collect())
}
Kernel::Lanczos => {
let stretch = if scale < 1.0 { 1.0 / scale } else { 1.0 };
let u = (o as f64 + 0.5) / scale - 0.5;
let u = edge.map_or(u, |(a, b)| u.clamp(a as f64, b.max(a) as f64));
let lo = (u - 3.0 * stretch).ceil() as i32;
let hi = (u + 3.0 * stretch).floor() as i32;
let w: Vec<f64> = (lo..=hi).map(|i| lanczos_weight((i as f64 - u) / stretch)).collect();
let sum: f64 = w.iter().sum();
let k = if sum.abs() > 1e-12 { 1.0 / sum } else { 1.0 };
(lo, w.into_iter().map(|v| v * k).collect())
}
}
}
/// Taps for mapping output coordinate `o` (in `[dst0, dst1)`) to the source at `scale`
/// (a scale of 1 copies). With `edge` (`[first, last]` source index), taps outside it read the
/// nearest sample inside it: the image repeats its edge instead of fading out.
/// Taps outside `content` (`[first, last]`) all read the default pixel, so they are folded into
/// `outside` instead of being read: the read window never grows past the content, however small
/// the scale (#713).
fn taps(dst0: i32, dst1: i32, scale: f64, filter: Resample, edge: Option<(i32, i32)>, content: (i32, i32)) -> Vec<Taps> {
let stretch = if scale < 1.0 { 1.0 / scale } else { 1.0 };
let support = filter.support() * stretch;
fn taps(dst0: i32, dst1: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>, content: (i32, i32)) -> Vec<Taps> {
let clamp = |i: i32| edge.map_or(i, |(a, b)| i.clamp(a, b));
let kernel = if scale == 1.0 { Kernel::Nearest } else { kernel };
(dst0..dst1)
.map(|o| {
let u = (o as f64 + 0.5) / scale - 0.5;
if filter == Resample::Nearest {
let i = clamp((u + 0.5).floor() as i32);
return if (content.0..=content.1).contains(&i) {
Taps { start: i, weights: vec![1.0], outside: 0.0 }
} else {
Taps { start: content.0, weights: Vec::new(), outside: 1.0 }
};
}
let lo = (u - support).ceil() as i32;
let hi = (u + support).floor() as i32;
let mut w: Vec<f64> = (lo..=hi).map(|i| filter.weight((i as f64 - u) / stretch)).collect();
let s: f64 = w.iter().sum();
if s.abs() > 1e-12 {
for v in &mut w {
*v /= s;
}
}
let (lo, w) = weights(o, scale, kernel, edge);
let hi = lo + w.len() as i32 - 1;
// Fold the weights of taps beyond the edge onto the edge sample.
let start = clamp(lo);
let mut folded = vec![0.0f32; (clamp(hi) - start + 1).max(1) as usize];
@@ -141,8 +266,7 @@ pub fn resize_surface(s: &Surface, sx: f64, sy: f64, filter: Resample) -> Surfac
/// keeps it (an opaque layer stays opaque to the border) instead of fading into the default pixel.
/// Pixels beyond the canvas count as the surface's own, and inside the canvas nothing changes.
pub fn resize_surface_in_canvas(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Rect) -> Surface {
let area = s.content_bounds().union(&canvas);
resize(s, sx, sy, filter, (!area.is_empty()).then_some(area))
resize(s, sx, sy, filter, Some(canvas))
}
/// Samples one source read may hold (64 MB of `f32`); larger windows are read in row chunks.
@@ -183,11 +307,37 @@ fn horizontal_dynamic(row: &[f32], out: &mut [f32], taps: &[Taps], x_lo: i32, de
}
}
fn resize(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Option<Rect>) -> Surface {
resize_with_budget(s, sx, sy, filter, edge, READ_BUDGET)
fn resize(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option<Rect>) -> Surface {
resize_with_budget(s, sx, sy, filter, canvas, READ_BUDGET)
}
fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Option<Rect>, budget: usize) -> Surface {
/// Runs `filter`'s passes. Between two passes the image is stored in its own format, which
/// clips it (and rounds it at 8 and 16 bits) as Photoshop does; the smaller scale goes first, so
/// the intermediate image is never larger than the result.
fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Option<Rect>, budget: usize) -> Surface {
let mut cur: Option<Surface> = None;
let mut canvas = canvas;
for p in plan(filter, sx, sy) {
let next = resize_pass(cur.as_ref().unwrap_or(s), p, canvas, budget);
canvas = canvas.map(|c| scaled_rect(c, p.sx, p.sy));
cur = Some(next);
}
let mut out = cur.unwrap_or_else(|| s.clone());
if filter == Resample::PreserveDetails && (sx > 1.0 || sy > 1.0) {
let dst = out.content_bounds();
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, dst, dst, None);
out = crate::apply(&out, &p, area, dst, None);
}
out.prune();
out
}
/// One pass. With `canvas`, the content and the canvas repeat their edge outwards (see
/// [`resize_surface_in_canvas`]).
fn resize_pass(s: &Surface, pass: Pass, canvas: Option<Rect>, budget: usize) -> Surface {
let Pass { sx, sy, kx, ky } = pass;
let edge = canvas.map(|c| s.content_bounds().union(&c)).filter(|a| !a.is_empty());
let fmt = s.format();
let mut out = Surface::with_default(fmt, &s.default_pixel());
let src = s.content_bounds();
@@ -199,8 +349,8 @@ fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Opt
let alpha = fmt.alpha;
// With `edge`, every tap is clamped into the edge area, which holds the content.
let area = edge.unwrap_or(src);
let hx = taps(dst.x0, dst.x1, sx, filter, edge.map(|r| (r.x0, r.x1 - 1)), (area.x0, area.x1 - 1));
let vy = taps(dst.y0, dst.y1, sy, filter, edge.map(|r| (r.y0, r.y1 - 1)), (area.y0, area.y1 - 1));
let hx = taps(dst.x0, dst.x1, sx, kx, edge.map(|r| (r.x0, r.x1 - 1)), (area.x0, area.x1 - 1));
let vy = taps(dst.y0, dst.y1, sy, ky, edge.map(|r| (r.y0, r.y1 - 1)), (area.y0, area.y1 - 1));
let x_lo = hx.iter().filter(|t| !t.weights.is_empty()).map(|t| t.start).min().unwrap_or(area.x0);
let x_hi = hx.iter().map(|t| t.start + t.weights.len() as i32).max().unwrap_or(area.x0).max(x_lo);
let mut def = s.default_pixel();
@@ -312,12 +462,6 @@ fn resize_with_budget(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Opt
out.write_region(r, &d);
}
}
if filter == Resample::PreserveDetails && (sx > 1.0 || sy > 1.0) {
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, out.content_bounds(), dst, None);
out = crate::apply(&out, &p, area, dst, None);
}
out.prune();
out
}
@@ -353,7 +497,7 @@ mod tests {
fn fixed_channel_rows_match_dynamic_resampling() {
fn check<const N: usize>() {
for scale in [0.12, 0.5, 1.0, 2.3] {
for filter in [Resample::Nearest, Resample::Bilinear, Resample::Bicubic, Resample::Lanczos] {
for filter in [Kernel::Nearest, Kernel::Bilinear, keys(Resample::Bicubic, scale), keys(Resample::BicubicSharper, scale), Kernel::Lanczos] {
for clamp in [None, Some((-3, 31))] {
let ts = taps(-5, 41, scale, filter, clamp, (-3, 31));
let row: Vec<f32> = (0..35 * N).map(|i| ((i * 73) % 251) as f32 / 250.0).collect();
@@ -374,6 +518,170 @@ mod tests {
check::<5>();
}
/// Photoshop 25.4's Image Size of a 16-bit greyscale pattern, in levels of 32768. Covers the
/// filters and their 2-D rules: Sharper reducing the width while the height grows is designed
/// for the height (not widened) and runs the width first; Bicubic Automatic splits a mixed
/// resize into Sharper and Smoother; Bicubic widens for the height's 0.6 while the width
/// grows; Smoother below ¼ averages down first; Bilinear below ½ averages whole pixels. On black
/// and white the overshoot is clipped between the passes, so their order shows.
#[test]
fn matches_photoshop_image_size() {
// (black and white pattern, filter, size, new size, Photoshop's levels)
type Probe = (bool, Resample, (i32, i32), (i32, i32), &'static [u16]);
let cases: [Probe; 9] = [
(
false,
Resample::BicubicSharper,
(12, 10),
(7, 16),
&[
3777, 13165, 22219, 16176, 10460, 19488, 29643, 11249, 12634, 21696, 13330, 10319, 18965, 29120, 21280, 11204, 20279, 16184, 8200, 17548,
27703, 21824, 10182, 19259, 26967, 5238, 16528, 26683, 17366, 8895, 17922, 32768, 2402, 15190, 25345, 17493, 7834, 16903, 31316, 1748,
14172, 24326, 24718, 6368, 15746, 30390, 0, 13107, 23169, 31215, 4790, 14456, 27639, 5080, 11597, 21880, 29649, 3864, 13507, 24136, 20600,
10264, 20931, 28009, 0, 12446, 21385, 28735, 8754, 19642, 26154, 4049, 10895, 20832, 23523, 7620, 18485, 23204, 20126, 8787, 19818, 21447,
6173, 17467, 20390, 31862, 6616, 18480, 24094, 6500, 16129, 19749, 27472, 5810, 17460, 28281, 7655, 15108, 18394, 25515, 4427, 16043,
28749, 7026, 13691, 17871, 24992, 3904, 15520, 27200, 6075, 13168,
],
),
(
false,
Resample::BicubicAutomatic,
(12, 10),
(7, 16),
&[
4201, 12337, 27677, 15894, 4816, 20021, 29631, 10348, 9882, 27304, 14492, 3766, 19574, 29046, 18743, 5944, 25299, 16116, 4147, 17829,
27787, 19399, 4206, 21790, 23748, 7966, 15117, 26569, 15941, 2695, 19445, 28341, 10104, 13050, 25465, 16251, 2613, 18023, 26770, 8432,
11951, 24281, 22800, 7141, 15351, 24790, 5953, 11004, 23130, 28322, 10921, 12440, 24940, 9659, 9368, 22012, 27611, 10006, 10488, 26419,
18657, 7071, 20807, 25492, 6937, 9239, 26574, 22363, 5435, 19688, 24808, 9136, 8184, 25295, 19708, 3939, 18677, 25053, 18234, 7120, 24061,
17941, 2545, 17568, 24869, 23748, 6081, 21401, 21862, 6018, 15056, 23602, 22177, 4859, 17816, 28450, 11911, 11653, 22253, 20263, 3595,
15790, 29734, 12969, 9683, 21668, 19678, 3010, 15424, 28429, 11728, 9300,
],
),
(
false,
Resample::Bicubic,
(12, 10),
(20, 6),
&[
11517, 10000, 8617, 9364, 12272, 16362, 21396, 25508, 25506, 19474, 10887, 5289, 5842, 10286, 14986, 18566, 21910, 25511, 28611, 29922,
24788, 17949, 8497, 4593, 7309, 12594, 17150, 20945, 24892, 26196, 22979, 16123, 9691, 7483, 10061, 14653, 18841, 22420, 25514, 26824,
25702, 23396, 17586, 11378, 8127, 8882, 12310, 17039, 22345, 25871, 24700, 18125, 9951, 5493, 6802, 11215, 15576, 19259, 22384, 23694,
26692, 26985, 22732, 14270, 6889, 4950, 8348, 13583, 17529, 21570, 25966, 27275, 22817, 14643, 8068, 6911, 10694, 15930, 19443, 20705,
21499, 23413, 25601, 24336, 18665, 11686, 7800, 8947, 12974, 18067, 22707, 25285, 23077, 16645, 9789, 6586, 8147, 12329, 15935, 17465,
17962, 20462, 24615, 25377, 20036, 11458, 5421, 5415, 9528, 14134, 17782, 22482, 26926, 27479, 21881, 13308, 7533, 7734, 11549, 13992,
],
),
(
false,
Resample::BicubicSmoother,
(40, 3),
(9, 3),
&[
11315, 18740, 17475, 15974, 16503, 16042, 18215, 14712, 16238, 18482, 14351, 14664, 18012, 20260, 13338, 15511, 18468, 18275, 16790, 19168,
14745, 14998, 20341, 18150, 13819, 16493, 16432,
],
),
(
false,
Resample::Bilinear,
(12, 10),
(5, 4),
&[11316, 14541, 17920, 10855, 24679, 19763, 9933, 23757, 6247, 20070, 25601, 5325, 19149, 17306, 15463, 20992, 16384, 14541, 23143, 10854],
),
(
false,
Resample::Nearest,
(12, 10),
(7, 16),
&[
1638, 12698, 23757, 3482, 9011, 20070, 31130, 1638, 12698, 23757, 3482, 9011, 20070, 31130, 31130, 10854, 21914, 1638, 7168, 18227, 29286,
29286, 9011, 20070, 31130, 5325, 16384, 27443, 29286, 9011, 20070, 31130, 5325, 16384, 27443, 27443, 7168, 18227, 29286, 3482, 14541,
25600, 25600, 5325, 16384, 27443, 1638, 12698, 23757, 25600, 5325, 16384, 27443, 1638, 12698, 23757, 23757, 3482, 14541, 25600, 31130,
10854, 21914, 23757, 3482, 14541, 25600, 31130, 10854, 21914, 21914, 1638, 12698, 23757, 29286, 9011, 20070, 20070, 31130, 10854, 21914,
27443, 7168, 18227, 20070, 31130, 10854, 21914, 27443, 7168, 18227, 18227, 29286, 9011, 20070, 25600, 5325, 16384, 16384, 27443, 7168,
18227, 23757, 3482, 14541, 16384, 27443, 7168, 18227, 23757, 3482, 14541,
],
),
(
true,
Resample::Bicubic,
(12, 10),
(20, 6),
&[
9960, 6313, 993, 0, 4525, 14807, 26472, 32768, 32768, 23510, 10514, 0, 0, 0, 10110, 22576, 31764, 32768, 32768, 32768, 26136, 18594, 6164,
0, 0, 2565, 14499, 26983, 32768, 32768, 26358, 14709, 3969, 0, 0, 6602, 17734, 27952, 32768, 32768, 31590, 27808, 18307, 7429, 0, 0, 3712,
14435, 26473, 32768, 31131, 20665, 7535, 0, 0, 2177, 13238, 25049, 32768, 32768, 32768, 32768, 26136, 14174, 2548, 0, 0, 6190, 18705,
30273, 32768, 32768, 25517, 13238, 2177, 0, 0, 7250, 19418, 26624, 32768, 32768, 32768, 28203, 18522, 7200, 0, 0, 3285, 14794, 26862,
32768, 29913, 19013, 6667, 0, 0, 4443, 14506, 20751, 17344, 23066, 31075, 32361, 23949, 10738, 0, 0, 0, 10110, 22657, 32768, 32768, 32768,
22658, 10192, 1004, 0, 0, 0,
],
),
(
true,
Resample::BicubicSharper,
(12, 10),
(7, 16),
&[
801, 3050, 32489, 16260, 0, 29819, 32767, 9161, 1781, 29578, 14350, 0, 32768, 32767, 21132, 0, 25409, 17862, 0, 26925, 32767, 22769, 0,
24839, 27566, 0, 9397, 32768, 19813, 0, 27795, 32768, 0, 0, 32768, 20744, 0, 25797, 32768, 0, 0, 32768, 27555, 0, 11184, 32768, 0, 0,
32768, 32768, 0, 0, 32768, 5110, 0, 30386, 32768, 0, 0, 32768, 20385, 0, 23267, 32768, 0, 0, 32768, 29130, 0, 19191, 32768, 2670, 0, 31290,
25387, 0, 21263, 32767, 20198, 0, 21586, 24510, 0, 23714, 32768, 32768, 0, 14165, 27804, 1007, 14254, 32768, 30149, 0, 16077, 32112, 2886,
1884, 21043, 29607, 0, 16384, 32768, 3566, 0, 12683, 29616, 0, 16384, 32768, 3196, 0,
],
),
(
true,
Resample::BicubicSharper,
(12, 10),
(20, 6),
&[
9273, 5043, 0, 0, 2698, 14484, 28464, 32768, 32768, 24841, 10877, 0, 0, 0, 9684, 22346, 31492, 32768, 32768, 32768, 25960, 18430, 4978, 0,
0, 977, 15047, 28475, 32768, 32768, 27939, 14157, 1813, 0, 0, 5296, 17828, 28439, 32768, 32768, 32768, 27849, 17576, 6209, 0, 0, 2169,
15076, 28935, 32768, 32768, 22167, 7399, 0, 0, 211, 12977, 24638, 32768, 32768, 32768, 32768, 25960, 14244, 824, 0, 0, 5093, 18802, 31993,
32768, 32768, 26467, 12977, 211, 0, 0, 6300, 19418, 26712, 32768, 32768, 32768, 27907, 17874, 5820, 0, 0, 1853, 16055, 29518, 32768, 32768,
20889, 5880, 0, 0, 2879, 13798, 20185, 19010, 25565, 32768, 32302, 25431, 11478, 0, 0, 0, 9684, 23083, 32768, 32768, 32768, 23084, 10422,
1276, 0, 0, 0,
],
),
];
for (binary, filter, (w, h), (wo, ho), want) in cases {
let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U16, false));
let canvas = Rect::new(0, 0, w, h);
let level = |x: i32, y: i32| match ((x * 37 + y * 101) % 17) as f32 {
k if binary => {
if k > 8.0 {
32767.0 / 32768.0
} else {
0.0
}
}
k => ((k / 16.0 * 0.9 + 0.05) * 32768.0).round() / 32768.0,
};
let px: Vec<f32> = (0..h).flat_map(|y| (0..w).map(move |x| level(x, y))).collect();
s.write_region(canvas, &px);
let o = resize_surface_in_canvas(&s, wo as f64 / w as f64, ho as f64 / h as f64, filter, canvas);
let got = o.read_region(Rect::new(0, 0, wo, ho));
assert_eq!(got.len(), want.len(), "{filter:?}");
let worst = got.iter().zip(want).map(|(g, &p)| (g * 32768.0 - p as f32).abs()).fold(0.0f32, f32::max);
assert!(worst <= 8.0, "{filter:?} {w}x{h} to {wo}x{ho}: {worst} levels of 32768 from Photoshop");
}
}
/// Photoshop's Bicubic doubles with the Keys cubic at a = −0.75 (Catmull-Rom is −0.5): an
/// impulse spreads as −9, −27, 67, 225 /256 and back, as Photoshop 25.4 gives it.
#[test]
fn bicubic_is_photoshops_keys_cubic() {
let mut s = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false));
let mut row = [0.25; 30];
row[10] = 0.75;
s.write_region(Rect::new(0, 0, 30, 1), &row);
let o = resize_surface(&s, 2.0, 1.0, Resample::Bicubic);
let got: Vec<f32> = o.read_region(Rect::new(17, 0, 25, 1)).iter().map(|v| (v - 0.25) * 512.0).collect();
for (g, want) in got.iter().zip([-9.0, -27.0, 67.0, 225.0, 225.0, 67.0, -27.0, -9.0]) {
assert!((g - want).abs() < 1e-3, "{got:?}");
}
}
#[test]
fn identity_scale_is_lossless() {
for f in [Resample::Nearest, Resample::Bilinear, Resample::Bicubic, Resample::Lanczos] {
+122
View File
@@ -16,6 +16,13 @@ use photocraft_raster::{Cancelled, Interrupt};
/// Energy added per unit of protection: far above any gradient magnitude.
const PROTECT_ENERGY: f32 = 1.0e4;
/// Keep full horizontal detail while reducing the height used to find vertical seams. At a
/// 24 MP working size this cuts the dynamic-programming work by about four times without
/// quantising the output pixels or changing the requested output dimensions.
const SEAM_SEARCH_MAX_HEIGHT: usize = 512;
const PROXY_SHRINK_MIN_HEIGHT: usize = 2_048;
const PROXY_SHRINK_MIN_SEAMS: usize = 64;
/// Gradient-magnitude energy (the paper's e1, `|∂x| + |∂y|` summed over channels). Forward and
/// backward differences are both counted so one-pixel lines (whose central difference is zero)
/// carry energy too.
@@ -248,6 +255,9 @@ pub fn carve_width_with(w: usize, h: usize, ch: usize, img: &[f32], protect: Opt
}
let seams = new_w.abs_diff(w).max(1) as f32;
if new_w < w {
if h >= PROXY_SHRINK_MIN_HEIGHT && w - new_w >= PROXY_SHRINK_MIN_SEAMS {
return carve_width_proxy_height(w, h, ch, img, protect, new_w, ctl);
}
let mut carver = Carver::new(w, h, ch, img.to_vec(), protect.map(<[f32]>::to_vec), false);
while carver.cw > new_w {
ctl.check()?;
@@ -310,6 +320,89 @@ pub fn carve_width_with(w: usize, h: usize, ch: usize, img: &[f32], protect: Opt
Ok(cur)
}
/// Find large-image seams on vertically averaged rows, then apply those paths to the original
/// pixels. The proxy has the same width, so each seam still removes exactly one source pixel per
/// row. Keeping output pixels at full resolution avoids the quality loss of resizing the result;
/// only the seam decisions use the coarser vertical signal. Small operations retain the exact
/// full-resolution algorithm above.
fn carve_width_proxy_height(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, ctl: &Interrupt) -> Result<Vec<f32>, Cancelled> {
let step = h.div_ceil(SEAM_SEARCH_MAX_HEIGHT);
let proxy_h = h.div_ceil(step);
let row_len = w * ch;
let proxy_row_len = proxy_h * row_len;
let mut proxy = vec![0.0; proxy_row_len];
for (py, out_row) in proxy.chunks_exact_mut(row_len).enumerate() {
let y0 = py * step;
let y1 = (y0 + step).min(h);
let divisor = (y1 - y0) as f32;
for y in y0..y1 {
let in_row = &img[y * row_len..(y + 1) * row_len];
for (out, &sample) in out_row.iter_mut().zip(in_row) {
*out += sample / divisor;
}
}
}
let proxy_protect = protect.map(|mask| {
let mut reduced = vec![0.0; proxy_h * w];
for (py, out_row) in reduced.chunks_exact_mut(w).enumerate() {
let y0 = py * step;
let y1 = (y0 + step).min(h);
let divisor = (y1 - y0) as f32;
for y in y0..y1 {
let in_row = &mask[y * w..(y + 1) * w];
for (out, &sample) in out_row.iter_mut().zip(in_row) {
*out += sample / divisor;
}
}
}
reduced
});
let seam_count = w - new_w;
let mut carver = Carver::new(w, proxy_h, ch, proxy, proxy_protect, true);
let mut removed = Vec::with_capacity(seam_count);
while carver.cw > new_w {
ctl.check()?;
removed.push(carver.remove_seam());
ctl.progress(removed.len() as f32 / seam_count as f32);
}
let mut removed_by_row = Vec::with_capacity(proxy_h);
for proxy_y in 0..proxy_h {
let mut columns: Vec<usize> = removed.iter().map(|path| path[proxy_y]).collect();
columns.sort_unstable();
removed_by_row.push(columns);
}
let mut out = Vec::with_capacity(new_w * h * ch);
let mut columns = vec![0; seam_count];
for y in 0..h {
ctl.check()?;
let proxy_y = (y / step).min(proxy_h - 1);
let next_proxy_y = (proxy_y + 1).min(proxy_h - 1);
let blend = (y % step) as f32 / step as f32;
let lower = &removed_by_row[proxy_y];
let upper = &removed_by_row[next_proxy_y];
let mut min_x = 0;
for (i, x) in columns.iter_mut().enumerate() {
let interpolated = lower[i] as f32 + (upper[i] as f32 - lower[i] as f32) * blend;
let max_x = w - (seam_count - i);
*x = (interpolated.round() as usize).clamp(min_x, max_x);
min_x = x.saturating_add(1);
}
let mut next = 0;
let row = &img[y * row_len..(y + 1) * row_len];
for (x, pixel) in row.chunks_exact(ch).enumerate() {
if columns.get(next) == Some(&x) {
next += 1;
} else {
out.extend_from_slice(pixel);
}
}
}
ctl.progress(1.0);
Ok(out)
}
/// Content-aware resize of `img` to `new_w × new_h` (width first, then height by transposing).
pub fn carve(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, new_h: usize) -> Vec<f32> {
// Never cancelled; the fallback is unreachable.
@@ -639,6 +732,35 @@ mod tests {
assert!(red_columns(24, h, &plain) < 4);
}
#[test]
fn tall_proxy_shrink_preserves_full_resolution_salient_and_protected_pixels() {
let (w, h, ch, new_w) = (512, PROXY_SHRINK_MIN_HEIGHT, 3, 384);
let mut img = bar(w, h);
let mut protect = vec![0.0; w * h];
for y in 0..h {
let i = (y * w + 256) * ch;
img[i..i + ch].copy_from_slice(&[0.03, 0.87, 0.42]);
protect[y * w + 256] = 1.0;
}
let out = carve_width_with(w, h, ch, &img, Some(&protect), new_w, &Interrupt::NONE).unwrap();
assert_eq!(out.len(), new_w * h * ch);
assert_eq!(red_columns(new_w, h, &out), 4, "the full-resolution bar survives");
for y in 0..h {
let row = &out[y * new_w * ch..(y + 1) * new_w * ch];
assert!(row.chunks_exact(ch).any(|px| px == [0.03, 0.87, 0.42]), "protected mark survives row {y}");
}
}
#[test]
fn tall_proxy_shrink_observes_cancellation_before_modifying_source() {
let (w, h, ch) = (128, PROXY_SHRINK_MIN_HEIGHT, 3);
let img = vec![0.5; w * h * ch];
let cancelled = || true;
assert_eq!(carve_width_with(w, h, ch, &img, None, 64, &Interrupt::cancel_only(&cancelled)), Err(Cancelled));
}
#[test]
fn enlarging_duplicates_flat_areas() {
let (w, h) = (30, 8);
+26 -4
View File
@@ -23,6 +23,12 @@ const INF_DIST: u32 = u32::MAX;
/// Residual capacities at or below this count as saturated (guards against float round-off
/// leaving tiny positive residuals that would cause long chains of negligible augmentations).
const EPS: f32 = 1e-5;
/// How many steps the max-flow loops take between asks whether to stop.
const CANCEL_EVERY: u32 = 1024;
fn never() -> bool {
false
}
/// A directed graph with source/sink terminal capacities.
#[derive(Clone, Debug, Default)]
@@ -110,24 +116,35 @@ impl Graph {
/// Computes the maximum flow (= minimum cut capacity).
pub fn maxflow(&mut self) -> f64 {
self.maxflow_with(&never).unwrap_or(self.flow)
}
/// [`Graph::maxflow`] that stops when `cancel` returns true (asked every few thousand steps):
/// `None` then, and the cut is meaningless. Not cancelled, it does exactly what
/// [`Graph::maxflow`] does.
pub fn maxflow_with(&mut self, cancel: &dyn Fn() -> bool) -> Option<f64> {
if self.node_count() >= 4096 {
let mut source = false;
let mut sink = false;
for &capacity in &self.tr_cap {
if !capacity.is_finite() {
return self.maxflow_bk();
return self.bk(cancel);
}
source |= capacity > EPS;
sink |= capacity < -EPS;
}
if source && sink && self.r_cap.iter().all(|v| v.is_finite() && *v >= 0.0) {
return self.maxflow_push_relabel();
return self.push_relabel(cancel);
}
}
self.maxflow_bk()
self.bk(cancel)
}
pub(super) fn maxflow_bk(&mut self) -> f64 {
self.bk(&never).unwrap_or(self.flow)
}
fn bk(&mut self, cancel: &dyn Fn() -> bool) -> Option<f64> {
let n = self.node_count();
let mut queue: VecDeque<u32> = VecDeque::new();
let mut orphans: VecDeque<u32> = VecDeque::new();
@@ -145,7 +162,12 @@ impl Graph {
}
}
let mut time = 0u32;
let mut steps = 0u32;
while let Some(i) = queue.pop_front() {
steps = steps.wrapping_add(1);
if steps.is_multiple_of(CANCEL_EVERY) && cancel() {
return None;
}
let i = i as usize;
self.active[i] = false;
if self.parent[i] == NONE {
@@ -197,7 +219,7 @@ impl Graph {
self.adopt(o as usize, time, &mut queue, &mut orphans);
}
}
self.flow
Some(self.flow)
}
fn augment(&mut self, m: usize, orphans: &mut VecDeque<u32>) {
@@ -5,14 +5,20 @@
//! The existing SoA residual graph is reused. Terminal source arcs are saturated
//! into a preflow; `back` keeps their reverse capacity, `excess` uses f64 so local
//! f32 capacity updates do not accumulate excess-rounding drift.
use super::{EPS, Graph, NONE, TERMINAL};
use super::{CANCEL_EVERY, EPS, Graph, NONE, TERMINAL};
use std::collections::VecDeque;
impl Graph {
#[cfg(test)]
pub(super) fn maxflow_push_relabel(&mut self) -> f64 {
self.push_relabel(&super::never).unwrap_or(self.flow)
}
/// Push/relabel; `None` when `cancel` stopped it.
pub(super) fn push_relabel(&mut self, cancel: &dyn Fn() -> bool) -> Option<f64> {
let n = self.node_count();
let Some(source_height) = u32::try_from(n).ok().filter(|&n| n < u32::MAX / 2).map(|n| n + 2) else {
return self.maxflow_bk();
return self.bk(cancel);
};
let mut back = vec![0.0f32; n];
let mut excess = vec![0.0f64; n];
@@ -34,7 +40,12 @@ impl Graph {
}
let mut work = 0usize;
let frequency = self.r_cap.len().saturating_mul(2).saturating_add(n);
let mut steps = 0u32;
while let Some(i) = queue.pop_front() {
steps = steps.wrapping_add(1);
if steps.is_multiple_of(CANCEL_EVERY) && cancel() {
return None;
}
self.active[i] = false;
while excess[i] > f64::from(EPS) {
if self.dist[i] == 1 && self.tr_cap[i] < -EPS {
@@ -127,7 +138,7 @@ impl Graph {
a = self.next[ai];
}
}
self.flow
Some(self.flow)
}
fn global_relabel(&mut self, source_height: u32, back: &[f32]) {
+29 -5
View File
@@ -242,14 +242,38 @@ pub fn grid_cut_with(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[
grid_cut_weighted::<true>(img, cost_fg, cost_bg, fixed, weight)
}
/// [`grid_cut_with`] that stops when `cancel` returns true (asked every few thousand max-flow
/// steps): `None` then. Not cancelled, the same cut.
pub fn grid_cut_cancellable(
img: &RgbImage,
cost_fg: &[f32],
cost_bg: &[f32],
fixed: &[u8],
weight: impl Fn(f32) -> f32,
cancel: &dyn Fn() -> bool,
) -> Option<Vec<bool>> {
grid_cut_impl::<true>(img, cost_fg, cost_bg, fixed, weight, cancel)
}
mod edge_starts;
// The const parameter also runs the previous full scan in before/after workflow tests.
fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[u8], weight: impl Fn(f32) -> f32) -> Vec<bool> {
grid_cut_impl::<OPTIMIZED>(img, cost_fg, cost_bg, fixed, weight, &|| false).unwrap_or_default()
}
fn grid_cut_impl<const OPTIMIZED: bool>(
img: &RgbImage,
cost_fg: &[f32],
cost_bg: &[f32],
fixed: &[u8],
weight: impl Fn(f32) -> f32,
cancel: &dyn Fn() -> bool,
) -> Option<Vec<bool>> {
let (w, h) = (img.w as i32, img.h as i32);
let Some(n) = img.w.checked_mul(img.h).filter(|&n| n < u32::MAX as usize - 2) else { return Vec::new() };
let Some(n) = img.w.checked_mul(img.h).filter(|&n| n < u32::MAX as usize - 2) else { return Some(Vec::new()) };
if img.w > i32::MAX as usize || img.h > i32::MAX as usize || [img.px.len(), cost_fg.len(), cost_bg.len(), fixed.len()].iter().any(|&len| len < n) {
return Vec::new();
return Some(Vec::new());
}
let mut free = Vec::new();
let mut idx = vec![u32::MAX; n];
@@ -262,7 +286,7 @@ fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cos
}
let mut out: Vec<bool> = fixed.iter().map(|f| *f == HARD_FG).collect();
if count == 0 {
return out;
return Some(out);
}
let mut g = Graph::with_capacity(count as usize, count as usize * 4);
for i in 0..n {
@@ -307,7 +331,7 @@ fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cos
}
}
if OPTIMIZED {
g.maxflow();
g.maxflow_with(cancel)?;
} else {
g.maxflow_bk();
}
@@ -316,7 +340,7 @@ fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cos
out[i] = g.in_source(idx[i] as usize);
}
}
out
Some(out)
}
fn add_fixed_link(g: &mut Graph, node: usize, fixed: u8, wt: f32) {
+29
View File
@@ -35,6 +35,35 @@ fn maxflow_clrs_network() {
assert_eq!(g.residual_out_of_source_side(), 0.0);
}
#[test]
fn maxflow_can_be_cancelled_and_otherwise_cuts_the_same() {
// A 120 × 100 grid with terminal links both ways (push/relabel) and a small one (BK).
for (w, h) in [(120usize, 100usize), (20, 15)] {
let mut g = Graph::with_capacity(w * h, w * h * 2);
let mut z = 7u64;
let mut next = || {
z = z.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(z >> 40) as f32 / (1u64 << 24) as f32
};
for i in 0..w * h {
g.add_tweights(i, next() * 2.0, next() * 2.0);
if i % w + 1 < w {
g.add_edge(i, i + 1, next(), next());
}
if i + w < w * h {
g.add_edge(i, i + w, next(), next());
}
}
let (mut plain, mut asked) = (g.clone(), g.clone());
let flow = plain.maxflow();
assert_eq!(asked.maxflow_with(&|| false), Some(flow));
assert!((0..w * h).all(|i| plain.in_source(i) == asked.in_source(i)));
if w * h > 4096 {
assert_eq!(g.maxflow_with(&|| true), None);
}
}
}
/// Reference max flow (Edmonds–Karp on a dense matrix; node 0 = s, n−1 = t).
fn edmonds_karp(cap: &mut [Vec<f64>]) -> f64 {
let n = cap.len();
+60 -48
View File
@@ -3,12 +3,14 @@
//! modify (expand, contract, border, smooth, feather), anti-aliased lasso,
//! and combining with an existing selection.
use std::sync::Arc;
use photocraft_color::PixelFormat;
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
use photocraft_raster::Surface;
use serde::{Deserialize, Serialize};
use crate::photo_util::par_rows;
use crate::photo_util::{par_map, par_rows};
/// How a new selection combines with the current one.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -79,49 +81,57 @@ fn coverage8(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
}
/// Builds a GRAY8 selection surface from 8-bit coverage over `area`, allocating only the tiles
/// that hold something (as [`Surface::prune`] would leave them).
fn surface_from_coverage8(bytes: &[u8], area: Rect) -> Surface {
/// Builds a GRAY8 selection surface over `area` (masks row-major over it) whose coverage at
/// each sample is `f(prev, new)`, `prev` reading 0 without a `prev` mask and missing samples
/// reading 0. Tiles are filled in parallel straight from the masks, a row at a time (no
/// full-canvas byte buffer); only the tiles that hold something are kept (as [`Surface::prune`]
/// would leave them), and fully selected tiles share one tile (copy-on-write), so the inside of a
/// large selection costs no memory per tile (#211).
fn surface_from_masks(area: Rect, new: &[f32], prev: Option<&[f32]>, f: impl Fn(f32, f32) -> f32 + Sync + Send) -> Surface {
let mut s = Surface::new(PixelFormat::GRAY8);
if area.is_empty() {
return s;
}
let w = area.width() as usize;
let ts = TILE_SIZE as usize;
for tc in area.tiles() {
let tr = tc.rect().intersect(&area);
let (lx, span) = ((tr.x0 - area.x0) as usize, tr.width() as usize);
let row = |y: i32| bytes.get((y - area.y0) as usize * w + lx..(y - area.y0) as usize * w + lx + span);
if !(tr.y0..tr.y1).any(|y| row(y).is_some_and(|r| r.iter().any(|b| *b != 0))) {
continue;
}
let data = s.tile_mut(tc).bytes_mut();
for y in tr.y0..tr.y1 {
let o = (y - tc.ty * TILE_SIZE) as usize * ts + (tr.x0 - tc.tx * TILE_SIZE) as usize;
if let (Some(src), Some(dst)) = (row(y), data.get_mut(o..o + span)) {
dst.copy_from_slice(src);
let full = s.solid_tile(&[1.0]);
let row = |i0: usize, dst: &mut [u8]| {
let n = dst.len();
match (new.get(i0..i0 + n), prev.map(|p| p.get(i0..i0 + n))) {
(Some(sn), None) => dst.iter_mut().zip(sn).for_each(|(d, v)| *d = coverage8(f(0.0, *v))),
(Some(sn), Some(Some(sp))) => dst.iter_mut().zip(sn.iter().zip(sp)).for_each(|(d, (v, p))| *d = coverage8(f(*p, *v))),
_ => {
let at = |m: &[f32], i: usize| m.get(i).copied().unwrap_or(0.0);
for (i, d) in (i0..).zip(dst.iter_mut()) {
*d = coverage8(f(prev.map_or(0.0, |p| at(p, i)), at(new, i)));
}
}
}
}
};
// Every tile starts as one shared blank tile; `Arc::make_mut` gives each task its own copy.
let blanks = s.take_tiles(area);
let built = par_map(blanks.len(), |k| {
let (tc, blank) = blanks.get(k)?;
let tr = tc.rect().intersect(&area);
let (lx, span) = ((tr.x0 - area.x0) as usize, tr.width() as usize);
let mut tile = Arc::clone(blank);
let data = Arc::make_mut(&mut tile).bytes_mut();
let (mut lo, mut hi) = (if tr == tc.rect() { u8::MAX } else { 0 }, 0u8);
for y in tr.y0..tr.y1 {
let o = (y - tc.ty * TILE_SIZE) as usize * ts + (tr.x0 - tc.tx * TILE_SIZE) as usize;
let Some(dst) = data.get_mut(o..o + span) else { continue };
row((y - area.y0) as usize * w + lx, dst);
(lo, hi) = dst.iter().fold((lo, hi), |(lo, hi), b| (lo.min(*b), hi.max(*b)));
}
(hi > 0).then(|| (*tc, if lo == u8::MAX { Arc::clone(&full) } else { tile }))
});
s.put_tiles(built.into_iter().flatten());
s
}
/// 8-bit coverage of `m` (missing samples are 0), computed in parallel.
fn to_coverage8(m: &[f32], area: Rect, f: impl Fn(usize, f32) -> f32 + Sync + Send) -> Vec<u8> {
let w = area.width() as usize;
let mut bytes = vec![0u8; w * area.height() as usize];
par_rows(&mut bytes, w, 1, |y, row| {
for (x, b) in row.iter_mut().enumerate() {
let i = y * w + x;
*b = coverage8(f(i, m.get(i).copied().unwrap_or(0.0)));
}
});
bytes
}
/// Builds a GRAY8 selection surface from a mask over `area`.
/// Builds a GRAY8 selection surface from a mask over `area` (missing samples are 0).
pub fn mask_to_surface(m: &[f32], area: Rect) -> Surface {
if area.is_empty() {
return Surface::new(PixelFormat::GRAY8);
}
surface_from_coverage8(&to_coverage8(m, area, |_, v| v), area)
surface_from_masks(area, m, None, |_, v| v)
}
/// Combines `new` with `old` by `mode`. Returns `None` when nothing is selected.
@@ -132,22 +142,18 @@ pub fn combine(old: Option<&Surface>, new: &[f32], area: Rect, mode: SelectionMo
// Quantize to the 8-bit grid selections are stored on, so combining a mask with itself is
// exact. Below half an 8-bit step is nothing.
let q = |v: f32| f32::from(coverage8(v)) / 255.0;
let bytes = match mode {
SelectionMode::Replace => to_coverage8(new, area, |_, v| v),
let s = match mode {
SelectionMode::Replace => surface_from_masks(area, new, None, |_, v| v),
_ => {
let prev = mask_from_surface(old, area);
let at = |i: usize| prev.get(i).copied().unwrap_or(0.0);
match mode {
SelectionMode::Add => to_coverage8(new, area, |i, v| at(i).max(q(v))),
SelectionMode::Subtract => to_coverage8(new, area, |i, v| (at(i) - q(v)).max(0.0)),
_ => to_coverage8(new, area, |i, v| at(i).min(q(v))),
SelectionMode::Add => surface_from_masks(area, new, Some(&prev), |p, v| p.max(q(v))),
SelectionMode::Subtract => surface_from_masks(area, new, Some(&prev), |p, v| (p - q(v)).max(0.0)),
_ => surface_from_masks(area, new, Some(&prev), |p, v| p.min(q(v))),
}
}
};
if bytes.iter().all(|b| *b == 0) {
return None;
}
Some(surface_from_coverage8(&bytes, area))
(s.tile_count() > 0).then_some(s)
}
fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
@@ -710,8 +716,10 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec<f32> {
}
mod distance;
mod marquee;
pub use distance::edt;
pub use marquee::{combine_surfaces, ellipse_surface};
/// Grows the selection by `r` pixels.
pub fn expand(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
@@ -1202,7 +1210,10 @@ mod tests {
let mut rng = crate::photo_util::Rng(7);
let mut u = move || (rng.next() >> 11) as f32 / (1u64 << 53) as f32;
// Off-grid areas spanning several tiles, including negative origins.
for area in [Rect::new(0, 0, 300, 20), Rect::new(-37, 250, 530, 263), Rect::new(255, -3, 258, 600), Rect::new(5, 5, 6, 6)] {
// The last one holds whole tiles, which fully selected masks fill (shared tiles).
for area in
[Rect::new(0, 0, 300, 20), Rect::new(-37, 250, 530, 263), Rect::new(255, -3, 258, 600), Rect::new(5, 5, 6, 6), Rect::new(-37, -300, 530, 263)]
{
let n = area.width() as usize * area.height() as usize;
let old_m: Vec<f32> = (0..n).map(|i| if i % 97 < 40 { u() } else { 0.0 }).collect();
let old = old_mask_to_surface(&old_m, area);
@@ -1211,7 +1222,7 @@ mod tests {
let mut slow_read = Vec::new();
old.read_region_into(area, &mut slow_read);
assert_eq!(mask_from_surface(Some(&old), area), slow_read);
for kind in 0..3 {
for kind in 0..4 {
let new: Vec<f32> = (0..n)
.map(|i| match kind {
0 => u() * 1.2 - 0.1,
@@ -1222,7 +1233,8 @@ mod tests {
0.0
}
}
_ => 0.001,
2 => 0.001,
_ => f32::from(u8::from(i >= 1000)),
})
.collect();
for mode in [SelectionMode::Replace, SelectionMode::Add, SelectionMode::Subtract, SelectionMode::Intersect] {
+314
View File
@@ -0,0 +1,314 @@
//! Marquee shapes built straight into tiles, and the tile-wise combination of two selections
//! (#2888, part of #211).
//!
//! Both produce exactly the bytes the per-pixel versions did (an ellipse written with
//! `Surface::write_pixel`, a combination read and written through whole-canvas `f32` buffers),
//! but work a tile at a time in parallel, keep only the tiles that hold something (as
//! `Surface::prune` would leave them) and share one tile for every fully selected one.
use std::sync::Arc;
use photocraft_color::{PixelFormat, SampleType, read_sample, write_sample};
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
use photocraft_raster::{Surface, Tile};
use crate::photo_util::par_map;
const TS: usize = TILE_SIZE as usize;
/// The byte a GRAY8 surface stores for `v`.
fn encode8(v: f32) -> u8 {
let mut b = [0u8];
write_sample(&mut b, SampleType::U8, 0, v);
let [b] = b;
b
}
/// The value a GRAY8 surface reads for `b`.
fn decode8(b: u8) -> f32 {
read_sample(&[b], SampleType::U8, 0)
}
/// An ellipse inscribed in a rectangle, tested in `f32` exactly as the Elliptical Marquee
/// always has (so its anti-aliased edge keeps the same bytes).
struct Ellipse {
cx: f32,
cy: f32,
rx: f32,
ry: f32,
}
impl Ellipse {
fn new(r: Rect) -> Self {
Self {
cx: (i64::from(r.x0) + i64::from(r.x1)) as f32 / 2.0,
cy: (i64::from(r.y0) + i64::from(r.y1)) as f32 / 2.0,
rx: r.width() as f32 / 2.0,
ry: r.height() as f32 / 2.0,
}
}
#[inline]
fn inside(&self, x: f32, y: f32) -> bool {
let (dx, dy) = ((x - self.cx) / self.rx, (y - self.cy) / self.ry);
dx * dx + dy * dy <= 1.0
}
/// Whether every point of pixel `(x, y)` that the coverage samples is inside: its four
/// corners (anti-aliased) or its centre. Along a row the pixels where this holds form one
/// run: each test is monotonic in the distance from the centre (IEEE rounding is monotonic).
#[inline]
fn full(&self, x: i32, y: i32, aa: bool) -> bool {
let (fx, fy) = (x as f32, y as f32);
if !aa {
return self.inside(fx + 0.5, fy + 0.5);
}
self.inside(fx, fy) && self.inside(fx + 1.0, fy) && self.inside(fx, fy + 1.0) && self.inside(fx + 1.0, fy + 1.0)
}
/// Coverage of pixel `(x, y)` in sixteenths: 16 when [`Ellipse::full`], else (anti-aliased)
/// the number of its 4×4 sub-samples inside.
#[inline]
fn coverage(&self, x: i32, y: i32, aa: bool) -> u8 {
if self.full(x, y, aa) {
return 16;
}
if !aa {
return 0;
}
let (fx, fy) = (x as f32, y as f32);
(0..16u8).filter(|i| self.inside(fx + ((i % 4) as f32 + 0.5) / 4.0, fy + ((i / 4) as f32 + 0.5) / 4.0)).count() as u8
}
}
/// One row of the ellipse: nothing outside `lo..hi`, fully selected in `full_lo..full_hi`, and
/// the edge pixels in between (`lo..full_lo`, `full_hi..hi`) need the exact coverage test.
#[derive(Clone, Copy)]
struct RowSpan {
lo: i32,
full_lo: i32,
full_hi: i32,
hi: i32,
}
/// Bounds row `y` of `e` within `cut.x0..cut.x1` from the exact (f64) ellipse, widened by a
/// margin that covers the f32 tests' rounding. The bounds are then checked with the exact tests
/// (the fully selected run at both ends, nothing just outside); any doubt evaluates the whole row.
fn row_span(e: &Ellipse, y: i32, cut: Rect, aa: bool) -> RowSpan {
let exact = RowSpan { lo: cut.x0, full_lo: cut.x1, full_hi: cut.x1, hi: cut.x1 };
let (cx, cy, rx, ry) = (f64::from(e.cx), f64::from(e.cy), f64::from(e.rx), f64::from(e.ry));
// Every point the tests sample in this row lies in `y..y + 1`.
let (ya, yb) = (f64::from(y), f64::from(y) + 1.0);
let near = if cy < ya { ya - cy } else { (cy - yb).max(0.0) };
let far = (ya - cy).abs().max((yb - cy).abs());
let half = |d: f64| {
let t = 1.0 - (d / ry) * (d / ry);
if t >= 0.0 { rx * t.sqrt() } else { -1.0 }
};
let (outer, inner) = (half(near).max(0.0), half(far));
// Near the top and bottom a rounding error δ in the test moves the edge by up to rx·√δ.
let m = 2.0 + rx * 1e-3;
if !(outer.is_finite() && inner.is_finite() && m.is_finite()) {
return exact;
}
let px = |v: f64| v.clamp(f64::from(cut.x0), f64::from(cut.x1)) as i32;
let lo = px((cx - outer - m).floor());
let hi = px((cx + outer + m).floor() + 1.0);
let (mut full_lo, mut full_hi) = (px((cx - inner + m).ceil()), px((cx + inner - m).floor()));
if inner < 0.0 || full_lo >= full_hi {
(full_lo, full_hi) = (hi, hi);
}
let empty = |x: i32| e.coverage(x, y, aa) == 0;
let ok = (lo <= cut.x0 || empty(lo - 1)) && (hi >= cut.x1 || empty(hi)) && (full_lo >= full_hi || (e.full(full_lo, y, aa) && e.full(full_hi - 1, y, aa)));
if ok { RowSpan { lo, full_lo, full_hi, hi } } else { exact }
}
/// The Elliptical Marquee's shape: the ellipse inscribed in `r`, cut to `clip`, as a GRAY8
/// selection. Anti-aliased edge pixels hold the fraction of their 4×4 sub-samples inside;
/// without anti-aliasing a pixel is selected when its centre is inside.
pub fn ellipse_surface(r: Rect, clip: Rect, anti_alias: bool) -> Surface {
let mut s = Surface::new(PixelFormat::GRAY8);
let cut = r.intersect(&clip);
if cut.is_empty() {
return s;
}
let e = Ellipse::new(r);
let enc: [u8; 17] = std::array::from_fn(|n| encode8(n as f32 / 16.0));
let one = encode8(1.0);
let rows = par_map(cut.height() as usize, |i| row_span(&e, cut.y0 + i as i32, cut, anti_alias));
let span = |y: i32| rows.get(usize::try_from(y - cut.y0).ok()?);
let full = s.solid_tile(&[1.0]);
let blank = s.solid_tile(&[0.0]);
let coords: Vec<TileCoord> = cut.tiles().collect();
let built = par_map(coords.len(), |k| {
let tc = *coords.get(k)?;
let rect = tc.rect();
let tr = rect.intersect(&cut);
if tr == rect && (tr.y0..tr.y1).all(|y| span(y).is_some_and(|s| s.full_lo <= tr.x0 && s.full_hi >= tr.x1)) {
return Some((tc, Arc::clone(&full)));
}
let mut tile: Option<Tile> = None;
for y in tr.y0..tr.y1 {
let Some(sp) = span(y) else { continue };
let row = (y - rect.y0) as usize * TS;
let at = |x: i32| row + (x - rect.x0) as usize;
let (a, b) = (sp.full_lo.max(tr.x0), sp.full_hi.min(tr.x1));
if a < b
&& let Some(d) = tile.get_or_insert_with(|| Tile::clone(&blank)).bytes_mut().get_mut(at(a)..at(b))
{
d.fill(one);
}
for x in (sp.lo.max(tr.x0)..sp.full_lo.min(tr.x1)).chain(sp.full_hi.max(tr.x0)..sp.hi.min(tr.x1)) {
let n = e.coverage(x, y, anti_alias);
if n > 0
&& let Some(d) = tile.get_or_insert_with(|| Tile::clone(&blank)).bytes_mut().get_mut(at(x))
{
*d = enc.get(usize::from(n)).copied().unwrap_or(one);
}
}
}
tile.map(|t| (tc, Arc::new(t)))
});
s.put_tiles(built.into_iter().flatten());
s
}
/// `f(a, b)` per pixel over `area` for two selections, as a GRAY8 selection holding nothing
/// outside `area`: the same bytes as reading both over `area`, mapping and writing the result.
/// GRAY8 inputs (every selection) go tile by tile in parallel through a table of all 256×256
/// byte pairs; a tile `f` leaves unchanged stays shared with its input.
pub fn combine_surfaces(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32) -> f32) -> Surface {
let mut out = Surface::new(PixelFormat::GRAY8);
if area.is_empty() {
return out;
}
if a.format() != PixelFormat::GRAY8 || b.format() != PixelFormat::GRAY8 {
let (ra, rb) = (a.read_region(area), b.read_region(area));
let data: Vec<f32> = ra.iter().zip(&rb).map(|(x, y)| f(*x, *y)).collect();
if data.len() == area.width() as usize * area.height() as usize {
out.write_region(area, &data);
out.prune();
}
return out;
}
let lut: Vec<u8> = (0..=u16::MAX).map(|i| encode8(f(decode8((i >> 8) as u8), decode8(i as u8)))).collect();
let pair = |x: u8, y: u8| lut.get(usize::from(x) << 8 | usize::from(y)).copied().unwrap_or(0);
let (da, db) = (a.default_bytes().first().copied().unwrap_or(0), b.default_bytes().first().copied().unwrap_or(0));
// Whether `f` passes one side through where the other side has no tile (e.g. Add, Subtract).
let keeps_a = (0..=255u8).all(|v| pair(v, db) == v);
let keeps_b = (0..=255u8).all(|v| pair(da, v) == v);
let (full, blank) = (out.solid_tile(&[1.0]), out.solid_tile(&[0.0]));
let (row_a, row_b) = ([da; TS], [db; TS]);
let coords: Vec<TileCoord> = area.tiles().collect();
let built = par_map(coords.len(), |k| {
let tc = *coords.get(k)?;
let rect = tc.rect();
let tr = rect.intersect(&area);
let (ta, tb) = (a.tile(tc), b.tile(tc));
if tr == rect {
let pass = match (ta, tb) {
(Some(t), None) if keeps_a => Some(t),
(None, Some(t)) if keeps_b => Some(t),
(None, None) if pair(da, db) == 0 => return None,
_ => None,
};
if let Some(t) = pass {
return t.bytes().iter().any(|v| *v != 0).then(|| (tc, Arc::clone(t)));
}
}
let mut tile = Tile::clone(&blank);
let data = tile.bytes_mut();
let (mut lo, mut hi) = (if tr == rect { u8::MAX } else { 0 }, 0u8);
let (x0, x1) = ((tr.x0 - rect.x0) as usize, (tr.x1 - rect.x0) as usize);
for ly in (tr.y0 - rect.y0) as usize..(tr.y1 - rect.y0) as usize {
let (s, e) = (ly * TS + x0, ly * TS + x1);
let src_a = ta.and_then(|t| t.bytes().get(s..e)).unwrap_or(&row_a[..x1 - x0]);
let src_b = tb.and_then(|t| t.bytes().get(s..e)).unwrap_or(&row_b[..x1 - x0]);
let Some(dst) = data.get_mut(s..e) else { continue };
for (d, (x, y)) in dst.iter_mut().zip(src_a.iter().zip(src_b)) {
*d = pair(*x, *y);
(lo, hi) = (lo.min(*d), hi.max(*d));
}
}
(hi > 0).then(|| (tc, if lo == u8::MAX { Arc::clone(&full) } else { Arc::new(tile) }))
});
out.put_tiles(built.into_iter().flatten());
out
}
#[cfg(test)]
mod tests {
use super::*;
/// The Elliptical Marquee as it was: every pixel tested and written one at a time.
fn old_ellipse(r: Rect, area: Rect, aa: bool) -> Surface {
let cut = r.intersect(&area);
let mut shape = Surface::new(PixelFormat::GRAY8);
let (cx, cy) = ((r.x0 + r.x1) as f32 / 2.0, (r.y0 + r.y1) as f32 / 2.0);
let (rx, ry) = (r.width() as f32 / 2.0, r.height() as f32 / 2.0);
let inside = |x: f32, y: f32| {
let (dx, dy) = ((x - cx) / rx, (y - cy) / ry);
dx * dx + dy * dy <= 1.0
};
for y in cut.y0..cut.y1 {
for x in cut.x0..cut.x1 {
let (fx, fy) = (x as f32, y as f32);
let corners = [(fx, fy), (fx + 1.0, fy), (fx, fy + 1.0), (fx + 1.0, fy + 1.0)].iter().filter(|(a, b)| inside(*a, *b)).count();
let cov = if !aa {
if inside(fx + 0.5, fy + 0.5) { 1.0 } else { 0.0 }
} else if corners == 4 {
1.0
} else {
let n = (0..16).filter(|i| inside(fx + ((i % 4) as f32 + 0.5) / 4.0, fy + ((i / 4) as f32 + 0.5) / 4.0)).count();
n as f32 / 16.0
};
if cov > 0.0 {
shape.write_pixel(x, y, &[cov]);
}
}
}
shape
}
/// Combining two selections as it was: whole-area `f32` buffers.
fn old_combine(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32) -> f32) -> Surface {
let (ra, rb) = (a.read_region(area), b.read_region(area));
let data: Vec<f32> = ra.iter().zip(&rb).map(|(x, y)| f(*x, *y)).collect();
let mut out = Surface::new(PixelFormat::GRAY8);
out.write_region(area, &data);
out.prune();
out
}
#[test]
fn marquee_matches_the_per_pixel_version() {
let area = Rect::new(0, 0, 1100, 700);
let shapes = [
Rect::from_xywh(40, 30, 1000, 620), // whole tiles inside
Rect::from_xywh(3, 5, 1, 1),
Rect::from_xywh(10, 10, 2, 7),
Rect::from_xywh(100, 200, 37, 301),
Rect::from_xywh(255, 255, 258, 2),
Rect::from_xywh(-300, -200, 900, 600), // dragged past the canvas
Rect::from_xywh(-500, -500, 2100, 1700), // larger than the canvas
Rect::from_xywh(1090, 690, 40, 40),
Rect::from_xywh(2000, 0, 50, 50), // entirely outside
];
for r in shapes {
for aa in [true, false] {
let new = ellipse_surface(r, area, aa);
assert!(new == old_ellipse(r, area, aa), "ellipse {r:?} aa={aa}");
}
}
// The other modes, against a rectangle and an anti-aliased ellipse with a partial edge.
let mut rect = Surface::new(PixelFormat::GRAY8);
rect.fill_rect(Rect::from_xywh(200, 100, 600, 520), &[1.0]);
let ell = old_ellipse(Rect::from_xywh(-60, 250, 700, 500), area, true);
let ops: [fn(f32, f32) -> f32; 3] = [|a, b| a.max(b), |a, b| a * (1.0 - b), |a, b| a.min(b)];
for (a, b) in [(&rect, &ell), (&ell, &rect), (&ell, &ell)] {
for f in ops {
assert!(combine_surfaces(a, b, area, f) == old_combine(a, b, area, f));
}
}
}
}
+29 -3
View File
@@ -73,7 +73,8 @@ fn stat(v: &mut [f32], s: Stat) -> f32 {
}
}
/// Combines `layers` (each `len` pixels) with statistic `s`.
/// Combines `layers` (each `len` pixels) with statistic `s`. Colour results retain their float
/// range; quantisation/clipping belongs to the destination surface, not the statistic.
pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> {
let len = layers.iter().map(Vec::len).max().unwrap_or(0);
let mut out = vec![[0.0f32; 4]; len];
@@ -97,7 +98,7 @@ pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> {
continue;
}
for c in 0..3 {
o[c] = stat(&mut samples[c], s).clamp(0.0, 1.0);
o[c] = stat(&mut samples[c], s);
}
o[3] = alpha;
}
@@ -121,7 +122,7 @@ mod tests {
assert_eq!(at(Stat::Minimum), 0.1);
assert_eq!(at(Stat::Maximum), 0.9);
assert!((at(Stat::Range) - 0.8).abs() < 1e-6);
assert_eq!(at(Stat::Summation), 1.0); // clamped
assert!((at(Stat::Summation) - 1.6).abs() < 1e-6);
let var = at(Stat::Variance);
assert!((at(Stat::StandardDeviation) - var.sqrt()).abs() < 1e-6);
assert!(at(Stat::Skewness) > 0.5, "right-skewed");
@@ -129,6 +130,31 @@ mod tests {
assert!(at(Stat::Kurtosis) > 0.0);
}
#[test]
fn statistics_preserve_hdr_and_signed_samples() {
let layers = vec![px(-2.0), px(4.0)];
for (mode, expected) in [
(Stat::Mean, 1.0),
(Stat::Median, 1.0),
(Stat::Minimum, -2.0),
(Stat::Maximum, 4.0),
(Stat::Range, 6.0),
(Stat::Summation, 2.0),
(Stat::Variance, 9.0),
(Stat::StandardDeviation, 3.0),
] {
assert_eq!(combine(&layers, mode)[0], [expected, expected, expected, 1.0], "{mode:?}");
}
for mode in [Stat::Mean, Stat::Median] {
assert_eq!(combine(&[px(2.0), px(4.0)], mode)[0], [3.0, 3.0, 3.0, 1.0]);
assert_eq!(combine(&[px(-2.0), px(-4.0)], mode)[0], [-3.0, -3.0, -3.0, 1.0]);
}
// These display-oriented statistics still apply their own normalisation.
for mode in [Stat::Entropy, Stat::Skewness, Stat::Kurtosis] {
assert!((0.0..=1.0).contains(&combine(&layers, mode)[0][0]), "{mode:?}");
}
}
#[test]
fn transparent_samples_are_ignored() {
let layers = vec![vec![[0.5, 0.5, 0.5, 1.0]], vec![[1.0, 1.0, 1.0, 0.0]]];
+14
View File
@@ -2,6 +2,9 @@
use photocraft_geom::Rect;
mod extrude;
mod wind;
use crate::fxutil::{MAXC, luma, native, ncol, subtractive, xy};
use crate::image::Image;
use crate::noise::hash01;
@@ -86,6 +89,10 @@ pub(crate) fn extrude_reach(depth: f32) -> f32 {
/// height, with shaded sides); Pyramids raise four shaded triangular faces.
/// Heights are random or follow the cell's brightness.
pub(crate) fn extrude(src: &Image, out: Rect, ctx: &Ctx, spec: &ExtrudeSpec) -> Vec<f32> {
extrude::blocks(src, out, ctx, spec).unwrap_or_else(|| extrude_direct(src, out, ctx, spec))
}
fn extrude_direct(src: &Image, out: Rect, ctx: &Ctx, spec: &ExtrudeSpec) -> Vec<f32> {
let n = src.ch;
let cs = spec.size.clamp(2.0, 255.0).round() as i32;
let b = ctx.bounds;
@@ -358,6 +365,10 @@ pub(crate) fn wind_reach(method: WindMethod) -> f32 {
/// Wind: streaks trail downwind from edges (Blast: longer and denser);
/// Stagger shifts random row segments along the wind.
pub(crate) fn wind(src: &Image, out: Rect, ctx: &Ctx, method: WindMethod, from_right: bool, seed: u32) -> Vec<f32> {
wind::streaks(src, out, ctx, method, from_right, seed).unwrap_or_else(|| wind_direct(src, out, ctx, method, from_right, seed))
}
fn wind_direct(src: &Image, out: Rect, ctx: &Ctx, method: WindMethod, from_right: bool, seed: u32) -> Vec<f32> {
let n = src.ch;
let lmax = wind_reach(method) as i32;
// Streaks move downwind: from the left they extend to the right (+x).
@@ -406,3 +417,6 @@ pub(crate) fn wind(src: &Image, out: Rect, ctx: &Ctx, method: WindMethod, from_r
}
res
}
#[cfg(test)]
mod tests;
+159
View File
@@ -0,0 +1,159 @@
//! Rasterize each swept block only where it can hit, in the original cell order.
use super::{ExtrudeSpec, extrude_reach, read_px};
use crate::fxutil::{MAXC, luma, ncol, subtractive, unpremul_px};
use crate::image::Image;
use crate::noise::hash01;
use crate::{Ctx, ExtrudeType};
use photocraft_geom::Rect;
const MAX_PIXELS: usize = 4_194_304;
const MAX_CELLS: i64 = 262_144;
// Beyond this, f32 pixel centres can lose enough precision to widen the sweep bounds.
const MAX_COORD: i32 = 1 << 22;
pub(super) fn blocks(src: &Image, out: Rect, ctx: &Ctx, spec: &ExtrudeSpec) -> Option<Vec<f32>> {
if spec.kind != ExtrudeType::Blocks || src.ch == 0 || src.ch > MAXC || !spec.size.is_finite() || !spec.depth.is_finite() {
return None;
}
let b = ctx.bounds;
if [b.x0, b.y0, b.x1, b.y1, out.x0, out.y0, out.x1, out.y1].iter().any(|v| v.unsigned_abs() > MAX_COORD as u32) {
return None;
}
let n = src.ch;
let w = out.width() as usize;
let count = w.checked_mul(out.height() as usize)?;
if count > MAX_PIXELS {
return None;
}
let mut heights = Vec::new();
heights.try_reserve_exact(count).ok()?;
heights.resize(count, f32::NEG_INFINITY);
let mut res = src.crop(out);
let area = out.intersect(&b);
if area.is_empty() {
return Some(res);
}
let cs = spec.size.clamp(2.0, 255.0).round() as i32;
let reach = extrude_reach(spec.depth);
let k = (reach / cs as f32).ceil() as i32 + 1;
let (gx0, gy0) = ((area.x0 - b.x0).div_euclid(cs), (area.y0 - b.y0).div_euclid(cs));
let (gx1, gy1) = ((area.x1 - 1 - b.x0).div_euclid(cs), (area.y1 - 1 - b.y0).div_euclid(cs));
let (cx0, cy0) = ((gx0 - k).max(0), (gy0 - k).max(0));
let (cx1, cy1) = ((gx1 + k).min((b.width() as i32 - 1) / cs), (gy1 + k).min((b.height() as i32 - 1) / cs));
if i64::from(cx1 - cx0 + 1).checked_mul(i64::from(cy1 - cy0 + 1))? > MAX_CELLS {
return None;
}
let (bcx, bcy) = ((b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0);
let half_diag = ((b.width() as f32).hypot(b.height() as f32) / 2.0).max(1.0);
let half = cs as f32 / 2.0;
let cc = ncol(ctx, n);
let sub = subtractive(ctx);
// Ascending gy, then gx, is the tie order of every pixel's old candidate search.
for cy in cy0..=cy1 {
for cx in cx0..=cx1 {
let r = Rect::new(b.x0 + cx * cs, b.y0 + cy * cs, b.x0 + (cx + 1) * cs, b.y0 + (cy + 1) * cs);
let clipped = r.intersect(&b);
if spec.mask_incomplete && clipped != r {
continue;
}
let mut col = [0.0f32; MAXC];
let mut cnt = 0.0;
let step = (cs / 8).max(1) as usize;
for yy in (clipped.y0..clipped.y1).step_by(step) {
for xx in (clipped.x0..clipped.x1).step_by(step) {
let a = if ctx.alpha { src.get(xx, yy, n - 1) } else { 1.0 };
for (c, v) in col.iter_mut().enumerate().take(n) {
let s = src.get(xx, yy, c);
*v += if ctx.alpha && c < n - 1 { s * a } else { s };
}
cnt += 1.0;
}
}
if cnt > 0.0 {
for v in col.iter_mut().take(n) {
*v /= cnt;
}
}
unpremul_px(&mut col[..n], ctx.alpha);
let h = if spec.level_based { luma(ctx, &col[..n]).clamp(0.0, 1.0) } else { hash01(cx, cy, 21, spec.seed) };
if !h.is_finite() {
return None;
}
let (mx, my) = (r.x0 as f32 + half, r.y0 as f32 + half);
let off = reach * h / half_diag;
let (ox, oy) = ((mx - bcx) * off, (my - bcy) * off);
// Two extra pixels conservatively cover rounding in the unchanged interval test.
let sweep = Rect::new(
(mx.min(mx + ox) - half).floor() as i32 - 2,
(my.min(my + oy) - half).floor() as i32 - 2,
(mx.max(mx + ox) + half).ceil() as i32 + 2,
(my.max(my + oy) + half).ceil() as i32 + 2,
)
.intersect(&area);
for y in sweep.y0..sweep.y1 {
let gy = (y - b.y0).div_euclid(cs);
if (cy - gy).abs() > k {
continue;
}
let fy = y as f32 + 0.5;
let Some(iy) = interval(fy, my, oy, half) else { continue };
for x in sweep.x0..sweep.x1 {
if (cx - (x - b.x0).div_euclid(cs)).abs() > k {
continue;
}
let i = (y - out.y0) as usize * w + (x - out.x0) as usize;
let height = heights.get_mut(i)?;
if *height >= h {
continue;
}
let fx = x as f32 + 0.5;
let Some(ix) = interval(fx, mx, ox, half) else { continue };
let (lo, hi) = (ix.0.max(iy.0), ix.1.min(iy.1));
if lo > hi {
continue;
}
*height = h;
let face = if hi >= 1.0 {
0
} else if ix.1 < iy.1 {
if ox > 0.0 { 1 } else { 2 }
} else if oy > 0.0 {
3
} else {
4
};
let px = res.get_mut(i * n..(i + 1) * n)?;
if face == 0 && !spec.solid_front {
let (sx, sy) = ((fx - (mx - bcx) * off).floor() as i32, (fy - (my - bcy) * off).floor() as i32);
read_px(src, sx.clamp(r.x0, r.x1 - 1), sy.clamp(r.y0, r.y1 - 1), px);
} else {
let shade = match face {
0 => 1.0,
1 => 0.85,
2 => 0.55,
3 => 0.95,
_ => 0.45,
};
for (v, c) in px.iter_mut().zip(&col).take(cc) {
*v = if sub { 1.0 - (1.0 - *c) * shade } else { *c * shade }.clamp(0.0, 1.0);
}
if ctx.alpha {
*px.last_mut()? = col.get(n - 1).copied()?;
}
}
}
}
}
}
Some(res)
}
fn interval(p: f32, m: f32, o: f32, half: f32) -> Option<(f32, f32)> {
if o.abs() < 1e-6 {
return ((p - m).abs() <= half).then_some((0.0, 1.0));
}
let (a, b) = ((p - m - half) / o, (p - m + half) / o);
let (lo, hi) = (a.min(b).max(0.0), a.max(b).min(1.0));
(lo <= hi).then_some((lo, hi))
}
+190
View File
@@ -0,0 +1,190 @@
use super::*;
use crate::{FilterParams, apply_tiled};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::Surface;
fn image(rect: Rect, n: usize, kind: usize) -> Image {
let mut img = Image::new(rect, n);
let mut s = 0x2545_f491u32;
for (i, v) in img.data.iter_mut().enumerate() {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
*v = match kind {
0 => (s % 256) as f32 / 255.0,
1 => ((i / n) % 5) as f32 / 4.0,
2 => 0.0,
3 => (s % 65536) as f32 / 65535.0,
_ => [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0, -2.0, 3.5, 0.5][s as usize % 7],
};
}
img
}
fn bits(v: Vec<f32>) -> Vec<u32> {
v.into_iter().map(f32::to_bits).collect()
}
const MODELS: [ColorMode; 8] =
[ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Bitmap, ColorMode::Duotone, ColorMode::Indexed, ColorMode::Multichannel];
#[test]
fn swept_blocks_match_the_pixel_search() {
let bounds = Rect::new(-13, -9, 31, 26);
for mode in MODELS {
for alpha in [false, true] {
let n = PixelFormat::new(mode, SampleType::F32, alpha).channels();
let ctx = Ctx { bounds, mode, alpha };
for kind in 0..5 {
let src = image(bounds.inflate(20), n, kind);
for (size, depth) in [(2.0, 0.0), (2.0, 255.0), (3.5, 190.0), (8.0, 64.0), (22.0, 255.0), (255.0, 100.0)] {
for flags in 0..8 {
let spec = ExtrudeSpec {
kind: ExtrudeType::Blocks,
size,
depth,
level_based: flags & 1 != 0,
solid_front: flags & 2 != 0,
mask_incomplete: flags & 4 != 0,
seed: 17,
};
let out = Rect::new(-17, -2, 24, 29);
assert_eq!(
bits(extrude(&src, out, &ctx, &spec)),
bits(extrude_direct(&src, out, &ctx, &spec)),
"{mode:?} alpha {alpha} kind {kind} size {size} depth {depth} flags {flags}"
);
}
}
}
}
}
}
#[test]
fn streak_scatter_matches_the_pixel_search() {
let bounds = Rect::new(-17, -9, 39, 14);
for mode in MODELS {
for alpha in [false, true] {
let n = PixelFormat::new(mode, SampleType::F32, alpha).channels();
let ctx = Ctx { bounds, mode, alpha };
for kind in 0..5 {
let src = image(bounds, n, kind);
for method in [WindMethod::Wind, WindMethod::Blast, WindMethod::Stagger] {
for right in [false, true] {
for seed in [0, 1, 777, u32::MAX] {
let out = bounds.inflate(3);
assert_eq!(
bits(wind(&src, out, &ctx, method, right, seed)),
bits(wind_direct(&src, out, &ctx, method, right, seed)),
"{mode:?} alpha {alpha} kind {kind} {method:?} right {right} seed {seed}"
);
}
}
}
}
}
}
}
#[test]
fn thin_and_partial_windows_match() {
for bounds in [Rect::new(0, 0, 1, 1), Rect::new(-5, -6, -4, 31), Rect::new(3, 8, 43, 9)] {
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
for src in [image(bounds, 4, 0), image(bounds.inflate(2), 4, 1)] {
let out = bounds.inflate(2);
for right in [false, true] {
assert_eq!(bits(wind(&src, out, &ctx, WindMethod::Blast, right, 17)), bits(wind_direct(&src, out, &ctx, WindMethod::Blast, right, 17)));
}
for kind in [ExtrudeType::Blocks, ExtrudeType::Pyramids] {
let spec = ExtrudeSpec { kind, size: 8.0, depth: 255.0, level_based: true, solid_front: false, mask_incomplete: false, seed: 17 };
assert_eq!(bits(extrude(&src, out, &ctx, &spec)), bits(extrude_direct(&src, out, &ctx, &spec)));
}
}
}
}
#[test]
fn block_bounds_cover_float_rounding_and_ties() {
for offset in [0, -(1 << 22), (1 << 22) - 80] {
let bounds = Rect::new(offset, offset, offset + 64, offset + 49);
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
let src = image(bounds, 4, 1);
for size in [2.0, 7.0, 19.0] {
let spec = ExtrudeSpec { kind: ExtrudeType::Blocks, size, depth: 255.0, level_based: true, solid_front: true, mask_incomplete: false, seed: 42 };
assert!(extrude::blocks(&src, bounds, &ctx, &spec).is_some());
assert_eq!(bits(extrude(&src, bounds, &ctx, &spec)), bits(extrude_direct(&src, bounds, &ctx, &spec)));
}
}
}
#[test]
fn optional_scratch_rejects_large_and_unsupported_geometry() {
let bounds = Rect::new(0, 0, 1, 1);
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
let src = image(bounds, 4, 0);
let spec = ExtrudeSpec { kind: ExtrudeType::Blocks, size: 2.0, depth: 255.0, level_based: false, solid_front: true, mask_incomplete: false, seed: 42 };
assert!(extrude::blocks(&src, Rect::new(0, 0, 4096, 4096), &ctx, &spec).is_none());
assert!(wind::streaks(&src, Rect::new(0, 0, 1_048_577, 1), &ctx, WindMethod::Blast, false, 0).is_none());
let far = Ctx { bounds: Rect::new(10_000_000, 0, 10_000_010, 10), ..ctx };
assert!(extrude::blocks(&src, bounds, &far, &spec).is_none());
}
#[test]
fn storage_selection_and_tile_sizes_match_original_kernels() {
let bounds = Rect::new(-11, -7, 32, 24);
let area = Rect::new(-9, -5, 29, 21);
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let fmt = PixelFormat::new(mode, sample, true);
let ctx = Ctx { bounds, mode, alpha: true };
let img = image(bounds, fmt.channels(), 3);
let mut s = Surface::new(fmt);
s.write_region(bounds, &img.data);
let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false));
let mask: Vec<f32> = (0..bounds.width() as usize * bounds.height() as usize).map(|i| (i % 5) as f32 / 4.0).collect();
selection.write_region(bounds, &mask);
let cases = [
FilterParams::Extrude {
kind: ExtrudeType::Blocks,
size: 7.0,
depth: 170.0,
level_based: true,
solid_front: false,
mask_incomplete: false,
seed: 17,
},
FilterParams::Wind { method: WindMethod::Blast, from_right: true, seed: 17 },
];
for p in cases {
let halo = match p {
FilterParams::Extrude { .. } => 70,
_ => 40,
};
let src = Image::read_clamped(&s, area.inflate(halo), bounds);
let mut raw = match p {
FilterParams::Extrude { kind, size, depth, level_based, solid_front, mask_incomplete, seed } => {
extrude_direct(&src, area, &ctx, &ExtrudeSpec { kind, size, depth, level_based, solid_front, mask_incomplete, seed })
}
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;
}
}
let mut want = s.clone();
want.write_region(area, &raw);
for tile in [1, 7, 17, 256] {
let got = apply_tiled(&s, &p, area, bounds, Some(&selection), tile, Some(bounds));
assert_eq!(bits(got.read_region(bounds)), bits(want.read_region(bounds)), "{mode:?} {sample:?} {} tile {tile}", p.label());
}
}
}
}
}
+73
View File
@@ -0,0 +1,73 @@
//! Scatter each source streak once, retaining the old nearest-source tie rule.
use super::{read_px, wind_reach};
use crate::fxutil::{MAXC, luma};
use crate::image::Image;
use crate::noise::hash01;
use crate::{Ctx, WindMethod};
use photocraft_geom::Rect;
const MAX_ROW: usize = 1_048_576;
pub(super) fn streaks(src: &Image, out: Rect, ctx: &Ctx, method: WindMethod, from_right: bool, seed: u32) -> Option<Vec<f32>> {
if method == WindMethod::Stagger || src.ch == 0 || src.ch > MAXC {
return None;
}
let n = src.ch;
let w = out.width() as usize;
if w == 0 || w > MAX_ROW || out.is_empty() {
return None;
}
let lmax = wind_reach(method) as i32;
let d = if from_right { -1 } else { 1 };
// Include the upstream halo and one more source for the edge difference.
let start = if from_right { out.x1.checked_add(lmax - 1)? } else { out.x0.checked_sub(lmax)? };
start.checked_sub(d)?;
let sources = w.checked_add(lmax as usize)?;
let mut best = Vec::new();
best.try_reserve_exact(w).ok()?;
best.resize(w, (0.0f32, lmax + 1));
let mut res = src.crop(out);
let mut tmp = [0.0f32; MAXC];
let strength = if method == WindMethod::Blast { 8.0 } else { 4.0 };
for y in out.y0..out.y1 {
best.fill((0.0, lmax + 1));
read_px(src, start - d, y, &mut tmp[..n]);
let mut prev = luma(ctx, &tmp[..n]);
for step in 0..sources {
let s = i32::try_from(i64::from(start) + i64::from(d) * i64::try_from(step).ok()?).ok()?;
read_px(src, s, y, &mut tmp[..n]);
let lum = luma(ctx, &tmp[..n]);
let edge = ((lum - prev).abs() * strength).min(1.0);
prev = lum;
if edge <= 0.0 {
continue;
}
let len = (hash01(s, y, 44, seed).powi(2) * lmax as f32).max(1.0);
for k in 1..=(len.floor() as i32).min(lmax) {
let x = s.checked_add(d * k)?;
if x < out.x0 || x >= out.x1 {
continue;
}
let weight = edge * (1.0 - k as f32 / (len + 1.0));
let hit = best.get_mut((x - out.x0) as usize)?;
// Sources are visited downwind; equal weights prefer the smallest original k.
if weight > hit.0 || (weight == hit.0 && k < hit.1) {
*hit = (weight, k);
}
}
}
let row = (y - out.y0) as usize * w * n;
for (x, &(weight, k)) in best.iter().enumerate() {
if weight > 0.0 {
let sx = i32::try_from(i64::from(out.x0) + x as i64 - i64::from(d * k)).ok()?;
read_px(src, sx, y, &mut tmp[..n]);
let px = res.get_mut(row + x * n..row + (x + 1) * n)?;
for (v, s) in px.iter_mut().zip(&tmp) {
*v += (*s - *v) * weight;
}
}
}
}
Some(res)
}
+215
View File
@@ -0,0 +1,215 @@
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::{Interrupt, Surface};
#[path = "tests_tilt_shift_reference.rs"]
mod reference;
fn image(rect: Rect, ch: usize) -> Image {
let data = (rect.y0..rect.y1)
.flat_map(|y| {
(rect.x0..rect.x1).flat_map(move |x| {
(0..ch).map(move |c| match c {
0 => (x * 17 + y * 31).rem_euclid(113) as f32 / 37.0 - 0.5,
1 => {
if (x + y).rem_euclid(3) == 0 {
1.0
} else {
0.0
}
}
_ => ((x ^ y).unsigned_abs() as usize + c) as f32 % 23.0 / 22.0,
})
})
})
.collect();
Image { rect, ch, data }
}
fn params(blur: f32, centre: (f32, f32), angle: f32, focus: f32, transition: f32) -> crate::FilterParams {
crate::FilterParams::TiltShift { blur, center_x: centre.0, center_y: centre.1, angle, focus, transition }
}
fn check(src: &Image, out: Rect, ctx: &Ctx, p: &crate::FilterParams) {
let crate::FilterParams::TiltShift { blur, center_x, center_y, angle, focus, transition } = *p else { panic!("Tilt-Shift fixture") };
let want = reference::tilt_shift(src, out, ctx, blur, (center_x, center_y), angle, focus, transition);
let got = tilt_shift(src, out, ctx, blur, (center_x, center_y), angle, focus, transition);
assert_eq!(got.len(), want.len());
for (i, (&got, &want)) in got.iter().zip(&want).enumerate() {
assert!(got.to_bits() == want.to_bits() || (got.is_nan() && want.is_nan()), "sample {i}: {got} != {want}, delta {}", (got - want).abs());
}
}
#[test]
fn tilt_shift_matches_frozen_kernel_at_band_boundaries_and_edges() {
let bounds = Rect::new(-19, 7, 54, 66);
let mut cases = Vec::new();
for blur in [0.0, 0.49, 0.5, 1.0, 15.0, 80.0, 500.0] {
for angle in [-90.0, -71.0, 0.0, 27.0, 90.0, 180.0] {
cases.push(params(blur, (0.18, 0.91), angle, 0.03, 0.01));
cases.push(params(blur, (0.5, 0.5), angle, 0.1, 0.6));
}
}
cases.push(params(80.0, (0.5, 0.5), 0.0, 5.0, 0.1));
cases.push(params(80.0, (-5.0, -5.0), 27.0, 0.1, 0.15));
for ch in 1..=6 {
let src = image(bounds.inflate(12), ch);
for alpha in [false, true] {
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha };
for p in &cases {
check(&src, bounds, &ctx, p);
check(&src, Rect::new(-7, 17, 15, 39), &ctx, p);
}
}
}
}
#[test]
fn tilt_shift_preserves_degenerate_and_nonfinite_parameter_behavior() {
for bounds in [Rect::EMPTY, Rect::new(1, -1, 2, 0), Rect::new(-5, 3, 14, 4)] {
let src = image(bounds, 4);
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
for value in [0.0, -1.0, f32::MIN, f32::MAX, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
for field in 0..6 {
// The old NaN blur amount panicked in the sigma clamp. Its
// corrected identity behavior has a separate regression below.
if field == 0 && value.is_nan() {
continue;
}
let mut p = params(15.0, (0.5, 0.5), 0.0, 0.1, 0.15);
if let crate::FilterParams::TiltShift { blur, center_x, center_y, angle, focus, transition } = &mut p {
match field {
0 => *blur = value,
1 => *center_x = value,
2 => *center_y = value,
3 => *angle = value,
4 => *focus = value,
_ => *transition = value,
}
}
check(&src, bounds, &ctx, &p);
}
}
}
let bounds = Rect::new(0, 0, 3, 5);
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
assert!(crate::kernel(&params(15.0, (0.5, 0.5), 0.0, 0.1, 0.15), &Image { rect: bounds, ch: 4, data: vec![] }, bounds, &ctx).is_empty());
assert!(crate::kernel(&params(15.0, (0.5, 0.5), 0.0, 0.1, 0.15), &Image { rect: bounds, ch: 0, data: vec![] }, bounds, &ctx).is_empty());
}
#[test]
fn tilt_shift_nan_blur_is_an_identity_instead_of_panicking() {
let bounds = Rect::new(-2, 3, 9, 14);
let src = image(bounds, 4);
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
let p = params(f32::NAN, (0.5, 0.5), 0.0, 0.1, 0.15);
assert_eq!(crate::kernel(&p, &src, bounds, &ctx), src.data);
let mut surface = Surface::new(PixelFormat::RGBA32F);
surface.write_region(bounds, &src.data);
assert_eq!(crate::apply_in(&surface, &p, bounds, bounds, None, bounds), surface);
// Prove this synthetic input panicked in the unchanged starting kernel.
assert!(std::panic::catch_unwind(|| reference::tilt_shift(&src, bounds, &ctx, f32::NAN, (0.5, 0.5), 0.0, 0.1, 0.15)).is_err());
}
#[test]
fn tilt_shift_keeps_running_sum_order_for_extreme_hdr_and_signed_zero() {
let bounds = Rect::new(-13, -5, 24, 28);
let mut src = image(bounds.inflate(15), 4);
let values = [0.0, -0.0, 1e30, -1e30, 1e-30, -1e-30, 0.8];
for (i, px) in src.data.as_chunks_mut::<4>().0.iter_mut().enumerate() {
px[0] = values[i % values.len()];
px[1] = values[(i + 3) % values.len()];
px[3] = [0.0, 1e-8, 0.5, 1.0][i % 4];
}
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
for blur in [0.5, 15.0, 80.0, 300.0] {
for angle in [0.0, 37.0, -90.0] {
let p = params(blur, (0.5, 0.5), angle, 0.1, 0.15);
check(&src, bounds, &ctx, &p);
check(&src, Rect::new(0, 0, 10, 17), &ctx, &p);
}
}
for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
let mut invalid = src.clone();
invalid.data[19] = value;
invalid.data[47] = value;
check(&invalid, bounds, &ctx, &params(15.0, (0.5, 0.5), 0.0, 0.1, 0.15));
}
}
#[test]
fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() {
let bounds = Rect::new(-17, 11, 96, 84);
let area = Rect::new(-9, 17, 79, 77);
let p = params(17.0, (0.3, 0.7), 29.0, 0.07, 0.3);
let mut selection = Surface::new(PixelFormat::GRAY8);
selection.fill_rect(bounds, &[0.4]);
selection.fill_rect(Rect::new(3, 19, 35, 44), &[1.0]);
selection.fill_rect(Rect::new(10, 20, 20, 30), &[0.0]);
for mode in [
ColorMode::Rgb,
ColorMode::Grayscale,
ColorMode::Cmyk,
ColorMode::Lab,
ColorMode::Bitmap,
ColorMode::Indexed,
ColorMode::Duotone,
ColorMode::Multichannel,
] {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
for alpha in [false, true] {
let fmt = PixelFormat::new(mode, sample, alpha);
let mut surface = Surface::new(fmt);
surface.write_region(bounds, &image(bounds, fmt.channels()).data);
let original = surface.clone();
let ctx = Ctx { bounds, mode, alpha };
let radius = reach(17.0).ceil() as i32 + 1;
assert_eq!(p.halo_for(bounds), crate::Halo::Radius(radius));
for extent in [None, Some(bounds)] {
for tile in [16, 47, 128] {
let got = crate::apply_tiled(&surface, &p, area, bounds, Some(&selection), tile, extent);
let mut want = surface.clone();
for y in (area.y0..area.y1).step_by(tile as usize) {
for x in (area.x0..area.x1).step_by(tile as usize) {
let t = Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1));
let src = match extent {
Some(e) => Image::read_clamped(&surface, t.inflate(radius), e),
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);
want.write_region(t, &data);
}
}
assert_eq!(got.read_region(bounds), want.read_region(bounds), "{fmt:?}, tile {tile}, extent {extent:?}");
}
}
assert_eq!(surface, original);
}
}
}
}
#[test]
fn tilt_shift_cancellation_preserves_source() {
use std::sync::atomic::{AtomicBool, Ordering};
let bounds = Rect::new(0, 0, 512, 256);
let mut source = Surface::new(PixelFormat::RGBA8);
source.write_region(bounds, &image(bounds, 4).data);
let original = source.clone();
let p = params(80.0, (0.5, 0.5), 27.0, 0.1, 0.15);
assert!(crate::apply_in_with(&source, &p, bounds, bounds, None, bounds, &Interrupt::cancel_only(&|| true)).is_none());
let cancelled = AtomicBool::new(false);
let cancel = || cancelled.load(Ordering::Relaxed);
let progress = |p| {
if p > 0.0 {
cancelled.store(true, Ordering::Relaxed);
}
};
let ctl = Interrupt::new(&cancel, &progress);
assert!(crate::apply_in_with(&source, &p, bounds, bounds, None, bounds, &ctl).is_none());
assert!(cancelled.load(Ordering::Relaxed));
assert_eq!(source, original);
let fresh = crate::apply_in_with(&source, &p, bounds, bounds, None, bounds, &Interrupt::NONE);
assert_eq!(fresh, Some(crate::apply_in(&source, &p, bounds, bounds, None, bounds)));
}
@@ -0,0 +1,134 @@
//! Frozen Tilt-Shift kernel from base a28d786638ffbb84c5fb7d5d30ea1c3d594cd3bc.
use super::*;
/// Gaussian σ for a gallery blur amount in px.
fn sigma_of(blur: f32) -> f32 {
blur.clamp(0.0, 500.0) * 0.6
}
const LEVELS: usize = 6;
/// Blurs `src` over `out` with a per-pixel σ (`sigma(x, y)`, ≤ `smax`).
fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, trim_levels: bool, sigma: impl Fn(f32, f32) -> f32) -> Vec<f32> {
let n = src.ch;
if n == 0 || out.is_empty() {
return Vec::new();
}
if smax < 0.3 {
return src.crop(out);
}
let win = src.rect;
let (ww, wh) = (win.width() as usize, win.height() as usize);
let (ow, oh) = (out.width() as usize, out.height() as usize);
let Some(row_len) = ww.checked_mul(n).filter(|&len| len > 0) else { return Vec::new() };
let Some(source_len) = row_len.checked_mul(wh) else { return Vec::new() };
let Some(pixels) = ow.checked_mul(oh) else { return Vec::new() };
let Some(samples) = pixels.checked_mul(n) else { return Vec::new() };
if trim_levels && src.data.len() != source_len {
return Vec::new();
}
// Level σs: 0, then smax / 2^(LEVELS-1) … smax.
let lv: Vec<f32> = std::iter::once(0.0).chain((0..LEVELS).map(|k| smax / 2f32.powi((LEVELS - 1 - k) as i32))).collect();
let sig: Vec<f32> = (0..pixels)
.map(|i| {
let (x, y) = xy(out, i);
sigma(x as f32 + 0.5, y as f32 + 0.5).clamp(0.0, smax)
})
.collect();
// Hat-function weight of level k for σ s.
let weight = |k: usize, s: f32| -> f32 {
let c = lv[k];
if k > 0 && s >= lv[k - 1] && s <= c {
(s - lv[k - 1]) / (c - lv[k - 1]).max(1e-6)
} else if k + 1 < lv.len() && s >= c && s <= lv[k + 1] {
(lv[k + 1] - s) / (lv[k + 1] - c).max(1e-6)
} else if k + 1 == lv.len() && s >= c {
1.0
} else {
0.0
}
};
let p = if trim_levels {
// The window is exactly src.rect: read interleaved channels together,
// retaining the same multiplication and alpha order as premul_window.
let mut p = src.data.clone();
premultiply(&mut p, n, ctx.alpha);
p
} else {
premul_window(src, win, ctx.alpha)
};
let mut acc = vec![0.0f32; samples];
let mut buf = Vec::new();
for (k, &lvk) in lv.iter().enumerate() {
if !sig.iter().any(|&s| weight(k, s) > 0.0) {
continue;
}
let (level, lw): (&[f32], usize) = if k == 0 {
(&p, ww)
} else {
// Only Iris opts in. Each level needs its own box-cascade reach,
// not the maximum blur's trailing halo. Keep the original top/left:
// moving the running sums' start changes floating-point cancellation.
let trim = trim_levels && win.intersect(&out) == out;
let reach = if !trim || lvk < 0.3 { 0 } else { gauss_box_radii(lvk).iter().sum::<usize>() };
let reach = i32::try_from(reach).unwrap_or(i32::MAX);
let lw = if trim { (i64::from(out.x1.saturating_add(reach).min(win.x1)) - i64::from(win.x0)) as usize } else { ww };
let lh = if trim { (i64::from(out.y1.saturating_add(reach).min(win.y1)) - i64::from(win.y0)) as usize } else { wh };
buf.clear();
if buf.capacity() == 0 {
// Match the old single full-window allocation. Row appends must
// not geometrically grow the capacity beyond that window.
buf.reserve_exact(p.len());
}
if trim {
for row in p.chunks_exact(row_len).take(lh) {
if let Some(row) = row.get(..lw * n) {
buf.extend_from_slice(row);
}
}
} else {
buf.extend_from_slice(&p);
}
gauss_blur_n(&mut buf, lw, lh, n, lvk);
(&buf, lw)
};
for (i, &s) in sig.iter().enumerate() {
let wk = weight(k, s);
if wk <= 0.0 {
continue;
}
let (x, y) = xy(out, i);
let o = (((y - win.y0) as usize) * lw + (x - win.x0) as usize) * n;
if let (Some(dst), Some(px)) = (acc.get_mut(i * n..(i + 1) * n), level.get(o..o + n)) {
for (dst, px) in dst.iter_mut().zip(px) {
*dst += px * wk;
}
}
}
}
for px in acc.chunks_exact_mut(n) {
unpremul_px(px, ctx.alpha);
}
acc
}
fn short_side(b: Rect) -> f32 {
(b.width().min(b.height()) as f32).max(1.0)
}
/// Tilt-Shift: a sharp band through the centre at `angle`, blurring with
/// distance beyond `focus` over `transition` (both fractions of the shorter side).
#[allow(clippy::too_many_arguments)]
pub(crate) fn tilt_shift(src: &Image, out: Rect, ctx: &Ctx, blur: f32, centre: (f32, f32), angle: f32, focus: f32, transition: f32) -> Vec<f32> {
let b = ctx.bounds;
let ss = short_side(b);
let (cx, cy) = (b.x0 as f32 + centre.0 * b.width() as f32, b.y0 as f32 + centre.1 * b.height() as f32);
// Band normal (the band runs along `angle`).
let (s, c) = angle.to_radians().sin_cos();
let (nx, ny) = (s, c);
let smax = sigma_of(blur);
let (f0, f1) = (focus.max(0.0), focus.max(0.0) + transition.max(1e-3));
variable_blur(src, out, ctx, smax, false, |x, y| {
let d = ((x - cx) * nx + (y - cy) * ny).abs() / ss;
smax * smoothstep(f0, f1, d)
})
}
+129 -17
View File
@@ -21,6 +21,22 @@ pub struct Homography(pub [f64; 9]);
impl Homography {
pub const IDENTITY: Homography = Homography([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
pub fn is_identity(&self) -> bool {
let m = &self.0;
if m[8].abs() < 1e-12 {
return false;
}
let w = m[8];
((m[0] / w) - 1.0).abs() < 1e-9
&& ((m[4] / w) - 1.0).abs() < 1e-9
&& (m[1] / w).abs() < 1e-9
&& (m[2] / w).abs() < 1e-9
&& (m[3] / w).abs() < 1e-9
&& (m[5] / w).abs() < 1e-9
&& (m[6] / w).abs() < 1e-9
&& (m[7] / w).abs() < 1e-9
}
pub fn apply(&self, x: f64, y: f64) -> (f64, f64) {
let m = &self.0;
let w = m[6] * x + m[7] * y + m[8];
@@ -127,6 +143,56 @@ fn catmull_rom(t: f64) -> [f64; 4] {
/// 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_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))
}
/// Is `q` (TL, TR, BR, BL) a usable Perspective Crop quad: finite, strictly convex with every
/// corner angle above ~0.06°, wound clockwise on screen (y down) and at least 1 px² in area?
pub fn convex_quad(q: &[[f64; 2]; 4]) -> bool {
if q.iter().flatten().any(|v| !v.is_finite()) {
return false;
}
let mut area2 = 0.0;
for i in 0..4 {
let (a, b, c) = (q[i], q[(i + 1) % 4], q[(i + 2) % 4]);
let (e1, e2) = ([b[0] - a[0], b[1] - a[1]], [c[0] - b[0], c[1] - b[1]]);
let cross = e1[0] * e2[1] - e1[1] * e2[0];
// sin(turn) = cross / (|e1| |e2|): positive (clockwise on screen) and not a straight angle.
if cross.is_nan() || cross <= 1e-3 * e1[0].hypot(e1[1]) * e2[0].hypot(e2[1]) {
return false;
}
area2 += a[0] * b[1] - b[0] * a[1];
}
area2.is_finite() && area2 >= 2.0
}
/// The output size a Perspective Crop of `q` (TL, TR, BR, BL) takes when none is given: the mean
/// length of the top and bottom edges by the mean length of the left and right edges.
pub fn quad_size(q: &[[f64; 2]; 4]) -> (f64, f64) {
let len = |a: [f64; 2], b: [f64; 2]| (b[0] - a[0]).hypot(b[1] - a[1]);
let [tl, tr, br, bl] = *q;
((len(tl, tr) + len(bl, br)) / 2.0, (len(tl, bl) + len(tr, br)) / 2.0)
}
/// The homography taking the quad `q` (TL, TR, BR, BL) onto the rectangle (0, 0)–(`w`, `h`), corner
/// to corner: the inverse of the unit-square-to-quad map (Heckbert, "Fundamentals of Texture
/// Mapping and Image Warping", 1989, §2.2.3) scaled to the rectangle. `None` when `q` isn't a
/// [`convex_quad`] or the size isn't positive.
pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option<Homography> {
if !(convex_quad(q) && w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
return None;
}
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> {
let mut fmt = src.format();
let converted;
let mut src = if fmt.alpha {
@@ -137,13 +203,17 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter
&converted
};
let mut out = Surface::new(fmt);
if src_rect.is_empty() {
if src_rect.is_empty() || clip.is_some_and(|c| c.is_empty()) {
return Ok(out);
}
let mut h = *h;
let mut src_rect = src_rect;
// Pre-reduce for large downscales (bicubic alone aliases below ~50%).
let (cx, cy) = ((src_rect.x0 + src_rect.x1) as f64 / 2.0, (src_rect.y0 + src_rect.y1) as f64 / 2.0);
// Pre-reduce for large downscales (bicubic alone aliases below ~50%). With a clip, the scale
// that matters is the one at the source point landing in the clip's centre.
let (cx, cy) = match clip.and_then(|c| Some((c, h.inverse()?))) {
Some((c, inv)) => inv.apply((c.x0 as f64 + c.x1 as f64) / 2.0, (c.y0 as f64 + c.y1 as f64) / 2.0),
None => ((src_rect.x0 + src_rect.x1) as f64 / 2.0, (src_rect.y0 + src_rect.y1) as f64 / 2.0),
};
let scale = h.local_scale(cx, cy);
let reduced;
if interp != Interp::Nearest && scale < 0.5 && scale > 0.0 {
@@ -157,15 +227,32 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter
// Destination bounds: the warped corners (plus a pixel for filter support).
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));
if corners.iter().any(|c| !c.0.is_finite() || !c.1.is_finite()) {
// A sampled pixel's inverse w is 1 / (forward w at its source point). While the forward w keeps one sign over the source
// (plus the margin the sampler reads), every sampled pixel lies at least `w_lo` from the inverse horizon, so clipping a tile
// there keeps its footprint exact and bounded even when a steep Distort brings that horizon into the tile (#2271).
let (sx0, sy0, sx1, sy1) = (src_rect.x0 as f64 - 1.0, src_rect.y0 as f64 - 1.0, src_rect.x1 as f64 + 1.0, src_rect.y1 as f64 + 1.0);
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 {
// 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),
};
if dst.is_empty() {
return Ok(out);
}
let lim = 1 << 20;
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;
let dst = Rect::new(bx0, by0, bx1, by1);
let n = fmt.channels();
let a = n - 1;
let tile_count = dst.tiles().count();
@@ -174,13 +261,6 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter
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;
// A sampled pixel's inverse w is 1 / (forward w at its source point). While the forward w keeps one sign over the source
// (plus the margin the sampler reads), every sampled pixel lies at least `w_lo` from the inverse horizon, so clipping a tile
// there keeps its footprint exact and bounded even when a steep Distort brings that horizon into the tile (#2271).
let (sx0, sy0, sx1, sy1) = (src_rect.x0 as f64 - 1.0, src_rect.y0 as f64 - 1.0, src_rect.x1 as f64 + 1.0, src_rect.y1 as f64 + 1.0);
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 work = |t: &Rect| -> Result<Option<(Rect, Vec<f32>)>, photocraft_raster::AllocationError> {
// 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));
@@ -327,6 +407,30 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter
mod tests {
use super::*;
/// 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]
fn quad_to_rect_maps_corners_and_refuses_bad_quads() {
let q = [[30.0, 12.0], [140.0, 25.0], [128.0, 110.0], [14.0, 95.0]];
let h = quad_to_rect(&q, 100.0, 80.0).unwrap();
for (c, want) in q.iter().zip([[0.0, 0.0], [100.0, 0.0], [100.0, 80.0], [0.0, 80.0]]) {
let (x, y) = h.apply(c[0], c[1]);
assert!((x - want[0]).abs() < 1e-9 && (y - want[1]).abs() < 1e-9, "{c:?} -> ({x}, {y})");
}
let (w, ht) = quad_size(&q);
assert!((w - 112.874).abs() < 1e-3 && (ht - 85.185).abs() < 1e-3, "{w} x {ht}");
for bad in [
[[0.0, 0.0], [50.0, 0.0], [100.0, 0.0], [0.0, 50.0]],
[[0.0, 0.0], [50.0, 50.0], [50.0, 0.0], [0.0, 50.0]],
[[0.0, 0.0], [0.0, 50.0], [50.0, 50.0], [50.0, 0.0]],
[[0.0, 0.0], [f64::NAN, 0.0], [50.0, 50.0], [0.0, 50.0]],
[[0.0, 0.0]; 4],
] {
assert!(quad_to_rect(&bad, 10.0, 10.0).is_none(), "{bad:?}");
}
assert!(quad_to_rect(&q, 0.0, 10.0).is_none());
}
fn rgba() -> Surface {
Surface::new(PixelFormat::RGBA8)
}
@@ -427,4 +531,12 @@ mod tests {
assert_eq!(o.pixel(2, 2)[3], 0.0);
assert_eq!(o.pixel(7, 2), vec![1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn homography_is_identity() {
assert!(Homography::IDENTITY.is_identity());
assert!(Homography([2.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 2.0]).is_identity());
assert!(!Homography([1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]).is_identity());
assert!(!Homography([1.0, 0.1, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]).is_identity());
}
}
+140 -3
View File
@@ -8,8 +8,82 @@
use crate::photo_util::par_rows;
/// Separable grayscale dilation (max filter) of one interleaved channel, radius `r` px.
/// Prefix/suffix block maxima, with clipped edge windows and the original zero floor.
/// One comparison per sample in each direction, independent of the trap width.
fn max_line(out: &mut [f32], radius: usize, get: impl Fn(usize) -> f32) {
let len = out.len();
if len == 0 {
return;
}
let r = radius.min(len - 1);
let mut suffix = Vec::new();
if r <= 2 || suffix.try_reserve_exact(len).is_err() {
for (x, v) in out.iter_mut().enumerate() {
*v = (x.saturating_sub(r)..=x.saturating_add(r).min(len - 1)).fold(0.0f32, |m, i| m.max(get(i)));
}
return;
}
suffix.resize(len, 0.0f32);
let block = r.saturating_mul(2).saturating_add(1);
let mut acc = 0.0f32;
let mut negative_zero = false;
for (i, v) in out.iter_mut().enumerate() {
if i % block == 0 {
acc = 0.0;
}
let value = get(i);
negative_zero |= value.to_bits() == (-0.0f32).to_bits();
acc = acc.max(value);
*v = acc;
}
// f32::max may choose either sign of equal zero depending on operand order.
// Preserve the original left-to-right fold for these uncommon float lines.
if negative_zero {
for (x, v) in out.iter_mut().enumerate() {
*v = (x.saturating_sub(r)..=x.saturating_add(r).min(len - 1)).fold(0.0f32, |m, i| m.max(get(i)));
}
return;
}
acc = 0.0;
for (i, v) in suffix.iter_mut().enumerate().rev() {
if i == len - 1 || (i + 1) % block == 0 {
acc = 0.0;
}
acc = acc.max(get(i));
*v = acc;
}
for x in 0..len {
let (lo, hi) = (x.saturating_sub(r), x.saturating_add(r).min(len - 1));
let value = if lo == 0 {
out.get(hi).copied().unwrap_or(0.0)
} else if hi == len - 1 && lo / block == hi / block {
suffix.get(lo).copied().unwrap_or(0.0)
} else {
suffix.get(lo).copied().unwrap_or(0.0).max(out.get(hi).copied().unwrap_or(0.0))
};
if let Some(v) = out.get_mut(x) {
*v = value;
}
}
}
/// A channel's square dilation, transposed so each vertical scan writes a contiguous row.
fn dilate_channel(src: &[f32], w: usize, h: usize, ch: usize, c: usize, r: usize) -> Vec<f32> {
if r <= 2 {
return dilate_channel_direct(src, w, h, ch, c, r);
}
let mut tmp = vec![0.0f32; w * h];
par_rows(&mut tmp, w, 1, |y, row| {
max_line(row, r, |x| src.get((y * w + x) * ch + c).copied().unwrap_or(0.0));
});
let mut out = vec![0.0f32; w * h];
par_rows(&mut out, h, 1, |x, column| {
max_line(column, r, |y| tmp.get(y * w + x).copied().unwrap_or(0.0));
});
out
}
fn dilate_channel_direct(src: &[f32], w: usize, h: usize, ch: usize, c: usize, r: usize) -> Vec<f32> {
let get = |buf: &[f32], x: usize, y: usize| buf[(y * w + x) * ch + c];
// Horizontal pass.
let mut tmp = vec![0.0f32; w * h];
@@ -44,14 +118,14 @@ fn dilate_channel(src: &[f32], w: usize, h: usize, ch: usize, c: usize, r: usize
/// spread; any alpha channel (`ch > 4`) is untouched. A pixel keeps its original ink where it has
/// no ink at all (paper), so only internal colour edges gain a trap band.
pub fn trap(px: &mut [f32], w: usize, h: usize, ch: usize, width: usize) {
if width == 0 || w == 0 || h == 0 || ch < 4 {
if width == 0 || w == 0 || h == 0 || ch < 4 || w.checked_mul(h).and_then(|n| n.checked_mul(ch)) != Some(px.len()) {
return;
}
let inks = 4.min(ch);
let dil: Vec<Vec<f32>> = (0..inks).map(|c| dilate_channel(px, w, h, ch, c, width)).collect();
par_rows(px, w, ch, |y, row| {
for (x, pixel) in row.chunks_exact_mut(ch).enumerate() {
let i = y * w + x;
let i = if width <= 2 { y * w + x } else { x * h + y };
// Printed where any ink is present.
let inked = (0..inks).any(|c| pixel[c] > 1e-4);
if !inked {
@@ -123,3 +197,66 @@ mod tests {
assert_eq!(px, orig);
}
}
#[cfg(test)]
mod linear_tests {
use super::*;
#[test]
fn all_clipped_windows_match_the_direct_maximum() {
let mut seed = 1234567u32;
for len in 1..90 {
let values: Vec<f32> = (0..len)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
[f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0, 0.0, -2.0, 0.25, 1.0, 2.0][seed as usize % 9]
})
.collect();
for radius in [0, 1, 2, 3, 5, 16, 50, 128, usize::MAX] {
let mut out = vec![0.0; len];
max_line(&mut out, radius, |i| values[i]);
for (x, v) in out.iter().enumerate() {
let want = (x.saturating_sub(radius)..=x.saturating_add(radius).min(len - 1)).fold(0.0f32, |m, i| m.max(values[i]));
assert_eq!(v.to_bits(), want.to_bits(), "len {len} radius {radius} x {x}");
}
}
}
}
#[test]
fn trap_preserves_paper_alpha_and_spare_channels_exactly() {
for (w, h, ch) in [(1, 31, 5), (47, 1, 4), (37, 29, 4), (37, 29, 5), (23, 19, 8)] {
for radius in [1, 3, 10, 128] {
let src: Vec<f32> = (0..w * h * ch).map(|i| if (i / ch) % 5 == 0 { 0.0 } else { ((i * 71 + i / ch * 103) % 65536) as f32 / 65535.0 }).collect();
let mut want = src.clone();
for y in 0..h {
for x in 0..w {
let index = (y * w + x) * ch;
if !(0..4).any(|c| src[index + c] > 1e-4) {
continue;
}
for c in 0..4 {
let mut m = 0.0f32;
for yy in y.saturating_sub(radius)..=y.saturating_add(radius).min(h - 1) {
for xx in x.saturating_sub(radius)..=x.saturating_add(radius).min(w - 1) {
m = m.max(src[(yy * w + xx) * ch + c]);
}
}
want[index + c] = src[index + c].max(m);
}
}
}
let mut got = src;
trap(&mut got, w, h, ch, radius);
assert_eq!(got.iter().map(|v| v.to_bits()).collect::<Vec<_>>(), want.iter().map(|v| v.to_bits()).collect::<Vec<_>>());
}
}
}
#[test]
fn malformed_buffer_is_left_alone() {
let mut pixels = vec![0.5; 20];
let original = pixels.clone();
trap(&mut pixels, usize::MAX, 2, 5, 10);
assert_eq!(pixels, original);
}
}
+86
View File
@@ -114,6 +114,73 @@ impl VpPlane {
});
Lifted { focal: f, center: c, pts }
}
/// Turn of each corner (`sin` of the angle between consecutive edges, positive when the
/// corners run clockwise on screen); `None` when an edge is shorter than a pixel or a corner
/// is not finite.
fn turns(&self) -> Option<[f64; 4]> {
let q = &self.corners;
if q.iter().flatten().any(|v| !v.is_finite() || v.abs() > 1e9) {
return None;
}
let e = |i: usize| [q[(i + 1) % 4][0] - q[i][0], q[(i + 1) % 4][1] - q[i][1]];
let mut out = [0.0; 4];
for (i, t) in out.iter_mut().enumerate() {
let (a, b) = (e(i), e((i + 1) % 4));
let (la, lb) = (a[0].hypot(a[1]), b[0].hypot(b[1]));
if la < 1.0 || lb < 1.0 {
return None;
}
*t = (a[0] * b[1] - a[1] * b[0]) / (la * lb);
}
Some(out)
}
/// How usable the plane is, the way Photoshop colours its grid: [`PlaneCheck::Invalid`] for
/// a quad that isn't convex (self-intersecting, a corner pushed inside, a near-straight or
/// near-zero corner), [`PlaneCheck::Questionable`] for a convex quad whose perspective can't
/// come from a camera centred at `center` (or with a corner sharper than 10°), else
/// [`PlaneCheck::Valid`].
pub fn check(&self, center: [f64; 2]) -> PlaneCheck {
let Some(t) = self.turns() else { return PlaneCheck::Invalid };
let same_sign = t.iter().all(|v| *v > 0.0) || t.iter().all(|v| *v < 0.0);
let min = t.iter().fold(f64::MAX, |m, v| m.min(v.abs()));
if !same_sign || min < 1f64.to_radians().sin() || self.homography().is_none() {
return PlaneCheck::Invalid;
}
if min < 10f64.to_radians().sin() {
return PlaneCheck::Questionable;
}
// Both vanishing points finite but no real focal length fits them.
let q = &self.corners;
let (v1, v2) = (meet(q[0], q[1], q[3], q[2]), meet(q[0], q[3], q[1], q[2]));
let finite = |v: V3| v[2].abs() > 1e-9 * (v[0].abs() + v[1].abs()).max(1.0);
if finite(v1) && finite(v2) && self.focal(center).is_none() {
return PlaneCheck::Questionable;
}
PlaneCheck::Valid
}
/// The same quad with its corners clockwise on screen, starting at the top-left one
/// (the order [`paste`] and [`Scene::tear_off`] expect), however they were clicked.
pub fn normalized(&self) -> VpPlane {
let mut q = self.corners;
if self.turns().is_some_and(|t| t.iter().sum::<f64>() < 0.0) {
q = [q[0], q[3], q[2], q[1]];
}
let first = (0..4).min_by(|&a, &b| (q[a][0] + q[a][1]).total_cmp(&(q[b][0] + q[b][1]))).unwrap_or(0);
VpPlane { corners: [q[first], q[(first + 1) % 4], q[(first + 2) % 4], q[(first + 3) % 4]] }
}
}
/// Plane validity ([`VpPlane::check`]): Photoshop draws a valid plane's grid blue, a doubtful
/// one yellow and an invalid one red.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PlaneCheck {
Valid,
Questionable,
Invalid,
}
impl Lifted {
@@ -363,6 +430,25 @@ mod tests {
assert!((lu / lv - 2.0).abs() < 0.01, "aspect {}", lu / lv);
}
#[test]
fn plane_check_and_corner_order() {
let c = [400.0, 300.0];
assert_eq!(floor_quad(500.0, c).check(c), PlaneCheck::Valid);
let square = VpPlane { corners: [[10.0, 10.0], [110.0, 10.0], [110.0, 110.0], [10.0, 110.0]] };
assert_eq!(square.check(c), PlaneCheck::Valid, "fronto-parallel");
let bowtie = VpPlane { corners: [[10.0, 10.0], [110.0, 110.0], [110.0, 10.0], [10.0, 110.0]] };
assert_eq!(bowtie.check(c), PlaneCheck::Invalid);
let dent = VpPlane { corners: [[10.0, 10.0], [110.0, 10.0], [40.0, 40.0], [10.0, 110.0]] };
assert_eq!(dent.check(c), PlaneCheck::Invalid, "concave");
let sliver = VpPlane { corners: [[10.0, 10.0], [300.0, 10.0], [300.0, 40.0], [200.0, 40.0]] };
assert_eq!(sliver.check(c), PlaneCheck::Questionable, "a corner sharper than 10°");
let nan = VpPlane { corners: [[f64::NAN, 10.0], [110.0, 10.0], [110.0, 110.0], [10.0, 110.0]] };
assert_eq!(nan.check(c), PlaneCheck::Invalid);
// Clicked counter-clockwise from the bottom-right: clockwise from the top-left.
let ccw = VpPlane { corners: [[110.0, 110.0], [110.0, 10.0], [10.0, 10.0], [10.0, 110.0]] };
assert_eq!(ccw.normalized(), square);
}
#[test]
fn tear_off_is_perpendicular_and_shares_the_edge() {
let c = [400.0, 300.0];
+5 -5
View File
@@ -394,17 +394,17 @@ mod tests {
let out = warp_mesh_surface(&src, r, &|x, y| (x + 10.0, y - 2.0), Interp::Bilinear);
assert_eq!(out.content_bounds(), r.translate(10, -2));
assert_eq!(out.pixel(20, 10), src.pixel(10, 12));
// Arc 50 %: the centre column keeps its pixels, the ends drop.
// Arc 50 % (Photoshop): the bottom corners keep their pixels, the top rises and spreads.
let b = [f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)];
let w = Warp::preset(WarpStyle::Arc, 50.0, b);
let arc = warp_mesh_surface(&src, r, &|x, y| w.map(x, y), Interp::Bilinear);
let cx = (r.x0 + r.x1) / 2;
let (p, q) = (arc.pixel(cx, 10), src.pixel(cx, 10));
assert!(p.iter().zip(&q).all(|(a, b)| (a - b).abs() < 0.05), "{p:?} vs {q:?}");
let corner = w.map(f64::from(r.x0), f64::from(r.y1));
assert!((corner.0 - f64::from(r.x0)).abs() < 1e-9 && (corner.1 - f64::from(r.y1)).abs() < 1e-9, "{corner:?}");
let (ex, ey) = w.map(f64::from(r.x0) + 2.5, f64::from(r.y0) + 2.5);
let e = arc.pixel(ex.floor() as i32, ey.floor() as i32);
assert!(e[3] > 0.2, "a point near the top-left corner lands at its mapped position: {e:?}");
assert!(arc.content_bounds().y1 > r.y1 + 2, "ends drop below the original box");
let c = arc.content_bounds();
assert!(c.y0 < r.y0 - 2 && c.x0 < r.x0 && c.x1 > r.x1, "the top rises and spreads past the box: {c:?}");
}
#[test]
+141 -23
View File
@@ -1,9 +1,12 @@
//! Client for the desktop app's JSON-lines control protocol
//! (`docs/control-protocol.md`): one JSON request per line, replies matched
//! by `id`. Keeps one connection and reconnects for the next call after a failure.
//! by `id`. Keeps one connection, reconnecting after a failure — or before
//! a call, once the connection has been idle long enough that the app
//! (which closes idle control connections after
//! [`IO_TIMEOUT`](crate::security::IO_TIMEOUT)) may already have closed it.
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
@@ -12,16 +15,31 @@ use tokio::sync::Mutex;
use crate::AutomationError;
use crate::budgets::MAX_RESPONSE_BYTES;
use crate::security::{AUTH_METHOD, MAX_REQUEST_BYTES, validate_token};
use crate::security::{AUTH_METHOD, IO_TIMEOUT, MAX_REQUEST_BYTES, validate_token};
type Conn = (BufReader<tokio::net::tcp::OwnedReadHalf>, tokio::net::tcp::OwnedWriteHalf);
/// Reconnect before sending when the cached connection has been idle this long: the app
/// drops idle control connections after [`IO_TIMEOUT`], and reconnecting before the send
/// keeps the request out of the "was it applied?" state. The margin covers a request
/// still in flight when the idle clock runs out.
const IDLE_RECONNECT: Duration = IO_TIMEOUT.saturating_sub(Duration::from_secs(5));
/// The default call timeout sits strictly below the app's 60s reply deadline, so this
/// client's error — not the app's deadline reply, not the caller's own cutoff — is the
/// one the caller sees.
pub const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(55);
const NO_REPLY_CAUSES: &str =
"the command may still be running, or the app was not drawing frames (display asleep, window fully occluded, or a long operation blocked the frame loop)";
pub struct BridgeClient {
addr: String,
token: String,
conn: Mutex<Option<Conn>>,
conn: Mutex<Option<(Conn, Instant)>>,
next_id: AtomicU64,
timeout: Duration,
idle_reconnect: Duration,
}
impl BridgeClient {
@@ -35,7 +53,14 @@ impl BridgeClient {
return Err(AutomationError::BadRequest(format!("bridge address must be loopback, got `{addr}`")));
}
validate_token(&token)?;
Ok(BridgeClient { addr, token: token.to_ascii_lowercase(), conn: Mutex::new(None), next_id: AtomicU64::new(1), timeout: Duration::from_secs(60) })
Ok(BridgeClient {
addr,
token: token.to_ascii_lowercase(),
conn: Mutex::new(None),
next_id: AtomicU64::new(1),
timeout: DEFAULT_CALL_TIMEOUT,
idle_reconnect: IDLE_RECONNECT,
})
}
pub fn with_timeout(mut self, t: Duration) -> Self {
@@ -43,19 +68,69 @@ impl BridgeClient {
self
}
/// Shrink the idle-reconnect threshold; tests use it to run the reconnect path quickly.
pub fn with_idle_reconnect(mut self, t: Duration) -> Self {
self.idle_reconnect = t;
self
}
pub fn addr(&self) -> &str {
&self.addr
}
/// Call a control method; returns its `result` or the app's error.
/// Call a control method; returns its `result` or the app's error. Never resends after
/// a transport failure: the request may have been applied (#1007).
pub async fn call(&self, method: &str, params: Value) -> Result<Value, AutomationError> {
self.dispatch(method, &params, false).await
}
/// [`Self::call`] for a method the caller knows only reads state. A transport failure
/// applied nothing, so one fresh attempt is made on a new connection.
pub async fn call_read(&self, method: &str, params: Value) -> Result<Value, AutomationError> {
self.dispatch(method, &params, true).await
}
async fn dispatch(&self, method: &str, params: &Value, read: bool) -> Result<Value, AutomationError> {
match self.attempt(method, params).await {
Ok(v) => Ok(v),
// A dropped socket or an unsent request applied nothing: safe to retry once,
// and only for a call the caller vouched is a read.
Err(Failure::NotSent(_)) | Err(Failure::Transport(_)) if read => match self.attempt(method, params).await {
Ok(v) => Ok(v),
Err(retry) => Err(self.failure(method, retry, read)),
},
Err(f) => Err(self.failure(method, f, read)),
}
}
/// The message for a failed attempt, worded by what the failure proves.
fn failure(&self, method: &str, f: Failure, read: bool) -> AutomationError {
match f {
Failure::NotSent(e) => AutomationError::Bridge(format!("`{method}` failed: {e}; the request was not sent")),
Failure::Transport(e) => AutomationError::Bridge(format!("`{method}` failed: {e}; operation may have completed; inspect state before retrying")),
Failure::TimedOut(_) => {
let what = if read { "a read may be retried safely" } else { "operation may have completed; inspect state before retrying" };
AutomationError::Bridge(format!("`{method}` timed out after {:?}; {NO_REPLY_CAUSES}; {what}", self.timeout))
}
Failure::Definite(e) => e,
}
}
/// One exchange over one connection, reconnecting first when the cached one may be dead.
async fn attempt(&self, method: &str, params: &Value) -> Result<Value, Failure> {
let mut guard = self.conn.lock().await;
if let Some((_, last_used)) = guard.as_ref()
&& last_used.elapsed() >= self.idle_reconnect
{
// The request below goes to a fresh socket, not one the app may have dropped.
*guard = None;
}
if guard.is_none() {
let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr))
.await
.map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))?
.map_err(|_| Failure::NotSent(format!("timed out connecting to {}", self.addr)))?
.map_err(|e| {
AutomationError::Bridge(format!(
Failure::NotSent(format!(
"cannot connect to {} ({e}); start the app with `photocraft --control <port>` and matching control credentials",
self.addr
))
@@ -65,39 +140,65 @@ impl BridgeClient {
let auth_id = self.next_id.fetch_add(1, Ordering::Relaxed);
let auth = tokio::time::timeout(Duration::from_secs(5), exchange(&mut conn, auth_id, AUTH_METHOD, &json!({"token": self.token})))
.await
.map_err(|_| AutomationError::Bridge("control authentication timed out".into()))??;
auth?;
*guard = Some(conn);
.map_err(|_| Failure::NotSent("control authentication timed out".into()))?
.map_err(|e| Failure::NotSent(e.to_string()))?;
if let Err(e) = auth {
return Err(Failure::Definite(e));
}
*guard = Some((conn, Instant::now()));
}
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let Some(conn) = guard.as_mut() else {
return Err(AutomationError::Bridge(format!("not connected to {}", self.addr)));
let Some((conn, _)) = guard.as_mut() else {
return Err(Failure::NotSent(format!("not connected to {}", self.addr)));
};
match tokio::time::timeout(self.timeout, exchange(conn, id, method, &params)).await {
let outcome = tokio::time::timeout(self.timeout, exchange(conn, id, method, params)).await;
match outcome {
Ok(Ok(Err(error @ AutomationError::BadRequest(_)))) => {
// An oversized frame leaves unread bytes. Drop this connection and
// report the budget failure without retrying a possibly completed edit.
*guard = None;
Err(error)
Err(Failure::Definite(error))
}
Ok(Ok(v)) => v,
Ok(Ok(Ok(v))) => {
if let Some((_, last_used)) = guard.as_mut() {
*last_used = Instant::now();
}
Ok(v)
}
Ok(Ok(Err(e))) => {
// The app answered: the operation did not apply — except the app's own
// deadline reply, which fires while the command may still be running.
if let Some((_, last_used)) = guard.as_mut() {
*last_used = Instant::now();
}
Err(Failure::Definite(e))
}
// A failed write or lost reply does not prove the edit was not applied.
// Reconnect on the next call; only a vouched read may resend (#1007).
Ok(Err(e)) => {
// A failed write or lost reply does not prove the edit was not applied.
// Reconnect on the next call, but never resend this operation (#1007).
*guard = None;
Err(AutomationError::Bridge(format!("`{method}` failed: {e}; operation may have completed; inspect state before retrying")))
Err(Failure::Transport(e.to_string()))
}
Err(_) => {
*guard = None;
Err(AutomationError::Bridge(format!(
"`{method}` timed out after {:?}; operation may have completed; inspect state before retrying",
self.timeout
)))
Err(Failure::TimedOut(()))
}
}
}
}
/// How a single attempt failed, deciding the retry and the wording above.
enum Failure {
/// The connection could not be established or authenticated: nothing was sent.
NotSent(String),
/// The request was sent and the reply was lost: the outcome is uncertain.
Transport(String),
/// The call's own timeout elapsed. Retrying would only double the wait.
TimedOut(()),
/// The app answered (ok or error) or the request was refused before it was sent.
Definite(AutomationError),
}
/// Outer `Err` = transport failure with an uncertain outcome; inner = app-level result.
async fn exchange(conn: &mut Conn, id: u64, method: &str, params: &Value) -> Result<Result<Value, AutomationError>, AutomationError> {
let mut line = serde_json::to_string(&json!({"id": id, "method": method, "params": params})).map_err(|e| AutomationError::Other(e.to_string()))?;
@@ -133,3 +234,20 @@ async fn exchange(conn: &mut Conn, id: u64, method: &str, params: &Value) -> Res
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_call_timeout_stays_below_the_app_reply_deadline() {
// At or above the app's 60s deadline, callers would see their own cutoff first.
assert!(DEFAULT_CALL_TIMEOUT < Duration::from_secs(60));
}
#[test]
fn idle_reconnect_stays_below_the_app_idle_cutoff() {
// Reconnecting later than IO_TIMEOUT would send into a socket the app dropped.
assert!(IDLE_RECONNECT < IO_TIMEOUT);
}
}
+15 -2
View File
@@ -41,6 +41,17 @@ pub fn previews(doc: &Document) -> SaveOptions {
SaveOptions { thumbnail: Some(photocraft_compose::thumbnail(doc, 256)), composite: Some(photocraft_compose::thumbnail(doc, 1024)) }
}
/// Encode the name the saved native bundle will report on reopening. Save As updates
/// the session identity only *after* a successful write; without a separate snapshot
/// the manifest serializes the previous name instead.
fn named_for_output(doc: &Document, path: &str) -> Document {
let mut named = doc.clone();
if let Some(name) = path.rsplit(['/', '\\']).next().filter(|name| !name.is_empty()) {
named.name = name.to_owned();
}
named
}
/// Save or export `doc` to `path`, choosing the format from the extension
/// (or `format_override`, an extension such as `"png"`). `writer` makes
/// repeated `.pcraft` saves incremental. Returns warnings about lost data.
@@ -56,9 +67,10 @@ pub fn save(
.or_else(|| path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()))
.ok_or_else(|| AutomationError::BadRequest(format!("cannot tell the format of `{}`; pass a format", path.display())))?;
if ext == photocraft_format::EXTENSION {
let named = named_for_output(doc, &path.to_string_lossy());
let mut local = PcraftWriter::new();
let w = writer.unwrap_or(&mut local);
w.save_path(doc, path, &previews(doc))?;
w.save_path(&named, path, &previews(&named))?;
return Ok(Vec::new());
}
let r = photocraft_io::export(doc, &ext, opts)?;
@@ -75,7 +87,8 @@ pub fn save_bytes(doc: &Document, name: &str, format_override: Option<&str>, opt
.or_else(|| Path::new(name).extension().map(|extension| extension.to_string_lossy().to_ascii_lowercase()))
.ok_or_else(|| AutomationError::BadRequest(format!("cannot tell the format of `{name}`; pass a format")))?;
if ext == photocraft_format::EXTENSION {
return Ok((photocraft_format::save_to_bytes(doc, &previews(doc))?, Vec::new()));
let named = named_for_output(doc, name);
return Ok((photocraft_format::save_to_bytes(&named, &previews(&named))?, Vec::new()));
}
let result = photocraft_io::export(doc, &ext, opts)?;
Ok((result.bytes, result.warnings))
+125 -1
View File
@@ -9,7 +9,7 @@ use photocraft_format::PcraftWriter;
use photocraft_io::ExportOptions;
use serde_json::{Value, json};
use crate::workspace::{authorize_engine_command, authorize_engine_step};
use crate::workspace::{ExportSequence, authorize_engine_command, authorize_engine_step};
use crate::{AuthorizedWorkspace, AutomationError, files};
fn untrusted(filesystem: Filesystem) -> Headless {
@@ -106,6 +106,113 @@ impl Headless {
}))
}
/// Place a file into a document as a Smart Object layer: the capability-scoped counterpart
/// of `file.placeEmbedded`, which reads an ambient path and is therefore refused in
/// automation (#2810). `path` resolves beneath the read root the way `open` resolves one,
/// and `params` carries the command's own options (`scale`, `fit`, `center`).
pub fn place(&mut self, index: Option<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("path".into(), json!(path));
// Where ambient paths are allowed the command reads the file itself, as everywhere else.
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
return self.command_run("file.placeEmbedded", Value::Object(opts));
}
let bytes = match &self.filesystem {
Filesystem::Workspace(workspace) => workspace.read(path)?,
_ => {
return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into()));
}
};
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path);
self.sync_jobs();
// The bytes are already in memory and the path resolved beneath the root capability, so
// placing them needs no filesystem check of its own (`place_bytes` is the command body).
Ok(file_cmds::place_bytes(&mut self.session, name, bytes, None, &Value::Object(opts))?)
}
/// Export each exported layer of a document as its own file beneath the write root: the
/// capability-scoped `file.export.layersToFiles`, which takes an ambient `dir` and is
/// therefore refused in automation (#3216). The bytes come from the command's own core, so
/// both paths write the same files. The write capability refuses to overwrite a file that
/// already exists, where the ambient command would, and it creates the directory before the
/// export runs, so a refused export can leave an empty directory behind — never a file.
pub fn export_layers(&mut self, index: Option<usize>, dir: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("dir".into(), json!(dir));
return self.command_run("file.export.layersToFiles", Value::Object(opts));
}
self.sync_jobs();
let workspace = match &self.filesystem {
Filesystem::Workspace(workspace) => workspace,
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
};
let mut outputs = workspace.export_sequence(dir)?;
let written = file_cmds::export_layers_with(&self.session, params, |name, bytes| {
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
});
exported(dir, written, &mut outputs)
}
/// Export each data set of a document as its own flattened file beneath the write root: the
/// capability-scoped `file.export.dataSetsAsFiles` (#3216). As in the command, the whole
/// batch is planned first and colliding names are refused before any file is written; the
/// directory itself is created before that, so a refused batch can leave it empty behind.
pub fn export_data_sets(&mut self, index: Option<usize>, dir: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("dir".into(), json!(dir));
return self.command_run("file.export.dataSetsAsFiles", Value::Object(opts));
}
self.sync_jobs();
let workspace = match &self.filesystem {
Filesystem::Workspace(workspace) => workspace,
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
};
let mut outputs = workspace.export_sequence(dir)?;
let written = photocraft_engine::export_data_sets_with(&self.session, params, |name, bytes| {
outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
});
exported(dir, written, &mut outputs)
}
/// Import a data set CSV from the read root: the capability-scoped
/// `file.import.variableDataSets`, which takes an ambient `path` and is therefore refused
/// in automation (#3216). The file is read beneath the root the way `open` reads one and
/// handed to the command's own parser over the text it already has.
pub fn import_data_sets(&mut self, index: Option<usize>, path: &str, params: &Value) -> Result<Value, AutomationError> {
let i = self.doc_index(index)?;
if self.session.active_index() != Some(i) && !self.session.set_active(i) {
return Err(AutomationError::BadRequest(format!("no document at index {i}")));
}
if matches!(&self.filesystem, Filesystem::TrustedLocal) {
let mut opts = params.as_object().cloned().unwrap_or_default();
opts.insert("path".into(), json!(path));
return self.command_run("file.import.variableDataSets", Value::Object(opts));
}
let text = match &self.filesystem {
Filesystem::Workspace(workspace) => {
let bytes = workspace.read(path)?;
String::from_utf8(bytes).map_err(|e| AutomationError::BadRequest(format!("automation read `{path}` is not valid UTF-8: {e}")))?
}
_ => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())),
};
self.sync_jobs();
Ok(photocraft_engine::import_data_sets_text(&mut self.session, &text, params)?)
}
/// Save (`.pcraft` or any export format by extension). With no path,
/// saves to the document's own path.
pub fn save(&mut self, index: Option<usize>, path: Option<&Path>, format: Option<&str>, opts: &ExportOptions) -> Result<Value, AutomationError> {
@@ -282,3 +389,20 @@ impl Headless {
}
}
}
/// Common tail of the two capability-scoped exports: the sequence keeps what it wrote, or drops
/// every file it created, so a half-finished export never looks like a finished one.
fn exported(dir: &str, written: photocraft_engine::Result<Vec<String>>, outputs: &mut ExportSequence) -> Result<Value, AutomationError> {
match written {
Ok(names) => {
outputs.commit();
let files: Vec<String> = names.iter().map(|name| format!("{dir}/{name}")).collect();
let count = files.len();
Ok(json!({"files": files, "count": count}))
}
Err(error) => {
outputs.cleanup()?;
Err(error.into())
}
}
}
+148 -2
View File
@@ -8,7 +8,12 @@
//! - `engine.execute {command, params?, wait?}` / `engine.commands {filter?}`
//! - `jobs.list` / `jobs.cancel {job?}`: background jobs (#210)
//! - `session.list`, `doc.open {path}`, `doc.new {…file.new params}`,
//! `doc.save {path?, format?, quality?, index?}`, `doc.inspect {index?}`,
//! `doc.place {path, scale?, fit?, center?, index?}` (#2810: the read root is the scope,
//! the counterpart of the refused ambient `file.placeEmbedded`),
//! `doc.exportLayers {dir, format?, prefix?, visibleOnly?, quality?, index?}`,
//! `doc.exportDataSets {dir, format?, dataSets?, naming?, index?}`,
//! `doc.importDataSets {path, delimiter?, index?}` (#3216: the same treatment for the export
//! and CSV half of #2810), `doc.save {path?, format?, quality?, index?}`, `doc.inspect {index?}`,
//! `doc.render {index?, maxSide?, path?}` (writes a PNG to `path`, else
//! returns it base64-encoded), `doc.select {index}`, `doc.close {index?}`
//! - `batch {steps: [{command, params?, wait?} | {method, params?}], stopOnError?}`
@@ -37,8 +42,12 @@ pub const METHODS: &[&str] = &[
"engine.commands",
"session.list",
"doc.open",
"doc.place",
"doc.new",
"doc.save",
"doc.exportLayers",
"doc.exportDataSets",
"doc.importDataSets",
"doc.inspect",
"doc.render",
"doc.select",
@@ -95,10 +104,18 @@ impl Headless {
let path = str_of(&p, "path").ok_or_else(|| bad("doc.open needs `path`"))?;
self.open(&PathBuf::from(path))
}
"doc.place" => {
let path = str_of(&p, "path").ok_or_else(|| bad("doc.place needs `path`"))?;
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))
}
"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), ..Default::default() };
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() };
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;
@@ -107,6 +124,21 @@ impl Headless {
let path = str_of(&p, "path").map(PathBuf::from);
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`")))?;
let mut opts = p.as_object().cloned().unwrap_or_default();
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) }
}
"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))
}
"doc.inspect" => self.inspect(index_of(&p)),
"doc.render" => {
let max = match p.get("maxSide") {
@@ -571,6 +603,120 @@ mod tests {
std::fs::remove_dir(root).unwrap();
}
#[test]
fn doc_place_reads_through_the_read_root_and_never_outside_it() {
let base = std::env::temp_dir().join(format!("pc-doc-place-{}", std::process::id()));
let root = base.join("workspace");
let outside = base.join("outside");
for dir in [&root, &outside] {
std::fs::create_dir_all(dir).unwrap();
}
let img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgb, vec![42; 8 * 6 * 3]).unwrap();
let png = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
std::fs::write(root.join("logo.png"), &png).unwrap();
std::fs::write(outside.join("logo.png"), &png).unwrap();
let absolute = outside.join("logo.png").to_string_lossy().into_owned();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 40, "height": 30})).unwrap();
let placed = h.handle("doc.place", json!({"path": "logo.png", "scale": 50})).unwrap();
assert!(placed["layer"].as_u64().is_some(), "{placed}");
assert_eq!(placed["scale"].as_f64(), Some(50.0), "the options reach the command: {placed}");
let inspected = h.handle("doc.inspect", json!({})).unwrap();
assert_eq!(inspected["layers"].as_array().map(Vec::len), Some(2), "the placed layer is in the document: {inspected}");
// A path outside the read root is refused the way the ambient command is (#2810).
for (path, reason) in [("../outside/logo.png", "parent traversal"), (absolute.as_str(), "must be inside the automation root")] {
let refused = h.handle("doc.place", json!({"path": path})).unwrap_err().to_string();
assert!(refused.contains(reason), "{refused}");
let inspected = h.handle("doc.inspect", json!({})).unwrap();
assert_eq!(inspected["layers"].as_array().map(Vec::len), Some(2), "nothing was placed: {refused}");
}
// Trusted local keeps the ambient command: that caller may read the path itself.
let mut t = Headless::trusted_local();
t.handle("doc.new", json!({"width": 40, "height": 30})).unwrap();
let trusted = t.handle("doc.place", json!({"path": root.join("logo.png").to_string_lossy()})).unwrap();
assert!(trusted["layer"].as_u64().is_some(), "{trusted}");
// Release directory capabilities before cleanup on Windows.
drop(t);
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn doc_export_layers_writes_only_beneath_the_write_root() {
let base = std::env::temp_dir().join(format!("pc-doc-export-layers-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 12, "height": 8, "background": "white", "name": "card"})).unwrap();
h.handle("engine.execute", json!({"command": "layer.new.layer", "params": {"name": "Badge"}})).unwrap();
let out = root.join("out");
let exported = h.handle("doc.exportLayers", json!({"dir": out.to_string_lossy(), "format": "png", "prefix": "card"})).unwrap();
assert_eq!(exported["count"].as_u64(), Some(2), "{exported}");
let mut names: Vec<String> = std::fs::read_dir(&out).unwrap().map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()).collect();
names.sort();
assert_eq!(names.len(), 2, "{exported}");
for name in names {
assert!(name.starts_with("card_") && name.ends_with(".png"), "{name}");
let bytes = std::fs::read(out.join(&name)).unwrap();
assert_eq!(photocraft_codecs::decode(&bytes).unwrap().dimensions(), (12, 8), "{name}");
}
// Outside the write root nothing is created, not even the directory.
let escaped = base.join("escaped");
let refused = h.handle("doc.exportLayers", json!({"dir": escaped.to_string_lossy()})).unwrap_err().to_string();
assert!(refused.contains("must be inside the automation root"), "{refused}");
assert!(!escaped.exists());
// The ambient command keeps its refusal for the very same directory.
let ambient =
h.handle("engine.execute", json!({"command": "file.export.layersToFiles", "params": {"dir": out.to_string_lossy()}})).unwrap_err().to_string();
assert!(ambient.contains("ambient filesystem paths"), "{ambient}");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn doc_export_data_sets_refuse_collisions_and_import_reads_the_root() {
let base = std::env::temp_dir().join(format!("pc-doc-data-sets-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "card"})).unwrap();
let inspected = h.handle("doc.inspect", json!({})).unwrap();
let layer = inspected["layers"][0]["id"].as_u64().expect("the layer id");
h.handle("engine.execute", json!({"command": "image.variables.define", "params": {"defs": [{"name": "show", "layer": layer, "type": "visibility"}]}}))
.unwrap();
h.handle(
"engine.execute",
json!({"command": "image.variables.dataSets", "params": {"dataSets": [
{"name": "one", "values": [{"variable": "show", "kind": "visibility", "value": true}]},
{"name": "two", "values": [{"variable": "show", "kind": "visibility", "value": false}]},
]}}),
)
.unwrap();
let sets = root.join("sets");
let exported = h.handle("doc.exportDataSets", json!({"dir": sets.to_string_lossy(), "format": "png"})).unwrap();
assert_eq!(exported["count"].as_u64(), Some(2), "{exported}");
assert_eq!(std::fs::read_dir(&sets).unwrap().count(), 2, "{exported}");
// Colliding names are refused as in the command, before any file is written.
let clash = root.join("clash");
let refused = h.handle("doc.exportDataSets", json!({"dir": clash.to_string_lossy(), "naming": "{document}"})).unwrap_err().to_string();
assert!(refused.contains("{index}"), "{refused}");
assert_eq!(std::fs::read_dir(&clash).unwrap().count(), 0, "no file of the refused batch");
// A CSV from the read root, and one outside it.
std::fs::write(root.join("sets.csv"), "Data Set,show\none,true\ntwo,false\n").unwrap();
let imported = h.handle("doc.importDataSets", json!({"path": "sets.csv"})).unwrap();
assert_eq!(imported["imported"].as_u64(), Some(2), "{imported}");
std::fs::write(base.join("outside.csv"), "Data Set,show\none,true\n").unwrap();
let outside = h.handle("doc.importDataSets", json!({"path": base.join("outside.csv").to_string_lossy()})).unwrap_err().to_string();
assert!(outside.contains("must be inside the automation root"), "{outside}");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn trusted_local_render_keeps_full_size_behavior() {
let mut h = Headless::trusted_local();
+208 -20
View File
@@ -52,6 +52,79 @@ pub struct OpenParams {
pub path: String,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PlaceParams {
/// Path beneath the configured automation read root: relative (forward slashes) or absolute.
pub path: String,
/// Document index from `session_list` (default: the active document).
#[serde(default)]
pub index: Option<usize>,
/// Scale as a percentage; defaults to fitting a file larger than the canvas.
#[serde(default)]
pub scale: Option<f64>,
/// Place at a centred size instead of a scaled one.
#[serde(default)]
pub fit: Option<bool>,
/// Centre of the placed layer, in document pixels.
#[serde(default)]
pub center: Option<[f64; 2]>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ExportLayersParams {
/// Directory beneath the configured automation write root: relative (forward slashes) or absolute.
pub dir: String,
/// Document index from `session_list` (default: the active document).
#[serde(default)]
pub index: Option<usize>,
/// Export format (default png).
#[serde(default)]
pub format: Option<String>,
/// Filename prefix (default: the document name).
#[serde(default)]
pub prefix: Option<String>,
/// Export only the layers that are visible (default true).
#[serde(default, rename = "visible_only")]
pub visible_only: Option<bool>,
/// Encoder quality, 0..12, for the formats that take one.
#[serde(default)]
pub quality: Option<f64>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ExportDataSetsParams {
/// Directory beneath the configured automation write root: relative (forward slashes) or absolute.
pub dir: String,
/// Document index from `session_list` (default: the active document).
#[serde(default)]
pub index: Option<usize>,
/// Export format (default png).
#[serde(default)]
pub format: Option<String>,
/// Only these data sets, by name; default every one of them.
#[serde(default, rename = "data_sets")]
pub data_sets: Option<Vec<String>>,
/// Filename template with `{name}`, `{index}` and `{document}` (default `{name}`).
#[serde(default)]
pub naming: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ImportDataSetsParams {
/// Path beneath the configured automation read root: relative (forward slashes) or absolute.
pub path: String,
/// Document index from `session_list` (default: the active document).
#[serde(default)]
pub index: Option<usize>,
/// Field delimiter (default `,`).
#[serde(default)]
pub delimiter: Option<String>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NewParams {
@@ -90,6 +163,10 @@ pub struct SaveParams {
/// TIFF: keep the layers (Photoshop layer data). Off by default: a flat TIFF.
#[serde(default, rename = "tiffLayers")]
pub tiff_layers: bool,
/// TGA: 24 or 32 bits per pixel. 32 writes the alpha channel (or else the transparency) as the
/// alpha, 24 none. Unset: 32 with an alpha channel or transparency, else 24.
#[serde(default, rename = "tgaBits")]
pub tga_bits: Option<u8>,
#[serde(default)]
pub index: Option<usize>,
}
@@ -242,7 +319,8 @@ pub struct UiSetParams {
/// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100),
/// 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`. Other fields are an error.
/// `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.
pub fields: Value,
}
@@ -432,7 +510,7 @@ impl PhotocraftMcp {
if max_side.is_some_and(|side| side > MAX_PREVIEW_SIDE) {
return Ok(fail(format!("automation preview side exceeds {MAX_PREVIEW_SIDE} pixels")));
}
let response = match b.call("ui.screenshot", json!({})).await {
let response = match b.call_read("ui.screenshot", json!({})).await {
Ok(response) => response,
Err(error) => return Ok(fail(error)),
};
@@ -500,7 +578,7 @@ impl PhotocraftMcp {
let Some(b) = self.bridge_client() else {
return Ok(no_backend());
};
to_result(b.call("ui.inspect", json!({})).await)
to_result(b.call_read("ui.inspect", json!({})).await)
}
#[tool(description = "Open an image or document file and make it active. Returns index, size and import warnings.")]
@@ -515,6 +593,107 @@ impl PhotocraftMcp {
to_result(b.call("app.open", json!({"path": p.path})).await)
}
#[tool(
description = "Place a file into the document as a Smart Object layer (Place Embedded), reading `path` beneath the read root. `scale` (percent), `fit` and `center` are the command's own."
)]
async fn doc_place(&self, Parameters(p): Parameters<PlaceParams>) -> Result<CallToolResult, McpError> {
let mut opts = serde_json::Map::new();
if let Some(v) = p.scale {
opts.insert("scale".into(), json!(v));
}
if let Some(v) = p.fit {
opts.insert("fit".into(), json!(v));
}
if let Some(v) = p.center {
opts.insert("center".into(), json!(v));
}
let opts = Value::Object(opts);
// Bridge mode runs the app on this machine, which reads the path itself like any command.
let mut bridge_params = opts.clone();
if let Some(o) = bridge_params.as_object_mut() {
o.insert("path".into(), Value::String(p.path.clone()));
}
let (path, index) = (p.path, p.index);
if let Some(r) = self.headless_op(move |h| h.place(index, &path, &opts)).await {
return to_result(r);
}
self.run_command("file.placeEmbedded".into(), bridge_params, true).await
}
#[tool(
description = "Export each exported layer of the document as its own file beneath the write root (the capability-scoped Layers to Files). A file that already exists is an error, not an overwrite."
)]
async fn doc_export_layers(&self, Parameters(p): Parameters<ExportLayersParams>) -> Result<CallToolResult, McpError> {
let mut opts = serde_json::Map::new();
if let Some(v) = p.format {
opts.insert("format".into(), json!(v));
}
if let Some(v) = p.prefix {
opts.insert("prefix".into(), json!(v));
}
if let Some(v) = p.visible_only {
opts.insert("visibleOnly".into(), json!(v));
}
if let Some(v) = p.quality {
opts.insert("quality".into(), json!(v));
}
let opts = Value::Object(opts);
// Bridge mode runs the app on this machine, which writes its own paths like any command.
let mut bridge_params = opts.clone();
if let Some(o) = bridge_params.as_object_mut() {
o.insert("dir".into(), Value::String(p.dir.clone()));
}
let (dir, index) = (p.dir, p.index);
if let Some(r) = self.headless_op(move |h| h.export_layers(index, &dir, &opts)).await {
return to_result(r);
}
self.run_command("file.export.layersToFiles".into(), bridge_params, true).await
}
#[tool(
description = "Export every data set of the document as its own flattened file beneath the write root (the capability-scoped Export Data Sets). The batch is planned first, so colliding filenames are refused before any file is written."
)]
async fn doc_export_data_sets(&self, Parameters(p): Parameters<ExportDataSetsParams>) -> Result<CallToolResult, McpError> {
let mut opts = serde_json::Map::new();
if let Some(v) = p.format {
opts.insert("format".into(), json!(v));
}
if let Some(v) = p.data_sets {
opts.insert("dataSets".into(), json!(v));
}
if let Some(v) = p.naming {
opts.insert("naming".into(), json!(v));
}
let opts = Value::Object(opts);
let mut bridge_params = opts.clone();
if let Some(o) = bridge_params.as_object_mut() {
o.insert("dir".into(), Value::String(p.dir.clone()));
}
let (dir, index) = (p.dir, p.index);
if let Some(r) = self.headless_op(move |h| h.export_data_sets(index, &dir, &opts)).await {
return to_result(r);
}
self.run_command("file.export.dataSetsAsFiles".into(), bridge_params, true).await
}
#[tool(description = "Import a data set CSV from the read root into the document (the capability-scoped Import Variable Data Sets).")]
async fn doc_import_data_sets(&self, Parameters(p): Parameters<ImportDataSetsParams>) -> Result<CallToolResult, McpError> {
let mut opts = serde_json::Map::new();
if let Some(v) = p.delimiter {
opts.insert("delimiter".into(), json!(v));
}
let opts = Value::Object(opts);
let mut bridge_params = opts.clone();
if let Some(o) = bridge_params.as_object_mut() {
o.insert("path".into(), Value::String(p.path.clone()));
}
let (path, index) = (p.path, p.index);
if let Some(r) = self.headless_op(move |h| h.import_data_sets(index, &path, &opts)).await {
return to_result(r);
}
self.run_command("file.import.variableDataSets".into(), bridge_params, true).await
}
#[tool(description = "Create a new document (defaults: 1920x1080 RGB 8-bit white).")]
async fn doc_new(&self, Parameters(p): Parameters<NewParams>) -> Result<CallToolResult, McpError> {
let mut params = serde_json::Map::new();
@@ -571,7 +750,7 @@ impl PhotocraftMcp {
return Ok(no_backend());
};
let params = p.index.map(|index| json!({"document": index})).unwrap_or_else(|| json!({}));
to_result(b.call("engine.execute", json!({"command": "document.inspect", "params": params})).await)
to_result(b.call_read("engine.execute", json!({"command": "document.inspect", "params": params})).await)
}
#[tool(description = "Render the flattened document and return it as a PNG image (bridge mode: window screenshot).")]
@@ -621,7 +800,7 @@ impl PhotocraftMcp {
let Some(b) = self.bridge_client() else {
return Ok(no_backend());
};
b.call("engine.commands", json!({})).await
b.call_read("engine.commands", json!({})).await
};
let all = match all {
Ok(v) => v,
@@ -653,17 +832,18 @@ impl PhotocraftMcp {
#[tool(description = "List background jobs: running ones with progress (0-1), message and elapsed time, then the last few \
that ended (state done|failed|cancelled with their result or error). Finished jobs are applied first.")]
async fn jobs_list(&self) -> Result<CallToolResult, McpError> {
self.jobs_call("jobs.list", json!({})).await
self.jobs_call("jobs.list", json!({}), true).await
}
#[tool(description = "Cancel a background job by id (or every running job when `job` is omitted). The document is left \
unchanged.")]
async fn jobs_cancel(&self, Parameters(p): Parameters<JobCancelParams>) -> Result<CallToolResult, McpError> {
self.jobs_call("jobs.cancel", p.job.map_or_else(|| json!({}), |j| json!({"job": j}))).await
self.jobs_call("jobs.cancel", p.job.map_or_else(|| json!({}), |j| json!({"job": j})), false).await
}
#[tool(description = "Run several engine commands in one call (fewer round trips). Returns {completed, failed, \
results:[{ok, result|error}]}; stops at the first error unless stop_on_error is false.")]
#[tool(description = "Run several engine commands in one call, in order (fewer round trips). Returns {completed, failed, \
results:[{ok, result|error}]}; stops at the first error unless stop_on_error is false. \
Not an atomic transaction: earlier successful steps stay applied (no rollback).")]
async fn command_batch(&self, Parameters(p): Parameters<BatchParams>) -> Result<CallToolResult, McpError> {
if p.steps.len() > MAX_BATCH_STEPS {
return Ok(fail(format!("batch contains {} steps; maximum is {MAX_BATCH_STEPS}", p.steps.len())));
@@ -712,7 +892,7 @@ impl PhotocraftMcp {
)]
async fn ui_inspect(&self) -> Result<CallToolResult, McpError> {
match self.bridge_client() {
Some(b) => to_result(b.call("ui.inspect", json!({})).await),
Some(b) => to_result(b.call_read("ui.inspect", json!({})).await),
None => bridge_only("ui_inspect"),
}
}
@@ -787,8 +967,8 @@ impl PhotocraftMcp {
to_result(b.call("engine.execute", json!({"command": id, "params": params, "wait": wait})).await)
}
/// `jobs.list` / `jobs.cancel` in either backend.
async fn jobs_call(&self, method: &'static str, params: Value) -> Result<CallToolResult, McpError> {
/// `jobs.list` / `jobs.cancel` in either backend; `read` picks the bridge's retry rule.
async fn jobs_call(&self, method: &'static str, params: Value, read: bool) -> Result<CallToolResult, McpError> {
let p2 = params.clone();
if let Some(r) = self.headless_op(move |h| h.command_start(method, p2, true)).await {
return to_result(r);
@@ -796,15 +976,20 @@ impl PhotocraftMcp {
let Some(b) = self.bridge_client() else {
return Ok(no_backend());
};
to_result(b.call(method, params).await)
if read { to_result(b.call_read(method, params).await) } else { to_result(b.call(method, params).await) }
}
async fn save_impl(&self, p: SaveParams) -> Result<CallToolResult, McpError> {
let tga_bits = match p.tga_bits.map(|b| (b, photocraft_io::TgaBits::from_bits(u64::from(b)))) {
None => None,
Some((_, Some(bits))) => Some(bits),
Some((b, None)) => return Ok(fail(format!("`tgaBits` must be 24 or 32, 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
// does), so the extra options are headless-only. Saying so beats saving with defaults
// while the caller believes their quality or format was applied.
// does) and the Targa bits, so the other options are headless-only. Saying so beats
// saving with defaults while the caller believes their quality or format was applied.
let unsupported: Vec<&str> = [
p.format.is_some().then_some("format"),
p.quality.is_some().then_some("quality"),
@@ -817,15 +1002,18 @@ impl PhotocraftMcp {
if !unsupported.is_empty() {
return Ok(fail(format!("bridge mode saves with the app's current settings; `{}` need headless mode", unsupported.join("`, `"))));
}
let params = match p.path {
Some(path) => json!({"path": path}),
None => json!({}),
};
let mut params = json!({});
if let Some(path) = p.path {
params["path"] = json!(path);
}
if let Some(bits) = tga_bits {
params["tgaBits"] = json!(bits.bits());
}
return to_result(b.call("app.save", params).await);
}
let Some(r) = self
.headless_op(move |h| {
let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, ..Default::default() };
let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() };
if let Some(q) = p.quality {
opts.encode.jpeg_quality = q.clamp(1, 100);
opts.encode.webp_quality = q.clamp(1, 100);
+1 -1
View File
@@ -16,7 +16,7 @@ fn annotated(mut tool: Tool) -> Tool {
tool.name.as_ref(),
"command_list" | "doc_inspect" | "session_list" | "render_preview" | "doc_render_preview" | "ui_inspect" | "ui_screenshot"
);
let additive = matches!(tool.name.as_ref(), "doc_new" | "doc_open");
let additive = matches!(tool.name.as_ref(), "doc_new" | "doc_open" | "doc_place");
tool.title = Some(title.clone());
tool.annotations = Some(ToolAnnotations::from_raw(Some(title), Some(read), Some(!read && !additive), Some(read), Some(false)));
Arc::make_mut(&mut tool.input_schema).insert("additionalProperties".into(), json!(false));
+87 -2
View File
@@ -2,6 +2,38 @@
use super::*;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use crate::security::MAX_REQUEST_BYTES;
/// Read a complete request before allowing any of its bytes to reach rmcp. `lines()`
/// buffers without a limit, while forwarding partial input can make a valid JSON prefix
/// executable at EOF after an oversized suffix is rejected.
async fn bounded_line<R: tokio::io::AsyncRead + Unpin>(source: &mut BufReader<R>, maximum: usize) -> std::io::Result<Option<String>> {
let mut bytes = Vec::new();
loop {
let available = source.fill_buf().await?;
if available.is_empty() {
if bytes.is_empty() {
return Ok(None);
}
return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e));
}
let newline = available.iter().position(|&byte| byte == b'\n');
let length = newline.map_or(available.len(), |at| at + 1);
if length > maximum.saturating_sub(bytes.len()) {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("request exceeds {maximum} bytes")));
}
bytes.extend_from_slice(&available[..length]);
source.consume(length);
if newline.is_some() {
bytes.pop();
if bytes.last() == Some(&b'\r') {
bytes.pop();
}
return String::from_utf8(bytes).map(Some).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e));
}
}
}
impl PhotocraftMcp {
/// Serve a JSON-lines stream. A malformed line receives -32700/id:null; the next line
/// is still handled. A single writer serializes parse errors and SDK responses.
@@ -14,8 +46,8 @@ impl PhotocraftMcp {
let (service_out, from_service) = tokio::io::duplex(1 << 20);
let (errors, mut error_rx) = tokio::sync::mpsc::channel::<String>(16);
let reader = tokio::spawn(async move {
let mut lines = BufReader::new(input).lines();
while let Some(line) = lines.next_line().await? {
let mut lines = BufReader::new(input);
while let Some(line) = bounded_line(&mut lines, MAX_REQUEST_BYTES).await? {
if line.trim().is_empty() {
continue;
}
@@ -59,3 +91,56 @@ impl PhotocraftMcp {
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn bounded_line_accepts_exact_limit_and_final_line() {
let mut input = BufReader::new(std::io::Cursor::new(b"1234\nlast!".to_vec()));
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("1234"));
assert_eq!(bounded_line(&mut input, 5).await.unwrap().as_deref(), Some("last!"));
assert!(bounded_line(&mut input, 5).await.unwrap().is_none());
let mut oversized_eof = BufReader::new(std::io::Cursor::new(b"longer".to_vec()));
assert_eq!(bounded_line(&mut oversized_eof, 5).await.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
}
#[tokio::test]
async fn oversized_valid_tool_call_never_reaches_dispatch() {
let initialize = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "admission-test", "version": "1" }
}
});
let mut input = format!("{initialize}\n{}\n", json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).into_bytes();
let call = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": { "name": "doc_new", "arguments": { "width": 8, "height": 8 } }
})
.to_string()
.into_bytes();
assert!(call.len() < MAX_REQUEST_BYTES);
input.extend_from_slice(&call);
// The valid JSON prefix spans multiple BufReader fills; only its whitespace suffix
// crosses the byte limit. No part of this line may be forwarded to rmcp.
input.extend(vec![b' '; MAX_REQUEST_BYTES - call.len()]);
input.push(b'\n');
let server = PhotocraftMcp::headless();
let observer = server.clone();
let result = tokio::time::timeout(std::time::Duration::from_secs(5), server.serve_io(std::io::Cursor::new(input), tokio::io::sink()))
.await
.expect("oversized MCP input should end the session");
assert!(result.unwrap_err().to_string().contains("request exceeds 1048576 bytes"));
let documents = observer.headless_op(|h| Ok(h.session.documents().len())).await.unwrap().unwrap();
assert_eq!(documents, 0, "the oversized tool call must not run");
}
}
+124 -4
View File
@@ -1,6 +1,6 @@
//! Capability-based filesystem policy for untrusted automation paths.
use std::io::Write;
use std::io::{Read, Write};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
@@ -51,8 +51,22 @@ impl AuthorizedWorkspace {
if !metadata.is_file() {
return Err(AutomationError::BadRequest(format!("automation read path is not a regular file: `{path}`")));
}
// Bounded reads, and a clear error for a file larger than memory (#375).
photocraft_format::read::read_all(&mut file, metadata.len()).map_err(|e| file_error("read", path, e))
read_to_observed_length(&mut file, metadata.len())
}
/// Load one `.pcraft` document in directory form — the native on-disk layout — below the
/// read root. `read` serves regular files only (the zip form), so a regular file at the
/// path is reported as `Ok(None)` for the caller to fall back to.
pub fn open_pcraft(&self, path: &str) -> Result<Option<photocraft_doc::Document>, AutomationError> {
let relative = request_path(path, self.read.as_ref())?;
let root = self.read.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: read authority is absent")))?;
let dir = match root.dir.open_dir(&relative) {
Ok(dir) => dir,
Err(e) if e.kind() == std::io::ErrorKind::NotADirectory => return Ok(None),
Err(e) => return Err(file_error("read", path, e)),
};
let source = DirHandle { dir };
Ok(Some(photocraft_format::load_bytes_source(&source, &photocraft_format::LoadOptions::default())?))
}
/// Create or replace one file below the configured write root, crash-safely: the bytes go to
@@ -93,6 +107,34 @@ impl AuthorizedWorkspace {
}
}
fn read_to_observed_length(reader: &mut impl Read, observed_length: u64) -> Result<Vec<u8>, AutomationError> {
// Do not reserve from a potentially stale or attacker-controlled metadata length. Read in
// chunks, and reject growth beyond the length observed on the opened file before appending.
let mut bytes = Vec::new();
let mut chunk = [0u8; 64 << 10];
loop {
let remaining = observed_length.saturating_sub(bytes.len() as u64);
let ask = chunk.len().min(usize::try_from(remaining.saturating_add(1)).unwrap_or(chunk.len()));
let n = match reader.read(&mut chunk[..ask]) {
Ok(n) => n,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(AutomationError::Io(format!("automation read failed: {e}"))),
};
if n == 0 {
return Ok(bytes);
}
if n as u64 > remaining {
return Err(AutomationError::BadRequest(format!("automation file grew beyond its observed length of {observed_length} bytes")));
}
let needed = bytes.len().checked_add(n).ok_or_else(|| AutomationError::BadRequest("automation file is too large to read".into()))?;
if needed > bytes.capacity() {
let target = bytes.capacity().saturating_add(bytes.capacity() / 2).max(needed).min(usize::try_from(observed_length).unwrap_or(usize::MAX));
bytes.try_reserve_exact(target - bytes.len()).map_err(|e| AutomationError::Io(format!("automation read allocation failed: {e}")))?;
}
bytes.extend_from_slice(&chunk[..n]);
}
}
fn open_root(path: Option<&Path>, authority: &str) -> Result<Option<RootCapability>, AutomationError> {
let Some(path) = path else { return Ok(None) };
if path.as_os_str().is_empty() {
@@ -173,6 +215,24 @@ fn path_error(path: &str, reason: &str) -> AutomationError {
AutomationError::BadRequest(format!("automation path rejected: {reason}: `{path}`"))
}
/// A `.pcraft` directory behind the read-root capability: bounded reads, no ambient paths.
struct DirHandle {
dir: Dir,
}
impl photocraft_format::ByteSource for DirHandle {
fn get(&self, path: &str, max: usize) -> photocraft_format::Result<Vec<u8>> {
let missing = || photocraft_format::FormatError::Corrupt(format!("missing `{path}`"));
let mut file = self.dir.open(path).map_err(|_| missing())?;
let metadata = file.metadata().map_err(|_| missing())?;
if !metadata.is_file() {
// A FIFO or device would block the open and stream without end.
return Err(photocraft_format::FormatError::Corrupt(format!("`{path}` is not a regular file")));
}
photocraft_format::read_entry(&mut file, metadata.len(), max, path)
}
}
fn file_error(operation: &str, path: &str, error: std::io::Error) -> AutomationError {
let kind = error.kind();
AutomationError::Io(format!("automation {operation} `{path}` failed ({kind:?}): {error}"))
@@ -274,7 +334,8 @@ fn params_contain_ambient_path(id: &str, params: &Value) -> bool {
}
fn profile_command_may_read_ambient(id: &str, params: &Value) -> bool {
if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "view.proofSetup") {
// Installed profiles scan OS and user directories even without a request path.
if matches!(id, "color.profileMismatch" | "edit.colorSettings" | "edit.installedProfiles" | "view.proofSetup") {
return true;
}
matches!(id, "edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo")
@@ -431,6 +492,62 @@ mod tests {
assert_eq!(std::fs::read(inside.join("new-output.txt")).unwrap(), b"created");
}
#[test]
fn large_observed_length_does_not_trigger_a_fixed_limit_or_upfront_reservation() {
let mut shortened = std::io::Cursor::new(b"psb");
assert_eq!(read_to_observed_length(&mut shortened, (1 << 30) + 1).unwrap(), b"psb");
}
#[test]
fn read_growth_is_rejected_at_the_observed_length() {
let mut growing = std::io::repeat(b'x').take(65);
let error = read_to_observed_length(&mut growing, 64).unwrap_err().to_string();
assert!(error.contains("grew beyond its observed length of 64 bytes"), "{error}");
let mut allowed = std::io::repeat(b'x').take(64);
assert_eq!(read_to_observed_length(&mut allowed, 64).unwrap().len(), 64);
let mut empty = std::io::Cursor::new(b"x");
assert!(read_to_observed_length(&mut empty, 0).is_err());
}
#[test]
fn open_pcraft_loads_a_directory_below_the_read_root() {
let (inside, _, workspace) = roots("pcraft-dir");
let doc = photocraft_doc::Document::new("Native", photocraft_doc::Size::new(16, 12), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8);
photocraft_format::PcraftWriter::new().save_dir(&doc, &inside.join("native.pcraft"), &photocraft_format::SaveOptions::default()).unwrap();
let loaded = workspace.open_pcraft("native.pcraft").unwrap().expect("the directory form loads");
assert_eq!(loaded.name, doc.name);
assert_eq!((loaded.size.width, loaded.size.height), (16, 12));
}
#[test]
fn open_pcraft_reports_zip_files_and_missing_paths_clearly() {
let (inside, _, workspace) = roots("pcraft-errors");
let doc = photocraft_doc::Document::new("Zipped", photocraft_doc::Size::new(4, 4), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8);
let zip = inside.join("zipped.pcraft");
photocraft_format::PcraftWriter::new().save_path(&doc, &zip, &photocraft_format::SaveOptions::default()).unwrap();
assert!(zip.is_file());
assert!(workspace.open_pcraft("zipped.pcraft").unwrap().is_none(), "the zip form falls back to the bytes path");
let error = workspace.open_pcraft("missing.pcraft").unwrap_err().to_string();
assert!(error.contains("failed"), "a missing path propagates its io error: {error}");
assert!(AuthorizedWorkspace::default().open_pcraft("x.pcraft").is_err(), "no read authority fails closed");
}
#[cfg(unix)]
#[test]
fn open_pcraft_rejects_non_regular_entries_and_escaping_symlinks() {
let (inside, _, workspace) = roots("pcraft-nonregular");
let bundle = inside.join("native.pcraft");
std::fs::create_dir_all(bundle.join("manifest.json")).unwrap();
let error = workspace.open_pcraft("native.pcraft").unwrap_err().to_string();
assert!(error.contains("not a regular file"), "a directory entry is refused, not streamed: {error}");
let (inside2, _, workspace2) = roots("pcraft-symlink");
let bundle2 = inside2.join("native.pcraft");
std::fs::create_dir(&bundle2).unwrap();
std::os::unix::fs::symlink("/dev/zero", bundle2.join("manifest.json")).unwrap();
assert!(workspace2.open_pcraft("native.pcraft").is_err(), "an escaping symlink entry is refused");
}
fn temp_files(dir: &Path) -> Vec<String> {
std::fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".tmp")).collect()
}
@@ -729,8 +846,11 @@ mod tests {
| "path.moveHandle"
| "path.bendSegment"
| "path.convertPoint"
| "path.addAnchor"
| "path.deleteAnchor"
| "path.clippingPath.set"
| "path.rename"
| "path.stroke"
| "select.toWorkPath"
| "layer.vectorMask.add"
| "layer.vectorMask.edit"
+1
View File
@@ -330,6 +330,7 @@ async fn agent_play_refuses_a_recorded_file_open() {
headless.session.actions.list.push(photocraft_engine::actions_cmds::Action {
name: "Open".into(),
steps: vec![("file.open".into(), json!({"path": "/etc/passwd"})), ("layer.new.layer".into(), json!({}))],
disabled: Default::default(),
});
let (c, server) = connect_headless(headless).await;
let played = run(&c, "actions.play", json!({"action": "Open"})).await;
+166
View File
@@ -0,0 +1,166 @@
//! Bridge client behavior against a scripted fake control server: reconnect before
//! idle sockets die, retry only caller-vouched reads, never resend edits (#1007),
//! never retry timeouts.
use std::time::Duration;
use photocraft_automation::security::authentication_reply;
use photocraft_automation::{AutomationError, BridgeClient};
use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
const CONTROL_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
/// What the fake server does after serving one request on a connection.
#[derive(Clone, Copy, PartialEq)]
enum After {
KeepOpen,
Close,
}
/// Serve connections until `max_connections` were seen. Each connection authenticates,
/// then serves requests until it closes; every served request is recorded, and `accepted`
/// counts connections as they arrive.
async fn fake_server(
max_connections: usize,
after: After,
seen: tokio::sync::mpsc::UnboundedSender<Vec<Value>>,
accepted: std::sync::Arc<std::sync::atomic::AtomicUsize>,
) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
tokio::spawn(async move {
let mut connections = 0;
while connections < max_connections {
let Ok((socket, _)) = listener.accept().await else { break };
connections += 1;
accepted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let seen = seen.clone();
let after = after;
tokio::spawn(async move {
let mut served = Vec::new();
let mut socket = socket;
authenticate(&mut socket).await;
let (read, mut write) = socket.into_split();
let mut reader = BufReader::new(read);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) | Err(_) => break,
Ok(_) => {}
}
let Ok(request) = serde_json::from_str::<Value>(&line) else { continue };
served.push(request.clone());
let reply = json!({"id": request["id"], "ok": true, "result": {"served": served.len()}});
if write.write_all(format!("{reply}\n").as_bytes()).await.is_err() {
break;
}
if after == After::Close {
break; // the reply is flushed, then both halves drop: a dead connection
}
}
let _ = seen.send(served);
});
}
});
addr
}
/// Authenticate one connection: read the auth line, reply to it.
async fn authenticate(socket: &mut TcpStream) {
let (read, mut write) = socket.split();
let mut reader = BufReader::new(read);
let mut line = String::new();
reader.read_line(&mut line).await.unwrap();
let (auth, ok) = authentication_reply(&line, CONTROL_TOKEN);
assert!(ok, "each connection must authenticate");
write.write_all(format!("{auth}\n").as_bytes()).await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn read_retries_on_a_dropped_connection_and_edits_do_not() {
// Connection 1 serves one read then dies; the next vouched read retries on
// connection 2. The edit, sent after connection 2 also died, must fail with the
// #1007 wording and never open connection 3.
let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel();
let accepted = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let addr = fake_server(2, After::Close, seen_tx, accepted).await;
let bridge = BridgeClient::new(&addr, CONTROL_TOKEN).unwrap();
let first = bridge.call_read("document.inspect", json!({})).await.expect("read served before the connection dies");
assert_eq!(first["served"], 1);
let retried = bridge.call_read("document.inspect", json!({})).await.expect("a vouched read retried on a fresh connection");
assert_eq!(retried["served"], 1, "connection 2 sees it as its own first request");
let edit = bridge.call("engine.execute", json!({"command": "layer.new.layer", "params": {}})).await;
let error = edit.unwrap_err().to_string();
assert!(error.contains("operation may have completed"), "{error}");
assert!(error.contains("inspect state before retrying"), "{error}");
assert!(!error.contains("retried"), "{error}");
let mut all = Vec::new();
while let Ok(served) = seen_rx.try_recv() {
all.extend(served);
}
assert_eq!(all.len(), 2, "one request per connection, no edit resend: {all:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn an_idle_connection_is_replaced_before_the_next_call() {
// The production idle threshold sits under the app's 30s idle cutoff; the test
// shrinks it so the reconnect path runs quickly. The next call after idling must
// authenticate a new connection before sending.
let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel();
let accepted = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let addr = fake_server(2, After::KeepOpen, seen_tx, std::sync::Arc::clone(&accepted)).await;
let bridge = BridgeClient::new(&addr, CONTROL_TOKEN).unwrap().with_idle_reconnect(Duration::from_millis(100));
bridge.call_read("document.inspect", json!({})).await.expect("first call connects");
tokio::time::sleep(Duration::from_millis(250)).await;
bridge.call_read("document.inspect", json!({})).await.expect("second call reconnects after idling");
assert_eq!(accepted.load(std::sync::atomic::Ordering::SeqCst), 2, "the idle connection was replaced, not reused");
let first = seen_rx.try_recv().expect("the replaced connection closed, reporting what it served");
assert_eq!(first.len(), 1, "each connection served only the request that followed its auth: {first:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_timed_out_call_is_not_retried() {
// The server authenticates but never answers requests, keeping the connection open:
// the call hits the client's own timeout, which must name the likely causes and must
// not double the wait by retrying (the fake server counts every request it sees).
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
let seen = std::sync::Arc::clone(&requests);
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
authenticate(&mut socket).await;
let (read, _write) = socket.into_split();
let mut reader = BufReader::new(read);
let mut line = String::new();
loop {
line.clear();
if reader.read_line(&mut line).await.unwrap_or(0) == 0 {
return;
}
if let Ok(request) = serde_json::from_str::<Value>(&line) {
seen.lock().unwrap().push(request);
}
}
});
let bridge = BridgeClient::new(&addr, CONTROL_TOKEN).unwrap().with_timeout(Duration::from_millis(300));
let error = match bridge.call_read("document.inspect", json!({})).await {
Err(e @ AutomationError::Bridge(_)) => e.to_string(),
other => panic!("expected the bridge timeout, got {other:?}"),
};
assert!(error.contains("timed out after"), "{error}");
assert!(error.contains("not drawing frames"), "{error}");
assert!(error.contains("a read may be retried safely"), "{error}");
assert!(!error.contains("may have completed"), "a read timeout never claims an applied edit: {error}");
assert_eq!(requests.lock().unwrap().len(), 1, "a timed-out call is not resent");
}
+174 -1
View File
@@ -79,6 +79,10 @@ async fn lists_expected_tools() {
"session_list",
"doc_open",
"doc_new",
"doc_place",
"doc_export_layers",
"doc_export_data_sets",
"doc_import_data_sets",
"doc_save",
"doc_export",
"doc_inspect",
@@ -359,6 +363,49 @@ async fn bridge_doc_save_without_path_forwards_app_save_for_write_back() {
app.abort();
}
/// `tgaBits` reaches the app's `app.save` through the bridge; a value other than 24 or 32 is
/// refused before anything is sent.
#[tokio::test(flavor = "multi_thread")]
async fn bridge_doc_save_forwards_targa_bits() {
let (addr, app) = fake_app().await;
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
let saved = json_of(&call(&client, "doc_save", json!({"path": "out.tga", "tgaBits": 24})).await);
assert_eq!(saved, json!({"saved": {"path": "out.tga", "tgaBits": 24}}));
let r = call(&client, "doc_save", json!({"path": "out.tga", "tgaBits": 16})).await;
assert_eq!(r.is_error, Some(true), "{}", text(&r));
assert!(text(&r).contains("24 or 32"), "{}", text(&r));
client.cancel().await.unwrap();
app.abort();
}
/// Headless `doc_save`/`doc_export` write the TGA bits per pixel asked for (byte 16 of the
/// header); unset, a document with an alpha channel is written as 32-bit.
#[tokio::test(flavor = "multi_thread")]
async fn headless_doc_save_writes_targa_bits() {
let dir = tmp("tga-bits");
let client = connect(headless_in(&dir)).await;
call(&client, "doc_new", json!({"width": 16, "height": 8, "background": "white"})).await;
for id in ["select.all", "select.saveSelection"] {
let r = call(&client, "command_run", json!({"id": id, "params": {}})).await;
assert_ne!(r.is_error, Some(true), "{}", text(&r));
}
for (tool, params, bpp) in [
("doc_export", json!({"path": "auto.tga"}), 32),
("doc_export", json!({"path": "flat.tga", "tgaBits": 24}), 24),
("doc_save", json!({"path": "alpha.tga", "tgaBits": 32}), 32),
] {
let r = call(&client, tool, params.clone()).await;
assert_ne!(r.is_error, Some(true), "{params}: {}", text(&r));
let bytes = std::fs::read(dir.join(params["path"].as_str().unwrap())).unwrap();
assert_eq!(bytes[16], bpp, "{params}");
}
let r = call(&client, "doc_export", json!({"path": "bad.tga", "tgaBits": 8})).await;
assert_eq!(r.is_error, Some(true), "{}", text(&r));
assert!(!dir.join("bad.tga").exists());
client.cancel().await.unwrap();
cleanup(&dir);
}
#[tokio::test(flavor = "multi_thread")]
async fn bridge_response_budget_drops_connection_without_retrying_the_operation() {
use photocraft_automation::{BridgeClient, budgets::MAX_RESPONSE_BYTES};
@@ -568,6 +615,107 @@ async fn filesystem_policy_rejects_absolute_and_escaping_paths_before_effects()
cleanup(&base);
}
/// #2810: a file from the read root places as a Smart Object layer; `file.placeEmbedded` itself
/// stays refused, so `doc_place` is the only way in and the root is still the scope.
#[tokio::test(flavor = "multi_thread")]
async fn doc_place_is_capability_scoped() {
let base = tmp("place");
let root = base.join("workspace");
let outside = base.join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgb, vec![42; 144]).unwrap();
let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
std::fs::write(root.join("inside.png"), &bytes).unwrap();
std::fs::write(outside.join("outside.png"), bytes).unwrap();
let client = connect(headless_in(&root)).await;
json_of(&call(&client, "doc_new", json!({"width": 40, "height": 30})).await);
let placed = call(&client, "doc_place", json!({"path": "inside.png", "center": [10, 10]})).await;
assert_ne!(placed.is_error, Some(true), "{}", text(&placed));
let layers = json_of(&call(&client, "doc_inspect", json!({})).await)["layers"].as_array().unwrap().len();
assert_eq!(layers, 2, "the placed layer is in the document");
// An absolute path beneath the root is the same file (#2176), so it places too; outside it,
// nothing.
let inside = call(&client, "doc_place", json!({"path": root.join("inside.png").to_string_lossy()})).await;
assert_ne!(inside.is_error, Some(true), "{}", text(&inside));
let placed = json_of(&call(&client, "doc_inspect", json!({})).await)["layers"].as_array().unwrap().len();
assert_eq!(placed, 3, "the absolute path beneath the root places as well");
let refused = call(&client, "doc_place", json!({"path": outside.join("outside.png").to_string_lossy()})).await;
assert_eq!(refused.is_error, Some(true));
assert!(text(&refused).contains("must be inside the automation root"), "{}", text(&refused));
let after = json_of(&call(&client, "doc_inspect", json!({})).await)["layers"].as_array().unwrap().len();
assert_eq!(after, placed, "nothing outside the root was placed");
// The ambient command keeps its refusal: the capability-scoped method is the only way in.
let ambient = call(&client, "command_run", json!({"id": "file.placeEmbedded", "params": {"path": "inside.png"}})).await;
assert_eq!(ambient.is_error, Some(true));
assert!(text(&ambient).contains("ambient filesystem paths"), "{}", text(&ambient));
client.cancel().await.unwrap();
cleanup(&base);
}
/// #3216: the export half of #2810. The write root scopes both exports, the read root the CSV,
/// and the three ambient commands keep their refusal.
#[tokio::test(flavor = "multi_thread")]
async fn doc_exports_and_data_set_import_are_capability_scoped() {
let base = tmp("export");
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let client = connect(headless_in(&root)).await;
json_of(&call(&client, "doc_new", json!({"width": 10, "height": 6, "background": "white", "name": "card"})).await);
let inspected = json_of(&call(&client, "doc_inspect", json!({})).await);
let layer = inspected["layers"][0]["id"].clone();
json_of(
&call(&client, "command_run", json!({"id": "image.variables.define", "params": {"defs": [{"name": "show", "layer": layer, "type": "visibility"}]}}))
.await,
);
json_of(
&call(
&client,
"command_run",
json!({"id": "image.variables.dataSets", "params": {"dataSets": [
{"name": "one", "values": [{"variable": "show", "kind": "visibility", "value": true}]},
{"name": "two", "values": [{"variable": "show", "kind": "visibility", "value": false}]},
]}}),
)
.await,
);
let layers = call(&client, "doc_export_layers", json!({"dir": "layers", "prefix": "card"})).await;
assert_ne!(layers.is_error, Some(true), "{}", text(&layers));
assert_eq!(json_of(&layers)["count"], 1, "{}", text(&layers));
assert_eq!(std::fs::read_dir(root.join("layers")).unwrap().count(), 1);
let sets = call(&client, "doc_export_data_sets", json!({"dir": "sets", "format": "png"})).await;
assert_ne!(sets.is_error, Some(true), "{}", text(&sets));
assert_eq!(json_of(&sets)["count"], 2, "{}", text(&sets));
assert_eq!(std::fs::read_dir(root.join("sets")).unwrap().count(), 2);
// A colliding template is refused before any file of the batch is written.
let clash = call(&client, "doc_export_data_sets", json!({"dir": "clash", "naming": "{document}"})).await;
assert_eq!(clash.is_error, Some(true), "{}", text(&clash));
assert_eq!(std::fs::read_dir(root.join("clash")).unwrap().count(), 0);
// A CSV from the read root.
std::fs::write(root.join("sets.csv"), "Data Set,show\none,true\n").unwrap();
let imported = call(&client, "doc_import_data_sets", json!({"path": "sets.csv"})).await;
assert_ne!(imported.is_error, Some(true), "{}", text(&imported));
assert_eq!(json_of(&imported)["imported"], 1, "{}", text(&imported));
// Outside the roots nothing happens at all.
let escaped = call(&client, "doc_export_layers", json!({"dir": base.join("escaped").to_string_lossy()})).await;
assert_eq!(escaped.is_error, Some(true), "{}", text(&escaped));
assert!(!base.join("escaped").exists());
std::fs::write(base.join("outside.csv"), "Data Set,show\none,true\n").unwrap();
let outside = call(&client, "doc_import_data_sets", json!({"path": base.join("outside.csv").to_string_lossy()})).await;
assert_eq!(outside.is_error, Some(true), "{}", text(&outside));
for command in ["file.export.layersToFiles", "file.export.dataSetsAsFiles", "file.import.variableDataSets"] {
let refused = call(&client, "command_run", json!({"id": command, "params": {}})).await;
assert_eq!(refused.is_error, Some(true), "{command}");
assert!(text(&refused).contains("ambient filesystem paths"), "{}", text(&refused));
}
client.cancel().await.unwrap();
cleanup(&base);
}
/// The template workflow stays in memory, including saving nested contents back to the parent.
#[tokio::test(flavor = "multi_thread")]
async fn embedded_smart_objects_can_be_edited_saved_and_unpacked_without_file_authority() {
@@ -892,6 +1040,18 @@ async fn bridge_reports_unreachable_app() {
client.cancel().await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn command_batch_description_exposes_non_atomic_semantics_when_tools_are_listed() {
let client = connect(PhotocraftMcp::headless()).await;
let tools = client.list_all_tools().await.unwrap();
let tool = tools.iter().find(|tool| tool.name == "command_batch").expect("command_batch tool");
let description = tool.description.as_deref().expect("command_batch description");
for contract in ["in order", "Not an atomic transaction", "first error", "stop_on_error is false", "no rollback"] {
assert!(description.contains(contract), "command_batch description omits `{contract}`: {description}");
}
client.cancel().await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn command_batch_runs_steps_in_order() {
let client = connect(PhotocraftMcp::headless()).await;
@@ -1068,7 +1228,20 @@ async fn convention_resources_are_live_json() {
assert!(read("photocraft://document").await["document"].is_null());
json_of(&call(&client, "doc_new", json!({"width":8,"height":6})).await);
assert_eq!(read("photocraft://document").await["width"], 8);
assert_eq!(read("photocraft://commands").await, json_of(&call(&client, "command_list", json!({})).await));
// 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.
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"));
}
}
v
};
let commands = stable(read("photocraft://commands").await);
assert!(commands.as_array().is_some_and(|a| a.len() > 100), "{commands}");
assert_eq!(commands, stable(json_of(&call(&client, "command_list", json!({})).await)));
assert!(client.read_resource(rmcp::model::ReadResourceRequestParams::new("photocraft://missing")).await.is_err());
let preview = call(&client, "render_preview", json!({"max_side":4})).await;
assert!(preview.content.iter().any(|c| c.as_image().is_some()));
@@ -0,0 +1,40 @@
//! Save As must store the new filename in the native manifest, not just the in-session tab name.
use photocraft_automation::{Headless, files};
use serde_json::json;
#[test]
fn native_save_as_reopens_with_the_written_filename() {
let dir = std::env::temp_dir().join(format!(
"pc-native-save-name-{}-{}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let first = dir.join("FirstTest.pcraft");
let second = dir.join("SecondTest.pcraft");
let mut headless = Headless::trusted_local();
headless.handle("doc.new", json!({"width": 8, "height": 8, "name": "Old Title"})).unwrap();
headless.handle("doc.save", json!({"path": first.to_str().unwrap()})).unwrap();
assert_eq!(files::open(&first).unwrap().document.name, "FirstTest.pcraft");
headless.handle("doc.save", json!({"path": second.to_str().unwrap()})).unwrap();
assert_eq!(headless.session.active().unwrap().doc.name, "SecondTest.pcraft");
assert_eq!(files::open(&second).unwrap().document.name, "SecondTest.pcraft");
// Save back in place and retain the correct title after another edit.
headless.handle("engine.execute", json!({"command":"image.adjustments.invert", "params":{}})).unwrap();
headless.handle("doc.save", json!({})).unwrap();
assert_eq!(files::open(&second).unwrap().document.name, "SecondTest.pcraft");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn workspace_encoded_native_bundle_uses_requested_name_without_mutating_source() {
let mut headless = Headless::trusted_local();
headless.handle("doc.new", json!({"width": 8, "height": 8, "name": "Original"})).unwrap();
let doc = &headless.session.active().unwrap().doc;
let (bytes, warnings) = files::save_bytes(doc, "exports/Renamed.pcraft", None, &Default::default()).unwrap();
assert!(warnings.is_empty());
let reopened = files::open_bytes("Renamed.pcraft", &bytes).unwrap().document;
assert_eq!(reopened.name, "Renamed.pcraft");
assert_eq!(doc.name, "Original", "a copy should not retarget the open document");
}
+14 -2
View File
@@ -4,7 +4,7 @@ Codecs for flat raster image formats, written in pure Rust. The crate is standal
workspace crate and defines its own `Image` type. It also builds for `wasm32-unknown-unknown` and
does no I/O: decoders take `&[u8]` and encoders return `Vec<u8>`.
It follows the "avoid GIMP's hole" rules in `plan/architecture.md` §1.1:
It follows the "Avoiding GIMP's hole" rules in `docs/architecture.md` §1.1:
* **Symmetric.** Every format we write, we can also read. The only exceptions are listed in
`ASYMMETRIC_EXCEPTIONS`, each with a reason. The test suite checks this.
@@ -44,6 +44,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| 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 |
| 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` |
@@ -62,6 +63,14 @@ the same.
## Documented asymmetries and limitations
* **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
stay exact as U8/U16 (a camera's `BITPIX = 16`, `BZERO = 32768`); the rest become F32, taken as
normalised when at most 2 and scaled by 65535 (or the maximum) otherwise. Header cards are
kept as text (`HIERARCH` for long names). Tile-compressed (fpack) files are refused with
`CodecError::Unsupported`; Bayer mosaics open undebayered.
* **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.
@@ -92,7 +101,10 @@ the same.
`DecodeWarning::MoreFrames` / `MorePages` in `Image::warnings`, with the total when the file
states it (frames and TIFF directories are counted without decoding them; reduced-resolution
TIFF directories and transparency masks are not pages). Any TIFF page can still be opened
with `decode_tiff_page` (and `photocraft_io::import_tiff_page`).
with `decode_tiff_page` (and `photocraft_io::import_tiff_page`). Every frame of a GIF comes
from `decode_gif_frames`: each frame composited onto the logical screen with the disposal
methods applied, as RGBA8 with its delay in milliseconds, within `Limits` and
`MAX_GIF_FRAMES`. The video-layer commands use it (`photocraft_io::import_gif_frames`).
* **EXIF in TIFF** is stored as a sub-IFD rather than a blob, so it is not preserved yet.
`caps.exif = false` for TIFF, and a warning is raised.
* **Orientation** (EXIF tag 274 in JPEG, PNG `eXIf` and WebP; the decoded page's tag in TIFF and
+7
View File
@@ -62,3 +62,10 @@ path = "fuzz_targets/decode_heif.rs"
test = false
doc = false
bench = false
[[bin]]
name = "decode_gif"
path = "fuzz_targets/decode_gif.rs"
test = false
doc = false
bench = false
@@ -0,0 +1,13 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use photocraft_codecs::{decode_as_with, decode_gif_frames, DecodeOptions, Format, Limits};
fuzz_target!(|data: &[u8]| {
let opts = DecodeOptions {
limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 },
..Default::default()
};
let _ = decode_as_with(Format::Gif, data, &opts);
let _ = decode_gif_frames(data, &opts);
});
+675
View File
@@ -0,0 +1,675 @@
//! FITS (Flexible Image Transport System, FITS Standard 4.0), implemented here: the image in the
//! primary HDU, or the first `IMAGE` extension when the primary HDU holds no data.
//!
//! Reading:
//!
//! * `BITPIX` 8, 16, 32, 64, -32 and -64, with `BSCALE`/`BZERO` and integer `BLANK`.
//! * `NAXIS3 = 3` opens as RGB (the planes are R, G, B); any other plane count opens the first
//! plane as gray with [`DecodeWarning::MorePages`].
//! * Rows are stored bottom-to-top, as the standard's convention has it (Siril, DS9, Astropy),
//! unless `ROWORDER = 'TOP-DOWN'`.
//! * Physical values that fit (unsigned 8-bit, or integers in 0..=65535 such as a camera's
//! `BITPIX = 16`, `BZERO = 32768`) are kept exactly as U8/U16. Everything else becomes F32:
//! values up to 2 are taken as already normalised (Siril, PixInsight), larger ones are scaled by
//! 65535 when they fit in it (DeepSkyStacker, most stackers) and by the maximum otherwise. NaN
//! and blank pixels read as 0.
//! * The other header cards are kept as text: `KEY` → value (strings unquoted), `COMMENT` and
//! `HISTORY` by their text, `HIERARCH` keywords by their long name.
//!
//! Writing: gray or RGB, U8 (`BITPIX = 8`), U16 (`BITPIX = 16`, `BZERO = 32768`) or F32
//! (`BITPIX = -32`, 0..1), bottom-to-top with `ROWORDER = 'BOTTOM-UP'`, plus the text cards.
//! Tile-compressed files (fpack `ZIMAGE` tables) are recognised and refused.
use crate::Format;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::image::{ChannelLayout, DecodeWarning, Image, SampleType};
use crate::options::{EncodeOptions, Limits};
const F: Format = Format::Fits;
const BLOCK: usize = 2880;
const CARD: usize = 80;
/// Most HDUs walked looking for an image; FITS files from cameras and stackers hold a handful.
const MAX_HDUS: usize = 1000;
/// Keywords that describe the data layout. They are written from the image, never copied from
/// (or kept as) text metadata: after decoding they no longer describe the pixels.
const STRUCTURAL: &[&str] = &[
"SIMPLE", "BITPIX", "NAXIS", "EXTEND", "BZERO", "BSCALE", "BLANK", "END", "XTENSION", "PCOUNT", "GCOUNT", "ROWORDER", "CHECKSUM", "DATASUM", "DATAMIN",
"DATAMAX", "ZIMAGE",
];
fn err(msg: impl std::fmt::Display) -> CodecError {
CodecError::malformed(F, msg)
}
#[derive(Debug, Clone, PartialEq)]
enum Value {
/// A quoted string, unescaped, trailing blanks removed.
Str(String),
/// Anything else (number, logical, complex) as written.
Raw(String),
}
#[derive(Debug, Clone)]
struct Card {
key: String,
/// `None` for commentary cards (`COMMENT`, `HISTORY`, blank keyword).
value: Option<Value>,
/// The text of a commentary card.
text: String,
/// A `HIERARCH` keyword (`key` holds its long name).
hierarch: bool,
}
fn parse_value(field: &str) -> Value {
let t = field.trim_start();
if let Some(rest) = t.strip_prefix('\'') {
let mut s = String::new();
let mut chars = rest.chars().peekable();
while let Some(c) = chars.next() {
if c == '\'' {
if chars.peek() == Some(&'\'') {
chars.next();
s.push('\'');
} else {
break;
}
} else {
s.push(c);
}
}
Value::Str(s.trim_end().to_string())
} else {
Value::Raw(t.split('/').next().unwrap_or("").trim().to_string())
}
}
fn parse_card(raw: &[u8]) -> Card {
// Header bytes are ASCII 32–126 by the standard; anything else is replaced, never trusted.
let s: String = raw.iter().map(|&b| if (32..=126).contains(&b) { b as char } else { '?' }).collect();
let key_field = s.get(..8).unwrap_or(&s).trim_end().to_string();
if key_field == "HIERARCH" {
let rest = s.get(9..).unwrap_or("");
if let Some((k, v)) = rest.split_once('=') {
return Card { key: k.trim().to_string(), value: Some(parse_value(v)), text: String::new(), hierarch: true };
}
}
if s.get(8..10) == Some("= ") {
let v = parse_value(s.get(10..).unwrap_or(""));
return Card { key: key_field, value: Some(v), text: String::new(), hierarch: false };
}
Card { key: key_field, value: None, text: s.get(8..).unwrap_or("").trim_end().to_string(), hierarch: false }
}
struct Header {
cards: Vec<Card>,
/// Offset of the data unit.
data_start: usize,
}
impl Header {
fn get(&self, key: &str) -> Option<&Value> {
self.cards.iter().find(|c| !c.hierarch && c.key == key).and_then(|c| c.value.as_ref())
}
fn int(&self, key: &str) -> Option<i64> {
match self.get(key)? {
Value::Raw(r) => r.parse::<i64>().ok().or_else(|| self.float(key).filter(|f| f.fract() == 0.0 && f.abs() < 9.0e15).map(|f| f as i64)),
Value::Str(_) => None,
}
}
fn float(&self, key: &str) -> Option<f64> {
match self.get(key)? {
// Fortran-style `D` exponents are allowed in FITS.
Value::Raw(r) => r.replace(['D', 'd'], "E").parse::<f64>().ok().filter(|f| f.is_finite()),
Value::Str(_) => None,
}
}
fn logical(&self, key: &str) -> bool {
matches!(self.get(key), Some(Value::Raw(r)) if r == "T")
}
fn string(&self, key: &str) -> Option<&str> {
match self.get(key)? {
Value::Str(s) => Some(s.trim()),
Value::Raw(_) => None,
}
}
}
fn parse_header(b: &[u8], start: usize) -> Result<Header, CodecError> {
let mut cards = Vec::new();
let mut pos = start;
loop {
let raw = b.get(pos..pos.checked_add(CARD).ok_or_else(|| err("header too long"))?).ok_or_else(|| err("header ends before END"))?;
pos += CARD;
if raw.starts_with(b"END") && raw.get(3..).is_some_and(|r| r.iter().all(|&c| c == b' ')) {
break;
}
cards.push(parse_card(raw));
}
let data_start = pos.div_ceil(BLOCK).checked_mul(BLOCK).ok_or_else(|| err("header too long"))?;
Ok(Header { cards, data_start })
}
/// The axis lengths (`NAXIS1`, `NAXIS2`, …).
fn axes(h: &Header) -> Result<Vec<u64>, CodecError> {
let n = h.int("NAXIS").ok_or_else(|| err("missing NAXIS"))?;
if !(0..=999).contains(&n) {
return Err(err(format!("invalid NAXIS {n}")));
}
(1..=n).map(|i| h.int(&format!("NAXIS{i}")).and_then(|v| u64::try_from(v).ok()).ok_or_else(|| err(format!("missing or negative NAXIS{i}")))).collect()
}
/// Bytes in the data unit (before padding to a block).
fn data_len(h: &Header, bitpix: i64) -> Result<u64, CodecError> {
let ax = axes(h)?;
if ax.is_empty() {
return Ok(0);
}
let pcount = u64::try_from(h.int("PCOUNT").unwrap_or(0)).map_err(|_| err("negative PCOUNT"))?;
let gcount = u64::try_from(h.int("GCOUNT").unwrap_or(1)).map_err(|_| err("negative GCOUNT"))?;
let elems = ax.iter().try_fold(1u64, |a, &n| a.checked_mul(n)).ok_or_else(|| err("data size overflows"))?;
elems
.checked_add(pcount)
.and_then(|v| v.checked_mul(gcount))
.and_then(|v| v.checked_mul(bitpix.unsigned_abs() / 8))
.ok_or_else(|| err("data size overflows"))
}
/// Finds the image HDU: the primary one when it has at least two axes, else the first `IMAGE`
/// extension. Returns its header and the primary header (for its descriptive cards).
fn find_image(b: &[u8]) -> Result<(Header, Option<Header>), CodecError> {
let primary = parse_header(b, 0)?;
if primary.get("SIMPLE").is_none() {
return Err(err("missing SIMPLE"));
}
if axes(&primary)?.len() >= 2 {
return Ok((primary, None));
}
let mut compressed = false;
let mut cur_start = primary.data_start;
let mut cur_len = data_len(&primary, primary.int("BITPIX").unwrap_or(8))?;
for _ in 0..MAX_HDUS {
let next = usize::try_from(cur_len.div_ceil(BLOCK as u64).saturating_mul(BLOCK as u64))
.ok()
.and_then(|l| cur_start.checked_add(l))
.ok_or_else(|| err("data size overflows"))?;
if next >= b.len() {
break;
}
let h = parse_header(b, next)?;
let kind = h.string("XTENSION").unwrap_or("").to_string();
if kind == "IMAGE" && axes(&h)?.len() >= 2 {
return Ok((h, Some(primary)));
}
if kind == "BINTABLE" && h.logical("ZIMAGE") {
compressed = true;
}
cur_len = data_len(&h, h.int("BITPIX").unwrap_or(8))?;
cur_start = h.data_start;
}
if compressed {
return Err(CodecError::unsupported(F, "tile-compressed FITS (fpack) is not supported; decompress it with funpack first"));
}
Err(err("the file holds no image"))
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let (h, primary) = find_image(bytes)?;
let bitpix = h.int("BITPIX").ok_or_else(|| err("missing BITPIX"))?;
if !matches!(bitpix, 8 | 16 | 32 | 64 | -32 | -64) {
return Err(err(format!("invalid BITPIX {bitpix}")));
}
let ax = axes(&h)?;
let (Some(&w), Some(&hgt)) = (ax.first(), ax.get(1)) else { return Err(err("the image has fewer than two axes")) };
let w = u32::try_from(w).map_err(|_| CodecError::LimitExceeded(format!("width {w}")))?;
let hgt = u32::try_from(hgt).map_err(|_| CodecError::LimitExceeded(format!("height {hgt}")))?;
let planes = ax.iter().skip(2).try_fold(1u64, |a, &n| a.checked_mul(n)).ok_or_else(|| err("too many planes"))?;
let (layout, nplanes) = if ax.get(2) == Some(&3) && planes == 3 { (ChannelLayout::Rgb, 3usize) } else { (ChannelLayout::Gray, 1) };
let bscale = h.float("BSCALE").unwrap_or(1.0);
let bzero = h.float("BZERO").unwrap_or(0.0);
let blank = if bitpix > 0 { h.int("BLANK") } else { None };
let integral = bscale == 1.0 && bzero.fract() == 0.0;
let candidate = match bitpix {
8 if integral && bzero == 0.0 => SampleType::U8,
8 | 16 if integral => SampleType::U16,
_ => SampleType::F32,
};
limits.check(w, hgt, layout, candidate)?;
let bps = (bitpix.unsigned_abs() / 8) as usize;
let plane_len = (w as usize).checked_mul(hgt as usize).ok_or_else(|| CodecError::LimitExceeded(format!("{w}x{hgt} does not fit in memory")))?;
let n = plane_len.checked_mul(nplanes).ok_or_else(|| CodecError::LimitExceeded("image does not fit in memory".into()))?;
let need = n.checked_mul(bps).and_then(|l| l.checked_add(h.data_start)).ok_or_else(|| err("data size overflows"))?;
let data = bytes.get(h.data_start..need).ok_or_else(|| err("truncated data"))?;
// Physical value of sample `i` (planar order); `None` for a blank or NaN pixel.
let physical = |i: usize| -> Option<f64> {
let s = data.get(i * bps..(i + 1) * bps)?;
let raw = match bitpix {
8 => s.first().map(|&v| v as i64)?,
16 => i16::from_be_bytes([s[0], s[1]]) as i64,
32 => i32::from_be_bytes([s[0], s[1], s[2], s[3]]) as i64,
64 => i64::from_be_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]]),
-32 => return Some(f32::from_be_bytes([s[0], s[1], s[2], s[3]]) as f64 * bscale + bzero).filter(|v| v.is_finite()),
_ => return Some(f64::from_be_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]]) * bscale + bzero).filter(|v| v.is_finite()),
};
if blank == Some(raw) {
return None;
}
Some(raw as f64 * bscale + bzero)
};
let top_down = h.string("ROWORDER").is_some_and(|r| r.eq_ignore_ascii_case("TOP-DOWN"));
let (wu, hu) = (w as usize, hgt as usize);
// Planar, bottom-up (by default) → interleaved, top-down.
let dest = |i: usize| -> usize {
let (p, rem) = (i / plane_len, i % plane_len);
let (y, x) = (rem / wu, rem % wu);
let y = if top_down { y } else { hu - 1 - y };
(y * wu + x) * nplanes + p
};
let mut img = match candidate {
SampleType::U8 => {
let mut out = vec![0u8; n];
for i in 0..n {
out[dest(i)] = physical(i).unwrap_or(0.0) as u8;
}
Image::from_u8(w, hgt, layout, out)?
}
_ => {
let mut out16 = if candidate == SampleType::U16 { Some(vec![0u16; n]) } else { None };
if let Some(out) = out16.as_mut() {
for i in 0..n {
let v = physical(i).unwrap_or(0.0);
if !(0.0..=65535.0).contains(&v) {
// Signed data with negative values: fall back to float.
out16 = None;
break;
}
out[dest(i)] = v as u16;
}
}
match out16 {
Some(out) => Image::from_u16(w, hgt, layout, &out)?,
None => {
let max = (0..n).filter_map(physical).fold(0.0f64, f64::max);
let white = if max <= 2.0 {
1.0
} else if max <= 65535.0 {
65535.0
} else {
max
};
let mut out = vec![0f32; n];
for i in 0..n {
out[dest(i)] = (physical(i).unwrap_or(0.0) / white) as f32;
}
Image::from_f32(w, hgt, layout, &out)?
}
}
}
};
if planes > nplanes as u64 {
img.warnings.push(DecodeWarning::MorePages { total: u32::try_from(planes).ok() });
}
let mut text = Vec::new();
for c in primary.iter().flat_map(|p| p.cards.iter()).chain(h.cards.iter()) {
if !c.hierarch && (STRUCTURAL.contains(&c.key.as_str()) || is_naxis(&c.key)) {
continue;
}
let entry = match &c.value {
Some(Value::Str(s) | Value::Raw(s)) => (c.key.clone(), s.clone()),
None if c.key.is_empty() && c.text.trim().is_empty() => continue,
None => (if c.key.is_empty() { "COMMENT".to_string() } else { c.key.clone() }, c.text.trim().to_string()),
};
text.push(entry);
}
img.meta.text = text;
Ok(img)
}
fn is_naxis(key: &str) -> bool {
key.strip_prefix("NAXIS").is_some_and(|d| d.chars().all(|c| c.is_ascii_digit()))
}
/// A standard keyword: 1–8 characters from `A-Z 0-9 _ -`.
fn standard_key(key: &str) -> bool {
(1..=8).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
}
fn ascii(s: &str) -> String {
s.chars().map(|c| if (' '..='~').contains(&c) { c } else { '?' }).collect()
}
/// A FITS literal (number or logical) that can be written unquoted.
fn literal(v: &str) -> bool {
v == "T" || v == "F" || (v.replace(['D', 'd'], "E").parse::<f64>().is_ok_and(|f| f.is_finite()) && v.bytes().all(|b| b"0123456789+-.EeDd".contains(&b)))
}
fn quoted(v: &str, room: usize) -> String {
let mut s = String::from("'");
for c in ascii(v).chars() {
let add = if c == '\'' { 2 } else { 1 };
// Keep room for the closing quote.
if s.len() + add + 1 > room {
break;
}
s.push(c);
if c == '\'' {
s.push('\'');
}
}
// Strings are at least eight characters inside the quotes.
while s.len() < 9 {
s.push(' ');
}
s.push('\'');
s
}
struct Writer {
out: Vec<u8>,
}
impl Writer {
fn card(&mut self, text: &str) {
let mut c: Vec<u8> = ascii(text).into_bytes();
c.truncate(CARD);
c.resize(CARD, b' ');
self.out.extend_from_slice(&c);
}
fn value(&mut self, key: &str, v: &str) {
if literal(v) {
self.card(&format!("{key:<8}= {v:>20}"));
} else {
self.card(&format!("{key:<8}= {}", quoted(v, CARD - 10)));
}
}
fn commentary(&mut self, key: &str, text: &str) {
let chars: Vec<char> = ascii(text).chars().collect();
if chars.is_empty() {
self.card(key);
}
for chunk in chars.chunks(CARD - 8) {
self.card(&format!("{key:<8}{}", chunk.iter().collect::<String>()));
}
}
fn text(&mut self, key: &str, v: &str) {
let upper = key.to_ascii_uppercase();
if matches!(upper.as_str(), "COMMENT" | "HISTORY") {
self.commentary(&upper, v);
} else if standard_key(key) {
if !STRUCTURAL.contains(&key) && !is_naxis(key) {
self.value(key, v);
}
} else {
// Long or mixed-case names (`Description`, `Software`): the HIERARCH convention,
// when the card fits, else a comment.
let name = ascii(key);
let head = format!("HIERARCH {name} = ");
let fits_key = !name.contains('=') && !name.trim().is_empty();
let body = if literal(v) { v.to_string() } else { quoted(v, CARD.saturating_sub(head.len())) };
if fits_key && head.len() + body.len() <= CARD && (literal(v) || body.ends_with('\'')) && quoted_whole(v, &body) {
self.card(&format!("{head}{body}"));
} else {
self.commentary("COMMENT", &format!("{key} = {v}"));
}
}
}
}
/// `body` holds all of `v` (it was not cut short to fit the card).
fn quoted_whole(v: &str, body: &str) -> bool {
literal(v) || body.trim_matches('\'').trim_end().replace("''", "'") == ascii(v).trim_end()
}
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
let img = src.converted(plan.layout, plan.sample);
let (w, h) = img.dimensions();
let layout = img.layout();
let nc = match layout {
ChannelLayout::Gray => 1,
ChannelLayout::Rgb => 3,
l => return Err(CodecError::encode(F, format!("FITS cannot store {l:?}"))),
};
let bitpix = match img.sample_type() {
SampleType::U8 => 8,
SampleType::U16 => 16,
SampleType::F32 => -32,
s => return Err(CodecError::encode(F, format!("unsupported sample {s:?}"))),
};
let mut wr = Writer { out: Vec::new() };
wr.value("SIMPLE", "T");
wr.value("BITPIX", &bitpix.to_string());
wr.value("NAXIS", if nc == 3 { "3" } else { "2" });
wr.value("NAXIS1", &w.to_string());
wr.value("NAXIS2", &h.to_string());
if nc == 3 {
wr.value("NAXIS3", "3");
}
if bitpix == 16 {
wr.value("BZERO", "32768");
wr.value("BSCALE", "1");
}
wr.value("ROWORDER", "BOTTOM-UP");
if opts.embed_metadata {
for (k, v) in &img.meta.text {
wr.text(k, v);
}
}
wr.card("END");
let mut out = wr.out;
out.resize(out.len().div_ceil(BLOCK) * BLOCK, b' ');
// Planar, bottom row first.
let (wu, hu) = (w as usize, h as usize);
let idx = |p: usize, y: usize, x: usize| ((hu - 1 - y) * wu + x) * nc + p;
match bitpix {
8 => {
let d = img.data();
for p in 0..nc {
for y in 0..hu {
for x in 0..wu {
out.push(d[idx(p, y, x)]);
}
}
}
}
16 => {
let d = img.to_u16_samples().unwrap_or_default();
for p in 0..nc {
for y in 0..hu {
for x in 0..wu {
out.extend_from_slice(&((d[idx(p, y, x)] as i32 - 32768) as i16).to_be_bytes());
}
}
}
}
_ => {
let d = img.to_f32_samples().unwrap_or_default();
for p in 0..nc {
for y in 0..hu {
for x in 0..wu {
out.extend_from_slice(&d[idx(p, y, x)].to_be_bytes());
}
}
}
}
}
out.resize(out.len().div_ceil(BLOCK) * BLOCK, 0);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn header(cards: &[&str]) -> Vec<u8> {
let mut w = Writer { out: Vec::new() };
for c in cards {
w.card(c);
}
w.card("END");
let mut out = w.out;
out.resize(out.len().div_ceil(BLOCK) * BLOCK, b' ');
out
}
#[test]
fn camera_u16_with_bzero_reads_exactly_and_bottom_up() {
let mut b = header(&[
"SIMPLE = T",
"BITPIX = 16",
"NAXIS = 2",
"NAXIS1 = 2",
"NAXIS2 = 2",
"BZERO = 32768",
"OBJECT = 'M 31 '",
"EXPTIME = 300.0 / seconds",
]);
for v in [0u16, 1000, 40000, 65535] {
b.extend_from_slice(&((v as i32 - 32768) as i16).to_be_bytes());
}
b.resize(b.len().div_ceil(BLOCK) * BLOCK, 0);
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.sample_type(), SampleType::U16);
// The first stored row is the bottom one.
assert_eq!(img.to_u16_samples().unwrap(), vec![40000, 65535, 0, 1000]);
assert!(img.meta.text.contains(&("OBJECT".into(), "M 31".into())));
assert!(img.meta.text.contains(&("EXPTIME".into(), "300.0".into())));
assert!(!img.meta.text.iter().any(|(k, _)| k == "BZERO" || k == "NAXIS1"));
}
#[test]
fn roworder_top_down_is_honoured() {
let mut b = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 2", "NAXIS1 = 1", "NAXIS2 = 2", "ROWORDER= 'TOP-DOWN'"]);
b.extend_from_slice(&[10, 20]);
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.data(), &[10, 20]);
}
#[test]
fn stacked_float_in_adu_is_scaled_by_65535() {
let mut b = header(&["SIMPLE = T", "BITPIX = -32", "NAXIS = 2", "NAXIS1 = 2", "NAXIS2 = 1"]);
for v in [65535.0f32, f32::NAN] {
b.extend_from_slice(&v.to_be_bytes());
}
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.to_f32_samples().unwrap(), vec![1.0, 0.0]);
}
#[test]
fn signed_negative_data_falls_back_to_float() {
let mut b = header(&["SIMPLE = T", "BITPIX = 16", "NAXIS = 2", "NAXIS1 = 2", "NAXIS2 = 1"]);
for v in [-100i16, 1000] {
b.extend_from_slice(&v.to_be_bytes());
}
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.sample_type(), SampleType::F32);
}
#[test]
fn image_extension_after_empty_primary() {
let mut b = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 0", "EXTEND = T", "TELESCOP= 'Seestar S50'"]);
b.extend(header(&["XTENSION= 'IMAGE '", "BITPIX = 8", "NAXIS = 2", "NAXIS1 = 1", "NAXIS2 = 1", "PCOUNT = 0", "GCOUNT = 1"]));
b.push(42);
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.data(), &[42]);
assert!(img.meta.text.contains(&("TELESCOP".into(), "Seestar S50".into())));
}
#[test]
fn fpack_is_refused_as_unsupported() {
let mut b = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 0"]);
b.extend(header(&["XTENSION= 'BINTABLE'", "BITPIX = 8", "NAXIS = 2", "NAXIS1 = 0", "NAXIS2 = 0", "ZIMAGE = T"]));
b.extend(header(&["XTENSION= 'BINTABLE'", "BITPIX = 8", "NAXIS = 1", "NAXIS1 = 0"]));
assert!(matches!(decode(&b, &Limits::default()), Err(CodecError::Unsupported { .. })));
}
/// Hostile headers: absurd or negative axes, bad BITPIX, overflowing sizes, missing END,
/// a long chain of empty HDUs and declared data past the end all error, never panic or
/// allocate the declared size.
#[test]
fn hostile_headers_error_without_panicking() {
let base = ["SIMPLE = T", "BITPIX = 16"];
let cases: &[&[&str]] = &[
&["NAXIS = 2", "NAXIS1 = 4294967296", "NAXIS2 = 1"],
&["NAXIS = 2", "NAXIS1 = -5", "NAXIS2 = 1"],
&["NAXIS = 2", "NAXIS1 = 2000000", "NAXIS2 = 2000000"],
&["NAXIS = 3", "NAXIS1 = 65536", "NAXIS2 = 65536", "NAXIS3 = 9223372036854775807"],
&["NAXIS = 1000"],
&["NAXIS = -1"],
&["NAXIS = 2", "NAXIS1 = 0", "NAXIS2 = 0"],
&["NAXIS = 2", "NAXIS1 = 1.5D300", "NAXIS2 = 1"],
&["NAXIS = 2"],
&["NAXIS = 2", "NAXIS1 = 100", "NAXIS2 = 100"],
];
for extra in cases {
let cards: Vec<&str> = base.iter().chain(extra.iter()).copied().collect();
let b = header(&cards);
assert!(decode(&b, &Limits::default()).is_err(), "{extra:?}");
}
for bitpix in ["0", "7", "-16", "128", "'X'"] {
let card = format!("BITPIX = {bitpix}");
let b = header(&["SIMPLE = T", &card, "NAXIS = 2", "NAXIS1 = 1", "NAXIS2 = 1"]);
assert!(decode(&b, &Limits::default()).is_err(), "BITPIX {bitpix}");
}
// No END card: the header runs off the end of the file.
let mut w = Writer { out: Vec::new() };
for c in ["SIMPLE = T", "BITPIX = 8", "NAXIS = 2"] {
w.card(c);
}
assert!(decode(&w.out, &Limits::default()).is_err());
// An empty primary followed by many empty extensions: bounded walk, then "no image".
let mut b = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 0"]);
for _ in 0..(MAX_HDUS + 5) {
b.extend(header(&["XTENSION= 'IMAGE '", "BITPIX = 8", "NAXIS = 0"]));
}
assert!(decode(&b, &Limits::default()).is_err());
// An extension whose declared data size overflows.
let mut b = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 0"]);
b.extend(header(&[
"XTENSION= 'BINTABLE'",
"BITPIX = 64",
"NAXIS = 2",
"NAXIS1 = 9223372036854775807",
"NAXIS2 = 4",
"PCOUNT = 0",
"GCOUNT = 1",
]));
assert!(decode(&b, &Limits::default()).is_err());
}
#[test]
fn extra_planes_warn() {
let mut b = header(&["SIMPLE = T", "BITPIX = 8", "NAXIS = 3", "NAXIS1 = 1", "NAXIS2 = 1", "NAXIS3 = 4"]);
b.extend_from_slice(&[1, 2, 3, 4]);
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.layout(), ChannelLayout::Gray);
assert_eq!(img.warnings, vec![DecodeWarning::MorePages { total: Some(4) }]);
}
#[test]
fn strings_with_quotes_and_hierarch_round_trip() {
let mut w = Writer { out: Vec::new() };
w.text("OBSERVER", "O'Brien");
w.text("Software", "photocraft 1.0");
w.text("HISTORY", "stacked");
let cards: Vec<Card> = w.out.chunks(CARD).map(parse_card).collect();
assert_eq!(cards[0].value, Some(Value::Str("O'Brien".into())));
assert!(cards[1].hierarch && cards[1].key == "Software");
assert_eq!(cards[1].value, Some(Value::Str("photocraft 1.0".into())));
assert_eq!((cards[2].key.as_str(), cards[2].text.as_str()), ("HISTORY", "stacked"));
}
}
+79
View File
@@ -0,0 +1,79 @@
//! Every frame of an animated GIF. [`crate::decode`] keeps the first frame only; this walks the
//! whole animation for callers that can hold it (video layers).
//!
//! The `image` crate composites each frame onto the logical screen and applies the disposal
//! methods, so every returned frame is a full, self-contained picture.
use std::io::Cursor;
use image::codecs::gif::GifDecoder;
use image::{AnimationDecoder, ImageDecoder};
use crate::Format;
use crate::error::CodecError;
use crate::image::{ChannelLayout, DecodeWarning, Image};
use crate::options::DecodeOptions;
/// Most frames one GIF may yield. Frames are held in memory, and a tiny canvas could otherwise
/// ask for millions of them while staying inside the byte budget.
pub const MAX_GIF_FRAMES: usize = 100_000;
/// One composited frame of an animation and how long it stays on screen.
#[derive(Debug, Clone, PartialEq)]
pub struct AnimationFrame {
pub image: Image,
/// On-screen time in milliseconds. GIF counts in hundredths of a second, so this is a
/// multiple of 10 (0 when the file gives none).
pub delay_ms: u32,
}
/// Decodes every frame of a GIF, each as a full RGBA8 image of the logical screen size.
///
/// The first frame must decode. If a later frame is damaged the frames before it are returned
/// and the first one carries [`DecodeWarning::Truncated`]. Dimensions, the frame count
/// ([`MAX_GIF_FRAMES`]) and the total decoded bytes are checked against `opts.limits` before
/// each frame is kept.
pub fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result<Vec<AnimationFrame>, CodecError> {
let limits = &opts.limits;
let map = |e: image::ImageError| match e {
image::ImageError::Limits(l) => CodecError::LimitExceeded(l.to_string()),
image::ImageError::Unsupported(u) => CodecError::unsupported(Format::Gif, u.to_string()),
e => CodecError::malformed(Format::Gif, e),
};
let mut decoder = GifDecoder::new(Cursor::new(bytes)).map_err(map)?;
let mut il = image::Limits::default();
il.max_image_width = Some(limits.max_width);
il.max_image_height = Some(limits.max_height);
il.max_alloc = Some(limits.max_alloc);
decoder.set_limits(il).map_err(map)?;
let (w, h) = decoder.dimensions();
limits.check_bytes(w, h, 4)?;
let frame_bytes = u64::from(w) * u64::from(h) * 4;
let mut out: Vec<AnimationFrame> = Vec::new();
let mut damaged = false;
for frame in decoder.into_frames() {
let frame = match frame {
Ok(f) => f,
Err(e) if out.is_empty() => return Err(map(e)),
Err(_) => {
damaged = true;
break;
}
};
if out.len() >= MAX_GIF_FRAMES {
return Err(CodecError::LimitExceeded(format!("more than {MAX_GIF_FRAMES} frames")));
}
if frame_bytes.saturating_mul(out.len() as u64 + 1) > limits.max_alloc {
return Err(CodecError::LimitExceeded(format!("{} frames of {w}x{h} exceed max_alloc {}", out.len() + 1, limits.max_alloc)));
}
let (numer, denom) = frame.delay().numer_denom_ms();
let delay_ms = numer.checked_div(denom).unwrap_or(0);
let rgba = frame.into_buffer();
out.push(AnimationFrame { image: Image::from_u8(w, h, ChannelLayout::Rgba, rgba.into_raw())?, delay_ms });
}
if damaged && let Some(first) = out.first_mut() {
first.image.warnings.push(DecodeWarning::Truncated { format: Format::Gif });
}
Ok(out)
}
+2
View File
@@ -1,6 +1,8 @@
pub(crate) mod deep_exr;
pub(crate) mod exr;
pub(crate) mod exr_cryptomatte;
pub(crate) mod fits;
pub(crate) mod gif_frames;
pub(crate) mod heif;
pub(crate) mod jpeg;
pub(crate) mod png;
+22 -5
View File
@@ -521,8 +521,16 @@ fn adler32_combine(a1: u32, a2: u32, len2: usize) -> u32 {
}
/// Writes an 8-bit palette PNG (PNG-8): `indices` row-major, `palette` RGB entries (≤ 256),
/// optional fully transparent entry (`tRNS`). Used for Indexed Color documents.
pub fn encode_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3]], transparent: Option<u8>) -> Result<Vec<u8>, CodecError> {
/// optional fully transparent entry (`tRNS`) and optional resolution in dpi (`pHYs`, written when
/// both axes are positive and finite). Used for Indexed Color documents.
pub fn encode_indexed(
width: u32,
height: u32,
indices: &[u8],
palette: &[[u8; 3]],
transparent: Option<u8>,
dpi: Option<(f32, f32)>,
) -> Result<Vec<u8>, CodecError> {
if width == 0 || height == 0 || indices.len() != width as usize * height as usize {
return Err(CodecError::InvalidImage("index data does not match the size".into()));
}
@@ -538,6 +546,15 @@ pub fn encode_indexed(width: u32, height: u32, indices: &[u8], palette: &[[u8; 3
trns[usize::from(t)] = 0;
info.trns = Some(trns.into());
}
if let Some((x, y)) = dpi
&& x.is_finite()
&& y.is_finite()
&& x > 0.0
&& y > 0.0
{
info.pixel_dims =
Some(png::PixelDimensions { xppu: (x / METERS_PER_INCH).round() as u32, yppu: (y / METERS_PER_INCH).round() as u32, unit: png::Unit::Meter });
}
let mut out = Vec::new();
{
let encoder = png::Encoder::with_info(&mut out, info).map_err(|e| CodecError::encode(F, e))?;
@@ -556,14 +573,14 @@ mod indexed_tests {
fn png8_roundtrips_through_the_decoder() {
let pal = [[255, 0, 0], [0, 0, 255], [0, 0, 0]];
let idx = [0u8, 1, 2, 1, 0, 2];
let bytes = encode_indexed(3, 2, &idx, &pal, Some(2)).unwrap();
let bytes = encode_indexed(3, 2, &idx, &pal, Some(2), None).unwrap();
let img = decode(&bytes, &Limits::default()).unwrap();
assert_eq!(img.dimensions(), (3, 2));
let px = img.convert(ChannelLayout::Rgba, SampleType::U8);
assert_eq!(&px.data()[..8], &[255, 0, 0, 255, 0, 0, 255, 255]);
assert_eq!(px.data()[11], 0, "transparent entry");
assert!(encode_indexed(3, 2, &[9; 6], &pal, None).is_err());
assert!(encode_indexed(3, 3, &idx, &pal, None).is_err());
assert!(encode_indexed(3, 2, &[9; 6], &pal, None, None).is_err());
assert!(encode_indexed(3, 3, &idx, &pal, None, None).is_err());
}
}
+68 -32
View File
@@ -79,17 +79,43 @@ fn sample_for_maxval(maxval: u32) -> Result<SampleType, CodecError> {
}
}
/// Build an image from raw integer values in `[0, maxval]`.
fn from_values(w: u32, h: u32, layout: ChannelLayout, maxval: u32, values: Vec<u32>) -> Result<Image, CodecError> {
let sample = sample_for_maxval(maxval)?;
if values.iter().any(|&v| v > maxval) {
return Err(err("sample exceeds maxval"));
/// The decoded raster, built straight in the output representation. It is the only buffer sized
/// by the header, so `Limits::check` on the output budgets all of it: no wider scratch buffer
/// (such as one `u32` per sample) sits beside it (#2938).
struct Samples {
maxval: u32,
sample: SampleType,
bytes: Vec<u8>,
}
impl Samples {
/// Reserve room for `cap` samples (callers bound `cap` by the file length where the header
/// alone could overstate it).
fn with_capacity(maxval: u32, cap: usize) -> Result<Self, CodecError> {
let sample = sample_for_maxval(maxval)?;
let len = cap.checked_mul(sample.bytes()).ok_or_else(|| CodecError::LimitExceeded(format!("{cap} samples do not fit in memory")))?;
let mut bytes = Vec::new();
bytes.try_reserve_exact(len).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {len} bytes for the image")))?;
Ok(Samples { maxval, sample, bytes })
}
match (sample, maxval) {
(SampleType::U8, 255) => Image::from_u8(w, h, layout, values.into_iter().map(|v| v as u8).collect()),
(SampleType::U16, 65535) => Image::from_u16(w, h, layout, &values.into_iter().map(|v| v as u16).collect::<Vec<_>>()),
(SampleType::U8, m) => Image::from_u8(w, h, layout, values.into_iter().map(|v| ((v * 255 + m / 2) / m) as u8).collect()),
(_, m) => Image::from_u16(w, h, layout, &values.into_iter().map(|v| ((v as u64 * 65535 + m as u64 / 2) / m as u64) as u16).collect::<Vec<_>>()),
/// Append one raw value in `[0, maxval]`, rescaled to the full range of the output sample.
fn push(&mut self, v: u32) -> Result<(), CodecError> {
let m = self.maxval;
if v > m {
return Err(err("sample exceeds maxval"));
}
match (self.sample, m) {
(SampleType::U8, 255) => self.bytes.push(v as u8),
(SampleType::U8, m) => self.bytes.push(((v * 255 + m / 2) / m) as u8),
(_, 65535) => self.bytes.extend_from_slice(&(v as u16).to_ne_bytes()),
(_, m) => self.bytes.extend_from_slice(&(((v as u64 * 65535 + m as u64 / 2) / m as u64) as u16).to_ne_bytes()),
}
Ok(())
}
fn into_image(self, w: u32, h: u32, layout: ChannelLayout) -> Result<Image, CodecError> {
Image::from_raw(w, h, layout, self.sample, self.bytes)
}
}
@@ -102,18 +128,24 @@ fn sample_count(w: u32, h: u32, channels: usize) -> Result<usize, CodecError> {
.ok_or_else(|| CodecError::LimitExceeded(format!("{w}x{h}x{channels} samples do not fit in memory")))
}
fn read_binary(data: &[u8], n: usize, maxval: u32) -> Result<Vec<u32>, CodecError> {
fn read_binary(data: &[u8], n: usize, maxval: u32) -> Result<Samples, CodecError> {
let mut out = Samples::with_capacity(maxval, n)?;
if maxval < 256 {
if data.len() < n {
return Err(err("truncated raster"));
}
Ok(data[..n].iter().map(|&v| v as u32).collect())
} else {
let Some(len) = n.checked_mul(2).filter(|&len| data.len() >= len) else {
let Some(raw) = data.get(..n) else {
return Err(err("truncated raster"));
};
Ok(data[..len].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as u32).collect())
for &v in raw {
out.push(v as u32)?;
}
} else {
let Some(raw) = n.checked_mul(2).and_then(|len| data.get(..len)) else {
return Err(err("truncated raster"));
};
for c in raw.as_chunks::<2>().0 {
out.push(u16::from_be_bytes(*c) as u32)?;
}
}
Ok(out)
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
@@ -163,19 +195,19 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
let layout = if matches!(kind, b'2' | b'5') { ChannelLayout::Gray } else { ChannelLayout::Rgb };
limits.check(w, h, layout, sample_for_maxval(maxval)?)?;
let n = sample_count(w, h, layout.channels())?;
let values = if matches!(kind, b'5' | b'6') {
let samples = if matches!(kind, b'5' | b'6') {
r.single_ws()?;
read_binary(r.rest(), n, maxval)?
} else {
// Each ASCII sample is a digit and a separator (the last may lack one), so the
// file, not the header, bounds the reservation (#1110).
let mut v = Vec::with_capacity(n.min(r.rest().len() / 2 + 1));
let mut v = Samples::with_capacity(maxval, n.min(r.rest().len() / 2 + 1))?;
for _ in 0..n {
v.push(r.uint()?);
v.push(r.uint()?)?;
}
v
};
from_values(w, h, layout, maxval, values)
samples.into_image(w, h, layout)
}
b'7' => decode_pam(&mut r, limits),
b'F' | b'f' => {
@@ -195,17 +227,22 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
return Err(err("truncated raster"));
}
let le = scale < 0.0;
let mut out = vec![0f32; row * h as usize];
for y in 0..h as usize {
// Write native-endian bytes straight into the output buffer: a separate `Vec<f32>`
// would double the budgeted allocation (#2938).
let row_bytes = row * 4;
let mut out = vec![0u8; row_bytes * h as usize];
for (y, src) in data.chunks_exact(row_bytes).take(h as usize).enumerate() {
// PFM rows are stored bottom-to-top.
let dst = (h as usize - 1 - y) * row;
for i in 0..row {
let o = (y * row + i) * 4;
let b4 = [data[o], data[o + 1], data[o + 2], data[o + 3]];
out[dst + i] = if le { f32::from_le_bytes(b4) } else { f32::from_be_bytes(b4) };
let dst_start = (h as usize - 1 - y) * row_bytes;
let Some(dst) = out.get_mut(dst_start..dst_start + row_bytes) else {
return Err(err("truncated raster"));
};
for (d, s) in dst.as_chunks_mut::<4>().0.iter_mut().zip(src.as_chunks::<4>().0) {
let v = if le { f32::from_le_bytes(*s) } else { f32::from_be_bytes(*s) };
*d = v.to_ne_bytes();
}
}
Image::from_f32(w, h, layout, &out)
Image::from_raw(w, h, layout, SampleType::F32, out)
}
_ => Err(err("unknown Netpbm subtype")),
}
@@ -250,8 +287,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result<Image, CodecError>
};
limits.check(w, h, layout, sample_for_maxval(maxval)?)?;
let n = sample_count(w, h, layout.channels())?;
let values = read_binary(r.rest(), n, maxval)?;
from_values(w, h, layout, maxval, values)
read_binary(r.rest(), n, maxval)?.into_image(w, h, layout)
}
pub(crate) fn encode(src: &Image, plan: Plan, _opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
Loaded 100 of 598 files, more files were not shown because too many files have changed in this diff. Show more