Compare commits

...
277 Commits
Author SHA1 Message Date
Hamza Naveed efa1edd079 algo: gate the recorded-hash test's helpers with the test (#7053)
`blocks` and `fnv` exist only for `harris_and_describe_hashes_recorded_from_the
_serial_code`, which is `#[cfg(all(target_os = "linux", target_arch =
"x86_64"))]` because the hashes it compares against go through the platform's
hypot / ln / cos / atan2 / sin_cos. Everywhere else the test is compiled out and
the two helpers are left unused, so `-D warnings` fails on `dead_code`:

    error: function `blocks` is never used   crates/algo/src/features.rs:704
    error: function `fnv` is never used      crates/algo/src/features.rs:708

They now carry the same cfg as the only thing that calls them.

CI's matrix is ubuntu-latest and windows-latest and runs clippy on both, so this
fails the Windows leg as much as a local run on macOS: `target_os = "linux"` is
false for both.
2026-10-17 08:39:15 -06:00
Josephand00xJ0 351149f0df Fix the build: restore export_presets in load_presets_json (#5344 x #5616) (#6906)
#5344 added the export_presets field to Persisted; #5616 rewrote load_presets_json to read each set with take() and its Persisted initializer lacked the new field, so main stopped compiling (E0063 at presets/mod.rs:180). Read export_presets the same way as the other sets; the existing load step and round-trip test cover it.

Co-authored-by: 00xJ0 <[email protected]>
2026-10-17 08:32:54 -06:00
Nicolas FalesyandClaude Opus 5.5 5f65579ecb perf(panorama): Photomerge 4× faster, bit for bit the same (#6489)
N is the whole algo-side Photomerge pipeline on 3 × 8 MP photos (register at the engine's
scale with the Auto layout, gain + vignette fit, seams, multi-band blend), 16 threads:
1042 ms before, 264 ms after, 3.9×, rounded to 4×. The engine's own glue (reading regions,
building weights, writing layers) is not in this number.

Registration and multi-band blending ran almost entirely on one thread per image (four
images on 16 threads), so most of the machine sat idle. Every output is bit for bit what
the serial code produced: a probe that hashes the public Photomerge entry points
(detect_many, match_pairs, align for every layout, photometric, seam_labels, multiband at
1 to 7 levels with 1 and 3 channels, with holes in the alpha, with images sticking out of
the canvas, plus direct harris / describe calls) matches on all 349 outputs before and
after, on 4 × 2 MP, 3 × 8 MP, 97×61, 211×157, 333×251, 320×240 two-image and 40×35 sets,
at 16, 3 and 1 threads.

- features `blur` (Harris window and BRIEF smoothing): the row pass runs rows in
  parallel; the column pass cuts the image into bands of 64 columns, each holding the
  `&mut` row segments of its own columns (no unsafe), run in parallel. Every column keeps
  its own f32 running sum, started from its first window summed in order and updated in
  the same order, so only the loop nest changes.
- `harris`: Sobel, the corner response and the non-maximum scan are parallel by rows (the
  candidates are fully ordered by score, y, x afterwards, so arrival order is irrelevant).
  `describe`: one descriptor per corner in parallel, kept in corner order.
- `reduce`, `expand`, `push_pull`: per-row maps in parallel. `reduce`'s column pass adds
  five whole rows in tap order (same per-element sum, now vectorised); `expand` works out
  its source columns once and truncates instead of calling libm `floor` (the clamped
  position is never negative); `push_pull` builds each coarse row from its two fine rows in
  raster order, which is the order the serial scatter-add used.
- `multiband`: grid fill per image, Laplacian difference, band accumulation (every row
  walks the bands in the original order), normalisation, collapse and the coverage union
  are parallel. Float additions are never reordered.

Left serial on purpose: the peak fold in `harris` (about 1 ms, an order-free max but not
worth the proof), the corner spacing grid, `seam_labels` (a dynamic-programming path whose
rows depend on the previous one), `photometric` sample gathering, and the pyramid levels
themselves (each level needs the one above).

Median of 5 (8 MP rows: 7), release, ms, interleaved before/after runs. The laptop was
busy with unrelated builds the whole time (load average 11 to 18 on 16 threads), so the
1-thread rows are noisy (a single thread is time-sliced against them) and rows can move
by 10 to 20 percent.

| Case (probe)                   | before (16 thr) | after (16 thr) | speedup | before (1 thr) | after (1 thr) |
|--------------------------------|----------------:|---------------:|--------:|---------------:|--------------:|
| register, 4 × 2 MP             |             154 |             60 |    2.6× |            336 |           167 |
| multiband, 4 × 2 MP, 3 ch      |             407 |            108 |    3.8× |            591 |           407 |
| multiband, 4 × 2 MP, 1 ch      |             307 |             73 |    4.2× |            813 |           566 |
| whole pipeline, 4 × 2 MP       |             437 |            160 |    2.7× |            716 |           504 |
| register, 3 × 8 MP (full size) |             327 |            124 |    2.6× |            978 |           391 |
| multiband, 3 × 8 MP, 3 ch      |            2348 |            916 |    2.6× |           1742 |          1150 |
| whole pipeline, 3 × 8 MP       |            1042 |            264 |    3.9× |           2264 |          1280 |

The 8 MP multiband row at 16 threads is the noisiest (minimum of 7: 959 to 224 ms, 4.3×);
the whole-pipeline row is steady. Max RSS for the 3 × 8 MP whole pipeline (the probe's
inputs are about 1.05 GB of it): 2.79 GB before, 2.79 GB after (three runs each).

Tests: the serial `blur`, `reduce`, `expand`, `push_pull` and `multiband` are kept in the
test modules as references and compared bit for bit over sizes that cross every band and
row boundary (1×1, 1-wide, 1-high, odd sizes, 63 / 64 / 65 / 129 columns, radius 0 to
wider than the image, holes, partial and zero weights, ROIs outside the canvas, 1 to 7
levels). Hard-coded hashes recorded from the old code cover multiband, push_pull,
reduce + expand everywhere (plain f32 arithmetic), and harris + describe on Linux x86-64
(they go through the platform's hypot / ln / cos / atan2 / sin_cos).


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

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-16 20:20:22 -06:00
wwew bd3b5d9192 fix(automation): refuse a group/world-readable control-token file (#4966) (#5459) 2026-10-16 20:20:03 -06:00
ZuujisandClaude Opus 5.5 fbcb70b317 Save: ask before overwriting a file another app changed (#6596) (#6652)
* Save: ask before overwriting a file another app changed (#6596) [WIP]

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

* Save: ask before overwriting a file another app changed (#6596)

File › Save now notes the size and modification time of a document's
own file when it is opened or saved, and checks them again before
writing back. If another app changed the file in between, a prompt asks
whether to replace it (Cancel keeps both; Save As saves elsewhere)
instead of silently overwriting the other app's changes.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-16 20:19:32 -06:00
Korg1ch 4c881326ea fix(windows): keep crash-recovery autosaves out of the roaming profile (#5648) (#6412)
* fix(windows): keep crash-recovery autosaves out of the roaming profile (#5648)

* test(windows): cover the local recovery folder and moving old autosaves (#5648)
2026-10-16 20:19:06 -06:00
MarekSpodBiedry fbfc72ceb5 Close All, Close Others and Quit ask again about documents dirtied during the prompt (#4954) (#5079) 2026-10-16 20:18:47 -06:00
MarekSpodBiedry 20013ef9bd Linked smart objects: don't refresh a document while a background job runs on it (#4973) (#5074) 2026-10-16 20:18:17 -06:00
David MannandClaude Opus 5.5 3d2914368d PSD type: clamp warp bend and distortion before the f32 cast (#6856) (#6857)
A TySh warpValue, warpPerspective or warpPerspectiveOther beyond f32 became inf and the warp was silently dropped; they are now clamped to -100..100 like type.warpText.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 20:17:27 -06:00
Nicolas FalesyandClaude Opus 5.5 5bd428a239 perf(stack): Stack Mode 11× faster (#6462)
Layer › Smart Objects › Stack Mode (`algo::stack::combine`) was one serial
per-pixel loop (1.0x parallelism, identical at 1 and 16 threads), and Entropy
built a HashMap per channel per pixel.

- Output pixels are computed in parallel in 4096-pixel chunks (`par_rows`, so
  wasm keeps its serial path). Each chunk owns its scratch buffers.
- Each pixel uses exactly the same arithmetic in the same order as before, so
  every mode except Entropy is bit-identical: 110 output hashes (10 modes x 11
  cases: 3 x 24 MP, 7 layers of odd/mismatched sizes, transparency, HDR values,
  1 layer, no layers) match the old code.
- Entropy: a `[u32; 256]` histogram plus a short list of the bins touched
  replaces the HashMap; touched bins are summed in ascending order, so the result
  is now deterministic. The old HashMap summed in random iteration order and
  differed run to run (up to 1.2e-7 between two runs of the old code). New vs old
  output: max abs difference 1.19e-7 (one f32 ulp near 1.0), on up to ~10% of
  values in the 7-layer cases; the 3-layer 24 MP case is identical.
- Modes that never use the mean (Median, Min, Max, Range, Summation, Entropy) no
  longer compute it; Median uses an unstable sort (same result under total_cmp).

3 x 24 MP, release, median of 5 interleaved before/after runs (load average
~3.5-4.5 from an unrelated build running alongside), ms:

| Mode       | before | after (16 thr) | speedup | after (1 thr) |
|------------|-------:|---------------:|--------:|--------------:|
| Median     |   1100 |             99 |   11.1x |          1064 |
| Mean       |    308 |             36 |    8.6x |           338 |
| StdDev     |    342 |             41 |    8.3x |           380 |
| Entropy    |   4727 |            269 |   17.5x |          2730 |
| Kurtosis   |    458 |             55 |    8.3x |           488 |
| Maximum    |    368 |             36 |   10.1x |           346 |

The other modes land between 8x and 10x. Max RSS for Median is unchanged
(1.50 GB, of which 1.13 GB is the input layers).

Tests: hard-coded bit patterns recorded from the old code for every mode but
Entropy; Entropy against hand-computed values and run-to-run determinism; and a
chunk-boundary test comparing a multi-chunk image to per-pixel results.


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

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-16 20:16:45 -06:00
F4LCON 75978a72f0 Targa: RLE packets end with their scanline (#6382) (#6386)
The image crate's Targa encoder let run-length packets run on from the end of
one row into the next. TGA 2.0 says a packet must not cross a scanline, and
readers that decode a row at a time misread such files: Pillow fails on them
with "buffer overrun", and texture tools and game engines reject them.

Targa is now written by our own encoder (codecs::tga), as Photoshop writes
it: RLE true colour or grayscale, every packet within its row, rows stored
bottom-up (the origin readers that ignore the descriptor assume), the alpha
bits in the descriptor and the TGA 2.0 footer. Reading stays with the image
crate.
2026-10-16 20:15:32 -06:00
nrq6993-a11yandCursor c61cf9f0bd fix(engine): load each persisted preset set independently (#5616)
One malformed set in the preferences file (gradients, swatches, styles...)
made load_presets_json ignore the whole presets blob, so saved New Document
presets vanished at every launch: the shell merges back only changed keys,
leaving the damaged set on disk. Each set now loads on its own; null and
malformed sets keep their defaults.

Refs storytold/photocraft#5602

Co-authored-by: Cursor <[email protected]>
2026-10-16 19:20:06 -06:00
nrq6993-a11yandCursor a1e19a0b87 fix(text): break paragraph lines at the horizontally scaled width (#5611)
Horizontal scale was applied after line breaking, so paragraph text below
100% wrapped early and text above 100% overflowed the box. Break box text
at the available width divided by the paragraph's horizontal scale (the
largest one when runs mix), scale the first-line indent and alignment
offset back to the box, and keep the scaled trailing space out of the
line extent.

Fixes storytold/photocraft#5565

Co-authored-by: Cursor <[email protected]>
2026-10-16 19:19:59 -06:00
Matthew SantiagoandClaude Opus 5.5 d213273c74 GPU composite: lower-stack cache chunks have no texture view (#5423)
The prefix cache's chunk textures are only copied to and from
(COPY_SRC | COPY_DST), but went through Tex::new, which always creates
a default view. Vulkan rejects a view on an image with no sampled,
storage or attachment usage (VUID-VkImageViewCreateInfo-image-04441);
wgpu doesn't check it, so it only shows with the validation layers on,
once per new chunk while painting. The view was never used. Chunks now
hold a bare wgpu::Texture. No rendering change.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-16 19:19:53 -06:00
Ahmed MagdyandClaude Opus 5.5 4e5c6a018a fix(io): SVG text placed by a transform keeps it on import (#4806) (#5334)
usvg builds the glyph outlines of a text element (Text::flattened) with
identity absolute transforms, and the SVG importer read each path's
abs_transform(). Text positioned by its own or an ancestor's transform,
as Illustrator writes every text element (matrix(1 0 0 1 x y)), lost it:
it landed off the canvas, so nothing showed, or at its untransformed
place and size.

The converter now composes each node's transform from its parent chain,
which equals abs_transform() outside text, and sizes raster fallbacks
from the same transform. A new test checks translate, matrix, scale and
transformed groups against resvg's own render; it fails on main.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-16 19:19:47 -06:00
F4LCON a05daee325 Filters: the live preview is never coarser than the screen (#6326) (#6351)
On a document over about 5 MP with Low Resolution Previews on, a filter
dialog's live preview ran on a reduced proxy at every zoom, so at 100 % the
canvas showed the image in k-pixel blocks and hid exactly what Unsharp Mask,
High Pass and Smart Blur do.

ensure_filter_preview now caps its factor with proxy::view_factor(k, zoom),
the same rule adjustment previews got in #5764, so from 100 % the preview is
full resolution and stays reduced when zoomed out. The preview already runs on
a cancellable background worker, so the full-resolution path does not block the
UI thread.

Also in this change:

- canvas.rs and filter_preview_ui.rs share one canvas::filter_preview_factor,
  so the in-dialog preview window cannot size textures from an uncapped factor
  and rebuild them when the first result arrives.
- The cap is gated on cfg!(target_arch = "wasm32"), so the web build keeps its
  proxy rather than running a full-resolution filter inline on the UI thread.
- The end-to-end test moved into mod tests and covers Low Resolution Previews
  off; a new factor test covers 25/50/75/100/400 %, NaN and zero zoom.
2026-10-16 19:14:50 -06:00
Tanish Manku 132257912a README: put Community packages after the Get started status note (follow-up to #1149) (#6886)
The Community packages heading from #1149 landed before the maintainers' releasing note and the IMPORTANT status admonition, so those two paragraphs rendered under Community packages instead of Get started. Move the section to just above Documentation; the content is unchanged.
2026-10-16 17:52:24 -07:00
Ivan · VanTan 10fdcbbefc chore: fix workspace Clippy collapsible_match warnings (#5190)
Strict workspace Clippy stopped on collapsible_match in affinity, raw, plugins, engine, ui-egui and xtask. Pure conditions become guarded match arms (each falling through to an existing empty arm), and the ruler drag state and the measurement command result are bound to locals, preserving evaluation order, error messages and fallbacks.

The same lint fixes also appear as a side commit in #5157 (David2766), which can drop it on rebase.
2026-10-16 17:42:36 -07:00
Gustavo Ziger d900daa0ba fix(web): File › Place Embedded is enabled in the browser and places the picked file's bytes (#1209, #5749) (#5585)
With a document open in the web app, Place Embedded was disabled because its menu enablement inherited the engine's native-filesystem check, although the menu path reads the picker's bytes and never touches a path. Place Embedded is now enabled whenever a document and a file-dialog service exist; Place Linked keeps the engine's native guard, since the web could only link to a bare file name (caught in #5817 by Higgs32584). An enablement test runs with the native check forced off, and a file-dialog test covers the browser answer shape, cancel, invalid bytes and the Free Transform handoff.

Fixes #1209. Fixes #5749.
2026-10-16 17:38:56 -07:00
Michael Higginsandahmedhosnypro 3722dfbc63 fix(ui): Arabic and Hebrew layer names and the title read right to left (#5159) (#5385)
egui places font runs left to right with no bidi reordering, so a right-to-left layer name showed every word correctly but in reversed order. bidi_text::visual puts a label into display order with the Unicode Bidirectional Algorithm (unicode-bidi, already in the tree via usvg): right-to-left words stay in logical order for the shaper, everything else in a right-to-left run is reversed around them and brackets are mirrored. The Layers panel's rows and the app-drawn title bar use it; the title keeps a left-to-right paragraph so '— PhotoCraft' stays at the right, and a long right-to-left name is cut at its logical end so its start survives. Display only: stored names and the OS title keep the logical text.

Fixes #5159.

Co-authored-by: ahmedhosnypro <[email protected]>
2026-10-16 17:36:06 -07:00
Gert Pellin - GPE 5a33441af3 fix(engine): an unknown layer id or name lists the document's layers, and a name matches regardless of case as a fallback (#5058) (#5133)
A command that fails on a layer id or name the document doesn't have now says which layers there are, top-down as id and name (at most 20, names cut at 64 characters), so an agent can recover from the error alone: ids are not positions, and a freshly opened PNG's Background is layer 2. NoLayer becomes UnknownLayer { id, known } at both command entry points, keeping the 'no such layer' prefix. A layer name that matches no layer exactly matches regardless of case; an exact match always wins.

Fixes #5058.
2026-10-16 17:34:40 -07:00
Gert Pellin - GPE a6a1d2ed22 feat(algo): face detection with libfacedetection's CNN in pure Rust, and image.detectFaces (part of #3603) (#4513)
photocraft_algo::faces::detect finds faces in an RGBA image with a score, a box and five landmarks, through a pure-Rust port of libfacedetection's YuNet network (Shiqi Yu, BSD-3-Clause; licence, NOTICE and ATTRIBUTION rows included). The weights are the library's, converted to half precision (107 KB), parsed defensively. image.detectFaces reports the document's faces to scripts and agents; it is read-only with no history step and no menu entry. The network is compiled out of the browser build, where the command reports itself unavailable. Head outputs are pinned against the C++ reference.

Part of #3603.
2026-10-16 17:31:02 -07:00
Gert Pellin - GPE 1b3bc48267 docs(readme): Community packages section with the Gentoo ::snakebyte overlay (#1149)
Adds a Community packages heading to the README, noting these installers are maintained by community members, with the Gentoo recipe for media-gfx/photocraft-bin from the ::snakebyte overlay. The overlay's ebuild fetches the official Linux x86_64 release tarball with Manifest-pinned hashes that match the release's SHA256SUMS.
2026-10-16 17:28:51 -07:00
Harrison O'Connor-Hoover 8a4ed48354 fix(ui): numeric fields drop oversized text, paste and IME events before layout (part of #2150) (#2258)
A single oversized input event reached egui text layout in a numeric field even though it could never become a valid value. number_edit now drops Text, Paste and IME events longer than 128 scalar values while the field has focus, and the custom parser rejects accumulated text over the cap before normalizing. DragValue keeps focus, accessibility, drag and arithmetic behaviour. Six harness tests cover the event kinds, the Unicode boundary, accumulation, undo, and document text fields staying unrestricted.

Part of #2150.
2026-10-16 17:25:50 -07:00
wwew dd69f0797d fix(engine): reserve the define-brush tip and selection-mask buffers fallibly (#212) (#5469)
brush.defineFromSelection allocated the sampled tip and the selection mask infallibly over the whole selection, so a large selection could abort the process. Both now go through the engine's fallible allocator and return a normal error that leaves the brush, presets, history and document untouched. A failpoint test asserts all four.

Part of #212.
2026-10-16 17:23:08 -07:00
safvanedge aecac608b4 fix(ui): Type tool caret stays solid after typing, navigation or a click, then resumes blinking (#5511) (#5512)
The caret's blink phase was tied to the wall clock, so an arrow key or keystroke that landed in the off half-cycle moved an invisible caret. A transient caret_blink_time on UiState is reset on every handled key event, on a click that places the caret, and while dragging; draw_overlay computes the phase from it so the caret is solid for 530 ms after each interaction, and the repaint is scheduled to the next phase boundary.

Fixes #5511.
2026-10-16 17:20:53 -07:00
Vikram Bhaskaran c7d2c85faf fix(ui): Color Picker's Only Web Colors snaps every edit and is remembered across pickers (#4995) (#5442)
With Only Web Colors on, every picker edit (field, sliders, hex, Lab/CMYK, screen sampler, Current) snaps through the clamped 216-colour ramp. The checkbox is remembered on the Color panel state so the foreground, field, command, Layer Style and type-style pickers open with the same setting. Opening with it on snaps the colour and its original together, so OK without an edit leaves a fill layer unchanged. The hex field follows the colour when another path changes it while focused.

Fixes #4995.
2026-10-16 17:19:03 -07:00
Nigel Ihza Firdausy 9628290b36 fix(ui): Layer › New Fill Layer › Solid Color opens the Color Picker, creating the layer on OK (#5144) (#5166)
Creating a Solid Color fill from the Layer menu or the Layers panel footer now opens the shared Color Picker seeded with the foreground colour. The layer is created on OK in one history step; Cancel leaves the document and history untouched. Parameterised calls (CLI, MCP, actions) still create the layer directly.

Fixes #5144.
2026-10-16 17:13:51 -07:00
Marco Arnulfo db46713c2c Unsaved-changes prompt: the default answer takes keyboard focus, so arrows and Enter work (part of #6441) (#6466) 2026-10-16 17:03:15 -06:00
Jason Xiang 06ad07ab9b fix(io): color-manage grayscale WebP exports (#5875) 2026-10-16 17:02:54 -06:00
Jason Xiang f61ed26e14 fix(io): honor version-aware OpenRaster group isolation (#5872) 2026-10-16 17:02:23 -06:00
Jason Xiang 840edc311c fix(io): preserve linear grayscale profiles for selected EXR parts (#5867) 2026-10-16 17:01:52 -06:00
Jason Xiang 462adeadd2 test(io): cover mixed-region CMYK TIFF composites (#5588) 2026-10-16 17:01:10 -06:00
xsvm 8fe2c74f58 perf(testkit): sample RSS from /proc/self/statm, not smaps, on Linux (#5399)
memory-stats reads /proc/self/smaps by default on Linux: every sample formats and parses one
block per mapping while holding the mmap lock. The benches' RssSampler polls it every 2 ms, so
on a profile of bevel_bench --size 4000x3000 the rss-sampler thread was ~22% of all samples
(smaps_account 14.7%, smaps_pte_entry 6.3%), contending with the measured threads.

Enable the crate's always_use_statm feature: one short read of the kernel's RSS counters, the
same number VmRSS reports. The measured code is unchanged; only the harness overhead goes.
2026-10-16 17:00:43 -06:00
wwew 93f6376c5e docs: write product name as PhotoCraft in package descriptions and comments (#5615) 2026-10-16 16:59:41 -06:00
wwew e33384c9ba docs: write product name as PhotoCraft in crate module docs (#5455) 2026-10-16 16:58:50 -06:00
PerchenkoAnton f360cb0b4a fix(engine): give artboard exports unique indexed filenames (#6655) 2026-10-16 16:57:33 -06:00
wwew 651647fdf6 docs(cli): write product name as PhotoCraft in USAGE and description (#5449) 2026-10-16 16:56:48 -06:00
Jason Xiang f1c45b887e fix(codecs): respect PAM ENDHDR line boundaries (#5874) 2026-10-16 16:38:54 -06:00
Marco Arnulfo 6a7fc88c79 Crop tool: custom ratios keep three decimals, 1.414 : 1 no longer becomes 1.4 : 1 (fixes #6487) (#6503) 2026-10-16 16:38:35 -06:00
Marco Arnulfo 97f50b4714 Pen: the path in progress stays visible while panning with Space (fixes #6592) (#6625) 2026-10-16 16:38:11 -06:00
Diego Chagas ad6463b50f Paragraph text: a word wider than the box breaks at the box edge (#6694) (#6696)
parley's default overflow wrap only breaks at UAX #14 opportunities, so
a word longer than the paragraph box ran out past its edge and resizing
the box changed nothing. Paragraph text now sets OverflowWrap::BreakWord,
so such a word wraps at the box edge as Photoshop does; a space or hyphen
still wins over a mid-word break, a box that fits the word lays out as
before, and point text is untouched.
2026-10-16 16:37:45 -06:00
Korg1ch 3c75db7a80 fix(linux): avoid duplicate AppImage desktop launcher when managed by AM / AppMan (#6392) (#6394)
* fix(linux): avoid duplicate AppImage desktop launcher when managed by AM / AppMan (#6392)

* test(linux): cover AM / AppMan launcher detection in AppRun smoke test (#6392)
2026-10-16 16:37:17 -06:00
midasmatterhorn 19227022ca Histogram: scale bar heights linearly to the tallest bin, not sorted.get(250) (#6638) 2026-10-16 16:36:58 -06:00
Nicolas Falesy 54a4782849 perf(camera-raw): Shadows/Highlights 1.9× faster, bit for bit the same (#5964) (#6247)
The local Laplacian filter behind Shadows and Highlights builds a whole
pyramid per reference value (up to 96). Three costs dominated it:

- blur_step called mirror() (an integer remainder) for every tap of every
  pixel; the source rows are now found once per row and columns mirror only
  within two pixels of an edge.
- up() blurred a zero-stuffed plane, three samples in four zero; it now adds
  only the nonzero taps, in the same order.
- the small pyramid levels were split into one-row rayon tasks, costing more
  in scheduling than the rows; planes under 32K samples run on one thread.

The output is bit for bit the same: a new test checks blur_step and up
against the plain versions on odd, even, tiny and parallel-sized planes.
2026-10-16 16:26:19 -06:00
JGF 2a952f164c Windows/Linux: open files in the running PhotoCraft instead of a second instance (#3708) (#3755)
Fixes #3708; part of #1540 (Flatpak needs its own follow-up: each sandbox has its own loopback).

A second launch hands its files to the running instance over an authenticated loopback channel (random port, 32-byte token in a private instance.json, constant-time check, bounded clients, requests and paths). Files are acknowledged only once the UI has taken them; the instance shuts the hand-off down before a CPU-compatibility relaunch; it runs only in the per-user settings mode. --new-instance and PHOTOCRAFT_NEW_INSTANCE=1 opt out.

Thanks to @MrCloudy2 (#2358), whose Linux version first handled the relaunch and per-user endpoints, and @Desayuno64 (#5273), whose shutdown flag and read deadline informed the final design.
2026-10-15 22:48:30 -07:00
Tanish Manku d12be8ecd0 test(ui-egui): canvas_16f and rotation preview tests run on the app's wgpu device (#6454)
Both harnesses built their device with egui_kittest's .wgpu() (egui's default limits) instead of
gpu_canvas::wgpu_setup() (the adapter's own limits, what the app requests), as #5954 fixed for the
background_redraw and navigator tests. On that device:
- canvas_16f's sixteen_bit_gradient_on_screen and thirty_two_bit_preview_exposes_values_above_one
  panicked in egui-wgpu ("Failed to create staging buffer for index data", renderer.rs:984), every
  run, single-threaded too;
- arbitrary_rotation_preview's two tests failed "the fixture must use the GPU canvas".
Both binaries pass on the app's device.
2026-10-15 22:33:14 -07:00
58f855a40f Save As on Linux: the file type picked sets the extension (#6121, #4328, #4394) (#6151)
* Save As on Linux: the file type picked sets the extension (#6121, #4328, #4394)

The GTK file chooser behind the desktop portal keeps the typed name when
another file type is picked, and rfd 0.17.2 reads only the file name from
the portal's answer, so Untitled-1.psd with PNG picked was written as a
PSD. On Linux, Save As now asks the portal's FileChooser.SaveFile itself
(over the zbus already used for the portal), reads current_filter, and
gives the name that type's extension: an extension PhotoCraft saves is
replaced, none or another is appended. If that names an existing file, the
dialog opens again under the new name so the portal asks before replacing
it. rfd remains the fallback without a portal, and this can go once an rfd
release reads current_filter (PolyMeilex/rfd#341).

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

* Save As on Linux: keep typed extensions, open on the stem, follow the request handle

Review follow-ups, checked on real GNOME 46 and KDE Plasma 6.7.5 sessions
(Wayland and X11):

- An extension the user typed is kept, as in PolyMeilex/rfd#341: only a
  name with none, or still with the extension the dialog opened with, takes
  the picked type's. GTK reports the type even when it is untouched, so
  art.png with the PSD type left alone was saved as art.psd.
- The dialog opens on the stem (test), as on Windows (#4407): with
  test.psd in the name and PNG picked, GTK asked to replace test.psd,
  which the save leaves alone. A name that then exists is asked about
  again under that name; the rule compares against the suggested name, so
  a test.psd GTK completes back still becomes test.png.
- Response 2 (GNOME's dialog closed with Esc) is a cancel: it fell back to
  rfd, which opened a second Save As.
- The answer is awaited on the handle SaveFile returns, which a portal
  older than 0.9 picks itself (the Request documentation).

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

* style(automation): format smart filter refresh closure

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: skylar taylor-barrick <[email protected]>
2026-10-15 22:47:17 -06:00
Tanish Manku 6368e6a851 GPU memory follow-ups (#5973 review): fit cleared on device loss and on the CPU canvas; measured when switching to a document whose canvas is current; notice names the 3/8 card cap; VRAM override documented; perf rows count memory like the app (#6458) 2026-10-15 21:37:05 -07:00
Tanish Manku 6d3443c098 GPU memory: discrete cards aren't charged for the document; GPU Memory preference; GPU Memory Full status (#5973)
* GPU memory: a graphics card's own memory isn't charged for the document; GPU Memory preference; GPU Memory Full in the status bar

Many layers hit a cliff: past what the GPU memory budget leaves for layer pages, every edit
that redraws the whole document (opacity, visibility, a nudge) goes to the CPU compositor.
Measured on 3000x2000 full-canvas layers (RTX 5090, 31 GB RAM, Memory Usage 8 GB): 150
layers took 2.6 s per such edit, 300 layers 6 s. The budget was Memory Usage minus the
document's pixels and History, which is right where the GPU shares system memory, but a
discrete card's memory doesn't hold the document, so 150 layers of 6 MP (3.6 GB) squeezed
the budget below what they needed.

- Automatic budget: discrete adapters are no longer charged for the document's pixels
  (integrated and software adapters still are). 150 layers: 2.6 s -> 25 ms.
- Preferences › Performance › GPU Memory (MB, 0 = Automatic; performance.gpuMemoryMb) sets
  the budget outright. 300 layers with 12 GB: 6 s -> 45-72 ms.
- The status bar shows "GPU Memory Full" while the active document's layers don't fit
  (both status bar styles); hovering gives the numbers, clicking opens Preferences ›
  Performance. Its Graphics rows show the budget and what the layers need. The slow CPU
  redraw notice gives the numbers and points at GPU Memory.
- Headless: the preference is a normal pref (prefs.set), and ui.state's perf.timings
  reports gpuLayerFit {doc, needed, budget} next to gpuBudgetMb.

Next (owner-approved plan): after #5370, read the card's real limit from the driver
(wgpu memory budget thresholds) and shrink on out-of-memory; then redraw the visible
area on the GPU first when layers still don't fit.

* GPU memory: typed discrete check; the status and budget follow a GPU Memory change at once (measure_fit); perf rows P60/P61 for whole-document edits among 150 full-canvas layers under the app's budget

* GPU memory on top of #5234: memory_budget takes the card's memory (vram) and the GPU Memory value; the document's pixels are charged unless the card's own memory is known; a set GPU Memory is capped at the card's share; benches use video_memory
2026-10-15 21:22:46 -07:00
FinnNkandClaude Opus 5.5 297520b9c8 perf(gpu): cap the GPU memory budgets by a discrete GPU's own memory (#5234)
* perf(gpu): cap the GPU memory budgets by a discrete GPU's own memory

The compositor and canvas budgets came from system RAM only, so a 16 GB card in a 96 GB machine planned for up to 8 GB of compositor pages plus 24 GB of display textures. Detect a discrete adapter's dedicated memory (DXGI on Windows through winsafe, amdgpu sysfs on Linux) and cap each budget at three eighths of it. PHOTOCRAFT_VRAM_MB simulates a smaller card. Fixes #4536.

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

* perf(gpu): keep a 16-bit canvas on the GPU in 8-bit when floats don't fit

With the canvas budget capped by a small card's memory, a 16-bit document under the float-canvas pixel limit could need more than the budget as Rgba16Float and fall back to the CPU canvas although its Rgba8Unorm set fits. Choose the float canvas only when it fits the budget, which is now shared atomically so format_for can read it while the renderer is locked.

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

* perf(gpu): detect a card's memory once per adapter, and test the Linux reader

video_memory built a DXGI factory each time it was asked, three times at
startup; it now remembers the answer per (vendor, device), so a GPU retry
that picks another adapter still gets its own. The amdgpu sysfs reader
takes the drm folder as a parameter and is tested against a temporary
folder: the matching card's VRAM, a card without the file, garbage ids, a
malformed size, no match and no folder.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 21:05:51 -07:00
Tanish Manku 57437ea1a9 fix(ui-egui): canvas textures fit the device's texture limit; GPU tests hold the GPU lock on the app's device (#5954)
- The CPU canvas texture and the crop's beyond-canvas bands were reduced to fit MAX_TEXTURE (4096)
  whatever the device allows. On a device with a smaller limit (2048 on some older GPUs and WebGL
  contexts) a 2600 px document was uploaded at full size: egui asserts and the backend can't
  create it. They now fit the smaller of the two. Test: cpu_canvas_texture_fits_the_device_limit.
- background_redraw and navigator GPU tests built their harness with egui's default wgpu limits
  instead of the app's wgpu_setup (the adapter's own limits), and the GPU canvas lost the device
  on it ("Device is lost", every run on an RTX 5090). They now use wgpu_setup.
- Eleven tests built a wgpu device without photocraft_testkit::gpu_lock, which testkit requires so
  devices aren't created concurrently; they now hold it.
2026-10-15 21:05:39 -07:00
skylar taylor-barrick 7053f703a5 Merge pull request #2933 from 1Git2Clone/ci-wallclock
CI: cut wall time, starting with the Windows test job
2026-10-15 21:38:18 -06:00
skylar taylor-barrick fd1faf4e13 Merge pull request #6381 from dylanmtaylor/ci/shared-freebsd-workflow
ci: delegate FreeBSD workflow to craft-common-actions and add nix check
2026-10-15 21:34:22 -06:00
skylartaylor 8f58ea4629 ci: integrate current main while retaining strict test gates 2026-10-16 05:25:24 +02:00
Dylan M. Taylor 0cc161e2a8 ci: delegate FreeBSD workflow to craft-common-actions and add nix check 2026-10-15 22:04:09 -04:00
PerchenkoAnton 3cb3580a46 fix(psd): recover layered imports with damaged merged data (#6170) 2026-10-15 19:14:05 -06:00
skylar taylor-barrick b630d7cb9a Security: enforce the automation read boundary on stored smart-filter params (#5684)
* Security: enforce the automation read boundary on stored smart-filter params

Smart filters store a command id and params inside the document, so a
crafted .pcraft could resolve stored ambient paths (displace mapPath,
color-lookup LUT files) at render time without the authorize gate:
synchronous smart-object rendering has no Session argument, spawned job
threads drop the requesting session's authority, and a trusted UI
render could warm the process-wide stack cache with a stage the gate
never saw.

Thread the requesting session's gate through a thread-local in jobs.rs
(entered around command execution, reinstalled on spawned workers and on
job apply, restored on drop), re-check stored filter commands in
render_in before source resolution and any cache lookup and in
apply_filter_to_surface, and re-authorize layer.smartFilter.setParam
with the complete merged params after merging stored document params —
including edits whose request contains no path and actions.play steps.
Denial is conservative: the document is unchanged and no worker starts.

Regression tests in the automation crate cover rerender, cached-stage
reuse, merged-param edits, action playback, background jobs, and filter
transfer, for both the displace map and color-lookup LUT vectors.

* Review: carry render authority through Session::edit and surface denials

Session::edit is the engine's universal mutation entry (linked-file
refresh, transforms, place); renders reached through it ran with no
authorization scope installed, so stored smart-filter params read
ambiently there even after the execute-path fix. Install the scope in
Session::edit as well, keyed on the session's authorize gate.

apply_filter_to_surface also conflated a denied filter with an unknown
one: authorize_render_command's Result was discarded with .ok()?, so a
denial silently rendered unfiltered pixels and the stage entered the
process-wide stack cache. The signature becomes
Result<Option<Surface>>: Err for denials (callers propagate), None for
ids that aren't pixel filters (pass-through semantics unchanged), and an
unreadable map keeps its deliberate pass-through. apply_smart_filter and
apply_smart_filters propagate instead of cur.clone().

Two tests: a refresh driven outside command execution (Session::edit)
is denied and leaves the document unchanged; a denied render caches no
stage. Nine authorization tests pass, engine suite and strict Clippy
clean, panic hunt green.

* ui_set description: name the layerFilter field main added

The Layers-panel filter dropdown (#5048) added layerFilter to the
control method's UI_SET_FIELDS without extending the ui_set tool's
description, so the drift guard (mcp.rs, ui_set_description_names_every_
accepted_field) fails on Windows CI for any branch built on main. Name
the field with its patch shape: by (kind, name or mode), kinds (a list
of layer kinds), name (matches part of the layer name), mode (a blend
mode name). The 40 MCP tests pass; strict Clippy clean.

* wasm size: compose and algo join cms at opt-level "z" to clear the 24 MiB gate

Main's browser build is 171 KB over the 24 MiB wasm gate in CI
(25,337,343 bytes), so every branch built on current main fails the
web size check. Demote three pixel crates in the wasm-release profile:

- cms: transforms are built once and cached (transform::cached),
  so "z" costs little runtime (measured -42 KB).
- compose: the browser app composites per frame on the GPU (crates/gpu,
  WebGPU/WebGL2), so the CPU compositor is not the browser's hot path
  there (measured -122 KB).
- algo: filters run per pixel on every interactive filter application.
  "z" there is the lever of last resort (measured -331 KB), taken
  because the gate is enforced in CI; if browser filters feel too slow,
  restore 3 here and reclaim the bytes from a feature-gated decoder.

Final size 24,811,949 bytes locally, 354 KB under the gate; the native
profiles are untouched. If a maintainer prefers keeping algo at 3, the
alternative is raising the self-imposed 24 MiB early-warning limit
(Cloudflare's hard cap is 25 MiB) - a policy call in packaging/web/
package.sh, not a code fix.
2026-10-15 18:19:26 -06:00
Tanish Manku 2b3d05636f Follow-ups to #5801/#5859: precheck docs name the group nesting limit; precheck test covers valid ids and null; Free Transform refusals translated; stranded-backup error tested (#6365) 2026-10-15 15:32:08 -07:00
Tanish Manku fdaa9b8316 Free Transform: New Layer applies the transform first; tests for switching tool, layer or New Layer (#5665, #5667) (#5859)
* Tests: Free Transform applies when another tool, layer or New Layer is picked (#5665, #5667)

Whole-app regression tests for the paths #5665 and #5667 report (a moved box, and a placed image moved or untouched; toolbar tool click, Layers panel row click, New Layer). They pass on main, so these issues don't reproduce here; the tests pin Photoshop's behaviour (apply, never silently cancel) while the reporters narrow down their environment.

* New Layer while Free Transform is open applies the transform first (History: the transform, then New Layer; a placed layer's step no longer swallows New Layer); tests: a real placed fixture, the New Layer button, History order

* Free Transform + New Layer: check the command can run before applying the transform; a refused transform keeps the box and the placed layer and returns the refusal

* Free Transform + New Layer: Session::precheck validates the call (params shape, New Layer's values, precondition) before the box is applied

* Free Transform + New Layer: commit-first only for PRECHECKED commands; precheck shares dispatch's admission checks (named-layer precondition, jobs, hidden target) and checks a named layer exists; Group from Layers documents its layer param; test: precheck agrees with execute

* Free Transform + New Layer: precheck resolves a layer given by name as the command does; Layer via Copy/Cut leave PRECHECKED (their failures show only when they run); rebased (lib.rs mod lines)
2026-10-15 15:26:13 -07:00
Tanish Manku 8376363d8c Shortcuts without the UI: shell commands and tools in the engine, reserved keys, set files (#5801)
* Shortcuts without the UI: shell commands and tools in the engine, reserved keys, set files

A retro against "everything is drivable headless; the UI is a layer" found the shortcut
work leaning on the UI. Fixed:

- The shell's command table (UI_COMMANDS: View › Zoom In, the panels…) moves into the
  engine as data (shell_commands::SHELL_COMMANDS; menus::UI_COMMANDS is now an alias), with
  the tools (shell_commands::TOOLS: id, Photoshop name, default key; a test keeps it equal
  to the toolbar). edit.keyboardShortcuts binds, lists, imports, exports and checks them for
  conflicts with no UI: a headless .kys import takes tool keys, and an export is the whole set.
  Tools may share a key (Shift cycling); a command given a tool's key takes it from them.
- Reserved system keys are refused by the engine (the dialog uses the same check) and left
  out of imported sets.
- importKys / exportKys / summarize take {"path": …} to read or write files, like
  actions.save; the automation gate refuses those paths for MCP.

* Keyboard shortcuts: bindings commit only after their exports are written (a bad target changes nothing); docs: the command knows the tools

* Keyboard shortcuts: both export targets are resolved and checked before either file is written; a failed later write removes the earlier one

* docs: reserved keys per platform

* Keyboard shortcuts export: files are staged next to their targets and moved into place only when all are written; a failure leaves the user's files untouched

* Keyboard shortcut exports all or nothing: unique create_new staging and backups beside each target, restored on any failure; the same target twice refused; docs: Ctrl+Shift+Esc

* Shortcut exports: a failed backup move removes its placeholder; undo restores backups without deleting first and names any it couldn't; read-only targets refused up front
2026-10-15 15:26:09 -07:00
Tanish Manku b09aeae2e2 Low-Latency Drawing preference (vsync off, opt-in), plus present-mode overrides for testers (#1343) (#5274)
* PHOTOCRAFT_PRESENT_MODE / PHOTOCRAFT_FRAME_LATENCY launch overrides for latency A/B tests (#1343)

Linux traces put the remaining stroke latency in presentation queueing (the app's frame fits
in one refresh; the rest waits on the swap chain), and an NVIDIA user cured it by forcing
vsync off. Testers can now compare without rebuilding: PHOTOCRAFT_PRESENT_MODE (fifo,
mailbox, immediate, autovsync, autonovsync) and PHOTOCRAFT_FRAME_LATENCY (1-3), applied over
the platform default. Bad values are logged and ignored. The active mode is logged at start,
kept in GpuInfo.present and shown in every stroke trace line.

RTX 5090, Vulkan, 120 Hz: default autovsync/latency 1 waits 6 ms per 8.3 ms frame; mailbox
renders every 2.3 ms with 0.2 ms wait, so each vsync shows the newest frame.

* docs: present mode and frame latency overrides

* Present mode overrides never request a mode the driver may lack (Mailbox crashed on X11/NVIDIA)

* Preferences › Performance › Low-Latency Drawing, on by default: no vsync wait while drawing (#1343)

Testers' traces put the ~50 ms stroke lag in the swap chain's vsync wait. With vsync off it
disappears (X11/NVIDIA; Windows/NVIDIA via the driver), and a shorter queue doesn't help. The
surface now presents with AutoNoVsync by default: wgpu picks Immediate, else Mailbox, else
Fifo by what the driver offers, so no unsupported mode is ever requested. Turning the
preference off brings vsync back (smooth, but late). It applies at the next launch, like the
other graphics settings. Where only Immediate exists the canvas may tear while it changes;
frames render as fast as input arrives during a stroke and stop when idle.

* Runtime and launch controls: ui.set strokeTrace, --stroke-trace, --present-mode, --frame-latency

The retro's remaining gap: the stroke trace and the presentation overrides were env-only. The trace now switches at runtime over the control channel (ui.set {"strokeTrace": true|false|"stderr"|path}) and with --stroke-trace; --present-mode / --frame-latency sit beside their env vars and win over them. Bad values are reported and ignored; none fails a launch.

* docs: strokeTrace beside the other ui.set fields

* docs: regenerate the scorecard after rebasing onto main

* Low-Latency Drawing: frames capped at 240 fps without vsync (after #4472); ui.set strokeTrace takes on/off only (a CSV path is launch-only); tearing documented as usual (Immediate); pref hidden on web; doc comment order

* stroke_trace: from_value doc says ui.set passes only on or off

* Low-Latency Drawing ships off (opt-in), per the owner's decision

* docs: present mode default is vsync (Low-Latency Drawing ships off)
2026-10-15 15:23:47 -07:00
skylar taylor-barrick af54f4aed1 ci: limit the font pass to font-consuming crates (#6127)
Keep the full workspace and feature build selection while running the craft-fonts pass only for text, io and ui-egui. Ordinary workspace test coverage remains in the first test pass.
2026-10-15 16:03:39 -06:00
skylar taylor-barrick 1a4167936e ci: run tests with cargo nextest (#6118)
Run workspace tests with pinned cargo-nextest, a CI fail-fast profile and a ten-minute per-test hang limit. Keep cheap checks first and doctests in a separate step so failures propagate on Windows.
2026-10-15 16:02:57 -06:00
Tanish Manku 54f4baf6f9 Layers panel: blend mode, Opacity, Fill and locks change every selected layer; Photoshop's History names (#5856)
* Layers panel: blend mode, Opacity, Fill and locks change every selected layer; Photoshop's History names

With several layers selected, the Layers panel's blend mode, Opacity, Fill and lock controls
changed only the active layer, silently (the layers audit's top panel gap). They now change
every selected layer, as in Photoshop, in one undo step. layer.setProps takes
"layers": [id, …] for that (headless too); a list with an unknown id, a bad shape, or a
name for several layers is an error that changes nothing.

History names follow Photoshop for a single kind of change: Opacity Change, Fill Opacity
Change, Blending Change, Rename Layer (a mix stays Layer Properties).

* Layers panel with several selected: Pass Through only for groups (refused for other layers), the Basic lock toggle and the blend-mode hover preview take every selected layer, the controls grey with the Background selected; History icon for opacity/fill/blend changes

* Layers panel: leave the Background-in-a-selection behaviour as it was until Photoshop's is confirmed (#5856)

* Layers panel: a selected Background is left out of several-layer Opacity, Fill, blend and lock changes until Photoshop's behaviour is confirmed (#5856)
2026-10-15 14:33:51 -07:00
Tanish Manku 3cd0f6c954 Keyboard Shortcuts: Accept and Go to Conflict / Undo Changes, and reserved keys (K4a) (#5349)
* Keyboard Shortcuts: Accept and Go to Conflict / Undo Changes, and reserved keys (K4a)

A key that another command holds now offers Photoshop's two answers under the warning.
Accept and Go to Conflict takes the key from the other command at once (its main key or
an alternate) and selects it for a new one. Undo Changes puts the edited command's main and
further keys back as they were. OK without either still accepts, as in Photoshop.

Keys the system takes before the app sees them are refused with a message: Alt+Tab,
Alt+F4, Ctrl+Alt+Delete, Ctrl+Esc and Ctrl+Shift+Esc off the Mac; Cmd+Tab, Cmd+` and
Cmd+Opt+Esc on it.

* Keyboard Shortcuts: a conflict goes stale when the bindings change after it (Reset All, Use Default, Delete, ×, Escape, a set switch); Accept takes the key from every holder; Linux reserves its own keys; doc comment back on shortcut_text
2026-10-15 14:18:14 -07:00
Tanish Manku 04db697b5d Paste Layer Style: a group's Pass Through pastes onto other layers as Normal; style clipboard comment (#6310) 2026-10-15 14:17:22 -07:00
Tanish Manku c11ef015b7 Paste Layer Style carries Blend If and channels; align to an active selection with several layers (#5881)
* Paste Layer Style carries Blend If and channels; align to an active selection with several layers

From the layers audit (L2):
- Copy / Paste Layer Style copied the effects, blend mode, Fill and advanced blending but
  dropped Blend If and the channel restrictions; Photoshop's layer style carries all of its
  Blending Options. Across colour modes only Blend If's Gray range carries over and the
  channels reset (RGB channel ranges mean nothing in CMYK). The copied style is now a named
  struct (CopiedStyle) instead of a tuple.
- With an active selection, aligning (Layer › Align, "auto") aligned two or more layers to
  their own bounds; Photoshop aligns them to the selection (Layer › Align Layers to
  Selection), as it already did for one layer.

* Paste Layer Style keeps Blend If normalised across modes; Align docs say it follows an active selection for any count; undo, group and 16-bit tests
2026-10-15 13:10:28 -07:00
Tanish Manku 77a258b9a0 Clip groups: Release Clipping Mask takes the layers above; merges keep clipping; Delete Hidden settles (#5868)
* Clip groups: Release Clipping Mask takes the layers above; merges keep clipping; Delete Hidden settles

From the layers audit (L2, silently wrong results):
- Release Clipping Mask on a clipped layer also releases the clipped layers above it, as
  Adobe documents ("removes the selected layer and any layers above it from the clipping
  mask"). Before, they stayed clipped to the released layer.
- Merge Down of two clipped layers, and Merge Layers of clipped layers of one chain, give a
  layer still clipped to their base (it was always unclipped). Merge Down's result also keeps
  the lower layer's colour label and link; Merge Layers' keeps the top layer's label.
- Delete Hidden Layers releases the layers clipped to a deleted hidden base (clip_group rule
  3, as single Delete already does), instead of leaving them clipped to the layer below.

* Merge Layers keeps the result clipped only when the clipped layers share one clip chain

* fmt
2026-10-15 13:10:22 -07:00
e4b2868a02 Check deep EXR tile count tables against max_alloc (#5632)
* Check deep EXR tile count tables against max_alloc

A deep tile's sample-count table was decompressed at the header's full tile size with no
budget check, so a 20 KB file could build a 4 MiB table and a scratch copy under a 1 MiB
max_alloc. Try that size only when max_alloc has room for both; otherwise read the clipped size.

Fixes #4958.

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

* Keep deep EXR decompression within the size it was admitted at

The count-table check admits the table and its scratch copy, 2x the table, but Vec doubling
gave a 4 MiB ZIPS table 8 MiB of capacity (a 3 MiB RLE table 4 MiB), so an admitted table
and its copy could take 3x. Grow the decompressed buffer by doubling capped at the expected
size, inflating straight into it, and allocate it and the scratch copy fallibly.

Tests: decompressed buffers never reserve past the expected size; a near-maximal tile with a
clipped table decodes under every budget; per-tile admission; half and uint channels after
full-size tables; the 8 MiB boundary for uncompressed tables too.

Refs #4958.

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

* Tests: show deep tile tables are admitted one tile at a time

The 17x17 image in 16x16 tiles had an interior tile, which never reaches the max_alloc check,
so only three edge tables (6 KiB) were checked against the 8000-byte budget and a check that
summed admissions across tiles still passed. Use a 48x1 image instead: its three tiles are all
edge tiles, and under 4096 bytes a summed check fails at the third tile.

Refs #4958.

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

* Complete deep EXR count-table allocation admission

Account for scratch space on clipped and interior compressed tables while preserving raw-table budgets. Borrow packed input before admission and make initial RLE reservations and raw copies fallible.

Add single-channel admission boundaries, raw fallback controls, and hostile-size borrowed-read regressions.

* ci: run deep EXR regressions independently

* Drop the separate deep EXR CI job

It was added so these tests could run while main's workspace suite was
failing. Main is green again, and the regular test jobs run them.

This reverts commit 5aa4869b45.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: jonathnwxh <[email protected]>
2026-10-15 13:40:50 -06:00
Michael HigginsandClaude Opus 5.5 64520f645e Control channel: connections must authenticate within 10 s, however active (#4965) (#5229)
The only limit on an unauthenticated control connection was the 30 s
idle timeout, which starts again with every read. A local client that
kept sending bytes or blank lines without ever completing `auth` kept
its connection, and one of the 16 connection permits, for as long as it
liked; sixteen of them locked every other client out.

AuthDeadlineStream wraps the read half of a connection: until the
connection authenticates, reads fail once AUTH_DEADLINE (10 s) has
passed since it connected, and a blocking read waits no longer than
what is left. Both transports use it (the desktop app's control server
and the headless `photocraft-cli serve`). After `auth` succeeds only
the idle timeout applies, as before.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 13:39:19 -06:00
Michael HigginsandClaude Opus 5.5 b24c0360f1 Dependencies: require ruzstd 0.9.1 for RUSTSEC-2026-0344 (#4952) (#5225)
ruzstd before 0.9.1 lets a crafted zstd input under 128 KB allocate
several gigabytes at once. PhotoCraft decodes zstd when it opens .pcraft
and Affinity files, so such a file could end the process with an
out-of-memory abort.

Raise the minimum in both crates that use it and update the lockfile
from 0.9.0 to 0.9.1.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 13:37:56 -06:00
Khawar Zamman WaniandClaude Opus 5.5 1b666f4790 Pen: Auto Add/Delete on the path being drawn (#6140) (#6172)
While a path was being drawn, the Pen's Auto Add/Delete was off, so adding
a point meant finishing the path and switching to Add Anchor Point. Now a
click on a segment of the path in progress adds an anchor, a click on a
middle anchor deletes it, and drawing carries on from the last anchor. The
first anchor still closes the path and the last still breaks its handle.

The hover highlight draws over the path in progress, and the rubber band
hides where a click adds or deletes instead of placing an anchor.


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

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 13:12:35 -06:00
ZuujisandClaude Opus 5.5 99083fca4c Quick Mask: ⌘I inverts the whole mask, unstored black included (#6095) (#6108)
* Quick Mask: ⌘I inverts the whole mask, unstored black included (#6095)

Image › Adjustments on an alpha channel or the Quick Mask ran on the surface's
stored content only. The black outside a selection-made Quick Mask (or a new
black alpha channel) is the surface default, never stored, so Invert left it
black: the whole mask turned red and leaving Quick Mask selected nothing.
Channel targets now go through `pixels::adjust_mask`, as layer masks do, which
adjusts the default pixel too.

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

* Quick Mask: test ⌘I with Color Indicates Selected Areas too (#6095)

A rectangle comes back as everything but the rectangle, and with nothing
selected ⌘I selects everything. Both failed on main.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 13:11:42 -06:00
skylartaylor d68a1cbc2d ci: preserve separate nextest and doctest results 2026-10-15 19:56:43 +02:00
skylartaylor bcf2cefd4b ci: preserve nextest failures on Windows 2026-10-15 19:56:43 +02:00
skylartaylor 4f6e617555 ci: retain the font filter with integrated nextest checks 2026-10-15 19:44:21 +02:00
skylartaylor 40a7c20430 ci: integrate nextest with early checks and runtime limits 2026-10-15 19:44:21 +02:00
skylar taylor-barrick 5f9190f8cc ci: run cheap checks before tests (#6124)
Run the existing Clippy, dependency layering and scorecard checks before the test passes so failures are reported earlier.

Preserve the existing commands, test coverage and runtime limits. Linux, Windows, corpus, WASM, web and packaging checks passed.
2026-10-15 11:42:53 -06:00
skylar taylor-barrick d2fd1a012a ci: add comfortable runtime limits to unbounded jobs (#5931)
Add explicit runtime limits to eight previously unbounded CI, documentation and packaging jobs, preserving existing limits and quality gates.

Validated the timeout-only diff and completed Linux, Windows, corpus, WASM, web, documentation and packaging checks.
2026-10-15 11:26:52 -06:00
midasmatterhorn c06eca9b68 fix(psd): preserve PSB foreign-order block lengths
Choose PSB block length width from the canonical big-endian key before converting the key for little-endian TIFF output. Preserve foreign payloads, padding and conversion of following blocks.

Addresses #5599, finding 2 only.

Fixes #6104.
2026-10-15 11:23:57 -06:00
skylar taylor-barrick f9ed9e3e57 ci: refresh timeout draft against current main 2026-10-15 16:27:05 +02:00
skylar taylor-barrick 4e7a17cd5d fix(compose): serialize effect identity cache misses
Keep effect-identity memo lookup, pure hashing and insertion atomic across concurrent render callers. Preserve existing identities and add a contention regression with eight synchronized callers.
2026-10-15 08:18:21 -06:00
David MannandClaude Opus 5.5 caa28cb7ab CI: run only the font tests in the craft-fonts pass (#6120)
The pass reran every test in the workspace, but CRAFT_FONTS is only read in photocraft-text,
photocraft-io (svg) and photocraft-ui-egui. The build selection stays the same (the disk warning
above still applies); a nextest filter runs those three packages' tests: ~3,800 of ~8,900.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:41:12 +02:00
David MannandClaude Opus 5.5 e22c4ec7ab CI: run the tests with cargo nextest (#6117)
cargo test runs the ~258 test binaries one at a time; nextest runs them in parallel. Its new `ci`
profile stops at the first failure (a red PR frees its runner early), fails a test that hangs for
10 minutes instead of letting it hold the runner, and repeats every failure at the end of the log;
the GPU test group still applies. Same --workspace --locked --features heif selection, so the build
is unchanged. Doctests stay on cargo test --doc.

postscript_only_style_shows_actual_subfamily relied on another test in the same process having
started the font scan; under nextest it runs alone, so it starts the scan itself (as #2933 does).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:40:15 +02:00
David MannandClaude Opus 5.5 9660dca263 CI: run clippy, layers and scorecard checks before the tests (#6119)
They take about 1.5 min together but ran after all the test passes, so a clippy failure (15 s)
showed red only 41-48 min into the job. Same steps, reordered: no cost on a green run.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 14:22:13 +02:00
df055f3dcb CI: fix the tests that keep main red, and ship the wasm as compressed parts (#5926, #5302) (#5941)
* Fix two ui-egui tests broken on main by concurrent landings

- profile_open_tests: #5614 renamed the built-in profile to "(PhotoCraft)" after #5208
  hard-coded "(Photocraft)" in the Use-embedded button label.
- smart_object_curves_preview_keeps_its_size_on_a_reduced_proxy (#5779): the reduced
  square is in the right place, but its corner pixels are 220/255 after the smart
  object's resampling; assert full coverage inside and over half at the corners.

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

* Web: ship the wasm as compressed parts behind a boot module (#5302)

The release site held the app's wasm as one file, which sits at the 25 MiB
per-file cap of Cloudflare Pages, so the 24 MiB gate failed unrelated PRs.

packaging/web/package.sh now gzips the wasm and cuts it into parts of at
most 20 MiB under app/, and ships the page of a new crate,
apps/photocraft-web-boot. That module fetches the parts in order, has the
browser inflate them (DecompressionStream) and passes the stream to the
app's own wasm-bindgen loader, so the browser still compiles while it
downloads. A failed download is retried twice, then the page says so.

The check is now that no file in the site is over 24 MiB. trunk build in
apps/photocraft-web and the Docker image still give one .wasm.

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

* Web boot: inflate the parts in the module where the browser can't (#5302)

* Tests: the adjustment-layer drag preview test follows #5778 (#5926)

#5778 caps a live adjustment-layer preview by the view's zoom, so at 100 %
the drag shows full resolution even with Low Resolution Previews on. The
test still expected the reduced copy there. It now checks that at 100 % every
column shows by default, and moves the low-res on/off comparison to 50 %,
where the reduced copy is still used: on, the drag shows only the (inverted)
black columns; off, it matches the committed view.

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-15 09:21:30 +02:00
ad96df566b File > Save As / Save a Copy / CLI can write PDF (#3401) (#3441)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

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

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

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

Fixes #4936

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Vector: bound stroke outline points (#5211)

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

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

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

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

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

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

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

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

Refs #1366

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

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

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

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

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

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

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

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

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

Refs #1366

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

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

Refs #1366

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

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

Two changes in crates/algo:

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

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

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

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

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

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

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

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

* cargo fmt the extension lists

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

---------

Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: timothydaly1 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Fabio Lissi <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Finn Newick <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Diego Chagas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 01:03:27 -04:00
4e1e325b17 PSD: a smart object's filter mask shows when the file opens (#5005)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

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

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

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

Fixes #4936

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Vector: bound stroke outline points (#5211)

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

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

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

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

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

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

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

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

Refs #1366

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

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

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

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

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

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

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

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

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

Refs #1366

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

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

Refs #1366

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

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

Two changes in crates/algo:

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

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

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

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

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

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

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

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

* cargo fmt the extension lists

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

---------

Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Fabio Lissi <[email protected]>
Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Finn Newick <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Diego Chagas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:58:45 -04:00
8a6af66cf6 DNG: decode lossy (JPEG-compressed) DNG tiles (FILE-215-11) (#5112)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

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

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

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

Fixes #4936

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Vector: bound stroke outline points (#5211)

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

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

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

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

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

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

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

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

Refs #1366

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

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

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

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

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

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

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

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

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

Refs #1366

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

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

Refs #1366

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

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

Two changes in crates/algo:

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

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

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

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

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

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

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

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

* cargo fmt the extension lists

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

---------

Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Finn Newick <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Diego Chagas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:54:55 -04:00
a7301f1db8 PSD: cap pattern memory across a parse, reject mixed-depth pattern channels (#5198) (#5207)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

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

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

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

Fixes #4936

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs #1366

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

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

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

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

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

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

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

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

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

Refs #1366

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

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

Refs #1366

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

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

Two changes in crates/algo:

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

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

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

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

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

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

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

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

* cargo fmt the extension lists

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

---------

Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Finn Newick <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Diego Chagas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:51:37 -04:00
1a4f867958 TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab (#4949)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

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

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

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

Fixes #4936

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

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

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

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

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

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

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

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

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

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

Refs #1366

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

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

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

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

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

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

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

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

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

Refs #1366

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

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

Refs #1366

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

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

Two changes in crates/algo:

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

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

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

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

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

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

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

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

* cargo fmt the extension lists

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

---------

Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Finn Newick <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Diego Chagas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:48:20 -04:00
47cf5af9fd perf: keep effect-cache pins and History snapshots inside the memory budgets (#5336)
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

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

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

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

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

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

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

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

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

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

Refs #1366

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

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

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

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

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

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

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

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

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

Refs #1366

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

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

Refs #1366

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

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

Two changes in crates/algo:

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

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

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

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

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

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

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

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

* cargo fmt the extension lists

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

---------

Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Diego Chagas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:44:55 -04:00
c7d9ade95b Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open (#4462)
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

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

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

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

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

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

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

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

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

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

Refs #1366

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

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

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

Refs #1366

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

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

Refs #1366

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

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

Two changes in crates/algo:

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

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

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

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

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

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

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

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

* cargo fmt the extension lists

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

---------

Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Gert Pellin <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:40:46 -04:00
cb8ed7620e Content-Aware Fill: no ghost edges or object colour around thin removed objects (#5290)
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

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

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

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

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

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

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

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

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

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

Refs #1366

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

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

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

Refs #1366

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

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

Refs #1366

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

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

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

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

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

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

* cargo fmt the extension lists

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

---------

Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: vangel76 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:36:50 -04:00
4446ec6d65 PSD: write tiff blocks without copies, inflate merged ZIP once (#5221)
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

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

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

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

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

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

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

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

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

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

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

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

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

* cargo fmt the extension lists

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

---------

Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
2026-10-15 00:32:55 -04:00
5dafbeeafc Codecs: open Cineon (.cin) film frames read-only (#4691)
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

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

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

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

* cargo fmt the extension lists

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

---------

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

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-15 00:22:48 -04:00
17c85e4175 fix(ui): brush outline matches the stroke on Wayland at fractional scales (#5796)
* fix(ui): brush outline matches the stroke on Wayland at fractional scales

On Hyprland at 1.25 the brush outline was 1.25x too large and sat right of
and below where the brush painted, by more the larger the brush. The engine
places dabs exactly; the OS cursor bitmap was wrong. winit 0.30 sizes custom
cursor bitmaps on Wayland with a wp_viewport destination whenever the scale
isn't 1, and Hyprland (like Mutter before GNOME 48) ignores viewports on
cursor surfaces: it shows the bitmap one pixel per logical pixel with the
hotspot winit divided by the scale.

A client can't tell which compositors do this, so on Wayland at any scale
but 1 the brush outline and crosshairs are painted in-app, as on the web.
Other platforms and Wayland at scale 1 keep the low-latency OS bitmap.

Measured on Hyprland 0.56.2 at 1.25 (100 % zoom, 100 px brush): dab centre
(288.5, 560.5), diameter 97 px; outline before (303, 574.5), ~130 px;
after (288.5, 562), 107 px including its border.

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

* Merge main; 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-14 23:50:19 -04:00
96c683b25d Info panel: show guide coordinates while dragging outside the canvas (#5728) (#5768)
* Fix Info panel guide coordinates during ruler drags (storytold/photocraft#5728).

Show the dragged guide position on one axis while the pointer is outside the canvas, matching Photoshop.

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

* Merge main; fill GuideDrag's new press field in the test; cargo fmt

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-14 23:50:14 -04:00
FredApps be32a7c651 fix: commit type editing on Layers-panel selection (#5584) (#5612) 2026-10-14 23:50:08 -04:00
David MannandClaude Opus 5.5 8987980d16 Dialogs: a close box in the title bar cancels like Cancel (#5496) (#5505)
Dialogs drawn by the shared renderer had no close box: the only ways
out were Cancel/OK or Esc. As in Photoshop on Windows, the title band now
ends in a × (the icon button the floating panels close with) on Windows,
Linux and the web, none on macOS. A click on it joins Esc in the single
cancel path of `dialogs::show` (outcome = Cancel -> `cancel()`), so the
live preview goes and nothing reaches the document or history. The × is
left out of the title band's drag area, so a drag starting on it neither
moves nor closes the dialog; the rest of the band still drags.

Check Spelling (an untitled window) gets the same close box; it is Done,
as every change there is already made.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 23:50:03 -04:00
Valentin KleinandClaude Opus 5.5 94b1f0a4cb Warp: a thin layer stretched and bent keeps its curve on commit (#5793) (#5810)
* Warp: a thin layer stretched and bent keeps its curve on commit (#5793)

The mesh warp sized its triangle grid by the source rectangle only (one cell per 4 source px). A 3 px column scaled into a wide band and then warped got a single cell across, so the committed result was a straight, affine band while the preview showed the curve. The grid now also follows the destination length of the mapped rows and columns.

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

* Warp: module doc describes the grid sized on both sides of the map (#5793)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:49:58 -04:00
bazandClaude Opus 5.5 38b96f273a Move: Show Transform Controls frames every selected layer (#5917)
As in Photoshop, with several layers selected (Shift-click with Auto-Select,
or the Move marquee) Show Transform Controls draws one frame around all of
them (the Background excluded) and outlines every selected shape layer.
Before, the frame showed only the active layer, so the other selected layers
had no feedback on the canvas. The frame joins the boxes Free Transform would
open on each layer (transform_frame), and it hides while a corner radius
widget is dragged.

Free Transform works on one layer, so the shared frame has no handles and a
press on it doesn't start Free Transform; a drag inside still moves every
selected layer.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:49:52 -04:00
WendelVolm 0c7308adc5 Snapping: snap a drawn marquee, Object Selection box or shape by its edges while Space repositions it (#5748) 2026-10-14 23:49:47 -04:00
Michael HigginsandClaude Opus 5.5 0c5709e9bd Free Transform: shapes and paths stay on whole pixels (#5751) (#5841)
* Free Transform: shapes and paths stay on whole pixels (#5751)

With Snap Vector Tools and Transforms to Pixel Grid on, only the pointer was
rounded, and only while a shape tool was active. Under the Move tool, or with
a grab offset or a proportional scale, the box landed between pixels (a
rectangle dragged to 540 px came out 539.6). An upright box on a shape layer
or path now keeps whole-pixel edges whatever the tool.

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

* Format (cargo fmt)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:49:42 -04:00
Michael HigginsandClaude Opus 5.5 6729fa1e50 Layer Style: drag on the image to move a drop or inner shadow (#5701) (#5893)
As in Photoshop, with the Layer Style dialog on a Drop Shadow or Inner Shadow
page, dragging on the image moves the shadow: its offset follows the pointer
and is stored as the effect's angle and distance (whole degrees and pixels),
moving the shared global light angle when the effect uses it. Space and the
middle button still pan; colour pickers still sample.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:49:36 -04:00
Michael HigginsandClaude Opus 5.5 56d3b7f983 Pen and Path Selection: Delete deletes the finished work path (#5702) (#5732)
A finished work path is drawn whenever the Pen, Freeform Pen or Path
Selection tool is active, and it is saved with the document, as in
Photoshop. But once the path was finished, Delete/Backspace fell through
to Edit > Clear (the pixels under it), so the only ways to remove it were
the right-click Delete Path and the Paths panel, and it looked impossible
to delete.

With those tools, no path being drawn and the work path the one they
edit (no shape layer or targeted vector mask in front of it), an
unmodified Delete/Backspace now runs path.delete on the work path: one
undoable Delete Path step. Direct Selection and the Curvature Pen are
left alone, since Photoshop deletes their selected anchors, not the path.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:49:30 -04:00
David MannandClaude Opus 5.5 8226139806 Image Size, Canvas Size: points and picas follow the Point/Pica Size preference (#5756) (#5757)
The dialogs converted points and picas at a fixed 72 pt/in, while the
rulers, Info, layer properties and the shape dialog use Preferences >
Units & Rulers > Point/Pica Size. With Traditional (72.27) a document
showed different point sizes in the rulers and in Image Size.

The dialog now stores the preference's points per inch when it opens
(__ptPerIn, 72 when missing) and uses it for points, picas (points / 12)
and typed units in the Width/Height fields.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 23:49:25 -04:00
David MannandClaude Opus 5.5 b404d50581 Type: Swap and Default Colors recolour the selected text (#5690) (#5808)
The toolbar's Switch Colors and Default Colors buttons ran their commands
without type_tool::foreground_changed, so characters highlighted in the
type layer being edited kept their colour. As in Photoshop, they now take
the new foreground, like picking a colour in the Color or Swatches panel.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 23:49:20 -04:00
envy-ai e47086717a Channels: load the targeted layer mask as selection (#5562)
* Load the targeted layer mask from Channels footer

* Fix Channels test setup after mask reveal
2026-10-14 23:49:15 -04:00
Vikram Bhaskaran f02c0cbef7 File › Scripts › Browse: reject Photoshop ExtendScript (#5406) (#5435)
* File › Scripts › Browse rejects Photoshop ExtendScript.

The picker listed .js/.jsx files that parse_script cannot run. Filter to
JSON/text/droplet scripts and return a clear error for .js/.jsx paths.

* File › Scripts › Browse keeps Photoshop extensions in the picker.

Review feedback on #5435: filtering .js/.jsx out of the picker hid the
rejection error from UI users. List Photoshop's script types (as #2452
asked) alongside PhotoCraft's so selecting one shows the ExtendScript
error instead of hiding the file.
2026-10-14 23:49:09 -04:00
safvanedgeandsafvanedge cc9967654c fix(ui): restore previous tool when committing or canceling text edit (#5527) (#5528)
* fix(ui): restore previous tool when committing or canceling text edit initiated via double-click

* fix(ui): restore return tool only while on type and stay on type for layer thumbs

- commit_with/cancel: restore return tool only while app.ui.tool.is_type()
- edit_active: keep tool on Type when editing from layer thumbnail (matches Photoshop)
- clear text_edit_return_tool when text_edit is dropped on lost text or stale transform
- test tool switch mid-edit and edit_active remaining on Type

---------

Co-authored-by: safvanedge <[email protected]>
2026-10-14 23:48:55 -04:00
David MannandClaude Opus 5.5 8d8db22c94 Type: a scaled type layer keeps its size when the Character fields show it (#5717) (#5718)
egui's DragValue clamps an existing value to its range and reports a change, so showing a
type layer scaled past 1296 pt (or below 0.1 pt) sent type.setStyle with the clamped size.
Value fields now clamp only edits, never a value they merely show.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 23:48:48 -04:00
Stevan MarjanovićandStevan Marjanović 49f878d91b Scaling around reference point no longer snaps center of image to reference point (#5510)
Co-authored-by: Stevan Marjanović <[email protected]>
2026-10-14 23:48:43 -04:00
Michael HigginsandClaude Opus 5.5 3fd787a2b5 Canvas: no scroll glide with Enable Animations off (#5721) (#5883)
Interface > Enable Animations off already made scroll areas jump straight
to their target, but the canvas still panned from egui's smoothed wheel
delta, gliding for several frames after each notch. With the preference off
a plain wheel scroll now moves the canvas by the whole notch in the frame it
arrives and stops there.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:48:37 -04:00
midasmatterhorn f79f21931e fix(ui): smart filter adjustments open their own editor on double-click (#5812)
An Image > Adjustments recorded on a smart object (Brightness/Contrast,
Levels, Curves...) stores image.adjustments.<kind>. The Layers panel row
called smart_ui::open_editor, which only knew filter_dialog and silently did
nothing for those commands, so the filter could never be reopened. Route
adjustments to adjust_dialog, seed it with the recorded params and let OK run
layer.smartFilter.setParams (#5689).
2026-10-14 23:48:32 -04:00
Michael HigginsandClaude Opus 5.5 f44d3b6f06 Brushes: dragging a preset to the list's edge scrolls the list (#5741) (#5862)
A preset, group or folder dragged in the Brushes panel or the Brush Preset
picker couldn't reach rows out of view: the list didn't scroll while
dragging, so the drag stopped at the last visible row. Holding the drag at
the list's top or bottom edge (or past it) now scrolls it, with the same
ramp as the Layers panel's row drags.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:48:26 -04:00
Michael HigginsandClaude Opus 5.5 b8e48d6d4c Filter dialogs: a Random button for seeds (#5710) (#5886)
Clouds, Difference Clouds, Add Noise, Fibers and the other seeded filters
exposed a seed number, but a new pattern meant typing a different one. A
Random button beside every seed field now picks a fresh seed, which the live
preview renders at once. "Random" gains its row in the zh-Hans, zh-Hant, ru,
fr and id catalogs, which claim complete coverage.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 23:48:20 -04:00
fd28da56da Type: keep text selection highlight visible while Command/Ctrl is held (#5542)
* Type: keep text selection highlight visible while Command/Ctrl is held

* Type: match Photoshop 1px dark frame and handle styling for type transform

* Merge main; keep the transform box and underline colours from the theme tokens

The selection-highlight fix stands on its own; the hard-coded box and
underline colours and the removed reference point were unrelated to it.

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

---------

Co-authored-by: safvanedge <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:46:46 -04:00
7efebfa050 fix(path): Shift locks a Direct Selection anchor drag to 45° (#5475) (#5498)
Dragging selected anchors with the Direct Selection tool ignored Shift,
so lengthening a slanted bar also nudged it vertically. As in Photoshop,
Shift now locks the anchor move to multiples of 45° from the press, in
the live preview and in the committed path.moveAnchors. It works whether
Shift is held from the press or added during the drag; Shift at the
press still toggles the anchor's selection as before.

Reuses stroke_constraint::snap45 (the Gradient tool's Shift lock).

Fixes #5475

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 22:46:41 -04:00
Michael HigginsandClaude Opus 5.5 3887d8f738 Title bar: dialogs no longer stop the window dragging on macOS (#5826) (#5860)
The overlay that keeps the title strip's free gap draggable under a dialog's
modal layer (#4913) only ran with the custom title bar. macOS keeps the OS
traffic lights over the app's own title strip (custom_titlebar is false), so
with the Embedded Profile Mismatch prompt or any dialog open the window could
not be moved. The overlay now runs whenever the strip is drawn.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:46:36 -04:00
Michael HigginsandClaude Opus 5.5 8e2c5ddb40 macOS: a bundled app keeps its own Dock icon (#5849) (#5879)
The main window set a runtime PNG icon everywhere. On macOS that replaces the
app bundle's PhotoCraft.icns in the Dock, so the system couldn't render it
in its own style (Liquid Glass and its tinted and dark variants). The runtime
icon is now skipped when running from an .app bundle; unbundled runs and the
Windows and Linux builds still set it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:46:30 -04:00
nrq6993-a11yandCursor b6cdb16caf UI: unsaved-changes buttons show every key in one style (storytold/photocraft#5564) (#5603)
German showed "Ja (Y)" next to "(N)ein". Keys now go inside the words only when
every translated label starts with its key; otherwise all labels append it
("Ja (Y)", "Nein (N)", "Abbrechen (C)").

Co-authored-by: Cursor <[email protected]>
2026-10-14 22:46:25 -04:00
Michael HigginsandClaude Opus 5.5 3aaffdde68 Adjustment layers: a live preview is never coarser than the screen (#5764) (#5778)
Dragging a Curves point (or any control) in the Properties panel of an
adjustment layer previews through `canvas::ensure_proxy_preview`, which
picked its downscale factor from the document size alone
(`proxy::preview_factor`): any document over 5 MP was drawn from a 2-5x
reduced copy at every zoom, so at 100 % the canvas turned blocky until
the mouse was released.

Cap the factor by the view's zoom with the rule the Image > Adjustments
dialogs already use (`adjust_preview::proxy_factor`: the proxy stays at
least at screen resolution) when the wgpu compositor can draw the
full-size document. At 50 % and closer the drag now shows the full-size
composite; zoomed out, the proxy still saves the work. Documents the GPU
compositor doesn't cover keep the old proxy (the full size would be a
CPU composite per change).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:46:20 -04:00
Michael HigginsandClaude Opus 5.5 be4cadd83d Rulers: Alt-drag makes a guide of the other orientation (#5698) (#5885)
As in Photoshop, dragging a guide out of the top ruler with Alt/Option held
makes a vertical guide, and out of the left ruler a horizontal one; without
Alt each ruler makes its own kind, as before.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:46:15 -04:00
FredApps 37f57f2e9c Preserve Layers scroll per document (#5522) (#5540)
* chore: reserve draft implementation for #5522

* fix(ui): retain each document's Layers scroll position (#5522)
2026-10-14 22:46:09 -04:00
Valentin KleinandClaude Opus 5.5 2fbd171427 Options bar: Show Transform Controls in every theme (#5740) (#5802)
The Studio, Adwaita and Breeze themes' Move tool options bar had only Auto-Select and a hint, so Show Transform Controls could not be turned on there and a click on a layer never showed its box and handles. The toggle is now in every theme, as Auto-Select is (#1275).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:46:04 -04:00
Michael HigginsandClaude Opus 5.5 f4351cb37c Drag and drop: PhotoCraft comes to the front after a drop (#5687) (#5894)
Files dropped from another app's window (a file manager) were imported, but
the source window stayed in front of PhotoCraft. The frame that takes a drop
now asks for focus, so PhotoCraft comes forward as Photoshop does.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 22:45:59 -04:00
Nigel Ihza Firdausy 9b59a8ca6e fix(pen): keep unfinished paths in their original document (#5460)
* fix(pen): finish draft before creating a document

* test(pen): keep open work path in its source document
2026-10-14 22:45:45 -04:00
midasmatterhorn 88c7286d99 fix(ui): the Move tool keeps Photoshop's arrow pointer (#5814)
The canvas cursor match mapped Tool::Move to CursorIcon::Move, the 4-way
move cursor, while Photoshop shows its standard arrow pointer and marks the
movable layer with the hover outline. Use CursorIcon::Default for the tool
(#5646).
2026-10-14 22:45:39 -04:00
Stevan MarjanovićandStevan Marjanović 2b934aa53a UI: centre text vertically in shared text fields (#5550)
* UI: centre text vertically in shared text fields

TextEdit draws its line at the top of its rect, and the theme's 24 pt
interact height made the edit taller than the field's inside, so text in
widgets::text_field sat a few pixels high (Preferences search, hex fields,
name fields) while dropdowns and value fields were centred. Fill the box
and centre the line in it.

* Fixed failing test

---------

Co-authored-by: Stevan Marjanović <[email protected]>
2026-10-14 22:45:34 -04:00
Bihacsy László 414ae0b2c2 i18n(hu): align six rows with the catalog conventions (#5434)
Three prompts used the formal verb form ("Adjon", "Válasszon",
"kattintson"); the catalog addresses the user informally throughout.

Two colour profile messages put an article before a placeholder
("A(z) „{name}”") and glued a suffix to one ("{mode}-profilja"). The
correct form depends on the inserted value, so the placeholders now
stand after a colon or in brackets.

The Ask On Mismatch hint kept the English option name; it now names
the Hungarian label of that option in Color Settings.
2026-10-14 22:45:29 -04:00
skylartaylor b2fafcf68c ci: add comfortable runtime limits to unbounded jobs 2026-10-15 04:27:54 +02:00
FredAppsandFredApps cb319fd742 Document layerFilter in the ui_set MCP schema (#5776)
Co-authored-by: FredApps <[email protected]>
2026-10-14 21:54:48 -04:00
Tanish Manku 7bb10a7759 style: rustfmt five files on main (fmt break from #5448, #5670, #5135) (#5873)
Formatting only; cargo fmt --all -- --check passes again.
2026-10-14 16:11:18 -07:00
wwew 98fc6ab072 fix(cms): write the product name as PhotoCraft in built-in profile descriptions (#5614) 2026-10-14 15:06:35 -07:00
Ikki Nishiharaandharanishi 42fb79e909 fix(ui): widen tool-options drag targets (#5135)
Co-authored-by: haranishi <[email protected]>
2026-10-14 15:06:22 -07:00
Ahmed0_0Mamdouh 1f1e8d734f Fix #5414: Keep created paths visible and editable (#5424)
* Paths: keep work and shape paths visible with any tool (#5414)

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

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

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

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

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

What this deliberately does not change:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

Fixes #5474

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

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

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

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

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

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

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

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

Related: #5297, #3402.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #4969

* style: cargo fmt duplicate_variables test

---------

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

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

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

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

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

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

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

* Vector: bound stroke outline points (#5211)

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

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

---------

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

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

---------

Co-authored-by: gorevyoneticisi <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:36:32 -04:00
7e0f48a290 Blur Gallery: Tilt-Shift and Spin Blur on the image (#3373) (#3829)
* Blur Gallery: Field Blur pins on the image (#3373)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

* Regenerate docs/scorecard.md after merging main

---------

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

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

Part of #2289

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

* Translate Add Shortcut into Hungarian; regenerate the scorecard

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

* Scorecard: regenerate for the new alternate_shortcuts preference

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

* docs: regenerate scorecard after merging main

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

* Regenerate docs/scorecard.md after merging main

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

* Regenerate docs/scorecard.md after merging main

---------

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

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

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

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

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

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

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

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

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

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

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

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

Port the improvements from TheSeanLavery/photocraft#33:

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

* Merge main: UI_SET_FIELDS counts both new fields

---------

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

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

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

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

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

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

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

---------

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

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 15:14:30 -04:00
d56aa35bce Blur Gallery: Iris Blur's pins and handles on the image (#3373) (#3822)
* Blur Gallery: Field Blur pins on the image (#3373)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

* rustfmt

---------

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

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

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

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

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

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

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 15:13:20 -04:00
28e58d20d9 fix(ui): snap distorted corners to visible guides (#3928)
Co-authored-by: zenjabba <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 15:12:49 -04:00
a62ef68ca2 CMYK: Hue/Saturation and Color Balance keep the black ink (#5000)
* Modern Brightness/Contrast: Photoshop's brightness curve

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* CMYK legacy Brightness/Contrast pivots at 128 in ink

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

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

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

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

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

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

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

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

* CMYK ink compositing inlines open groups

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:12:06 -04:00
1e6f565a7b CMYK: adjustment layers composite on the document's own inks (14 oracles pass) (#4945)
* Modern Brightness/Contrast: Photoshop's brightness curve

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* CMYK legacy Brightness/Contrast pivots at 128 in ink

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

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

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

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

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

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

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

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

* CMYK ink compositing inlines open groups

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:11:42 -04:00
92c7922230 CMYK: Brightness/Contrast curves each ink, as Photoshop does (#4930)
* Modern Brightness/Contrast: Photoshop's brightness curve

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* CMYK legacy Brightness/Contrast pivots at 128 in ink

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:11:10 -04:00
00c16f2c6d Modern Brightness/Contrast: Photoshop's brightness curve (six oracles pass) (#4722)
* Modern Brightness/Contrast: Photoshop's brightness curve

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

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

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

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

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

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

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

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

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

Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Modern brightness: knee at output 126, fit out to -95 and +150

psd-tools' brightnesscontrast_rgb.psd masks four Brightness/Contrast
layers to their own columns. Read through the exact contrast curve, it
gives Photoshop's brightness at -95, -31, +23 and +150, well past the
+/-50 samples the first fit had. The +/-50 curve extrapolated badly
there: 22 levels off at -95 and 25 at +150.

Every setting puts the knee at about the same output level, not input.
The roll-off's end slope falls as 1/(1 + 0.084 b). With the knee at
126/255 and that end slope, the cubic Hermite fits -95, -50, -31, +23,
+25 and +50 within 1.4 levels. Past about +110 no such cubic stays
below white, so there the roll-off is 1 - (1 - y0)(1 - u)^alpha, with
the slope continuous at the knee. That gives +150 within 6 levels,
where every curve family tried bottoms out. Lowered curves are still
the inverse of raised ones.

The photoshop corpus's six brightness-contrast oracles still pass.
psd-tools brightnesscontrast_rgb goes from 0.13 to 0.020 max error and
_grayscale from 0.098 to 0.016. The new measurements are pinned in
modern_brightness_matches_photoshop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Levels: whole levels at every step and Photoshop's shadow toe

In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).

The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).

Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Lab: adjustment layers composite on the stored L, a and b

The compositor reads a Lab document's layers through display RGB, which
clips colours outside its gamut, so the adjustments Photoshop evaluates
on L, a and b (Invert, Posterize, Threshold, Brightness/Contrast, Levels
and Curves with Lab records) only saw the clipped colours: the photoshop
corpus's Lab oracles were up to 11 levels off on saturated colours.

compose::native_lab works a Lab stack of plain layers out on the stored
values instead, like the CMYK ink path: Normal mixes L, a and b
linearly, an opaque layer keeps them exactly, a layer mask scales the
mix, and adjustment layers run adjust::lab_stored_op on each pixel.
Stacks with such adjustments render for display this way; flattening a
plain Lab stack keeps its stored values (exports no longer clip).
Anything else (other blend modes, effects, other adjustments) keeps the
RGB compositor.

Photoshop oracles 145 -> 152: lab16 invert, posterize-4, levels, curves
and brightness-contrast-legacy; lab8 invert and posterize-4. lab8
levels and legacy B/C come within a level (their 8-bit rules are #5143
and #5132). psd-tools and io unchanged; every round trip unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:10:57 -04:00
GiuseppeandBrandon Thomas 86037b2354 feature(ui): added brushes panel dockable with floating option (#4262) (#4603)
* feature(ui): added brushes panel dockable with floating option (#4262)

* feat(ui): implement dockable and floating brushes panel (#4262)

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:10:49 -04:00
4c450b3879 Lab: adjustment layers composite on the stored L, a and b (seven Lab oracles pass) (#5171)
* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped

In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
  128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
  255/512 of the constant and is exact on pure red, green, blue, black
  and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
  as the blend mode is there; white tints to (1.05, 1.00, 0.87).

We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.

Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Levels: whole levels at every step and Photoshop's shadow toe

In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).

The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).

Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Lab: adjustment layers composite on the stored L, a and b

The compositor reads a Lab document's layers through display RGB, which
clips colours outside its gamut, so the adjustments Photoshop evaluates
on L, a and b (Invert, Posterize, Threshold, Brightness/Contrast, Levels
and Curves with Lab records) only saw the clipped colours: the photoshop
corpus's Lab oracles were up to 11 levels off on saturated colours.

compose::native_lab works a Lab stack of plain layers out on the stored
values instead, like the CMYK ink path: Normal mixes L, a and b
linearly, an opaque layer keeps them exactly, a layer mask scales the
mix, and adjustment layers run adjust::lab_stored_op on each pixel.
Stacks with such adjustments render for display this way; flattening a
plain Lab stack keeps its stored values (exports no longer clip).
Anything else (other blend modes, effects, other adjustments) keeps the
RGB compositor.

Photoshop oracles 145 -> 152: lab16 invert, posterize-4, levels, curves
and brightness-contrast-legacy; lab8 invert and posterize-4. lab8
levels and legacy B/C come within a level (their 8-bit rules are #5143
and #5132). psd-tools and io unchanged; every round trip unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:10:36 -04:00
4fb866942c Layers panel: Clip Thumbnails to Layer Bounds (#4281) (#4312)
* Layers panel: Clip Thumbnails to Layer Bounds (#4281)

Add interface.clipThumbnailsToLayerBounds (off by default), toggled from the
Layers panel menu or Preferences > Interface. When on, a pixel layer's
thumbnail frames the layer's own pixels (cut to the canvas) instead of the
whole canvas, so small objects in a large document stay visible. Empty or
fully off-canvas layers keep the canvas frame.

The frame is computed only when the thumbnail is rebuilt (tile-cached
content bounds) and is part of the cache key, so toggling rebuilds it.

Closes #4281

* Regenerate docs/scorecard.md after merging main

* Regenerate docs/scorecard.md after merging main

---------

Co-authored-by: badaaif <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:10:22 -04:00
d24f6e9d5f Levels: whole levels at every step and Photoshop's shadow toe (levels_rgb + stack oracle pass) (#5143)
* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped

In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
  128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
  255/512 of the constant and is exact on pure red, green, blue, black
  and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
  as the blend mode is there; white tints to (1.05, 1.00, 0.87).

We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.

Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Levels: whole levels at every step and Photoshop's shadow toe

In 8 bits Photoshop rounds each of the three Levels steps to a whole
level (half up): the input-range stretch (already modelled), the
midtone gamma and the output range. A narrow output range therefore
merges neighbouring gamma levels: psd-tools levels_rgb maps R 43..48 to
58 through a 0.47 channel gamma and the master's 48..255, where the
unrounded curve gave 56..59 (3 levels off in blue).

The gamma above 1 starts along a toe a*u - b*u^q of slope a = 2^gamma
that joins the power curve with the same value and slope (q = 2.2,
fitted). With the rounding, that is exact on 1116 of the 1118 levels
of gamma 1.16, 1.22, 1.3, 1.78 and 2.0 oracles; the soft-min it replaces
missed 52. Gamma below 1 stays the plain power curve (0.67 and 0.7:
every level exact).

Oracles: psd-tools levels_rgb 0.0118 -> 0.0039 (PASS); photoshop
rgb8/stack-levels-curves-hue-posterize 0.2000 -> 0.0000 (PASS); rgb8 and
gray8 levels.psd 0.0039 -> 0.0000; rgb16/gray16 0.0039 -> 0.0020. Floors:
photoshop 145 -> 146, psd-tools 236 -> 238 (main is at 237).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Empty commit to resync the pull request head

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:10:14 -04:00
835f33a476 Pen tool: Shift constrains segments and handles to 45° (TOOL-214-10, #3751) (#3756)
* Pen tool: Shift constrains segments and handles to 45° (TOOL-214-10, #3751)

Shift-click puts the next Pen anchor on the nearest 45-degree ray from the
previous anchor (at the pointer's projection onto it); Shift held while
dragging out direction handles turns them to 45-degree steps, read live
per move. The first anchor, closing on the first anchor and the last-anchor
break are unaffected; after Alt breaks the pair Shift constrains the
outgoing handle alone. The rubber band previews the constrained point.
Reuses stroke_constraint::snap45. Flips scorecard TOOL-214-10 to done.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Regenerate docs/scorecard.md after merging main

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Regenerate docs/scorecard.md after merging main

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Regenerate docs/scorecard.md after merging main

* Regenerate docs/scorecard.md after merging main

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:10:01 -04:00
f5606093cd Brush Settings: a response curve per dynamic control (#3294) (#3302)
* Brush Settings: a response curve per dynamic control (#3294)

Every brush Dynamic (Size, Opacity, Flow, Roundness, Scatter, Count,
Depth, Foreground/Background...) now carries its own response curve for
its control (Fade, Pen Pressure, Pen Tilt, Stylus Wheel, Rotation),
applied after the global Preferences pen curve. It is part of the brush
JSON, so tools.setBrush sets it (e.g. shapeDynamics.size.curve), actions
record it and presets keep it; linear by default, so existing brushes
paint unchanged.

- paint: ResponseCurve, the global pen curve's monotone cubic moved down
  from the engine as a fixed-size Copy type (no allocation per dab);
  serialises as its point list. dynamics::dynamic_value applies it.
- engine: prefs::PressureCurve is now that type.
- ui-egui: each Control row gets a curve button (lit when the curve isn't
  linear) that shows the same editor as Preferences > Tools.
- scorecard: TOOL-213-11 done.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n: Response curve for hu, tr

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: regenerate scorecard after merging main

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Fix i18n after merge: Serbian Response curve, drop duplicate hu row; regenerate scorecard

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Regenerate docs/scorecard.md after merging main

* Regenerate docs/scorecard.md after merging main

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:09:39 -04:00
973e868173 Brushes: import an image file as a brush tip (#3279) (#3292)
* Brushes: import an image file as a brush tip (#3279)

`brush.presets.importImage` reads an image (path, or base64 data with
an optional fileName) through the normal importer, so any format and
bit depth File > Open reads works. Its composite becomes a sampled tip
the way Define Brush Preset reads a selection: darkness x alpha,
cropped to the painted extent, at most 2500 px a side. The bounds are
found band by band first, so a large mostly-white image never needs a
full-size buffer. The preset is named after the file (never replacing
an existing one) and becomes the current brush unless select is false.

Import Brushes... in the Brushes panel and the brush preset picker now
opens a picker for .abr and image files; images go to the new command,
.abr files to brush.presets.importAbr as before. Automation refuses an
ambient `path` for it like the other import commands.

Define Brush Preset and the new command share `sampled_brush`.

Scorecard TOOL-213-12 -> done.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Regenerate scorecard

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: regenerate scorecard after merging main

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Regenerate docs/scorecard.md after merging main

* Regenerate docs/scorecard.md after merging main

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:09:08 -04:00
david419krandBrandon Thomas 03aa7e4962 fix(ui): remember the selected tool between launches (Photoshop parity) (#5043)
* fix(ui): remember the selected tool between launches

* fix(ui): preserve explicit Eraser selection during temporary tool overrides

Clear temporary pen and spring state for explicit toolbar, menu, preset and control selections. Keep spring-loaded key behavior; only permanent key choices clear the pen marker. Add exit/relaunch coverage for explicit Eraser with pen and spring overrides.

* Regenerate docs/scorecard.md after merging main

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:08:43 -04:00
aad56285ad Batch: Include All Subfolders (AUTO-219-5, #3753) (#3758)
* Batch: Include All Subfolders (AUTO-219-5, #3753)

File > Automate > Batch and File > Scripts > Image Processor take an
optional "includeSubfolders" (default false): a folder input also yields
the images in its subfolders, walked depth first in name order, bounded at
32 levels and 100000 files, with a seen-set of canonical folders against
symlink loops and the output folder left out. Each result keeps its
relative subfolder under the output folder, so same-named files in
different subfolders no longer collide. A non-boolean value is BadParams.

Both dialogs gain an "Include All Subfolders" checkbox (translated in
every complete catalog). Scorecard AUTO-219-5 stays partial (naming
template, progress/cancel, error log file); gaps.md G11 updated.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* Batch: translate Include All Subfolders for Hungarian and Turkish

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-14 14:08:20 -04:00
Zach SchneiderandBrandon Thomas 7833a6cad4 Layers panel: filter-type dropdown with Kind, Name and Mode (#5048) (#5268)
The filter row's "Kind" was painted text. It is now a dropdown, as in
Photoshop: Kind keeps the five layer-kind toggles, Name lists layers whose
name contains the typed text (ignoring case), and Mode lists layers with
one blend mode (Pass Through included), filtering by Normal at first.

The criteria live in UiState (layer_filter_by, layer_filter_name,
layer_filter_mode; serde defaults, so saved settings still load) and are
drivable with ui.set {layerFilter: {by, kinds, name, mode}}, validated
before anything changes. ui-parity.md gets a partial row: Effect,
Attribute, Color, Smart Object, Selected and Artboard are still missing.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:07:59 -04:00
3ee78a368b Free Transform: the box is the selection, and the marching ants follow it (#5297) (#5337)
With a selection, ⌘T and the Move tool's Show Transform Controls framed the
layer's content clipped to the selection, so the box was smaller than the
marching ants wherever the layer did not fill the selection. The ants then
stayed where the selection started until the transform was applied.

- transform_frame / target_frame (engine): the box is the selection's own
  bounds when a selection limits the transform. transform_bounds is unchanged,
  so snapping and the command defaults are as before.
- The selection outline is drawn through the open box (homography, or the warp
  in Warp mode), so it follows move, scale, rotate, skew, distort, perspective
  and warp before the transform is applied. The whole outline is traced while
  such a box is open, not only the part in view where it started.
- marching_ants_lines: ants along screen-space lines; non-finite lines are
  skipped and very long ones are clipped to the view.

Part of #3402.


Claude-Session: https://claude.ai/code/session_015vKPoMXFhSvWmX5D9XM646

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:07:42 -04:00
620bccef42 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped, as in Photoshop (#5011)
* 32-bit: legacy Brightness/Contrast, Channel Mixer, Photo Filter and B&W tint unclipped

In 32-bit documents Photoshop keeps these adjustments' results above 1
and below 0. On the photoshop corpus's rgb32/gray32 files:
- Legacy Brightness/Contrast pivots its contrast at 127 of 255 (8/16-bit:
  128) and is exact on every pixel.
- Channel Mixer cuts each source percentage to 1/1024 toward zero, counts
  255/512 of the constant and is exact on pure red, green, blue, black
  and white.
- Photo Filter works on the linear samples, negative results included.
- The Black & White tint is the Color blend mode without the clip at 1,
  as the blend mode is there; white tints to (1.05, 1.00, 0.87).

We clipped all four to 0..1. The GPU kernels follow suit under F_HDR
(the legacy pivot and the mixer's steps come from the planner), and the
HDR parity test covers them.

Photoshop oracles: rgb32 and gray32 brightness-contrast-legacy, rgb32
channel-mixer and black-white-tint pass (145 -> 149 of 258); floor
145 -> 149. rgb32 photo-filter improves (0.19 -> 0.035 max); the rest is
its colour (#3790).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:03:03 -04:00
eb26e8f0d7 Masks: feathers blur with exactly the feather's variance (mask-parameters-no-real-channel passes) (#5163)
* Masks: feathers blur with exactly the feather's variance

A mask feather is a Gaussian with sigma = feather, which we approximate
with three box passes per axis. Whole-pixel boxes rounded the variance:
a 6 px feather blurred like 6.48 px and a 3 px one like 3.46 px, so
feathered edges were too soft by up to 4 levels against Photoshop
(psd-tools mask-parameters-no-real-channel.psd: a 6 px vector and 3 px
pixel feather).

Each pass is now an "extended box" (Gwosdek et al. 2011): weight 1 on
the whole radius and a fractional weight on the two samples beyond it,
so three passes have variance sigma^2 exactly. It stays O(n), reusing
the window's leaving sample for the low end weight (50 MP at a 1000 px
feather: 180 ms parallel, was 172).

psd-tools: mask-parameters-no-real-channel 0.0168 -> 0.0060 (PASS),
layer_mask_data.psd 26.9 % -> 3.9 % of pixels off (psb 25.9 % -> 1.8 %).
Corpus test 239 -> 240; floor 236 -> 240.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:02:40 -04:00
9954c2935e Threshold: split on Photoshop's luminosity of whole samples (threshold_rgb, threshold_16bits_rgb pass) (#5263)
* Threshold: split on Photoshop's luminosity of whole samples

Photoshop's Threshold splits on the luminosity 0.30 R + 0.59 G + 0.11 B
(the blend modes' lum, as Gradient Map uses), not the Rec. 601 grey,
taken over the document's whole sample values:

- 8 bits: 30 R + 59 G + 11 B > 100 level - 50, i.e. the luminosity
  rounded to a level with halves down reaches the level;
- 16 bits: 30 R + 59 G + 11 B >= 100 x the level in 16 bits
  (round(level x 32768 / 255): mid grey 16448 is level 128);
- 32 bits: the luminosity reaches the level.

Exact on every pixel of psd-tools threshold_rgb (36432 px; Rec. 601 split
28 the other way), threshold_16bits_rgb (100) and threshold_16bits_
grayscale, and the photoshop corpus's rgb16 threshold-128. The sums are
exact in f32, on the CPU and in the GPU kernel, so ties (grey exactly on
the level) split the same way on both.

psd-tools: threshold_rgb and threshold_16bits_rgb pass; corpus test
239 -> 241, floor 236 -> 241.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:02:26 -04:00
3feebb9b13 Groups: a pass-through clipping base with an adjustment renders isolated (passthrough_clipping_mask_adjustment passes) (#5272)
* Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does

At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Groups: a pass-through clipping base with an adjustment renders isolated

A pass-through group composites its children straight into the
backdrop, so an adjustment layer inside reaches the layers beneath.
When the group is the base of a clipping group and holds an adjustment
layer, Photoshop renders it isolated instead: in psd-tools
passthrough_clipping_mask_adjustment an Exposure in such a group leaves
the layers beneath alone (we darkened them: half the image 170 levels
off). The same group with a Linear Dodge child and no adjustment keeps
reaching the backdrop (passthrough_clipping_mask_blendmode), and so do
clipping-mask3/4/5, so the rule is the adjustment, not the clipping
alone (isolating every pass-through clipping base broke those two).

CPU compositor and GPU plan alike. psd-tools 239 -> 240; floor 240.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:01:50 -04:00
8504dc615e Posterize: 16-bit documents bin the 16-bit sample, as Photoshop does (#5267)
At 16 bits Photoshop doesn't round to 8-bit levels before binning. It
bins the 16-bit sample, floor(v16 * n / 32768), and lands on 8-bit levels
through a 16-bit step: floor(b * 32768 / (n - 1)), converted with
floor((o * 255 + 16384) / 32768). So the middle of 3 levels is 128 where
8 bits give 127, while 42.5 and 25.5 stay 42 and 25.

Exact on every sample of psd-tools posterize_16bits_rgb (levels 3, 7,
13, 21) and posterize_16bits_grayscale (2, 6, 9, 11); the 8-bit rule
missed up to half the range at bin edges. The same rule applies to
CMYK and Lab channels at 16 bits: on the stored inks (#4945's native
path) psd-tools posterize_16bits_cmyk goes from 4.26 % to 0.01 % of
pixels off. 8-bit Posterize is unchanged.

The GPU kernel runs the same rule; 16-bit layers reach it as half
floats, so a sample within their precision of a bin edge can land in
the neighbouring bin there (the CPU, which exports, is exact).

psd-tools 239 -> 241; floor 236 -> 241.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 14:01:01 -04:00
texjerandBrandon Thomas f499d97eee Embedded Profile Mismatch: the profile names on the choices, and Remember this choice (#5208)
The embedded and working profile names now sit on the choices they belong
to ("Use the embedded profile (Display P3)", "Convert to the working space
(sRGB IEC61966-2.1)") instead of two lines above them; the sentence naming
the file stays. The missing-profile prompt is unchanged.

"Remember this choice for future images" saves the answer as the Color
Settings policy for the file's mode (preserve, convert or off) and turns
Ask On Mismatch off, so files read from then on open without asking. Files
already waiting in the queue with a mismatch in the same mode get the same
answer (open_prompt::answer_remembering); a different question still asks.
The checkbox's tooltip says where to turn the prompt back on.

The catalogs drop the rows nothing uses any more ("Embedded: {embedded}" and
the two old choice labels) and add the four new strings in every language.

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 13:47:43 -04:00
caad010b61 feat(actions): add Button Mode on the current Actions model (#5213)
An action can take one of Photoshop's button colours, and the panel can switch to Button Mode so a click plays that action. The colour is stored on the action and is empty until it is set. The new labels are in every complete catalog.

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 13:47:27 -04:00
c208e8ffc1 feat(ui): Darken and Lighten Interface (Ctrl/Cmd+Shift+F1, F2) (#3709)
* feat(ui): Darken and Lighten Interface (Ctrl/Cmd+Shift+F1, F2)

Photoshop's Shift+Ctrl+F1 (⇧⌘F1 on macOS) darkens the interface by one of
its four brightnesses and Shift+Ctrl+F2 lightens it, stopping at the
darkest and lightest. PhotoCraft has the same four as themes (Pro, Pro
Medium Gray, Pro Light Gray, Pro Light), so view.darkenInterface and
view.lightenInterface step through them from the theme on screen and save
the choice like picking it in Preferences › Interface (setting the dark or
light theme and the matching appearance). From another theme they step
from the nearest brightness: Medium Gray for a dark one, Light Gray for a
light one. They work with no document open. Both labels are in every
catalogue.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(i18n): Vietnamese strings for Darken and Lighten Interface

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(i18n): Hungarian and Turkish strings for Darken and Lighten Interface

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(sr): translate Darken and Lighten Interface

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 13:47:09 -04:00
da6ceeaf96 Layer Style: Bevel & Emboss Technique dropdown (#5147) (#5181)
The engine, renderer and PSD mapping already supported Smooth, Chisel Hard
and Chisel Soft, but the dialog's Bevel & Emboss page had no control for it.
Add the Technique dropdown under Style (Photoshop's order), a smooth default
for new bevels, and translations.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 13:46:51 -04:00
Elvis33LEandBrandon Thomas aa44eafe2c ACEScg: File Handling option opens colour-less EXR renders in the ACES working space (#4908)
* ACEScg: File Handling option opens colour-less EXR renders in the ACES working space

Our render pipelines write ACEScg (AP1 primaries, ACES white ~D60,
linear) and — like most — state no colour space in the EXR header, so
io's linear-sRGB default misrepresented them (saturated hues shifted).
- cms: AcesCg joins the built-in profiles, generated from the published
  AP1 primaries and ACES white point with a linear TRC (id "aces-cg",
  aliases acescg).
- engine: File Handling gets "Open color-less EXR as ACEScg". When on,
  an EXR that io tagged with the linear-sRGB default opens as ACEScg
  instead; embedded or EXIF-declared profiles stay untouched.
Translated in every catalog. ACES view transforms (OCIO-based display)
remain future work; this covers the document's source colours.

* scorecard: 26 of 158 (main's #3472 preference added one too)

* Regenerate docs/scorecard.md after merging main

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 13:46:27 -04:00
David MannandClaude Opus 5.5 a3a47e9cb0 History panel: the trash deletes the selected snapshot (#5324) (#5325)
Clicking a snapshot now selects it (highlighted in place of the current
state) until the history moves on. With a snapshot selected the footer's
trash runs history.deleteSnapshot instead of history.deleteState, as in
Photoshop. Snapshot rows and past state rows can also be dragged onto
the trash to delete them.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 13:46:08 -04:00
IvanandClaude Opus 5.5 691a022b12 CI: skip the test (windows-latest) gate in cancelled runs
With always() the gate also ran when a newer push cancelled the run,
taking a runner slot only to report a red check on the superseded
commit. !cancelled() still runs it when a test leg fails (a skipped
required check would count as passing), but not in a cancelled run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:29:16 +03:00
fabiodalez-dev 32fdc4e402 i18n(it): translate the 103 labels other catalogs added since #3742 (#4860)
* i18n(it): translate the 103 labels other catalogs added since the last sync

* i18n(it): translate the 53 labels main added
2026-10-14 13:28:56 -04:00
Elvis33LE 3e74c4d8d3 raster: a shifted surface copies in one pass per row without pre-filled tiles (#2667)
* raster: a shifted surface copies in one pass per row without pre-filled tiles

Surface::translated's misaligned path allocated every target tile through
Tile::filled (a per-pixel default fill when the default is nonzero)
before overwriting nearly all of it, and addressed spans through
per-row closures. The path now writes each stored row directly: content
spans copy as single memcpys crossing source tiles as needed, vacated
edges fill from one prebuilt default row, and filled() builds its
default rows in bulk. Same output (the read-back oracle covers every
depth, sign and tile crossing), cheaper for nonzero defaults and one
less pass over each tile.

* raster: a missing source tile in the shifted content reads the default pixel

The misaligned path skipped absent tiles inside the content rect,
leaving zeros where the surface's default pixel belongs (a white mask
with sparse tiles turned black after a move). The span now fills from
the prebuilt default row. echelon's snippet is a regression test, and a
randomized oracle compares the whole path against a per-pixel reference
gather across formats, default pixels, sparse tiles and tile-boundary
crossing shifts.

* ci: retrigger wasm32 (the full workspace wasm check is clean locally; the job failed before writing a log and the run is stuck queued)
2026-10-14 13:28:48 -04:00
jonathanwxh-cellandjonathnwxh 7e447dfd40 Start Free Transform after placing an image from the file picker (#5390)
* test(ui): reproduce missing transform after menu placement

* fix(ui): start placement transform after choosing a file

* test(ui): exercise native linked placement file paths

* test(ui): pass context to existing menu wrapper tests

---------

Co-authored-by: jonathnwxh <[email protected]>
2026-10-14 13:28:42 -04:00
FredApps c73bb0ed95 Fix custom-frame maximization with a top taskbar (#5237)
* fix(windows): respect top and side taskbars in custom-frame maximize

* Keep work-area correction stable for narrow taskbar reservations

* style: apply rustfmt to taskbar window correction

* fix(window): repaint after custom-frame work-area correction
2026-10-14 13:28:35 -04:00
Vikram BhaskaranandBrandon Thomas 8215b0b969 Undo and redo Camera Raw adjustments from the keyboard. (#4136)
* Undo and redo Camera Raw adjustments from the keyboard.

Changing a slider and pressing Ctrl+Z did nothing, because the dialog blocked application shortcuts and kept no history of its own. Each finished edit, including one slider drag, is now one step. The control channel accepts ui.undo and ui.redo.

Refs #4100

* Format camera_raw_ui.rs (rustfmt)

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 13:28:27 -04:00
Sabbir Ahmed 4dec65f4ce fix(ui): guides draw over the Free Transform preview, under its box (#5086) (#5214)
The canvas drew guides before transform_tool::draw_overlay, so the moving
pixels of a layer under Free Transform covered them. Split the overlay:
draw_content paints the preview (plain, styled or warped) and the layers
above it right after the document, under the grid and guides; draw_overlay
keeps the box, handles, reference point, warp grid and split guides over
them, as in Photoshop. The hand-pan drag path does the same.

Test: guides_draw_over_the_transform_preview_and_under_its_box checks the
painted order preview < guide < box; it failed before the fix.
2026-10-14 13:28:18 -04:00
Valentin KleinandClaude Opus 5.5 e51ba6720d Actions: a recorded File > Save of a flat file saves the file it is played on (#3531) (#3537)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:28:11 -04:00
max 6a766345d3 Layer styles: editing effects no longer re-enables a disabled effect list (#3648) (#3799)
* Layer styles: editing effects no longer re-enables a disabled effect list (#3648)

* Layer styles: follow #3648's exact dialog steps in a test

Preview off shows the document as saved (the in-dialog stroke disable is
a pending edit), so the state the issue reports is Photoshop's preview
semantics, not a resurrected effect: the live preview honours the
disable, and OK applies it.

* style: cargo fmt
2026-10-14 13:28:04 -04:00
90b3f9e891 Large document: large damage over the GPU budget redraws in the background; open warms layer bounds (#4024) (#5083)
On top of #5001. Two UI-thread stalls it leaves on a 16000 × 10000, 30-layer PSB
whose layers exceed the GPU memory budget:

- GpuCanvas::composite checked the memory budget for full refreshes only. Hiding
  or showing a layer that covers most but not all of the canvas took the
  synchronous GPU path, paging every layer in on the UI thread. Damage of
  DEFER_PX or more now gets the same check, so it goes to the background redraw.
  Small edits keep the GPU path without the check.
- The first draw after a background open scanned every layer's content bounds
  on the UI thread (GPU planning). The open job now warms the shared tile bounds
  cache before handing the document over (warm_layer_bounds, cancellable between
  layers).

Co-authored-by: syncroot <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:27:58 -04:00
12a474d550 Interface: hide the Home screen's recent files (#4382) (#4387)
* Interface: a preference to hide the Home screen's recent files (#4382)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n: drop the hu/tr 'Enable animations' lines, #4362 adds them

Both PRs added the same two lines; whichever merged second would leave a duplicate key (bundled_catalogs_are_consistent rejects it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: regenerate scorecard after merging main

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Merge main; fix i18n (duplicate ja row, missing sr row) and regenerate scorecard

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Drop the duplicate hu row

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 13:27:52 -04:00
Ivan · VanTan 5ae3ee0ae5 fix(ui): show brush tip thumbnails while dragging presets (#5203)
* fix(ui): show brush tip thumbnails while dragging presets

* test(app): wait for autosave sidecar revision before recovery assertions
2026-10-14 13:08:05 -04:00
Sabbir Ahmed 11c46b7121 fix(ui): swatches touch with no padding, as in Photoshop (#5103) (#5253)
The Swatches panel used the shared preset browser grid, with a 4 px gap
between cells and a rounded, bordered card for each. Photoshop's panel
packs flat squares edge to edge. Place gets a `flush` flag: no gap, no
per-cell border (only the selected and hovered cells are outlined) and
square corners. Swatches sets it; gradients, patterns, styles and shapes
keep the gap, now the named GRID_GAP and GRID_INSET constants.

Test: swatches_touch_without_a_gap clicks 1 px past the first swatch and
expects the second one selected; with the gap that point was empty.
2026-10-14 13:08:00 -04:00
MarekSpodBiedry 94fdd279e8 fix(puppet-warp): drop open Puppet Warp and other distort sessions when the active document changes (#5092) (#5123) 2026-10-14 13:07:55 -04:00
mroxsoandhighperfocused a076f617e0 fix(ui): give the Quick Selection tool its own icon (#5116) (#5184)
Quick Selection reused the Ellipse Marquee's dashed circle. It now has a
distinct glyph: Lucide's brush with a dashed selection arc around the
bristles (Photoshop's Quick Selection mark). A rasterized test asserts
the two icons read as distinct and that the arc actually renders.

Co-authored-by: highperfocused <[email protected]>
2026-10-14 13:07:48 -04:00
imSlicedBread 237a82b87f fix(ui): show feathering in layer mask views (#5342) 2026-10-14 13:07:41 -04:00
FinnNkandClaude Opus 5.5 51674512db perf(ui): the Info panel composites its Color Sampler readouts once per revision (#4528) (#4753)
While the Info tab was visible, every frame ran
`view.colorSamplers.list` through the command dispatcher, compositing
each sampler point (up to 10) through the whole layer stack, with layer
effects building a 1-pixel effect-map entry each time.

sampler_readouts keeps the readouts for (document, revision). Adding,
moving or deleting a sampler and every pixel edit bump the revision,
so the readouts refresh exactly when they can change and are shared
between frames otherwise.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:07:35 -04:00
Valentin KleinandClaude Opus 5.5 a7f7f1fd1e Edit › Menus: Menus, Keyboard Shortcuts, Preferences, Save and Exit can't be hidden (#5096) (#5183)
Hiding Edit › Menus (or Keyboard Shortcuts) left no way back into the
dialog, and Save, Save As, Exit and Preferences could disappear too.
edit.menus now refuses to hide them, the dialog greys their Visible box,
and the menus keep them even when a preferences file lists them.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:07:28 -04:00
Pharbieandkwesi f053a924a6 fix(ui): Color Picker hex field accepts repeated pastes (#5398)
The hex field's six-character limit dropped any paste into a full field
(every paste after the first) and any paste with a leading #. A pasted hex
colour now replaces the whole code.

Co-authored-by: kwesi <[email protected]>
2026-10-14 13:07:22 -04:00
44bb43e94d Number fields: centre the number in its box (#5150) (#5226)
#1322 lifted the number's slot 1 pt above the box's centre because digits
have no descenders. Measured on today's mono font at 4x, the digits' ink
already sits within 0.2 pt of centre without the lift; with it the ink is
0.9 pt high, the focus outline has 1 pt of gap above and 3 pt below, and the
digits start 1 pt above the unit. The slot is centred again.

Tests: value_field_digits_ink_is_centred_and_level_with_the_unit renders the
field (Pro and Studio) and checks the digits' ink is centred and starts level
with the "%"; value_field_number_slot_is_centred_in_its_box replaces the
1 pt-lift test, and the #2823 slim-box test now expects centring. Both new
assertions fail with the lift restored.

Co-authored-by: prekabreki <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:07:15 -04:00
0f3d2f133e Type: hovering a font previews it with the Character section shown too (#5152) (#5222)
The options bar and the Properties panel's Character section both draw the
"type-font" family picker, and the Character one is drawn later. With its
menu closed it removed the shared hover key every frame, so the options bar's
hover never reached the canvas. Only the picker whose menu is open now clears
the key; follow_hover already drops it each frame once nothing writes it.

Test: options_bar_hover_previews_with_the_character_picker_also_shown runs
the whole app (Pro theme, both pickers on screen), hovers a font in the
options bar and checks the canvas shows it, then that leaving the row
restores it. It fails without the fix.

Co-authored-by: prekabreki <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:07:08 -04:00
Nicolas FalesyandClaude Opus 5.5 89a763a368 Display profile notice: one card for all displays without a usable profile (#5312)
Two monitors without ICC profiles (a common Linux desk, e.g. DP-4 and DP-5)
showed two identical "Display profile not used" cards at every launch. A
reading now posts one notice: one reason line per display, then the advice
line naming them all. Displays added by a later reading still get a card of
their own, and a reading with nothing new stays quiet, as before.


Claude-Session: https://claude.ai/code/session_01SvxNXnS4tcg6BHT9VG67t7

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:06:47 -04:00
FinnNkandClaude Opus 5.5 8428645311 perf(gpu): keep the DX12 mip scratch textures for Intel adapters only (#5354)
The separate mip render targets work around reduced-preview corruption on Intel DX12 drivers (#892, verified on Intel UHD 620), but every DX12 adapter took them. Creating a scratch texture per level on every refresh costs about 1 ms of CPU time per brush dab on an NVIDIA DX12 adapter. Gate them to Intel under DX12; PHOTOCRAFT_SEPARATE_MIPS=1/0 forces either way. Part of #4515.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:06:39 -04:00
MarekSpodBiedry a904bd7a39 Frame tool: take type, shape and smart object layers into the frame (#4435) (#4441) 2026-10-14 13:06:32 -04:00
Ivan · VanTan df10129f6c fix(ui): fit Curves dialog width to its graph (#5160) 2026-10-14 13:06:25 -04:00
Michael HigginsandClaude Opus 5.5 42c5cc64be Canvas: themes without a dot pattern draw no dots texture (#4542) (#5093)
* Canvas: themes without a dot pattern draw no dots texture (#4542)

Switching the dark theme to Pro filled the empty workspace with red and
black blocks on the reporter's machine. The blocks repeat every 22 px,
the tile of the canvas-surround dots texture, so it is that texture
showing wrong contents.

Pro and ten other themes set canvas_dot to their canvas colour: they
have no dot pattern, yet still uploaded a texture for it and tiled it
over the whole pasteboard. dots() now returns None for them, so nothing
is uploaded or drawn. Studio, Studio Light and Solarized Dark, whose
dots differ from the canvas, are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Contributors: credit Higgs32584 by GitHub profile name

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 13:06:18 -04:00
mjohnson7cd 08d9c1d67d fix(transform): keep masked objects visible during transform (#5239) (#5319) 2026-10-14 13:06:12 -04:00
Gert Pellin - GPE fe8fa3b9bc Color Range: sample colours outside the active selection (#5244) (#5296)
The eyedroppers (the points/subtractPoints params, and clicks on the
canvas while the dialog is open) refused points outside the active
selection. Photoshop samples anywhere and only limits the result to the
selection, which Color Range already does; the refusal is gone.
2026-10-14 13:06:05 -04:00
Nigel Ihza Firdausy 08def9648d fix(ui): make Preferences modal resizable on desktop (#5170) 2026-10-14 13:05:59 -04:00
mjohnson7cd be64d2dcbc fix(windows): handle GPU initialization failures gracefully (#5191) (#5309)
On Windows with the automatic backend, GL adapters now rank after every
other adapter, WARP included. VMware SVGA3D over GL and GL-only old GPUs
drew a black window (#1788, #1647); WARP renders.
2026-10-14 13:05:53 -04:00
mroxsoandhighperfocused b37a148759 ui: remove the title-bar Discord button (#1737)
Co-authored-by: highperfocused <[email protected]>
2026-10-14 13:05:47 -04:00
Zach Schneider 72d4359999 Font preview test: skip installed fonts whose Arabic glyphs are blank (#5259) (#5261)
a_system_arabic_face_renders_both_samples took the first family whose
character map claims Latin and Arabic and expected its Arabic sample to
render. A font can map those characters to empty glyphs (for example
Black Mountain), so cargo test failed on machines that have one
installed. The test now renders every candidate and checks the first one
that actually draws an Arabic sample, and skips only if none does.
2026-10-14 13:05:26 -04:00
Nigel Ihza Firdausy b8ae1cd92f fix(brush): preserve Dual Brush tip spacing and scale its size with the primary brush (#4856)
* fix(abr): prefer dual brush tip spacing over wrapper spacing

* test(abr): cover conflicting dual brush spacing descriptors

* fix(paint): scale secondary brush tip when changing primary diameter

* fix(engine): preserve dual tip proportions through brush commands

* test(brush): cover secondary tip scaling across settings, strokes and presets

* style: apply rustfmt to Dual Brush ABR code

* style: apply rustfmt to Dual Brush ABR code
2026-10-14 12:47:23 -04:00
Valentin KleinandClaude Opus 5.5 2d5e60ad51 Compose: build a layer's effect-map identity once per render, not once per tile (#4437) (#4438)
effect_maps hashed the whole layer (Debug of effects and vector mask, a walk
over every pixel and mask tile, every child of a group) on every call, i.e.
once per render tile per effect layer. The identity depends on the layer
only; memoise it in the render's Ctx for layers that are part of the
document being rendered (their addresses cannot be reused during the render).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 12:46:51 -04:00
Valentin KleinandClaude Opus 5.5 88b2a21b33 Linked smart objects: read embedded WebP/GIF/BMP/EXR sizes from their headers (#5216)
source_size_probe decoded every embedded file other than PSD/TIFF/PNG/JPEG
in full just to learn its size. photocraft_codecs::probe_size reads the
size from the same decoders' headers (WebP chunks, the image crate's
GIF/BMP/TGA/ICO/QOI/HDR headers, flat EXR part headers), with the same
limit checks and the EXIF quarter-turn swap, so it agrees with decode
wherever decode succeeds. Deep EXR, AVIF and anything else still decode.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 12:46:44 -04:00
David MannandClaude Opus 5.5 8fb3298161 Color Balance: fold the tones into one Levels per channel (#3423) (#5339)
Photoshop doesn't apply the shadows, midtones and highlights curves one after
another: per channel it builds a single Levels (black point from shadows,
white point from highlights, one midtone gamma 2^((midtones + (shadows +
highlights) / 2) / 100)), and a gamma > 1 starts with slope 2^gamma at black
instead of a pure power curve. Applying the tones in sequence let a lowered
shadows and a raised highlights slider compound their gammas (up to 85/255 off
on mixed settings), and the pure power curve over-brightened the shadows of
the issue's red +100 / magenta -100 repro by up to 16/255.

Measured against Photoshop on generated 16-bit ramps and pure colours:
worst error 0.8/255 (was 85/255), the issue's repro 0.02/255 (was 16.5).

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 12:46:38 -04:00
Wil 0eb008ce5d Actions: record layer selections by name and display the target (#2441)
* Record action layer selections by name and show their targets

* Use document pixel format in grouped-layer regression

* Document the named layer action UI with a synthetic capture
2026-10-14 12:46:31 -04:00
Valentin KleinandClaude Opus 5.5 3b45e8baee PSD import: flat fallback respects the decode budget; checked sizes (#5200, #5202) (#5206)
* PSD import: flat fallback respects the decode budget; checked sizes (#5200, #5202)

The flattened Bitmap/Indexed/Duotone path retried every composite_rgba8
error, including LimitExceeded, by allocating the refused RGBA buffer
(OOM abort on e.g. a 50000x50000 1-bit PSB). LimitExceeded now
propagates; the Multichannel fallback checks its output against the cap,
reserves fallibly, borrows the merged image, uses get for planes and
converts in TILE_SIZE bands. A failed composite now warns.

Checked usize arithmetic for alpha channel names, merged plane sizes and
the gradient map name length (overflowed on 32-bit targets).

Also: EXR part import drops a cache that never hit but cloned each
image; raw KRA tiles are borrowed; flat import detects the format once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* PSD import: flat Background opacity check reads a band at a time (#5200)

flat_background read the whole surface into floats to see whether it is
opaque, undoing the banded conversion. Read TILE_SIZE row bands, stop at
the first transparent pixel, and drop the RGBA8 buffer before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 12:46:25 -04:00
Gert Pellin - GPE 7b08b56531 feat(engine): layer.list, a read-only listing of the layer tree (#5059) (#5148)
The layers were only in document.inspect, together with history,
channels and much more, so agents looked for a layer.* command that
didn't exist.

layer.list returns the layers top-down with id, name, kind, visibility,
bounds, selected and active; groups list their children, or with
"flat": true every layer is one row with its depth and parent group.
It takes the same optional "document" index as document.inspect, is not
journaled and bad params are errors.
2026-10-14 12:46:18 -04:00
45f8921ae8 Free Transform: solid pixel stretch (edge-repeat sampling, selected-pixel bounds) (#3867)
Stretching a 1 px row or column faded out along the stretch: bicubic and
bilinear sampling read transparent pixels past the source edge, and with a
selection the edge was the whole layer's bounds (lifted pixels kept their
colour at zero alpha). Samples past the source rect now repeat the edge and
the outline is antialiased by coverage, and the lifted pixels' bounds are the
selected pixels'.

Fixes #3773

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 12:46:11 -04:00
8d2806f66c Expose shared Smart Object instances in inspection and replacement results (#4832)
* Smart Objects: inspect and Replace Contents show shared instances (#4075)

Placed layers that share a source have imported as one set of contents
since #2222, and Replace Contents changes every instance, but nothing
reported it: `linkGroup` is Link Layers, and `smartSource` can't tell
copies of one contents from independent copies of the same file.

document.inspect now gives each smart object `smartContents`: the
contents `id` its copies share and how many `instances` there are.
Replace Contents, Relink to File and Convert to Embedded/Linked list the
`instances` they changed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Test rendered caches after shared Smart Object replacement

---------

Co-authored-by: jonathnwxh <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 12:46:04 -04:00
David MannandClaude Opus 5.5 d9eb46b485 Performance: the lower-stack cache follows an edit up the stack (P9 / P11, #5286) (#5288)
Repeating a duplicate or paste of a 12 MP layer cost as much as the first time because the
GPU compositor's lower-stack cache stayed at the checkpoint of the first edit after it was
filled: an edit higher up only checked that the layers below that checkpoint were unchanged,
so every refresh re-ran all passes above it (in the #209 layered document, 87 of 110 passes
over the 12 MP damage rect; the duplicate itself takes 0.3 ms, its tiles are already shared).

Each cached chunk now remembers its own checkpoint. A render captures at the highest
checkpoint below the edit, also where it resumed from a lower cached one, so the next
refresh of that edit (its undo, redo or repetition) resumes just below it. Chunks a refresh
doesn't reach keep their lower prefix; a chunk moved by a partial capture holds only that
rectangle afterwards. Validity is unchanged: a chunk survives only while the layers below its
checkpoint are untouched.

Release perf_scenarios, layered group, Windows 11, RTX 5070 Ti, 7 interleaved runs:
P9 p50 22-29 -> 12-17 ms, P11 p50 37-41 -> 11-18 ms; P8 and P10 fall too; P1-P7, P12 unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 12:45:59 -04:00
Michael HigginsandClaude Opus 5.5 a37fa8171b GPU compositor: pattern textures are freed on close, when idle and over a budget (#4971) (#5232)
The compositor kept every pattern texture it ever uploaded (RGBA32F,
16 bytes per pixel) until the device was lost: `forget_doc` and
`retain_docs` skipped them and no budget applied, so a long session
that opened and closed documents with pattern fills or pattern effects
only ever grew.

Each cached pattern now records the document that last painted with it
and the frame it last painted in:

- closing a document (`forget_doc`, `retain_docs`) frees its patterns;
- a pattern no frame has painted with for 240 frames is dropped;
- over PATTERN_BUDGET (256 MiB) the least recently painted go first,
  never one the current frame paints with.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 12:45:52 -04:00
Michael HigginsandClaude Opus 5.5 b059a6d8b3 ICC parser: checked arithmetic on offsets and counts from the file (#4976) (#5227)
Offsets and counts stored in a profile reach u32::MAX, which is
usize::MAX on 32-bit targets such as wasm32. Several places added to or
multiplied them before any bounds check, so a malformed profile could
panic (overflow checks on) or read from a wrapped offset (checks off).

- curv: reject an over-long count before computing the element length.
- mAB/mBA: the matrix and CLUT read through one bounds-checked slice at
  the stored offset instead of adding to the offset.
- mluc: the record offset is computed with checked arithmetic.
- XYZ: one bounds-checked slice instead of off + 4 and off + 8.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 12:45:46 -04:00
卢文彬andDeepSeek 6b9e173477 affinity: decoded layers are admitted against an aggregate pixel budget (#4960) (#5266)
The archive limits bound what one import extracts (256 MiB an entry, 1 GiB in
total), and one pixel layer is skipped past 64 megapixels, but nothing bounded
what the layers materialized out of that cost: each bitmap's declared
`BmpW`/`BmpH` sizes its RGBA output and its decode planes, with or without tile
data, so a small document with many layers could still allocate without end.

`Limits` gains `max_pixel_bytes` (1 GiB by default, the budget the other
importers allow decoded pixels). `Reader` admits a layer before `decode`
allocates it: the layer's RGBA joins the layers already materialized, and its
working planes are counted with them, since every earlier layer is still live
while this one decodes. Past the budget the import fails with `Error::Limit`,
which `photocraft-io` reports and falls back from to the embedded preview.

Tests: a synthetic 300-byte document declaring layers with no tiles at all - one
layer inside the budget imports, the second past it is rejected before
allocation, and a declared 2048x2048 layer is rejected under a 1 MiB budget.
The fuzz targets get a small pixel budget so mutations reach the new rejection
path.

Co-authored-by: DeepSeek <[email protected]>
2026-10-14 12:45:41 -04:00
shmuel biller 7b8e5034d0 Fix/scale marker rollback layer state 4975 (#5391)
* fix(engine): restore document targeting on failed compound edits

* test(engine): prove failed scale-marker rollback preserves layer targets

* fix(engine): resync tool mask target after restoring channel view

* test(engine): cover channel and tool-mask rollback after failed compound
2026-10-14 12:45:36 -04:00
Roberto DutraandClaude Opus 5.5 887dcd7ef9 Linux: a virtual keyboard claiming BTN_TOOL_PEN no longer sends the window through Xwayland (#5284) (#5295)
* Linux: a virtual keyboard claiming BTN_TOOL_PEN no longer sends the window through Xwayland (#5284)

On Wayland the app opens its window through Xwayland when a pen is attached
(#639), and it took any input device whose key bitmap has BTN_TOOL_PEN for a
pen. A virtual keyboard made with uinput (RustDesk's, for one) claims every
key code, that one included, so the window went through Xwayland on machines
with no pen at all. On a fractionally scaled desktop (GNOME 46 at 125%) the
X11 window is drawn at the logical size and upscaled by the compositor, so
the UI came out blurred even though Auto UI Scale already followed the
system's 1.25 (#1237): native Wayland, where winit negotiates
wp_fractional_scale_v1 and renders at the physical 3840 px, was never used.

A pen now also needs ABS_X and ABS_Y in its absolute-axis bitmap, as
libinput asks of a tablet tool; a keyboard has no axes. Regression tests
with the virtual keyboard's own bitmaps, a pen beside it, and a pen display.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* Docs: on a fractionally scaled Wayland desktop, Xwayland is blurred and native Wayland has no file drops (#5284)

Say where the trade-off is: a native Wayland window is sharp at 125%,
150% and 175% but takes no dropped files (#386); an Xwayland window takes
drops and pen input (#639) but is enlarged by the compositor. List the ways
to open through Xwayland and what counts as a pen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-14 12:45:30 -04:00
Gert Pellin - GPE 56c5b355a4 feat(commands): find the Elliptical Marquee by 'ellipse' or 'marquee' (#5140)
* feat(commands): find the Elliptical Marquee by 'ellipse' or 'marquee' (#5060)

The Elliptical and Single Row/Column Marquee are options of select.rect
("ellipse":true, "line"), but the command-list filters only looked at
the id and the label ("Rectangular Selection"), so 'ellipse' and
'marquee' found nothing.

Commands can now carry search keywords. command.list / engine.commands
rows include them as "keywords", and the three filters (CLI commands
--filter, the engine.commands RPC and the MCP command_list) share one
matcher over id, label and keywords. select.rect gets the Photoshop
marquee names as keywords.

* Command list replies: document the optional keywords key

The reply-shape test from #3684 pins the exact keys of each row; select.rect
now also carries keywords, so the test and docs/control-protocol.md allow
that one optional string.
2026-10-14 12:45:24 -04:00
Valentin KleinandClaude Opus 5.5 3888e95e47 Text: non-finite numbers and negative indices from PSD files are sanitized (#4429) (#4433)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 12:45:19 -04:00
Abdul karim mia 1ab33d5113 Map .atn duplicate-into-new-document (Mk) to layer.duplicate (#5371)
* Map .atn duplicate-into-new-document (Mk) to layer.duplicate

Photoshop records Layer > Duplicate Layer with Destination: New as a
Mk event (document class + layer Usng, Nm = new doc name, LyrN = copy
name), verified against a ScriptListener/XTensions export. Import maps
it to layer.duplicate {document: new}; export writes it back, so the
steps round-trip instead of becoming kept Photoshop steps that fail at
playback. A Dplc with a T document reference (another open doc) now
imports as a kept step rather than misplaying as a same-doc copy.

* Fix Rust formatting in ATN command tests

* Fix rustfmt formatting in ATN command tests
2026-10-14 12:45:11 -04:00
MarekSpodBiedry d12dc6b718 fix(layers): merging layers keeps pixels that lie outside the canvas (#5067) (#5124) 2026-10-14 12:45:05 -04:00
93a16218de Gradient Map is driven by the W3C luminosity, as in Photoshop (#5018)
Photoshop's Gradient Map picks each pixel's place on the gradient by
0.3 R + 0.59 G + 0.11 B, the luminosity of the blend modes. We used the
Rec. 601 grey (0.299, 0.587, 0.114). That is a few thousandths off, but
it is enough to land two levels away on steep stretches of a gradient.
The photoshop corpus's rgb8 and rgb16 gradient-map.psd now pass (they
were 2 levels off on 0.8-1.7% of pixels). Sampling the gradient through
a 256- or 1024-entry table didn't help; the weights did.

CPU (compose::adjust) and GPU (the shader's lum) both change. Lab
documents keep their Gradient Map driven by L, and grey pixels map as
before.

Photoshop oracles 145 -> 147 of 258; floor 145 -> 147.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 12:44:59 -04:00
Vikram BhaskaranandBrandon Thomas c4bf9eb045 Bound selection sampling buffers and report allocation failures. (#4594)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 12:44:54 -04:00
Vikram BhaskaranandBrandon Thomas e34cdcbcca Keep channel pastes transactional when pixel allocation fails. (#4588)
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-14 12:44:47 -04:00
IvanandClaude Opus 5.5 f0f0d6f58a CI: the gpu test group covers the other ui-egui wgpu tests
The canvas|gpu|mip|type_tool name filter missed ui-egui unit tests that
build a Harness with a real wgpu device: background_redraw's GPU tests,
panel_docking's floating_group_titles_are_opaque_in_studio_themes and
swap_arrow's rendered_glyph_has_a_left_head_and_a_lower_right_head. Name
them in both overrides, so they share the 4-thread cap and get the CI
retries. The other .wgpu() harnesses only render when a fixture env var
is set, or are #[ignore]d.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:22:15 +03:00
IvanandClaude Opus 5.5 3bd9bc72a7 Tests: monitor_macos answers nextest's --list
monitor_macos is harness = false, so nothing answered cargo-nextest's
discovery: `--list --format terse` ran the whole test and nextest failed
to parse its output on macOS. List the one test, and nothing for
`--ignored`, as mac_menu_appkit already does; `monitor_macos --exact`
still runs the body.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 19:22:14 +03:00
IvanandClaude Opus 5.5 b23a01aa8f Tests: postscript_only_style waits for the font scan
selected_style reads the font engine through try_shared, which returns None while the system
font scan is still running. Under cargo test the test passed only because an earlier test in the
same process had already finished the scan; run on its own (nextest runs every test that way, and
`cargo test -- --exact` does too) it saw no engine and got "Regular" instead of "SemiBold". It now
waits for the scan with photocraft_text::shared(), as every_theme_shows_the_type_options does.
On main, alone, it failed before this and passes after, under both cargo test and nextest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 16:03:09 +03:00
IvanandClaude Opus 5.5 4a93095939 CI: keep a test (windows-latest) check that reports the test result
Splitting the Windows tests renamed their check from `test (windows-latest)` to one per leg. No
status check is required on main today, but if one is added under the old name it would wait
forever. A small job under that name now fails unless the platform pick and the whole test matrix
passed (`needs` gives one result for all legs, so a failing Linux leg fails it too). It checks the
result instead of exiting 0 under `if: always()`, which would pass with every leg red. Suggested
by Zach in review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 14:29:13 +03:00
IvanandClaude Opus 5.5 e99f09b306 Tests: mac_menu_appkit answers nextest's --list
The test runs without a harness (AppKit needs the main thread), so nothing answered the `--list`
that cargo-nextest sends each test binary: on macOS the binary ran the whole test as its listing
and nextest failed on the output. It now lists itself in libtest's terse format, and nothing for
`--ignored`; a run passes `--exact <name>`, which falls through to the test as before. Found by
Zach running `cargo nextest run --workspace` on a Mac.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 14:28:41 +03:00
IvanandClaude Opus 5.5 e5bf9c22c9 CI: run the Windows tests on two runners instead of three
The integration tests run as one leg again. On the Windows runner (EPYC 7763, 3 runs) that leg
took 14.3-15.3 min, against 10.5-10.8 for each half, so the slowest leg is now the integration
one at ~15 min instead of the unit one at ~12.8. In exchange each PR that runs Windows takes two
Windows runners instead of three and ~7 fewer runner-minutes: every leg pays its own setup
(~1 min) and builds the workspace libraries its tests link against (~4 min even from sccache).
That matters more while PRs queue for the account's concurrent-job limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 01:48:31 +03:00
IvanandClaude Opus 5.5 39904a27ce Docs: CI runs nextest, the craft-fonts tests have their own job
Comments and docs still said CI runs cargo test, that the craft-fonts tests run a second time in
the Linux job, and that CI's debuginfo matches the dev profile everywhere. development.md also
notes that test builds compile ui-egui unoptimized.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 11:32:35 +03:00
IvanandClaude Opus 5.5 110d58bea9 CI: split the Windows integration tests over two runners; retry flaky GPU tests
nextest --partition splits the integration tests in two, so Windows runs three legs: on the
Windows runner (EPYC 7763, 2 runs) the integration halves took 9.8 and 10.8 min against 13.8 for
one, leaving the unit leg (~12.8) as the longest.

A ci nextest profile retries the GPU tests: on WARP, under load, one hit wgpu's poll timeout once
in ~40 runs while measuring. A test that passes on retry is reported as FLAKY.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 10:06:39 +03:00
IvanandClaude Opus 5.5 60d682d65d CI: cache the Windows workspace libraries with sccache
rust-cache keeps only the dependencies, so every run recompiled the workspace crates. sccache
caches their libraries (test executables can't be cached and still link). Warm, on the Windows
runner (EPYC 7763, 2-3 runs), the unit leg built in 7.1 min instead of 11.5 and the integration
leg in 4.3 instead of 8.8. Only main writes (PRs use SCCACHE_GHA_RW_MODE=READ_ONLY); a full set
of entries was ~90 MB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 08:58:53 +03:00
IvanandClaude Opus 5.5 6382148eb8 CI: run the Windows tests as two parallel legs
Unit tests (--lib --bins, plus doctests) and integration tests (--test '*', plus Clippy and the
layering check) now run on two Windows runners at once. The feature selection is the same, so
both legs restore one dependency cache; the integration leg saves it, so Clippy's metadata stays
in it. On the Windows runner (EPYC 7763) the job took 28.3 min; the legs take 18.2 and 18.3,
running the same 6095 tests between them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 07:48:54 +03:00
IvanandClaude Opus 5.5 2ff756b51a Tests: build ui-egui without optimization in the test profile
Its library and unit tests were the biggest test-build units after engine's (12.6 of ~63 compile
minutes on the Windows CI runner), and its tests run no heavy pixel code of their own. On the
Windows runner (EPYC 7763, 3 runs vs 7): build 18.9 -> 15.4 min, test run 10.0 -> 10.5. Only test
builds change; cargo run and cargo build use the dev profile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 06:36:32 +03:00
IvanandClaude Opus 5.5 fa17b6f4b1 CI: run the Linux without-heif and craft-fonts builds in parallel
They ran after the main tests in the Linux test job and added ~10 min to its critical path (and
were why that job ran out of disk). Now a two-leg job restores the test job's cache read-only
(shared-key: test) and saves nothing, so the cache budget (#2688) doesn't grow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 05:08:46 +03:00
IvanandClaude Opus 5.5 7b1dd35b7b CI: report every failing test, not just the first
With the binaries running in parallel, running the rest after a failure costs little, and a red
main no longer hides other failures behind the first one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 05:07:17 +03:00
IvanandClaude Opus 5.5 bf93459200 CI: no debuginfo for the Windows test build
Writing PDBs for ~200 test binaries took 2.2 of ~21 build minutes on the Windows runner (3 runs
each on the same CPU model). A panic still reports its file and line; Linux keeps
line-tables-only for backtraces with lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 05:06:06 +03:00
IvanandClaude Opus 5.5 b9aa935d19 CI: run tests with nextest and skip building examples (#2204)
cargo test runs the ~200 test binaries one after another; on Windows that took 14 of the job's
37 minutes, 5 of them in gpu's parity tests on the software adapter. nextest runs them in parallel
with the GPU group from .config/nextest.toml. The examples were built and linked but never run;
Clippy's --all-targets still checks them. Doctests get their own step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 01:41:45 +03:00
487 changed files with 39841 additions and 3673 deletions

No files matched your search

+11 -2
View File
@@ -1,5 +1,6 @@
# cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test
# binaries. Optional for local runs; `cargo test` keeps working and stays what CI runs (#2204).
# binaries. CI runs it with the `ci` profile below (#6117); locally it is optional and `cargo test`
# keeps working (#2204).
#
# cargo nextest run --workspace # all tests except doctests
# cargo test --doc --workspace # nextest does not run doctests
@@ -11,5 +12,13 @@
gpu = { max-threads = 4 }
[[profile.default.overrides]]
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))'
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool|background_redraw::|floating_group_titles|swap_arrow::tests::rendered_glyph/)))'
test-group = 'gpu'
# CI (`--profile ci`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its
# runner, fails a test that hangs instead of letting it hold the runner, and repeats every failure
# at the end of the log. The default profile's GPU override still applies.
[profile.ci]
fail-fast = true
slow-timeout = { period = "60s", terminate-after = 10 }
failure-output = "immediate-final"
+175 -46
View File
@@ -4,6 +4,7 @@ on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
inputs:
fuzz:
@@ -22,8 +23,8 @@ env:
PHOTOCRAFT_NO_PRINT: "1"
LPDEST: photocraft-no-such-printer
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
# debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough
# for the Linux job's second, craft-fonts build (it ran out of disk with full debug info).
# debug info: faster codegen and links, and a smaller target dir (the Linux test job ran out of
# disk with full debug info). The Windows test legs drop debuginfo entirely (see the test job).
CARGO_PROFILE_DEV_DEBUG: line-tables-only
# A newer push to a PR branch cancels its older run. Runs on main are never cancelled: a merge
@@ -39,10 +40,11 @@ jobs:
# platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
# macOS is tested only by release.yml.
platforms:
timeout-minutes: 10
name: select platforms
runs-on: ubuntu-latest
outputs:
os: ${{ steps.pick.outputs.os }}
legs: ${{ steps.pick.outputs.legs }}
web: ${{ steps.pick.outputs.web }}
steps:
- uses: actions/checkout@v4
@@ -56,34 +58,46 @@ jobs:
run: |
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
# release.yml builds and tests macOS (a failing release is fixed and rebuilt).
all='["ubuntu-latest","windows-latest"]'
# Windows tests run as two legs in parallel: the unit tests and the integration tests. As
# one job they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting
# the integration tests over a third runner (nextest --partition) saved ~2 min of wall
# time but cost ~7 runner-minutes and one more Windows slot per PR.
linux='{"os":"ubuntu-latest","part":"all"}'
win() { printf '{"os":"windows-latest","part":"%s"}' "$1"; }
all="[$linux,$(win unit),$(win integration)]"
if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then
echo "os=$all" >> "$GITHUB_OUTPUT"
echo "legs=$all" >> "$GITHUB_OUTPUT"
echo "web=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# The browser build (a fat-LTO wasm build) runs only when something it compiles or
# packages changed: the web app and its packaging, crate sources and manifests (the web
# build compiles nearly every crate), the workspace manifests and this workflow.
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web(-boot)?/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then
echo "web=true" >> "$GITHUB_OUTPUT"
else
echo "web=false" >> "$GITHUB_OUTPUT"
fi
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
echo "os=$all" >> "$GITHUB_OUTPUT"
echo "legs=$all" >> "$GITHUB_OUTPUT"
else
echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT"
echo "legs=[$linux]" >> "$GITHUB_OUTPUT"
fi
test:
name: test (${{ matrix.os }})
timeout-minutes: 100
name: test (${{ matrix.os }}${{ matrix.part != 'all' && format(', {0}', matrix.part) || '' }})
needs: platforms
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: ${{ fromJSON(needs.platforms.outputs.os) }}
include: ${{ fromJSON(needs.platforms.outputs.legs) }}
env:
# No debuginfo on Windows: writing PDBs for ~200 test binaries cost 2.2 of ~21 build minutes.
# A panic still names its file and line; only backtrace frames lose theirs (Linux keeps
# line-tables-only, so a backtrace with lines is one OS away).
CARGO_PROFILE_DEV_DEBUG: ${{ startsWith(matrix.os, 'windows') && '0' || 'line-tables-only' }}
steps:
- uses: actions/checkout@v4
@@ -106,49 +120,44 @@ jobs:
# Only main saves caches; PRs restore main's. Every PR used to save its own set (up to
# 3.8 GB), usable by that PR only, so the repo's 10 GB cache budget kept evicting main's.
# A failing test still saves: the cache holds compiled dependencies, not test results.
# The Windows legs share one key (same dependencies); the integration leg saves it,
# because it also runs Clippy and so leaves Clippy's dependency metadata in it.
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }}
cache-on-failure: true
# sccache caches the workspace crates' compiled libraries, which rust-cache leaves out:
# warm, each Windows leg built 4.5 min faster. Like rust-cache, only main writes (PRs read
# main's entries); a full set is ~90 MB, so it barely touches the 10 GB budget (#2688).
# Test executables can't be cached, so a PR still links its tests.
- name: Install sccache (Windows)
if: runner.os == 'Windows'
uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.18.0
- name: Use sccache (Windows)
if: runner.os == 'Windows'
env:
MAIN: ${{ github.ref == 'refs/heads/main' }}
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
if [ "$MAIN" != true ]; then echo "SCCACHE_GHA_RW_MODE=READ_ONLY" >> "$GITHUB_ENV"; fi
shell: bash
- name: Format
if: runner.os == 'Linux'
run: cargo fmt --all -- --check
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
- name: Test
run: cargo test --workspace --locked --features heif
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
# error for distributors who leave it out.
- name: Test without heif
if: runner.os == 'Linux'
run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
# Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace
# --features heif` of the Test step: any other package or feature selection resolves a
# different feature set, recompiles the whole dependency graph into the same target dir and
# runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800).
- name: Check out craft-fonts
if: runner.os == 'Linux'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Test with craft-fonts
if: runner.os == 'Linux'
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: cargo test --workspace --locked --features heif
# Keep formatting, strict Clippy, layering and scorecard checks ahead of tests.
- name: Clippy
if: matrix.part == 'all' || matrix.part == 'integration'
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
- name: Dependency layering
if: matrix.part == 'all' || matrix.part == 'integration'
run: cargo xtask layers
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
@@ -157,7 +166,121 @@ jobs:
if: runner.os == 'Linux'
run: cargo xtask scorecard --check
# nextest runs the test binaries in parallel (cargo test runs them one at a time), with the
# GPU tests capped by the `gpu` group in .config/nextest.toml (#2204).
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
with:
tool: [email protected]
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
# `--lib --bins --tests` leaves out the examples, which cargo test would build and link but
# never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so
# they get their own step.
#
# On Windows the unit leg runs `--lib --bins` and the integration leg `--test '*'` (every
# integration-test target); the feature selection stays the same, so both legs share the
# dependency cache. The `ci` profile fails fast and terminates hung tests
# (.config/nextest.toml); assertion failures are not retried.
- name: Test
env:
PART: ${{ matrix.part }}
run: |
case "$PART" in
unit) targets=(--lib --bins) ;;
integration) targets=(--test '*') ;;
*) targets=(--lib --bins --tests) ;;
esac
cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}"
shell: bash
- name: Doctests
if: matrix.part == 'all' || matrix.part == 'unit'
run: cargo test --workspace --locked --features heif --doc
# The Windows tests used to be one job named `test (windows-latest)`; they're now legs named
# `test (windows-latest, unit)` and `test (windows-latest, integration)`. This job keeps the old
# name reporting, so a required status check on it (none today) would keep working. It fails
# unless the whole test matrix and both Linux variants passed. `needs` reports one result
# for all matrix legs; when a PR runs no Windows legs it still checks every Linux result.
windows-gate:
timeout-minutes: 5
name: test (windows-latest)
needs: [platforms, test, test-variants]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- env:
PLATFORMS: ${{ needs.platforms.result }}
TEST: ${{ needs.test.result }}
VARIANTS: ${{ needs.test-variants.result }}
run: |
echo "platforms: $PLATFORMS, test: $TEST, variants: $VARIANTS"
[ "$PLATFORMS" = success ] && [ "$TEST" = success ] && [ "$VARIANTS" = success ]
# The two other builds of the Linux tests, in parallel with the test job instead of after it (they
# added ~10 min to its critical path). Each restores the test job's cache (`shared-key: test` is
# that job's key) and saves none: the dependencies are the same, and the 10 GB cache budget is
# tight (#2688).
test-variants:
timeout-minutes: 100
name: test (${{ matrix.variant }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
variant: [without heif, with craft-fonts]
steps:
- uses: actions/checkout@v4
- name: Install Linux system dependencies (egui/winit/rfd)
run: |
nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \
/opt/hostedtoolcache/CodeQL </dev/null >/dev/null 2>&1 &
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 libgtk-3-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: test
save-if: false
- uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22
with:
tool: [email protected]
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
# error for distributors who leave it out.
- name: Test without heif
if: matrix.variant == 'without heif'
run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci
# The tests built with the optional craft-fonts input (crates/text/build.rs), so the Japanese
# font tests that skip without it run. Keep the test job's exact `--workspace --features heif
# --lib --bins --tests`, so the restored dependencies match and only the workspace rebuilds.
# Filter execution to the three crates that read the optional font input (#6127).
- name: Check out craft-fonts
if: matrix.variant == 'with craft-fonts'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Test with craft-fonts
if: matrix.variant == 'with craft-fonts'
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: >-
cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests
-E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)'
corpus:
timeout-minutes: 30
# Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are
# pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache
# restore and a download; a missing corpus fails the tests. The cache key changes only when a
@@ -190,6 +313,7 @@ jobs:
run: cargo xtask test-corpus
wasm:
timeout-minutes: 30
name: wasm32 check
runs-on: ubuntu-latest
steps:
@@ -206,6 +330,7 @@ jobs:
run: cargo xtask wasm
web:
timeout-minutes: 60
name: web build and wasm size gate
# PRs run it only when web-relevant files change (see `platforms`); main always does.
needs: platforms
@@ -229,8 +354,8 @@ jobs:
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
# package.sh runs Trunk's release build, validates relative URLs, checks the 24 MiB
# wasm limit and exercises the same packaging step used by release.yml.
# package.sh runs Trunk's release builds, validates relative URLs, cuts the wasm into
# parts, checks the 24 MiB per-file limit and exercises the packaging step release.yml uses.
- name: Build and verify browser release
run: packaging/web/package.sh
- name: Report wasm size
@@ -241,15 +366,19 @@ jobs:
echo
echo "| File | Bytes | MiB | Limit |"
echo "| --- | ---: | ---: | ---: |"
row() { echo "| $1 | $2 | $(awk -v n="$2" 'BEGIN { printf "%.2f", n / 1048576 }') | $3 |"; }
# The app's wasm as built; it ships gzipped in parts, so only the download grows with it.
for wasm in dist/web/*.wasm; do
[ -f "$wasm" ] || continue
bytes=$(wc -c < "$wasm" | tr -d ' ')
mib=$(awk -v n="$bytes" 'BEGIN { printf "%.2f", n / 1048576 }')
echo "| $(basename "$wasm") | $bytes | $mib | 24 MiB |"
row "$(basename "$wasm")" "$(wc -c < "$wasm" | tr -d ' ')" "none"
done
# What a host sees: the largest file of the packaged site.
largest=$(find target/web-package -type f -printf '%s %f\n' | sort -n | tail -1)
row "largest file in the site: ${largest#* }" "${largest%% *}" "24 MiB"
} >> "$GITHUB_STEP_SUMMARY"
fuzz:
timeout-minutes: 60
name: fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }})
# Placeholder: manual trigger only until a nightly schedule is enabled.
if: github.event_name == 'workflow_dispatch' && inputs.fuzz
+1
View File
@@ -27,6 +27,7 @@ concurrency:
jobs:
build:
timeout-minutes: 15
name: mdBook build
runs-on: ubuntu-latest
steps:
+7 -35
View File
@@ -27,38 +27,10 @@ permissions:
jobs:
freebsd:
name: FreeBSD 14 (build + test + package)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
- name: Build, test and package in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
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 -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
set -e
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked --features heif
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc
# The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a GPU
# device at once, so the GPU integration tests run one at a time here.
CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1
# The release package, as release.yml builds it. Free the debug build's disk first.
rm -rf target/debug
CARGO_BUILD_JOBS=4 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$'
# 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/freebsd.yml@main
with:
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
cargo-build-extra-args: "--features heif"
custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1"
run-package-check: true
+33
View File
@@ -0,0 +1,33 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
+1
View File
@@ -23,6 +23,7 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
verified-appimage-inputs:
timeout-minutes: 10
name: Verified AppImage inputs
runs-on: ubuntu-latest
steps:
+1
View File
@@ -39,6 +39,7 @@ apps/
photocraft desktop app (eframe/wgpu), TCP control server
photocraft-cli headless CLI (convert/info/run/batch/commands/mcp)
photocraft-web the same app in the browser (trunk + wasm-bindgen)
photocraft-web-boot starts the packaged browser app from compressed parts of its wasm (no file over a host's size cap)
xtask/ cargo xtask layers | wasm | ci | stats | corpus | test-corpus | parity | perf | scorecard
```
+5 -2
View File
@@ -13,6 +13,7 @@ next to them.
| `crates/codecs/src/codecs/xpm/x11_colors.rs` | X11 `rgb.txt` color-name lookup table (normalized X.Org RGB values) | X.Org Foundation and X11 contributors | <https://cgit.freedesktop.org/xorg/app/rgb/tree/rgb.txt> | MIT (X.Org app/rgb) |
| `crates/ui-egui/src/i18n/el.tsv` | Greek UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/de.tsv` | German UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/es.tsv` | Spanish UI translations | @gorevyoneticisi; AI contributor: OpenAI Codex | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/it.tsv` | Italian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/ko.tsv` | Korean 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/nl.tsv` | Dutch 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) |
@@ -26,7 +27,7 @@ next to them.
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.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 |
@@ -41,6 +42,7 @@ next to them.
| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush` plus a dashed selection arc around the bristles | Lucide Icons and Contributors; arc 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) |
@@ -55,6 +57,7 @@ next to them.
| `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | <https://ethanschoonover.com/solarized/> | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) |
| `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, <https://invent.kde.org/plasma/breeze> (its colour schemes are LGPL-2.0-or-later) | Original work |
| `crates/ui-egui/src/theme.rs` (Soft Dark and Soft Light themes) | Original colour values and radii for two low-contrast themes on PhotoCraft's Studio layout; no third-party palette, file or code is used | PhotoCraft contributors | Original | Original work |
| `crates/algo/data/libfacedetection.bin` (face detection weights, read by `crates/algo/src/faces.rs`, whose network code is a port of the library's) | libfacedetection CNN (YuNet design) weights and inference code, weights converted to half precision | Shiqi Yu | [ShiqiYu/libfacedetection](https://github.com/ShiqiYu/libfacedetection) (`src/facedetectcnn-data.cpp`, `facedetectcnn.cpp`, `facedetectcnn-model.cpp`) | BSD-3-Clause, [`LICENSE-libfacedetection.txt`](crates/algo/data/LICENSE-libfacedetection.txt) |
### Optional build input: craft-fonts (not files in this repo)
@@ -112,6 +115,6 @@ next to the files:
| `corpus/exr/` (5 files, 2.3 MB) | The deep OpenEXR test images `11`, `42`, `64`, `multivariate` and `objectid.deep.exr` | Contributors to the OpenEXR Project | [openexr `src/test/bin/test_images`](https://github.com/AcademySoftwareFoundation/openexr/tree/main/src/test/bin/test_images) | BSD-3-Clause |
| `corpus/affinity/` (60 files) | Public Affinity documents and PNG references: `vector-art/` (9 documents), `afdesignload/` (9), `jac21/` (2), `asset-store-template/` (1), and `affinity-samples/` (18 CC0 documents plus exported references) | samuel-etver; Nick Beeuwsaert; Jeremy Cantu; DAIKI (eviltwo); satoshoe-dev | [vector-art `simple/`](https://github.com/samuel-etver/vector-art/tree/255f8add3c8f0740196e22bd59502b811b532f0b/simple), [AFDesignLoad `testDesigns/`](https://github.com/NickBeeuwsaert/AFDesignLoad/tree/a18dd50a7079fb861a28835eeefa0d015f03f4a1/testDesigns), [Jac21/Branding `Logos/JC/DesignerFiles/Affinity`](https://github.com/Jac21/Branding/tree/57ae3f45bf4637f6927c0b07de25b922c55f944f/Logos/JC/DesignerFiles/Affinity), [AssetStoreTemplate `AssetStoreTemplate/`](https://github.com/eviltwo/AssetStoreTemplate/tree/f671981ee89d15526285b3cc87b4393d3defe644/AssetStoreTemplate), [affinity-samples](https://github.com/satoshoe-dev/affinity-samples/tree/96aca6bd86bbe7a677f1cf0630bd832d23943be8) | CC0-1.0 (vector-art and affinity-samples); MIT, Copyright (c) 2015 Nick Beeuwsaert; MIT, Copyright (c) 2018 Jeremy Cantu; MIT, Copyright (c) 2024 DAIKI |
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
| `corpus/pixls/` (7 files, 226 MB) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
| `corpus/pixls/` (11 files) | Real camera raws, one per decode path: Canon PowerShot SX50 HS (CR2 and its DNG conversion), Nikon D3 uncompressed NEF and Z6 paired 12/14-bit lossy and packed NEFs, Sony DSC-RX0 cRAW ARW, Panasonic DC-G9 high-res RW2, Olympus E-1 GRBG ORF, plus the E-5 packed ORF as the documented known-unsupported case. Per file (each CC0, per its database entry): [IMG_4059.CR2](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/IMG_4059.CR2), [CRW_4061.DNG](https://raw.pixls.us/data/Canon/PowerShot%20SX50%20HS/CRW_4061.DNG), [JD1_8203.NEF](https://raw.pixls.us/data/Nikon/D3/JD1_8203.NEF), [DSC00009.ARW](https://raw.pixls.us/data/Sony/DSC-RX0/DSC00009.ARW), [P1000475.RW2](https://raw.pixls.us/data/Panasonic/DC-G9/P1000475.RW2), [E_1__C106743_gredos.ORF](https://raw.pixls.us/data/Olympus/E-1/E_1__C106743_gredos.ORF), [_7061961_copy.ORF](https://raw.pixls.us/data/Olympus/E-5/_7061961_copy.ORF), [DSC_0750.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0750.NEF), [DSC_0751.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0751.NEF), [DSC_0753.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0753.NEF), [DSC_0754.NEF](https://raw.pixls.us/data/Nikon/Z%206/DSC_0754.NEF) | The photographers of the pixls.us raw samples database | [raw.pixls.us](https://raw.pixls.us) `data/`, fetched per [xtask/src/corpus_pins.rs](xtask/src/corpus_pins.rs) | CC0 / public domain, per file as linked |
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
Generated
+14 -2
View File
@@ -5037,6 +5037,7 @@ dependencies = [
"serde_json",
"sys-locale",
"toml",
"unicode-bidi",
"winsafe",
]
@@ -5073,6 +5074,17 @@ dependencies = [
"web-sys",
]
[[package]]
name = "photocraft-web-boot"
version = "0.9.0"
dependencies = [
"flate2",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "pico-args"
version = "0.5.0"
@@ -6102,9 +6114,9 @@ dependencies = [
[[package]]
name = "ruzstd"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
dependencies = [
"twox-hash",
]
+29 -5
View File
@@ -75,9 +75,18 @@ opt-level = 2
opt-level = 1
# Line tables keep file:line in panic backtraces but drop variable info, which roughly halves link
# time and PDB size for the ~260 test binaries (#2204). CI sets the same through
# CARGO_PROFILE_DEV_DEBUG. To inspect locals in a debugger, build with CARGO_PROFILE_DEV_DEBUG=true.
# CARGO_PROFILE_DEV_DEBUG (and no debuginfo at all for the Windows tests). To inspect locals in a
# debugger, build with CARGO_PROFILE_DEV_DEBUG=true.
debug = "line-tables-only"
# Test builds compile ui-egui without optimization: its library and unit tests were the biggest
# test-build units after engine's (12.6 of ~63 compile minutes on the Windows CI runner), and its
# tests run no heavier pixel code of their own (that lives in the crates that stay optimized).
# Measured on CI: Windows build 18.9 -> 15.4 min, test run 10.0 -> 10.5. `cargo run` and
# `cargo build` use the dev profile and are unaffected.
[profile.test.package.photocraft-ui-egui]
opt-level = 0
[profile.release]
lto = "thin"
codegen-units = 1
@@ -127,17 +136,32 @@ opt-level = "z"
[profile.wasm-release.package.naga]
opt-level = "z"
# Pixel processing keeps its speed-oriented optimization level.
# The browser UI toolkit favors size to stay within the WebAssembly upload budget.
[profile.wasm-release.package.egui]
opt-level = "z"
[profile.wasm-release.package.epaint]
opt-level = "z"
[profile.wasm-release.package.eframe]
opt-level = "z"
# Pixel processing keeps its speed-oriented optimization level, with two exceptions.
# Colour-management transforms are built once and cached (`transform::cached`), so "z" costs
# little runtime. The browser app composites per frame on the GPU (`crates/gpu`, WebGPU/WebGL2),
# so the CPU compositor is not the browser's hot path there; "z" for compose keeps the wasm
# under the 24 MiB gate (measured −122 KB vs opt-level 3). algo's filters run per pixel on
# every interactive filter application; "z" there is the size lever of last resort (−331 KB),
# taken because the gate is enforced in CI: if browser filters feel too slow, restore 3 here
# and reclaim the bytes from a feature-gated decoder instead.
[profile.wasm-release.package.photocraft-raster]
opt-level = 3
[profile.wasm-release.package.photocraft-color]
opt-level = 3
[profile.wasm-release.package.photocraft-cms]
opt-level = 3
opt-level = "z"
[profile.wasm-release.package.photocraft-compose]
opt-level = 3
opt-level = "z"
[profile.wasm-release.package.photocraft-algo]
opt-level = 3
opt-level = "z"
[profile.wasm-release.package.photocraft-paint]
opt-level = 3
+1
View File
@@ -16,6 +16,7 @@ Inter font (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/
JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
Lucide icons (assets/icons/*.svg, except slice-knife.svg) — ISC; icons derived from Feather also MIT. See assets/icons/LICENSE-lucide.txt.
SCOWL English word list (assets/dict/en_US-scowl-50.txt.gz) — SCOWL license. See assets/dict/LICENSE-SCOWL.txt.
libfacedetection face detection weights and network (crates/algo/data/libfacedetection.bin, crates/algo/src/faces/cnn.rs) — BSD-3-Clause, Copyright (c) 2015-2019, Shiqi Yu, all rights reserved. See crates/algo/data/LICENSE-libfacedetection.txt.
Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and
+14 -1
View File
@@ -277,7 +277,7 @@ flatpak run ai.storyteller.photocraft
The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it.
On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it.
On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072).
On macOS, the command-line tool comes as `photocraft-cli-<version>-macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself:
@@ -300,6 +300,19 @@ Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are
> [!IMPORTANT]
> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, 15 missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity-checklist.md`](docs/parity-checklist.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap](ROADMAP.md) and [parity assessment](docs/target-app-parity.md). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft).
## Community packages
These installers are maintained by community members, not by the PhotoCraft team.
On Gentoo, the [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay) packages the Linux release as `media-gfx/photocraft-bin`:
```sh
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
emaint sync -r snakebyte
echo 'media-gfx/photocraft-bin ~amd64' >> /etc/portage/package.accept_keywords/photocraft
emerge --ask media-gfx/photocraft-bin
```
## Documentation
Developer, architecture, automation, format, and security documentation is maintained in the [PhotoCraft documentation book](book/).
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "photocraft-cli"
description = "Headless Photocraft: convert, inspect, run commands, batch, MCP server"
description = "Headless PhotoCraft: convert, inspect, run commands, batch, MCP server"
version.workspace = true
edition.workspace = true
license.workspace = true
+3 -5
View File
@@ -1,4 +1,4 @@
//! Headless Photocraft command line. `run` is the whole program, so it can be
//! Headless PhotoCraft command line. `run` is the whole program, so it can be
//! tested in-process as well as through the binary.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -12,7 +12,7 @@ use photocraft_io::ExportOptions;
use serde_json::{Value, json};
pub const USAGE: &str = "\
photocraft-cli: headless Photocraft
photocraft-cli: headless PhotoCraft
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
@@ -541,9 +541,7 @@ fn commands(a: &Args, out: &mut dyn Write) -> R {
.map(|v| v.iter().collect::<Vec<_>>())
.unwrap_or_default()
.into_iter()
.filter(|c| {
needle.as_ref().is_none_or(|n| format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or("")).to_lowercase().contains(n))
})
.filter(|c| needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n)))
.collect();
if a.has("--json") {
return print_json(out, &Value::Array(items.into_iter().cloned().collect()), false);
+5
View File
@@ -90,6 +90,11 @@ fn unknown_flags_are_usage_errors() {
// Every flag still works in both forms.
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
assert!(out.contains("filter.blur.gaussianBlur"), "{out}");
// The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060).
for needle in ["ellipse", "marquee"] {
let (out, _) = ok(bin().args(["commands", "--filter", needle]));
assert!(out.contains("select.rect"), "{needle}: {out}");
}
ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([
"--format=jpg",
"--quality",
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "photocraft-web-boot"
description = "Starts Photocraft in the browser from compressed parts of its WebAssembly"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[lints]
workspace = true
[[bin]]
name = "photocraft-web-boot"
path = "src/main.rs"
[dependencies]
# Inflates the parts in browsers without `DecompressionStream`.
flate2 = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Node", "Request", "RequestInit", "RequestCache", "Response", "ResponseInit", "Headers", "ReadableStream", "WritableStream", "TransformStream", "TransformStreamDefaultController", "Transformer", "StreamPipeOptions", "ReadableWritablePair", "console"] }
+6
View File
@@ -0,0 +1,6 @@
# Built by packaging/web/package.sh: trunk build --release (from apps/photocraft-web-boot)
[build]
target = "index.html"
dist = "../../dist/web-boot"
public_url = "./"
filehash = true
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>PhotoCraft</title>
<link data-trunk rel="rust" data-bin="photocraft-web-boot" data-cargo-profile="wasm-release" data-wasm-opt="z" data-wasm-opt-params="--converge" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#photocraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 0 16px; text-align: center; color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
#photocraft_loading p { margin: 0 0 8px; max-width: 420px; line-height: 1.5; }
#photocraft_loading a { color: #e0e0e0; pointer-events: auto; }
/* The app removes this element once it runs. If the WebAssembly never downloads or doesn't match
this page, no Rust runs to say so, so the page itself shows what to do after a while. */
#photocraft_stalled { color: #8c8c8c; visibility: hidden; animation: photocraft-stalled 0s 20s forwards; }
@keyframes photocraft-stalled { to { visibility: visible; } }
</style>
</head>
<body>
<canvas id="photocraft_canvas"></canvas>
<div id="photocraft_loading">
<p>Loading PhotoCraft&hellip;</p>
<p id="photocraft_stalled">This is taking longer than usual. If PhotoCraft doesn't start, its files may have failed to
download or come from a different version of this page. <a href="">Reload</a> to try again; the browser
console lists what failed.</p>
</div>
</body>
</html>
+356
View File
@@ -0,0 +1,356 @@
//! Starts Photocraft in the browser from compressed parts of its WebAssembly.
//!
//! Static hosts cap the size of one file (Cloudflare Pages: 25 MiB), and the app's wasm is at
//! that size. `packaging/web/package.sh` gzips it and cuts it into parts listed in
//! `app/parts.txt`. This small module is what the packaged page loads: it fetches the parts in
//! order, joins and inflates them as they arrive, and hands the stream to the app's own generated
//! loader, so the browser still compiles while downloading. No file on the host is larger than
//! one part, however large the app grows.
//!
//! The browser inflates the parts (`DecompressionStream`: Safari 16.4, Firefox 113, Chrome 80).
//! In one that can't, this module inflates them itself, a little slower.
//!
//! A download that fails is tried again, the later tries past the browser's cache (a bad copy
//! there would otherwise fail forever). When every try fails the page says so and offers a reload.
//!
//! `trunk build` in `apps/photocraft-web` still gives the app as one `.wasm`, for development and
//! for hosts without a per-file cap; only the packaged site starts through this module.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
/// First line of `parts.txt`. Some hosts answer a missing file with the home page and HTTP 200;
/// without this an HTML page would be read as a list of parts.
#[cfg(any(target_arch = "wasm32", test))]
const MARKER: &str = "photocraft-parts 1";
/// The part names in a `parts.txt`, in order, or `None` when the text isn't one. A name is a
/// file next to `parts.txt`: anything that could point elsewhere is refused.
#[cfg(any(target_arch = "wasm32", test))]
fn parts(list: &str) -> Option<Vec<String>> {
let mut lines = list.lines().map(str::trim).filter(|l| !l.is_empty());
if lines.next() != Some(MARKER) {
return None;
}
let parts: Vec<String> = lines.map(str::to_owned).collect();
let plain = |p: &String| !p.starts_with('.') && !p.contains(['/', '\\', '?', '#', ':', '%']);
(!parts.is_empty() && parts.iter().all(plain)).then_some(parts)
}
/// Gzip inflated a chunk at a time, for browsers that can't do it themselves.
#[cfg(any(target_arch = "wasm32", test))]
struct Inflater(flate2::write::GzDecoder<Vec<u8>>);
#[cfg(any(target_arch = "wasm32", test))]
impl Inflater {
fn new() -> Self {
Inflater(flate2::write::GzDecoder::new(Vec::new()))
}
/// What the next `chunk` of the gzip stream inflates to (often nothing: the decoder holds on
/// to input until it has a whole block).
fn push(&mut self, chunk: &[u8]) -> std::io::Result<Vec<u8>> {
std::io::Write::write_all(&mut self.0, chunk)?;
Ok(std::mem::take(self.0.get_mut()))
}
/// The rest, once the stream has ended. An error when it ended early or its checksum is
/// wrong, the same as the browser's inflater gives.
fn finish(&mut self) -> std::io::Result<Vec<u8>> {
self.0.try_finish()?;
Ok(std::mem::take(self.0.get_mut()))
}
}
#[cfg(target_arch = "wasm32")]
mod boot {
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{
Headers, ReadableStream, ReadableWritablePair, RequestCache, RequestInit, Response, ResponseInit, StreamPipeOptions, TransformStream,
TransformStreamDefaultController, Transformer, WritableStream,
};
use super::Inflater;
/// Where packaging puts the app, next to this module's own files.
const PARTS: &str = "./app/parts.txt";
/// Seconds to wait before each further try; one more than this many tries are made.
const RETRY_AFTER: [u32; 2] = [1, 3];
// The app's `wasm-bindgen` loader. Its default export instantiates the module it is given and
// runs the app's `main`. The path is fixed at compile time, so packaging gives the file this
// name (Trunk's own name for it carries a content hash).
#[wasm_bindgen(raw_module = "./app/photocraft-web.js")]
extern "C" {
#[wasm_bindgen(js_name = default, catch)]
async fn start_app(module: JsValue) -> Result<JsValue, JsValue>;
}
// The browser's own inflater. `web-sys` has it only behind its unstable-API flag, which would
// have to be set for the whole build, so the three members used are declared here.
#[wasm_bindgen]
extern "C" {
type DecompressionStream;
#[wasm_bindgen(constructor, catch)]
fn new(format: &str) -> Result<DecompressionStream, JsValue>;
#[wasm_bindgen(method, getter)]
fn readable(this: &DecompressionStream) -> ReadableStream;
#[wasm_bindgen(method, getter)]
fn writable(this: &DecompressionStream) -> WritableStream;
}
fn window() -> Result<web_sys::Window, JsValue> {
web_sys::window().ok_or_else(|| JsValue::from_str("no window"))
}
/// `fresh` skips the browser's cache.
async fn fetch(url: &str, fresh: bool) -> Result<Response, JsValue> {
let init = RequestInit::new();
if fresh {
init.set_cache(RequestCache::Reload);
}
let response: Response = JsFuture::from(window()?.fetch_with_str_and_init(url, &init)).await?.dyn_into()?;
if !response.ok() {
return Err(JsValue::from_str(&format!("{url}: HTTP {}", response.status())));
}
if response.headers().get("Content-Type")?.is_some_and(|t| t.starts_with("text/html")) {
return Err(JsValue::from_str(&format!("{url}: the server sent a web page, not the file")));
}
Ok(response)
}
/// Copies each part into `sink` in order, then closes it.
async fn feed(parts: Vec<String>, sink: WritableStream, fresh: bool) -> Result<(), JsValue> {
let keep_open = StreamPipeOptions::new();
keep_open.set_prevent_close(true);
for part in &parts {
let body = fetch(&format!("./app/{part}"), fresh).await?.body().ok_or_else(|| JsValue::from_str("part without a body"))?;
JsFuture::from(body.pipe_to_with_options(&sink, &keep_open)).await?;
}
JsFuture::from(sink.close()).await?;
Ok(())
}
/// Passes inflated bytes on to the stream's reader.
fn emit(inflated: std::io::Result<Vec<u8>>, out: &TransformStreamDefaultController) -> Result<(), JsValue> {
let inflated = inflated.map_err(|e| JsValue::from_str(&format!("the download is damaged ({e})")))?;
if inflated.is_empty() { Ok(()) } else { out.enqueue_with_chunk(&js_sys::Uint8Array::from(inflated.as_slice())) }
}
/// A gzip inflater with the shape of `DecompressionStream`, running in this module.
fn own_inflater() -> Result<TransformStream, JsValue> {
let inflater = Rc::new(RefCell::new(Inflater::new()));
let last = inflater.clone();
let busy = || JsValue::from_str("the inflater is in use");
let transform = Closure::<dyn FnMut(JsValue, TransformStreamDefaultController) -> Result<(), JsValue>>::new(
move |chunk: JsValue, out: TransformStreamDefaultController| {
let chunk = js_sys::Uint8Array::new(&chunk).to_vec();
emit(inflater.try_borrow_mut().map_err(|_| busy())?.push(&chunk), &out)
},
);
let flush = Closure::<dyn FnMut(TransformStreamDefaultController) -> Result<(), JsValue>>::new(move |out: TransformStreamDefaultController| {
emit(last.try_borrow_mut().map_err(|_| busy())?.finish(), &out)
});
let transformer = Transformer::new();
transformer.set_transform(&transform.into_js_value().unchecked_into());
transformer.set_flush(&flush.into_js_value().unchecked_into());
TransformStream::new_with_transformer(&transformer)
}
/// `compressed` inflated: by the browser where it can, otherwise here.
fn inflated(compressed: &ReadableStream) -> Result<ReadableStream, JsValue> {
let pair = if js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("DecompressionStream"))? {
let inflater = DecompressionStream::new("gzip")?;
ReadableWritablePair::new(&inflater.readable(), &inflater.writable())
} else {
let inflater = own_inflater()?;
ReadableWritablePair::new(&inflater.readable(), &inflater.writable())
};
Ok(compressed.pipe_through(&pair))
}
async fn run(fresh: bool) -> Result<(), JsValue> {
let list = JsFuture::from(fetch(PARTS, fresh).await?.text()?).await?.as_string().unwrap_or_default();
let parts = super::parts(&list).ok_or_else(|| JsValue::from_str("app/parts.txt is not a parts list"))?;
let joined = TransformStream::new()?;
let wasm = inflated(&joined.readable())?;
// Why the download stopped: the app's loader only reports that its stream broke.
let cause = Rc::new(RefCell::new(None));
let failed = cause.clone();
let sink = joined.writable();
spawn_local(async move {
if let Err(e) = feed(parts, sink.clone(), fresh).await {
let _ = sink.abort_with_reason(&e);
failed.replace(Some(e));
}
});
let headers = Headers::new()?;
headers.set("Content-Type", "application/wasm")?;
let init = ResponseInit::new();
init.set_headers(&headers);
let response = Response::new_with_opt_readable_stream_and_init(Some(&wasm), &init)?;
let arg = js_sys::Object::new();
js_sys::Reflect::set(&arg, &JsValue::from_str("module_or_path"), &response)?;
match start_app(arg.into()).await {
Ok(_) => Ok(()),
Err(e) => Err(cause.take().unwrap_or(e)),
}
}
async fn sleep(seconds: u32) {
let wait = js_sys::Promise::new(&mut |done, _| {
let ms = i32::try_from(seconds.saturating_mul(1000)).unwrap_or(i32::MAX);
if window().and_then(|w| w.set_timeout_with_callback_and_timeout_and_arguments_0(&done, ms)).is_err() {
let _ = done.call0(&JsValue::NULL);
}
});
let _ = JsFuture::from(wait).await;
}
/// Replaces the page's "Loading" text. `reload` adds a link that reloads the page.
fn say(text: &str, reload: bool) {
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let Some(loading) = document.get_element_by_id("photocraft_loading") else { return };
loading.set_text_content(None);
let Ok(line) = document.create_element("p") else { return };
line.set_text_content(Some(text));
if reload && let Ok(link) = document.create_element("a") {
link.set_text_content(Some("Reload"));
let _ = link.set_attribute("href", "");
let _ = line.append_child(&link);
}
let _ = loading.append_child(&line);
}
pub fn start() {
spawn_local(async {
let mut attempt = 0;
loop {
let error = match run(attempt > 0).await {
Ok(()) => return,
Err(e) => e,
};
web_sys::console::error_2(&JsValue::from_str("photocraft-web-boot: could not start PhotoCraft:"), &error);
let Some(&wait) = RETRY_AFTER.get(attempt) else {
say("PhotoCraft couldn't be downloaded. Check your connection, then try again: ", true);
return;
};
say("Loading PhotoCraft\u{2026} (the download failed, trying again)", false);
sleep(wait).await;
attempt += 1;
}
});
}
}
#[cfg(target_arch = "wasm32")]
fn main() {
boot::start();
}
#[cfg(not(target_arch = "wasm32"))]
fn main() {
eprintln!("photocraft-web-boot only runs in the browser: packaging/web/package.sh builds it with `trunk build --release` in apps/photocraft-web-boot");
}
#[cfg(test)]
mod tests {
use super::{Inflater, MARKER, parts};
fn gzip(bytes: &[u8]) -> Vec<u8> {
let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut z, bytes).expect("gzip");
z.finish().expect("gzip")
}
/// Bytes that don't compress to nothing, so the gzip stream spans many chunks.
fn sample() -> Vec<u8> {
let mut x = 0x2545_f491_4f6c_dd1d_u64;
(0..200_000)
.map(|i| {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
if i % 3 == 0 { x as u8 } else { (i / 7) as u8 }
})
.collect()
}
/// The parts arrive in whatever pieces the network gives; the inflater must not care where
/// they are cut.
#[test]
fn the_inflater_gives_back_the_bytes_however_the_stream_is_cut() {
let (plain, packed) = (sample(), gzip(&sample()));
for cut in [1, 7, 4096, 65_536, packed.len()] {
let mut inflater = Inflater::new();
let mut out = Vec::new();
for chunk in packed.chunks(cut) {
out.extend(inflater.push(chunk).expect("push"));
}
out.extend(inflater.finish().expect("finish"));
assert_eq!(out, plain, "chunks of {cut}");
}
}
/// A download that stops early, or whose bytes changed on the way, must fail (the boot module
/// then tries again), never start a damaged app.
#[test]
fn the_inflater_refuses_a_short_or_damaged_stream() {
let packed = gzip(&sample());
let run = |bytes: &[u8]| -> std::io::Result<Vec<u8>> {
let mut inflater = Inflater::new();
let mut out = inflater.push(bytes)?;
out.extend(inflater.finish()?);
Ok(out)
};
assert!(run(&packed).is_ok());
assert!(run(&packed[..packed.len() / 2]).is_err(), "cut short");
assert!(run(&packed[..packed.len() - 4]).is_err(), "trailer cut");
let mut damaged = packed.clone();
let middle = damaged.len() / 2;
damaged[middle] ^= 0x55;
assert!(run(&damaged).is_err(), "a changed byte");
assert!(run(b"<!DOCTYPE html><html>not gzip</html>").is_err(), "a web page");
assert!(run(b"").is_err(), "nothing at all");
}
#[test]
fn a_parts_list_gives_its_names_in_order() {
let list = format!("{MARKER}\nphotocraft-web-0123abcd.part-aa\r\n\n photocraft-web-0123abcd.part-ab \n");
assert_eq!(parts(&list), Some(vec!["photocraft-web-0123abcd.part-aa".to_owned(), "photocraft-web-0123abcd.part-ab".to_owned()]));
}
/// A host that answers a missing file with its home page must not have that page read as a
/// list of parts.
#[test]
fn a_web_page_is_not_a_parts_list() {
assert_eq!(parts(include_str!("../index.html")), None);
assert_eq!(parts(""), None);
assert_eq!(parts("photocraft-web.part-aa\n"), None, "no marker");
assert_eq!(parts(&format!("{MARKER}\n")), None, "no parts");
assert_eq!(parts("photocraft-parts 2\na.part-aa\n"), None, "a later layout this build doesn't know");
}
/// Part names are joined onto `./app/`, so one that could leave that folder, or name another
/// origin, refuses the whole list.
#[test]
fn a_part_name_cannot_point_outside_the_app_folder() {
for bad in ["../index.html", "a/b.part-aa", "a\\b", "https://example.com/x", "//example.com/x", "x?y", "x#y", ".hidden", "%2e%2e"] {
assert_eq!(parts(&format!("{MARKER}\ngood.part-aa\n{bad}\n")), None, "{bad}");
}
}
/// The packaged site shows this crate's page, the development build the app's own. They are
/// the same page apart from which program the `<link data-trunk>` line builds.
#[test]
fn the_page_is_the_apps_page() {
let trunk = |html: &str| html.lines().filter(|l| !l.contains("data-trunk")).collect::<Vec<_>>().join("\n");
let (boot, app) = (include_str!("../index.html"), include_str!("../../photocraft-web/index.html"));
assert_eq!(trunk(boot), trunk(app));
assert!(boot.contains(r#"data-bin="photocraft-web-boot""#) && boot.contains(r#"data-cargo-profile="wasm-release""#), "{boot}");
assert!(boot.contains(r#"id="photocraft_loading""#), "the element the failure message replaces");
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "photocraft-web"
description = "Photocraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
description = "PhotoCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
version.workspace = true
edition.workspace = true
license.workspace = true
+4 -4
View File
@@ -1,4 +1,4 @@
//! Photocraft in the browser.
//! PhotoCraft in the browser.
//!
//! Runs the same [`photocraft_ui_egui::PhotocraftApp`] as the desktop app through eframe's web
//! runner (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release`
@@ -6,10 +6,10 @@
//!
//! Differences from the desktop app:
//! - no TCP control server (browsers can't listen on sockets);
//! - File → Open uses the browser file picker; bytes arrive asynchronously through
//! `Services::inbox`;
//! - file pickers return browser file contents asynchronously through `Services::file_dialog`;
//! this includes File → Open and commands such as Place Embedded;
//! - saving/exporting triggers a browser download;
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
//! - dropped files are read asynchronously by `web::WebShell` and delivered through `Services::inbox`.
//!
//! URL query flags: `?cpu` forces the CPU canvas path (same as `PHOTOCRAFT_CPU_CANVAS=1`);
//! `?webgl` forces the WebGL2 backend instead of WebGPU.
+3 -16
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", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf",
"rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub",
"pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "cin", "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";
@@ -340,20 +340,7 @@ fn services(inbox: Inbox) -> Services {
photocraft_io::import_with_svg_group_depth(name, bytes, max_svg_group_depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
})),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
let mut opts = photocraft_io::ExportOptions::default();
if let Some(q) = settings.jpeg_quality {
opts.encode.jpeg_quality = q;
}
opts.encode.webp_lossless = settings.webp_lossless;
if let Some(q) = settings.webp_quality {
opts.encode.webp_quality = q;
}
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.psd_maximize_compatibility = settings.psd_maximize_compatibility;
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
photocraft_io::export(doc, path, &settings.io_options()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
})),
file_dialog: Some(Box::new(|request, _parent, reply| match request {
// The browser's file picker hands over the files' contents, not paths. File › Open
+1 -1
View File
@@ -4,7 +4,7 @@ version.workspace = true
edition.workspace = true
license.workspace = true
default-run = "photocraft"
description = "Photocraft: an open-source, native image editor"
description = "PhotoCraft: an open-source, native image editor"
build = "build.rs"
[features]
+670
View File
@@ -9,6 +9,9 @@
//! folder can't be written (read-only medium, Program Files), the app warns and falls back.
//! 3. The platform convention: macOS `~/Library/Application Support/Photocraft`, Windows
//! `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`.
//!
//! One exception: on Windows the platform convention keeps crash-recovery autosaves out of the
//! roaming profile ([`recovery_dir`]).
use std::ffi::OsString;
use std::path::{Path, PathBuf};
@@ -60,6 +63,379 @@ pub fn config_dir() -> Option<PathBuf> {
current().dir.clone()
}
/// The crash-recovery folder's name.
const RECOVERY: &str = "Recovery";
/// Where crash-recovery autosaves live, with any left in the settings directory by an earlier
/// version moved there first ([`adopt_legacy_recovery`]). Call it once, at startup.
pub fn recovery_dir() -> Option<PathBuf> {
let data = current();
let dir = resolve_recovery(data, |k| std::env::var_os(k))?;
Some(match data.dir.as_ref().map(|d| d.join(RECOVERY)) {
Some(legacy) => adopt_legacy_recovery(&legacy, &dir),
None => dir,
})
}
/// The recovery folder for `data` (#5648). Autosaves hold whole documents and are rewritten every
/// few minutes, so on Windows the platform convention puts them in the machine-local
/// `%LOCALAPPDATA%\Photocraft\Recovery`: the roaming `%APPDATA%` may be synced at sign-in and
/// sign-out or capped by policy. They stay out of `%TEMP%`, which cleanup tools may empty before
/// a crashed document is recovered. The override and portable folders keep everything in one
/// place, and other platforms already keep these folders local.
pub fn resolve_recovery(data: &DataDir, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
if cfg!(windows)
&& data.mode == Mode::Platform
&& let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty())
{
return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY));
}
data.dir.as_ref().map(|d| d.join(RECOVERY))
}
/// The lock file, beside the recovery folder, that keeps two PhotoCraft processes starting at
/// once (`--new-instance`, control launches) from moving the same autosaves.
const MIGRATION_LOCK: &str = "recovery-migration.lock";
/// How long a launch waits for another one's move before going ahead without moving anything.
const MIGRATION_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
/// Suffix of a copy staged for publishing ([`stage_copy`]): disposable, the original still exists.
const STAGED: &str = ".moving";
/// Suffix of an original set aside while it is copied to another volume ([`move_across`]): it
/// may be the only copy, so it is put back, never thrown away, if the move doesn't finish.
const HELD: &str = ".held";
/// Move autosaves from `legacy` (the recovery folder of versions before #5648) into `dir`, so a
/// crash just before an update is still offered for recovery. Returns the folder this process
/// uses for its recovery store. That is `legacy` only when `dir` doesn't exist and no process can
/// be moving autosaves (the move failed, or its lock can't be made), so nothing deletes what is
/// written there. A launch that finds another one moving uses `dir`, which the move never
/// replaces or removes. See [`adopt_locked`] for how they move.
pub fn adopt_legacy_recovery(legacy: &Path, dir: &Path) -> PathBuf {
adopt_legacy_recovery_waiting(legacy, dir, MIGRATION_WAIT)
}
/// [`adopt_legacy_recovery`], waiting up to `wait` for another process's move.
fn adopt_legacy_recovery_waiting(legacy: &Path, dir: &Path, wait: std::time::Duration) -> PathBuf {
let held_aside = || legacy.parent().is_some_and(|p| !side_entries(p, legacy, HELD).is_empty());
if legacy == dir || (!legacy.is_dir() && !held_aside()) {
return dir.to_path_buf();
}
// An empty old folder has nothing to move (`remove_dir` only removes an empty one).
if !held_aside() && std::fs::remove_dir(legacy).is_ok() {
return dir.to_path_buf();
}
let unmoved = || if dir.exists() || !legacy.is_dir() { dir.to_path_buf() } else { legacy.to_path_buf() };
let Some(parent) = dir.parent() else { return unmoved() };
let lock =
std::fs::create_dir_all(parent).and_then(|()| std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(parent.join(MIGRATION_LOCK)));
let Ok(lock) = lock else {
// No lock file means no other process can be moving: the old folder is safe to use.
log::warn!("couldn't open the recovery move lock in {}; autosaves stay where they are", parent.display());
return unmoved();
};
let deadline = std::time::Instant::now() + wait;
loop {
match lock.try_lock() {
Ok(()) => break,
Err(std::fs::TryLockError::WouldBlock) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(50)),
Err(std::fs::TryLockError::WouldBlock) => {
// Another process is moving and may yet remove the old folder: never write there.
log::info!("another PhotoCraft is moving crash-recovery autosaves; using {}", dir.display());
return dir.to_path_buf();
}
Err(std::fs::TryLockError::Error(e)) => {
// Locking fails for every process here alike, so none can be moving.
log::warn!("couldn't lock {}: {e}; autosaves stay where they are", parent.join(MIGRATION_LOCK).display());
return unmoved();
}
}
}
// The lock file stays (removing it would race a process about to lock it); the OS releases
// the lock when `lock` is dropped or the process ends.
let used = adopt_locked(legacy, dir);
if used == legacy {
// This process writes its autosaves to the old folder: it keeps the lock for as long as it
// runs, so no other launch moves (and removes) the folder under it.
KEPT_LOCKS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(lock);
}
used
}
/// Migration locks of the old folders this process uses (see [`adopt_legacy_recovery`]), kept
/// until it ends.
static KEPT_LOCKS: std::sync::Mutex<Vec<std::fs::File>> = std::sync::Mutex::new(Vec::new());
/// The move itself, under the migration lock. First, what a process that ended mid-move left is
/// tidied: originals it set aside are put back, copies it staged are removed. An empty `legacy`
/// is then just removed. Otherwise the whole folder moves if `dir` doesn't exist yet; when both
/// exist, each autosave (`<key>.pcraft` bundle and `<key>.json` sidecar, see
/// `photocraft_format::autosave`) moves as a whole, bundle first, unless `dir` already has either
/// part, and one that stays is left in `legacy` untouched. Every move is a [`move_path`].
fn adopt_locked(legacy: &Path, dir: &Path) -> PathBuf {
if let Some(parent) = legacy.parent() {
for held in side_entries(parent, legacy, HELD) {
put_back(&held, legacy);
}
}
// Autosave parts held aside inside the old folder by a per-autosave move.
let held_parts: Vec<(PathBuf, String)> = std::fs::read_dir(legacy)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| e.file_name().to_str().and_then(|n| side_of(n, HELD)).map(|orig| (e.path(), orig.to_owned())))
.collect();
for (held, original) in held_parts {
put_back(&held, &legacy.join(original));
}
for folder in [dir.parent(), Some(dir)].into_iter().flatten() {
remove_stale_staging(folder);
}
// Another process may have finished the move while this one waited.
if !legacy.is_dir() {
return dir.to_path_buf();
}
if std::fs::remove_dir(legacy).is_ok() {
return dir.to_path_buf();
}
if !dir.exists() {
// The common case: the whole folder moves at once.
return match move_path(legacy, dir) {
Ok(()) => {
log::info!("moved crash-recovery autosaves from {} to {}", legacy.display(), dir.display());
dir.to_path_buf()
}
Err(e) => {
log::warn!("couldn't move crash-recovery autosaves from {} to {} ({e}); using the old folder", legacy.display(), dir.display());
if legacy.is_dir() { legacy.to_path_buf() } else { dir.to_path_buf() }
}
};
}
move_entries(legacy, dir);
// Only an emptied folder goes; anything that couldn't move stays for a later launch.
let _ = std::fs::remove_dir(legacy);
dir.to_path_buf()
}
/// Move each autosave of `from` into `to` (both folders): bundle first, then sidecar, skipped if
/// `to` has either part. Temp files of an interrupted write (`photocraft_format::atomic`) go
/// along too, for the recovery store's startup sweep there.
fn move_entries(from: &Path, to: &Path) {
let Ok(entries) = std::fs::read_dir(from) else { return };
let names: Vec<String> = entries.flatten().filter_map(|e| e.file_name().to_str().map(str::to_owned)).collect();
let keys: std::collections::BTreeSet<&str> = names.iter().filter_map(|n| n.strip_suffix(".pcraft").or_else(|| n.strip_suffix(".json"))).collect();
for key in keys {
let parts = [format!("{key}.pcraft"), format!("{key}.json")];
if parts.iter().any(|p| std::fs::symlink_metadata(to.join(p)).is_ok()) {
continue;
}
// A bundle that moves without its sidecar is still listed (`list_recovery` reads a
// committed bundle's manifest); a sidecar must never move without its bundle.
for part in &parts {
let src = from.join(part);
if std::fs::symlink_metadata(&src).is_ok()
&& let Err(e) = move_path(&src, &to.join(part))
{
log::warn!("couldn't move {} to {}: {e}", src.display(), to.display());
break;
}
}
}
for name in names.iter().filter(|n| n.ends_with(".pcsave.tmp")) {
let _ = move_path(&from.join(name), &to.join(name));
}
}
/// How deep [`copy_tree`] follows folders: an autosave bundle is a few levels deep.
const MAX_COPY_DEPTH: u32 = 16;
fn already_exists(path: &Path) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", path.display()))
}
/// A path beside `of`, named `.<name>.<pid>-<n><suffix>`, that no other call uses.
fn side_path(of: &Path, suffix: &str) -> std::io::Result<PathBuf> {
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let parent = of.parent().ok_or_else(|| std::io::Error::other(format!("{} has no parent folder", of.display())))?;
let name = of.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
Ok(parent.join(format!(".{name}.{}-{}{suffix}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))))
}
/// The name `side` was made from, if it is a [`side_path`] name with `suffix`.
fn side_of<'a>(side: &'a str, suffix: &str) -> Option<&'a str> {
let rest = side.strip_prefix('.')?.strip_suffix(suffix)?;
let (name, ids) = rest.rsplit_once('.')?;
let (pid, n) = ids.split_once('-')?;
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
(!name.is_empty() && digits(pid) && digits(n)).then_some(name)
}
/// The side paths with `suffix` in `folder` made from `of`.
fn side_entries(folder: &Path, of: &Path, suffix: &str) -> Vec<PathBuf> {
let Some(name) = of.file_name().and_then(|n| n.to_str()) else { return Vec::new() };
std::fs::read_dir(folder)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.file_name().to_str().and_then(|n| side_of(n, suffix)) == Some(name))
.map(|e| e.path())
.collect()
}
/// Move the file or folder `from` to `to` without ever replacing anything at `to`. On one volume
/// a folder is renamed (Windows refuses an existing target; elsewhere only an empty folder could
/// be replaced) and a file is hard-linked, which fails on an existing target, and then unlinked
/// at `from`. Otherwise (another volume, e.g. a roaming profile redirected to a network share
/// while `%LOCALAPPDATA%` stays local) it is copied ([`move_across`]).
fn move_path(from: &Path, to: &Path) -> std::io::Result<()> {
if std::fs::symlink_metadata(to).is_ok() {
return Err(already_exists(to));
}
let meta = std::fs::symlink_metadata(from)?;
let moved = if meta.is_dir() { std::fs::rename(from, to) } else { std::fs::hard_link(from, to).and_then(|()| std::fs::remove_file(from)) };
match moved {
Ok(()) => Ok(()),
Err(e) if matches!(e.kind(), std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::DirectoryNotEmpty) => Err(e),
Err(_) => move_across(from, to),
}
}
/// Move `from` to `to` by copying. `from` is first renamed aside on its own volume
/// (`.<name>.<pid>-<n>.held`, [`hold`]), so a process still writing to the old path (another
/// launch that couldn't take the lock, an older PhotoCraft) starts a new `from` there instead of
/// adding to what is copied, and nothing it writes is removed. The held original is copied to a
/// staged path ([`stage_copy`]), published without replacing anything ([`publish`]), and only
/// then removed. If the copy or publish fails, the held original is put back ([`put_back`]).
fn move_across(from: &Path, to: &Path) -> std::io::Result<()> {
let held = hold(from)?;
match stage_copy(&held, to).and_then(|staged| publish(&staged, to)) {
Ok(()) => {
let removed = if std::fs::symlink_metadata(&held)?.is_dir() { std::fs::remove_dir_all(&held) } else { std::fs::remove_file(&held) };
if let Err(e) = removed {
log::warn!("copied {} to {} but couldn't remove {}: {e}", from.display(), to.display(), held.display());
}
Ok(())
}
Err(e) => {
put_back(&held, from);
Err(e)
}
}
}
/// Rename `from` aside on its own volume (see [`move_across`]); returns where it is now.
fn hold(from: &Path) -> std::io::Result<PathBuf> {
let held = side_path(from, HELD)?;
std::fs::rename(from, &held)?;
Ok(held)
}
/// Return the held original `held` to `original`. If `original` has been made again meanwhile (a
/// writer that kept using the old path), the held folder's entries move into it without replacing
/// anything ([`move_entries`]); whatever can't move stays held, logged, and is tried again under
/// the next move's lock ([`adopt_locked`] puts back what a process that ended mid-move held).
/// Nothing held is ever removed with data in it.
fn put_back(held: &Path, original: &Path) {
if std::fs::symlink_metadata(original).is_err() && std::fs::rename(held, original).is_ok() {
return;
}
if held.is_dir() && original.is_dir() {
move_entries(held, original);
if std::fs::remove_dir(held).is_ok() {
return;
}
}
log::warn!("{} is kept aside: {} exists", held.display(), original.display());
}
/// Copy `from` to a staging path beside `to` that this call creates exclusively, so nothing
/// another process made is ever written to or cleaned up. On failure the staged copy is removed.
fn stage_copy(from: &Path, to: &Path) -> std::io::Result<PathBuf> {
let staged = side_path(to, STAGED)?;
let is_dir = std::fs::symlink_metadata(from)?.is_dir();
let copied = if is_dir { copy_tree(from, &staged, 0) } else { copy_file(from, &staged) };
if let Err(e) = copied {
// `copy_tree` and `copy_file` create `staged` exclusively, and everything under a
// fresh folder is new: `AlreadyExists` means `staged` was someone else's to keep.
if e.kind() != std::io::ErrorKind::AlreadyExists {
let _ = if is_dir { std::fs::remove_dir_all(&staged) } else { std::fs::remove_file(&staged) };
}
return Err(e);
}
Ok(staged)
}
/// Remove copies a process left staged in `folder` when it ended mid-move: disposable, since the
/// original is still in place or held aside. Called only under the migration lock, which every
/// staging happens under. Links are never followed.
fn remove_stale_staging(folder: &Path) {
let Ok(entries) = std::fs::read_dir(folder) else { return };
for entry in entries.flatten() {
if !entry.file_name().to_str().is_some_and(|n| side_of(n, STAGED).is_some()) {
continue;
}
let removed = match entry.file_type() {
Ok(t) if t.is_dir() => std::fs::remove_dir_all(entry.path()),
Ok(t) if t.is_file() => std::fs::remove_file(entry.path()),
_ => continue,
};
if let Err(e) = removed {
log::warn!("couldn't remove the stale staged copy {}: {e}", entry.path().display());
}
}
}
/// Give the staged copy its final name `to` without replacing anything there (see [`move_path`]
/// for the folder and file cases). On failure the staged copy is removed and `to` is untouched.
fn publish(staged: &Path, to: &Path) -> std::io::Result<()> {
let is_dir = std::fs::symlink_metadata(staged)?.is_dir();
let published = if std::fs::symlink_metadata(to).is_ok() {
Err(already_exists(to))
} else if is_dir {
std::fs::rename(staged, to)
} else {
std::fs::hard_link(staged, to).and_then(|()| std::fs::remove_file(staged))
};
if published.is_err() {
let _ = if is_dir { std::fs::remove_dir_all(staged) } else { std::fs::remove_file(staged) };
}
published
}
/// Copy the folder `from` to the new folder `to` (created here; an existing `to` is an error):
/// plain files and folders only (a link or other special file is refused), at most
/// [`MAX_COPY_DEPTH`] levels.
fn copy_tree(from: &Path, to: &Path, depth: u32) -> std::io::Result<()> {
if depth > MAX_COPY_DEPTH {
return Err(std::io::Error::other(format!("{} is nested too deep", from.display())));
}
std::fs::create_dir(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let target = to.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_tree(&entry.path(), &target, depth.saturating_add(1))?;
} else {
copy_file(&entry.path(), &target)?;
}
}
Ok(())
}
/// Copy one plain file (a link or other special file is refused) to the new file `to` (an
/// existing `to` is an error), flushed to disk.
fn copy_file(from: &Path, to: &Path) -> std::io::Result<()> {
if !std::fs::symlink_metadata(from)?.file_type().is_file() {
return Err(std::io::Error::other(format!("{} is not a plain file", from.display())));
}
let mut src = std::fs::File::open(from)?;
let mut dst = std::fs::OpenOptions::new().write(true).create_new(true).open(to)?;
std::io::copy(&mut src, &mut dst)?;
dst.sync_all()
}
/// Resolve the data directory from the environment (`env`) and the executable's folder.
pub fn resolve(env: impl Fn(&str) -> Option<OsString>, exe_dir: Option<&Path>) -> DataDir {
if let Some(d) = env("PHOTOCRAFT_CONFIG_DIR").filter(|d| !d.is_empty()) {
@@ -223,6 +599,300 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
/// #5648: on Windows, autosaves go to the machine-local `%LOCALAPPDATA%`, not the roaming
/// profile; an override or portable folder keeps them inside itself.
#[test]
fn autosaves_stay_out_of_the_roaming_profile() {
let root = temp("recovery-local");
let settings = root.join("appdata").join("Photocraft");
let local = root.join("local");
let env = env_of(&[("LOCALAPPDATA", &local)]);
let platform = DataDir { dir: Some(settings.clone()), mode: Mode::Platform, warning: None };
let expected = if cfg!(windows) { local.join("Photocraft").join("Recovery") } else { settings.join("Recovery") };
assert_eq!(resolve_recovery(&platform, &env), Some(expected));
for mode in [Mode::Override, Mode::Portable] {
let d = DataDir { dir: Some(settings.clone()), mode, warning: None };
assert_eq!(resolve_recovery(&d, &env), Some(settings.join("Recovery")), "{mode:?}");
}
// No (or an empty) LOCALAPPDATA: beside the settings, as before.
let empty = PathBuf::new();
assert_eq!(resolve_recovery(&platform, env_of(&[("LOCALAPPDATA", &empty)])), Some(settings.join("Recovery")));
assert_eq!(resolve_recovery(&platform, |_| None), Some(settings.join("Recovery")));
let nowhere = DataDir { dir: None, mode: Mode::Platform, warning: None };
assert_eq!(resolve_recovery(&nowhere, |_| None), None);
let _ = std::fs::remove_dir_all(&root);
}
/// An autosave bundle and its sidecar, as `format::Autosaver` writes them.
fn autosave(dir: &Path, key: &str) {
std::fs::create_dir_all(dir.join(format!("{key}.pcraft"))).unwrap();
std::fs::write(dir.join(format!("{key}.pcraft")).join("manifest.json"), key).unwrap();
std::fs::write(dir.join(format!("{key}.json")), key).unwrap();
}
#[test]
fn autosaves_from_the_old_folder_move_to_the_new_one() {
let root = temp("recovery-move");
let legacy = root.join("roaming").join("Recovery");
let dir = root.join("local").join("Photocraft").join("Recovery");
autosave(&legacy, "a");
assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir);
assert!(!legacy.exists(), "the old folder is gone");
assert_eq!(std::fs::read_to_string(dir.join("a.pcraft").join("manifest.json")).unwrap(), "a");
assert!(dir.join("a.json").is_file());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn autosaves_merge_into_an_existing_folder_without_overwriting() {
let root = temp("recovery-merge");
let legacy = root.join("roaming").join("Recovery");
let dir = root.join("local").join("Recovery");
autosave(&legacy, "old");
autosave(&legacy, "both");
// A temp file of an interrupted write moves along, for the recovery store's sweep.
let temp_file = ".old.json.4294967294-0.pcsave.tmp";
std::fs::write(legacy.join(temp_file), b"partial").unwrap();
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("both.json"), "newer").unwrap();
assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir);
assert!(dir.join(temp_file).is_file() && !legacy.join(temp_file).exists());
assert!(dir.join("old.pcraft").is_dir() && dir.join("old.json").is_file(), "a whole autosave moves");
assert_eq!(std::fs::read_to_string(dir.join("both.json")).unwrap(), "newer", "never overwritten");
assert!(!dir.join("both.pcraft").exists(), "a clashing autosave isn't split");
assert!(legacy.join("both.pcraft").is_dir() && legacy.join("both.json").is_file(), "it stays behind whole");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn autosaves_that_cannot_move_keep_being_used_where_they_are() {
let root = temp("recovery-stuck");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "a");
// A file where the new folder's parent should be: nothing can be created there.
std::fs::write(root.join("blocked"), b"in the way").unwrap();
let dir = root.join("blocked").join("Recovery");
assert_eq!(adopt_legacy_recovery(&legacy, &dir), legacy);
assert!(legacy.join("a.json").is_file(), "nothing lost");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn an_empty_old_folder_is_removed_even_where_it_cannot_move() {
let root = temp("recovery-empty");
let legacy = root.join("roaming").join("Recovery");
std::fs::create_dir_all(&legacy).unwrap();
std::fs::write(root.join("blocked"), b"in the way").unwrap();
let dir = root.join("blocked").join("Recovery");
assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir, "nothing to keep listed");
assert!(!legacy.exists(), "the empty old folder is gone");
let _ = std::fs::remove_dir_all(&root);
}
/// Where a rename can't cross volumes (a roaming profile on a network share), an autosave
/// is copied whole, nested folders included, and only then removed.
#[test]
fn a_move_across_volumes_copies_then_removes() {
let root = temp("recovery-copy");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "a");
std::fs::create_dir_all(legacy.join("a.pcraft").join("tiles")).unwrap();
std::fs::write(legacy.join("a.pcraft").join("tiles").join("t0.zst"), b"tile").unwrap();
let dir = root.join("local").join("Recovery");
std::fs::create_dir_all(root.join("local")).unwrap();
move_across(&legacy, &dir).unwrap();
assert!(!legacy.exists(), "the original is removed after the copy");
assert_eq!(side_leftovers(&root.join("roaming")), 0, "nothing is left held aside");
assert_eq!(std::fs::read_to_string(dir.join("a.pcraft").join("manifest.json")).unwrap(), "a");
assert_eq!(std::fs::read(dir.join("a.pcraft").join("tiles").join("t0.zst")).unwrap(), b"tile");
assert_eq!(std::fs::read_to_string(dir.join("a.json")).unwrap(), "a");
// One file on its own, as the per-autosave moves do.
move_across(&dir.join("a.json"), &root.join("a.json")).unwrap();
assert!(!dir.join("a.json").exists() && root.join("a.json").is_file());
assert_eq!(side_leftovers(&dir) + side_leftovers(&root), 0);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_failed_copy_puts_the_original_back_and_leaves_no_partial_copy() {
let root = temp("recovery-copy-fail");
let legacy = root.join("Recovery");
autosave(&legacy, "a");
// Deeper than any bundle: the copy gives up part way.
let mut deep = legacy.join("a.pcraft");
for _ in 0..=MAX_COPY_DEPTH {
deep = deep.join("d");
}
std::fs::create_dir_all(&deep).unwrap();
let dir = root.join("Local");
assert!(move_across(&legacy, &dir).is_err());
assert!(!dir.exists(), "nothing is published");
assert_eq!(side_leftovers(&root), 0, "no staged copy, nothing left held aside");
assert!(legacy.join("a.json").is_file() && deep.is_dir(), "the original is back in place");
// A target that already exists is never copied into or removed.
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("keep"), b"mine").unwrap();
assert!(move_across(&legacy.join("a.json"), &dir).is_err());
assert_eq!(std::fs::read(dir.join("keep")).unwrap(), b"mine");
assert!(legacy.join("a.json").is_file());
assert_eq!(side_leftovers(&root) + side_leftovers(&legacy), 0);
let _ = std::fs::remove_dir_all(&root);
}
/// Staged (`.moving`) and held (`.held`) side paths left in `dir`.
fn side_leftovers(dir: &Path) -> usize {
std::fs::read_dir(dir)
.unwrap()
.flatten()
.filter(|e| e.file_name().to_str().is_some_and(|n| side_of(n, STAGED).is_some() || side_of(n, HELD).is_some()))
.count()
}
/// The target appears after the copy is staged (another process got there first): publishing
/// fails, that process's target keeps its contents, the staged copy is removed and the
/// original is left as it was. Folder and file alike.
#[test]
fn a_target_that_appears_while_copying_is_never_replaced_or_removed() {
let root = temp("recovery-race");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "a");
let local = root.join("local");
std::fs::create_dir_all(&local).unwrap();
// A whole folder.
let dir = local.join("Recovery");
let staged = stage_copy(&legacy, &dir).unwrap();
autosave(&dir, "theirs");
let e = publish(&staged, &dir).unwrap_err();
assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists);
assert_eq!(std::fs::read_to_string(dir.join("theirs.json")).unwrap(), "theirs", "their folder is intact");
assert!(!dir.join("a.json").exists(), "nothing was copied into it");
assert!(!staged.exists() && side_leftovers(&local) == 0, "only the staged copy is removed");
assert!(legacy.join("a.json").is_file(), "the original is untouched");
// One file.
let to = dir.join("a.json");
let staged = stage_copy(&legacy.join("a.json"), &to).unwrap();
std::fs::write(&to, "theirs").unwrap();
assert_eq!(publish(&staged, &to).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists);
assert_eq!(std::fs::read_to_string(&to).unwrap(), "theirs");
assert!(!staged.exists() && legacy.join("a.json").is_file());
let _ = std::fs::remove_dir_all(&root);
}
/// A process holds the lock and is copying the old folder (held aside, copy staged) when a
/// second launch gives up waiting. The second launch must get the new folder, never the old
/// one, and what it writes there survives the first one finishing: the publish refuses the
/// now-existing folder and the original is put back, so both autosaves stay (#6412 review).
#[test]
fn a_launch_that_times_out_never_writes_where_a_move_removes() {
let root = temp("recovery-timeout");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "old");
let local = root.join("local");
std::fs::create_dir_all(&local).unwrap();
let dir = local.join("Recovery");
// First process: takes the lock, sets the original aside and stages its copy, then pauses.
let first = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(local.join(MIGRATION_LOCK)).unwrap();
first.lock().unwrap();
let held = hold(&legacy).unwrap();
let staged = stage_copy(&held, &dir).unwrap();
// Second launch: times out and writes a newer autosave where it was told to.
let chosen = adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO);
assert_eq!(chosen, dir, "a launch that couldn't get the lock never uses the old folder");
autosave(&chosen, "new");
// First process resumes: publish, and on failure put the original back (as `move_across`).
assert_eq!(publish(&staged, &dir).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists);
put_back(&held, &legacy);
drop(first);
assert_eq!(std::fs::read_to_string(dir.join("new.json")).unwrap(), "new", "the newer autosave survives");
assert_eq!(std::fs::read_to_string(legacy.join("old.json")).unwrap(), "old", "the old one is back in place");
assert_eq!(side_leftovers(&local) + side_leftovers(&root.join("roaming")), 0);
// The next launch merges them.
assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
assert!(dir.join("old.json").is_file() && dir.join("new.json").is_file() && !legacy.exists());
let _ = std::fs::remove_dir_all(&root);
}
/// A writer that still uses the old path while it is being copied (an older PhotoCraft)
/// starts a new folder there; the move removes only the original it held aside.
#[test]
fn a_write_to_the_old_path_during_a_copy_is_not_removed() {
let root = temp("recovery-old-writer");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "old");
let local = root.join("local");
std::fs::create_dir_all(&local).unwrap();
let dir = local.join("Recovery");
let held = hold(&legacy).unwrap();
let staged = stage_copy(&held, &dir).unwrap();
autosave(&legacy, "new");
publish(&staged, &dir).unwrap();
std::fs::remove_dir_all(&held).unwrap();
assert_eq!(std::fs::read_to_string(dir.join("old.json")).unwrap(), "old");
assert_eq!(std::fs::read_to_string(legacy.join("new.json")).unwrap(), "new", "the new write is untouched");
let _ = std::fs::remove_dir_all(&root);
}
/// What a process killed mid-move left is tidied at the next move: a held original is put
/// back (it may be the only copy) and moved, a staged copy is removed; other dot-files and
/// look-alike names are not touched.
#[test]
fn a_killed_move_is_tidied_next_time() {
let root = temp("recovery-stale");
let roaming = root.join("roaming");
let legacy = roaming.join("Recovery");
autosave(&legacy, "a");
let held = hold(&legacy).unwrap();
assert!(!legacy.exists() && held.is_dir());
let local = root.join("local");
let dir = local.join("Recovery");
let stale = local.join(".Recovery.4294967294-3.moving");
autosave(&stale, "half");
let kept = [local.join(".Recovery.moving"), local.join(".keep.1-x.moving"), local.join("notes.txt")];
for k in &kept {
std::fs::write(k, b"x").unwrap();
}
assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
assert_eq!(std::fs::read_to_string(dir.join("a.json")).unwrap(), "a", "the held original was put back and moved");
assert!(!stale.exists(), "the stale staged copy is gone");
assert_eq!(side_leftovers(&roaming), 0);
assert!(kept.iter().all(|k| k.is_file()), "only side names are touched");
assert_eq!(side_of(".a.json.12-0.moving", STAGED), Some("a.json"));
assert!(side_of("a.json.12-0.moving", STAGED).is_none() && side_of(".a.json.-0.moving", STAGED).is_none());
assert!(side_of(".a.json.12-0.held", STAGED).is_none() && side_of(".a.json.12-0.held", HELD) == Some("a.json"));
let _ = std::fs::remove_dir_all(&root);
}
/// While another process holds the move lock, a launch moves nothing and uses the new folder;
/// once the lock is released, the move happens.
#[test]
fn a_move_waits_for_another_process_holding_the_lock() {
let root = temp("recovery-lock");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "a");
let dir = root.join("local").join("Recovery");
std::fs::create_dir_all(root.join("local")).unwrap();
let held = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(root.join("local").join(MIGRATION_LOCK)).unwrap();
held.lock().unwrap();
assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
assert!(legacy.join("a.json").is_file() && !dir.exists(), "nothing moved under another's lock");
drop(held);
assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
assert!(dir.join("a.json").is_file() && !legacy.exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn no_old_folder_or_the_same_folder_changes_nothing() {
let root = temp("recovery-noop");
let dir = root.join("Recovery");
assert_eq!(adopt_legacy_recovery(&root.join("missing"), &dir), dir);
assert!(!dir.exists(), "nothing is created up front");
autosave(&dir, "a");
assert_eq!(adopt_legacy_recovery(&dir, &dir), dir);
assert!(dir.join("a.json").is_file());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn missing_exe_dir_or_env_never_panics() {
let d = resolve(|_| None, None);
+35
View File
@@ -26,6 +26,25 @@ pub fn window_icon() -> egui::IconData {
}
}
/// Whether the window gets [`window_icon`] at runtime: everywhere except inside a macOS app
/// bundle. There the Dock shows the bundle's `PhotoCraft.icns` (`CFBundleIconFile`), which macOS
/// renders in its own style (Liquid Glass and its tinted and dark variants); a runtime PNG
/// replaced it with a flat picture (#5849). `exe` is the running executable.
pub fn sets_runtime_icon(exe: Option<&std::path::Path>) -> bool {
!(cfg!(target_os = "macos") && exe.is_some_and(in_app_bundle))
}
/// Whether `exe` is an app bundle's executable: `…/Name.app/Contents/MacOS/binary`.
fn in_app_bundle(exe: &std::path::Path) -> bool {
let name = |p: Option<&std::path::Path>| p.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned());
let macos = exe.parent();
let contents = macos.and_then(std::path::Path::parent);
let bundle = contents.and_then(std::path::Path::parent);
name(macos).as_deref() == Some("MacOS")
&& name(contents).as_deref() == Some("Contents")
&& name(bundle).is_some_and(|n| n.len() > 4 && n.to_ascii_lowercase().ends_with(".app"))
}
#[cfg(target_os = "macos")]
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
#[cfg(not(target_os = "macos"))]
@@ -45,6 +64,22 @@ mod tests {
u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize
}
/// #5849: a macOS app bundle keeps its .icns in the Dock; unbundled runs (cargo run, the
/// Linux and Windows builds) still set the runtime icon.
#[test]
fn only_a_macos_app_bundle_skips_the_runtime_icon() {
use std::path::Path;
assert!(in_app_bundle(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft")));
assert!(in_app_bundle(Path::new("/Users/a/Downloads/PhotoCraft Beta.APP/Contents/MacOS/photocraft")));
assert!(!in_app_bundle(Path::new("/Users/a/src/photocraft/target/release/photocraft")));
assert!(!in_app_bundle(Path::new("/opt/Contents/MacOS/photocraft")), "not inside a .app");
assert!(!in_app_bundle(Path::new("/.app/Contents/MacOS/photocraft")), "a bare `.app` is no bundle name");
assert!(!in_app_bundle(Path::new("photocraft")));
assert!(sets_runtime_icon(None));
assert!(sets_runtime_icon(Some(Path::new("/usr/bin/photocraft"))));
assert_eq!(sets_runtime_icon(Some(Path::new("/Applications/PhotoCraft.app/Contents/MacOS/photocraft"))), !cfg!(target_os = "macos"));
}
#[test]
fn window_icon_decodes() {
let icon = window_icon();
+41 -4
View File
@@ -5,11 +5,12 @@ use std::io::{BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Instant;
use std::time::{Duration, Instant};
use photocraft_automation::budgets::write_reply;
use photocraft_automation::security::{
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply,
AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream,
read_bounded_line, repeat_auth_reply,
};
use photocraft_ui_egui::ControlRequest;
use photocraft_ui_egui::control::REPLY_DEADLINE;
@@ -47,11 +48,17 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRe
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE);
}
/// [`serve`] for a connection that must complete `auth` within `auth_deadline` of connecting,
/// however much it sends meanwhile (#4965); after that only the idle timeout applies.
fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, auth_deadline: Duration) {
if configure_stream(&stream).is_err() {
return;
}
let Ok(read) = stream.try_clone() else { return };
let mut reader = BufReader::new(read);
let mut reader = BufReader::new(AuthDeadlineStream::new(read, auth_deadline));
let mut out = stream;
let mut line = String::new();
let mut authenticated = false;
@@ -74,6 +81,9 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
if !authenticated {
let (reply, ok) = authentication_reply(&line, token);
authenticated = ok;
if ok && reader.get_mut().authenticated().is_err() {
break;
}
if write_reply(&mut out, &reply).is_err() || out.flush().is_err() || !authenticated {
break;
}
@@ -120,7 +130,34 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
mod tests {
use super::*;
use std::io::BufRead;
use std::time::Duration;
/// #4965: a connection that keeps sending without authenticating is closed at the deadline
/// (releasing its connection permit), and nothing it sent is dispatched.
#[test]
fn an_unauthenticated_connection_is_closed_at_the_deadline_whatever_it_sends() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let server = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let started = Instant::now();
serve_with_auth_deadline(stream, "unused", tx, egui::Context::default(), Duration::from_millis(300));
started.elapsed()
});
// Blank lines and partial frames, one byte every 20 ms: each read succeeds long before
// the idle timeout.
let mut stream = TcpStream::connect(addr).unwrap();
for i in 0..150 {
let byte: &[u8] = if i % 5 == 0 { b"\n" } else { b" " };
if stream.write_all(byte).is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
let served = server.join().unwrap();
assert!(served < Duration::from_secs(3), "the trickle did not extend the deadline: {served:?}");
assert!(rx.try_recv().is_err(), "nothing was dispatched");
}
#[test]
fn oversized_reply_preserves_framing_id_and_the_next_control_request() {
+42 -2
View File
@@ -233,9 +233,10 @@ pub struct Candidate {
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
/// Intel on Windows. For `cpu`, any GL adapter comes before Vulkan, which is only the fallback
/// for presenting the window (see [`backends`]).
/// for presenting the window (see [`backends`]). On Windows GL comes after every other adapter,
/// WARP included: GL there (VMware's SVGA3D, GL-only old GPUs) drew a black window (#5191, #1788).
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8, u8) {
let fallback = u8::from(backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan);
let fallback = u8::from((backend == GpuBackend::Cpu && a.backend == wgpu::Backend::Vulkan) || (os == Os::Windows && a.backend == wgpu::Backend::Gl));
let ty = match a.device_type {
wgpu::DeviceType::DiscreteGpu => 0,
wgpu::DeviceType::IntegratedGpu => 1,
@@ -442,6 +443,14 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin
.unwrap_or_default()
}
/// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless
/// turned on), read before the window opens: it picks the surface's present mode.
pub fn read_low_latency(path: Option<&Path>) -> bool {
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
let default = photocraft_engine::prefs::Preferences::default().performance.low_latency_drawing;
v.get("performance").and_then(|p| p.get("lowLatencyDrawing")).and_then(Value::as_bool).unwrap_or(default)
}
/// Read the policy leniently at startup, including old settings without renderingMode.
pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) {
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
@@ -462,6 +471,23 @@ mod tests {
Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() }
}
#[test]
fn low_latency_drawing_is_read_before_the_window_opens_and_defaults_off() {
let dir = std::env::temp_dir().join(format!("photocraft-low-latency-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("preferences.json");
let read = |json: &str| {
std::fs::write(&file, json).unwrap();
read_low_latency(Some(&file))
};
assert!(read(r#"{"performance":{"lowLatencyDrawing":true}}"#));
assert!(!read(r#"{"performance":{"lowLatencyDrawing":false}}"#));
assert!(!read(r#"{"performance":{"lowLatencyDrawing":"nope"}}"#), "garbage keeps the default (off)");
assert!(!read("not json"));
assert!(!read_low_latency(None), "no settings file: the default (off)");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn display_server_preference_is_read_before_the_window_opens() {
use photocraft_engine::prefs::LinuxDisplayServer;
@@ -635,6 +661,20 @@ mod tests {
assert_eq!(pick(&[nvidia_gl, nvidia_vk], Auto, Os::Other), Some(1));
}
/// #5191, #1788: a Windows VM offers VMware's SVGA3D over GL (`Other`) and WARP (`Cpu`, DX12).
/// GL drew a black window; WARP works. GL stays the last resort.
#[test]
fn windows_auto_prefers_warp_over_gl() {
use wgpu::{Backend as B, DeviceType as T};
let svga_gl = cand(0x15ad, T::Other, B::Gl);
let warp = cand(0x1414, T::Cpu, B::Dx12);
assert_eq!(pick(&[svga_gl, warp], Auto, Os::Windows), Some(1));
assert_eq!(pick(&[cand(INTEL, T::IntegratedGpu, B::Gl), warp], Auto, Os::Windows), Some(1));
assert_eq!(pick(&[svga_gl], Auto, Os::Windows), Some(0));
// Elsewhere GL keeps its rank.
assert_eq!(pick(&[svga_gl, warp], Auto, Os::Other), Some(0));
}
#[test]
fn adapters_must_present_even_when_none_support_the_surface() {
let mut a = cand(INTEL, wgpu::DeviceType::IntegratedGpu, wgpu::Backend::Dx12);
+135 -8
View File
@@ -1,8 +1,18 @@
//! Photocraft desktop app.
//! PhotoCraft desktop app.
//!
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
//! --control-token-file <path>] [--automation-read-root <dir>]
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [files…]`
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [--present-mode <mode>]
//! [--frame-latency <n>] [--stroke-trace <stderr|file.csv>] [--new-instance] [files…]`
//!
//! On Windows and Linux a launch hands its files to the PhotoCraft that is already running (new
//! tabs in its window) and exits; `--new-instance` starts a separate copy instead, as does any
//! `--control` launch (see `single_instance`).
//!
//! `--present-mode` (`fifo`, `autovsync`, `novsync`) and `--frame-latency` (1–3) override how
//! frames reach the display for this launch (`PHOTOCRAFT_PRESENT_MODE` / `…_FRAME_LATENCY`;
//! `presentation`); `--stroke-trace` times each brush stroke's frames (`PHOTOCRAFT_STROKE_TRACE`;
//! `photocraft_ui_egui::stroke_trace`).
//!
//! `--safe-gpu` starts with the CPU renderer (no GPU canvas; a software adapter for the window
//! where the platform has one) for this launch, e.g. after a graphics driver crash. A start that
@@ -44,8 +54,15 @@ mod linux_libs;
mod logging;
mod monitor_profile;
mod photoshop_settings;
// Save As through the desktop portal, which reports the file type picked (#6121).
#[cfg(target_os = "linux")]
mod portal_save;
mod presentation;
mod screen_color;
mod services;
// macOS gets one instance from LaunchServices (open-documents Apple events).
#[cfg(any(not(target_os = "macos"), test))]
mod single_instance;
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
#[cfg(any(target_os = "macos", target_os = "linux", test))]
mod tablet;
@@ -78,9 +95,11 @@ fn custom_titlebar(prefs_file: Option<&std::path::Path>) -> bool {
/// different offset every launch (#419). A later launch restores the saved position instead
/// (`main` turns `centered` off, #4826). Wayland compositors place windows themselves.
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
let viewport = egui::ViewportBuilder::default();
// Not inside a macOS app bundle: its Dock icon is the bundle's .icns (`app_icon`, #5849).
let viewport = if app_icon::sets_runtime_icon(std::env::current_exe().ok().as_deref()) { viewport.with_icon(app_icon::window_icon()) } else { viewport };
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_icon(app_icon::window_icon())
viewport: viewport
.with_app_id(APP_ID)
.with_title("PhotoCraft")
.with_inner_size([1440.0, 900.0])
@@ -189,6 +208,10 @@ fn main() -> eframe::Result {
// reported in a notice once the app is up; a control token is a usage error like a bad port.
let mut unreadable_paths: Vec<String> = Vec::new();
let mut safe_gpu = false;
let mut new_instance = false;
// Options that apply only to the process they're given to; a launch with any keeps its own.
let mut launch_options: Vec<&'static str> = Vec::new();
let (mut present_mode_arg, mut frame_latency_arg, mut stroke_trace_arg): (Option<String>, Option<String>, Option<String>) = (None, None, None);
let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args_os().skip(1);
while let Some(a) = args.next() {
@@ -206,10 +229,40 @@ fn main() -> eframe::Result {
None => control_token = None,
},
Some("--control-token-file") => control_token_file = args.next().map(std::path::PathBuf::from),
Some("--automation-read-root") => automation_read_root = args.next().map(std::path::PathBuf::from),
Some("--automation-write-root") => automation_write_root = args.next().map(std::path::PathBuf::from),
Some("--safe-gpu") => safe_gpu = true,
Some("--in-window-menus") => in_window_menus = true,
Some("--automation-read-root") => {
launch_options.push("--automation-read-root");
automation_read_root = args.next().map(std::path::PathBuf::from);
}
Some("--automation-write-root") => {
launch_options.push("--automation-write-root");
automation_write_root = args.next().map(std::path::PathBuf::from);
}
Some("--safe-gpu") => {
launch_options.push("--safe-gpu");
safe_gpu = true;
}
Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => {
launch_options.push(match flag {
"--present-mode" => "--present-mode",
"--frame-latency" => "--frame-latency",
_ => "--stroke-trace",
});
let flag = flag.to_string();
match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(v)) => match flag.as_str() {
"--present-mode" => present_mode_arg = Some(v),
"--frame-latency" => frame_latency_arg = Some(v),
_ => stroke_trace_arg = Some(v),
},
// A bad value is reported and ignored; it never fails the launch.
_ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"),
}
}
Some("--in-window-menus") => {
launch_options.push("--in-window-menus");
in_window_menus = true;
}
Some("--new-instance") => new_instance = true,
Some("--version") => {
println!("photocraft {}", photocraft_engine::build_info::long_version());
return Ok(());
@@ -241,6 +294,48 @@ fn main() -> eframe::Result {
}
}
// One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs
// become tabs in its window. Only with the per-user settings folder (a portable copy's may be
// shared between users), and not for automation, `--new-instance` or per-process options.
#[cfg(not(target_os = "macos"))]
let single_instance = {
let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok();
let context = single_instance::LaunchContext {
per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform,
new_instance_flag: new_instance,
new_instance_env: new_instance_env.as_deref(),
control: control_port.is_some(),
unreadable_paths: !unreadable_paths.is_empty(),
launch_options: &launch_options,
};
match (single_instance::skip_reason(&context), services::config_dir()) {
(Some(reason), _) => {
log::info!("running as a separate PhotoCraft: {reason}");
None
}
(None, None) => None,
(None, Some(dir)) => {
let paths: Vec<String> = files.iter().map(|f| single_instance::absolute(f)).collect();
match single_instance::claim(&dir, &paths) {
single_instance::Launch::Forwarded => {
log::info!("handed {} file(s) to the running PhotoCraft", paths.len());
return Ok(());
}
single_instance::Launch::Primary(primary) => Some(primary),
single_instance::Launch::Standalone(reason) => {
log::warn!("starting a separate PhotoCraft: {reason}");
None
}
}
}
}
};
// Lives until the event loop returns, then `shutdown` lets the next launch become the primary.
#[cfg(not(target_os = "macos"))]
let single_instance = &single_instance;
#[cfg(target_os = "macos")]
let _ = (new_instance, &launch_options);
// 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")]
@@ -362,6 +457,21 @@ fn main() -> eframe::Result {
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
// Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about
// 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers.
if gpu_startup::read_low_latency(services::prefs_file().as_deref()) {
options.wgpu_options.surface.present_mode = eframe::wgpu::PresentMode::AutoNoVsync;
}
// Testers' present mode and frame latency overrides (#1343), after the preference.
let present = presentation::Overrides::from_args_or_env(present_mode_arg.as_deref(), frame_latency_arg.as_deref());
options.wgpu_options.surface = present.apply(options.wgpu_options.surface);
let present_desc = presentation::describe(&options.wgpu_options.surface, &present);
// Without vsync, frames are capped at 240 fps (#4472) so repaint loops don't spin flat out.
let uncapped = !matches!(
options.wgpu_options.surface.present_mode,
eframe::wgpu::PresentMode::Fifo | eframe::wgpu::PresentMode::FifoRelaxed | eframe::wgpu::PresentMode::AutoVsync
);
log::info!("presentation: {present_desc}");
let sentinel_ms = t_sentinel.elapsed().as_secs_f64() * 1000.0;
log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan);
let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu;
@@ -390,6 +500,7 @@ fn main() -> eframe::Result {
}
}
let mut app = PhotocraftApp::new(Session::new(), services);
app.min_frame_ms = uncapped.then_some(1000.0 / 240.0);
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = custom_titlebar;
// Only the title bar's free gap drags the window, never the menus (mac_window.rs).
@@ -421,6 +532,10 @@ fn main() -> eframe::Result {
// composites on the CPU).
let info = &mut app.perf.gpu_info;
info.preference = pref.name().to_string();
info.present = present_desc.clone();
if let Some(v) = &stroke_trace_arg {
app.stroke_trace.set(v);
}
info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() };
info.fallback = match (plan.reason.clone(), gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()) {
(Some(reason), Some(note)) => Some(format!("{reason}. {note}")),
@@ -483,6 +598,11 @@ fn main() -> eframe::Result {
}
#[cfg(not(target_os = "macos"))]
let _ = in_window_menus;
// Files from later launches (Explorer, file managers) open here as new tabs.
#[cfg(not(target_os = "macos"))]
if let Some(primary) = single_instance {
app.services.os_events = Some(primary.connect(&cc.egui_ctx));
}
// Where file drags and drops are (winit 0.30 doesn't say).
app.services.cursor_pos = cursor::service(cc);
// The custom title bar's undecorated window must stay borderless, or its caption and
@@ -520,6 +640,13 @@ fn main() -> eframe::Result {
Ok(Box::new(app))
}),
);
// The window is gone: refuse further files and free the lock, so a launch that is waiting (or
// the CPU-compatibility relaunch below) becomes the primary instead of handing its files to a
// process that is exiting.
#[cfg(not(target_os = "macos"))]
if let Some(primary) = single_instance {
primary.shutdown();
}
// Closed or failed outside graphics initialization: not a driver crash. A renderer error
// keeps the marker, so the next start tries a safer backend, unless there is no safer one:
// a failed CPU start would otherwise pin every later start to CPU.
+183
View File
@@ -0,0 +1,183 @@
//! File › Save As through the XDG desktop portal on Linux (#6121, #4328, #4394).
//!
//! The GTK file chooser behind the portal keeps the typed name when the user picks another file
//! type, and rfd 0.17 reads only the file name from the portal's answer, so `image.psd` with PNG
//! picked came back as `image.psd` and was written as a PSD. The answer does say which type was
//! picked (`current_filter`), so Save As asks the portal itself and gives a name the dialog left
//! alone that type's extension ([`crate::services::with_chosen_type`]). rfd stays the fallback
//! (zenity, or no portal), and this can go once an rfd release reads the picked type
//! (PolyMeilex/rfd#341).
//!
//! The dialog is attached to the window on X11. On Wayland it has no parent: naming the window to
//! the portal needs an xdg-foreign handle, which takes `unsafe` code the workspace forbids.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
/// One file type in the dialog: its label and extensions (`("PNG", ["png"])`).
pub type Filter = (String, Vec<String>);
/// A Save dialog to show.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SaveRequest {
/// The portal's parent window identifier (`x11:<xid>`), or empty.
pub parent: String,
/// The name the dialog shows: the stem of the suggested file (`test`), see
/// `services::dialog_file_name`.
pub name: Option<String>,
/// The suggested file's name (`test.psd`): a name that comes back with its extension still
/// takes the extension of the type picked ([`crate::services::with_chosen_type`]).
pub suggested: Option<String>,
pub folder: Option<PathBuf>,
pub filters: Vec<Filter>,
/// The filter the dialog starts on.
pub current: Option<usize>,
}
/// What the user chose: the path, as typed, and the label of the file type picked.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Saved {
pub path: PathBuf,
pub filter: Option<String>,
}
/// The portal's parent window identifier for `window`: `x11:<xid>` on X11, empty elsewhere.
pub fn parent_id(window: Option<eframe::wgpu::rwh::RawWindowHandle>) -> String {
use eframe::wgpu::rwh::RawWindowHandle;
match window {
Some(RawWindowHandle::Xlib(h)) => format!("x11:{:x}", h.window),
Some(RawWindowHandle::Xcb(h)) => format!("x11:{:x}", h.window.get()),
_ => String::new(),
}
}
/// Shows the portal's Save dialog and waits for it. `Ok(None)` is Cancel; `Err` means the portal
/// couldn't show it (no portal, no FileChooser), so the caller falls back to rfd.
pub fn save_file(conn: &zbus::blocking::Connection, req: &SaveRequest) -> Result<Option<Saved>, String> {
use futures_lite::StreamExt;
use zbus::blocking::Proxy;
let unique = conn.unique_name().ok_or("Portal connection has no unique name")?.as_str().trim_start_matches(':').replace('.', "_");
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let token = format!("photocraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
let path = format!("/org/freedesktop/portal/desktop/request/{unique}/{token}");
// Listen before asking, so a quick answer isn't missed.
let request = Proxy::new(conn, "org.freedesktop.portal.Desktop", path.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?;
let chooser = Proxy::new(conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser")
.map_err(|e| e.to_string())?;
let handle: OwnedObjectPath = chooser.call("SaveFile", &(req.parent.as_str(), "Save As", options(req, &token))).map_err(|e| e.to_string())?;
// A portal older than 0.9 ignores `handle_token` and answers on a request path of its own,
// which it returns: listen there instead, as the Request documentation asks.
let reply = if handle.as_str() == path {
futures_lite::future::block_on(replies.next())
} else {
let request = Proxy::new(conn, "org.freedesktop.portal.Desktop", handle.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?;
futures_lite::future::block_on(replies.next())
}
.ok_or("The save dialog closed without an answer")?;
let (code, results): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
answer(code, &results)
}
/// The `SaveFile` options for `req`.
fn options<'a>(req: &'a SaveRequest, token: &'a str) -> HashMap<&'static str, Value<'a>> {
let mut o: HashMap<&'static str, Value<'a>> = HashMap::from([("handle_token", Value::from(token)), ("modal", Value::from(true))]);
if let Some(name) = &req.name {
o.insert("current_name", Value::from(name.as_str()));
}
if let Some(folder) = &req.folder {
// A byte string with its terminating NUL, as the portal specifies.
use std::os::unix::ffi::OsStrExt;
let mut bytes = folder.as_os_str().as_bytes().to_vec();
bytes.push(0);
o.insert("current_folder", Value::from(bytes));
}
let wire = |(label, exts): &Filter| (label.clone(), exts.iter().map(|e| (0u32, format!("*.{e}"))).collect::<Vec<_>>());
if !req.filters.is_empty() {
o.insert("filters", Value::from(req.filters.iter().map(wire).collect::<Vec<_>>()));
}
if let Some(f) = req.current.and_then(|i| req.filters.get(i)) {
o.insert("current_filter", Value::from(wire(f)));
}
o
}
/// Reads the portal's `Response`: code 0 with the first `uris` entry. 1 is Cancel, and 2 a dialog
/// closed some other way, such as with Esc in GNOME's: no file either, and not a reason to show
/// rfd's dialog instead (it did, so Esc opened a second Save As).
fn answer(code: u32, results: &HashMap<String, OwnedValue>) -> Result<Option<Saved>, String> {
match code {
0 => {}
1 | 2 => return Ok(None),
_ => return Err(format!("the save dialog failed (portal response {code})")),
}
let uris: Vec<String> = results
.get("uris")
.ok_or("no file in the portal's answer")?
.try_clone()
.map_err(|e| e.to_string())?
.try_into()
.map_err(|e: zbus::zvariant::Error| e.to_string())?;
let uri = uris.first().ok_or("no file in the portal's answer")?;
let path = file_uri_path(uri).ok_or_else(|| format!("not a local file: {uri}"))?;
let filter = results.get("current_filter").and_then(|v| {
let (label, _globs): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
Some(label)
});
Ok(Some(Saved { path, filter }))
}
/// `file:///home/me/A%20b.psd` → `/home/me/A b.psd`; `None` for other schemes.
fn file_uri_path(uri: &str) -> Option<PathBuf> {
let rest = uri.strip_prefix("file://")?;
// Only local files: `file:///…` or `file://localhost/…`.
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
if !rest.starts_with('/') {
return None;
}
let bytes = rest.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& let Some(b) = bytes.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok())
{
out.push(b);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
use std::os::unix::ffi::OsStringExt;
Some(PathBuf::from(std::ffi::OsString::from_vec(out)))
}
/// Save As on Linux: the portal's dialog, with the name given the extension of the file type
/// picked. When that changes the name to one that already exists, the dialog opens again under
/// the new name, so the portal asks before replacing it (it asked only about the typed name).
pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Result<Option<PathBuf>, String> {
loop {
let Some(saved) = save_file(conn, &req)? else { return Ok(None) };
let picked = saved.filter.as_deref().and_then(|label| req.filters.iter().position(|(l, _)| l == label));
// With one type offered there is nothing to pick, and a typed extension is the user's.
let exts: &[String] = match picked {
Some(i) if req.filters.len() > 1 => &req.filters[i].1,
_ => &[],
};
let path = crate::services::with_chosen_type(&saved.path, req.suggested.as_deref(), exts);
if path == saved.path || !path.exists() {
return Ok(Some(path));
}
req.name = path.file_name().map(|n| n.to_string_lossy().into_owned());
req.suggested = req.name.clone();
req.folder = path.parent().map(Path::to_path_buf);
req.current = picked;
}
}
#[cfg(test)]
mod tests;
+232
View File
@@ -0,0 +1,232 @@
use super::*;
use std::sync::{Arc, Mutex};
#[test]
fn file_uris_become_local_paths() {
assert_eq!(file_uri_path("file:///home/me/Untitled-1.psd"), Some(PathBuf::from("/home/me/Untitled-1.psd")));
assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.png"), Some(PathBuf::from("/home/me/A bé.png")));
assert_eq!(file_uri_path("file://localhost/tmp/x.png"), Some(PathBuf::from("/tmp/x.png")));
assert_eq!(file_uri_path("sftp://host/x.png"), None);
assert_eq!(file_uri_path("file://host/x.png"), None);
}
#[test]
fn x11_windows_are_named_to_the_portal() {
use eframe::wgpu::rwh::{RawWindowHandle, XlibWindowHandle};
assert_eq!(parent_id(Some(RawWindowHandle::Xlib(XlibWindowHandle::new(0x3a0000f)))), "x11:3a0000f");
assert_eq!(parent_id(None), "");
}
/// What the mock portal answers to one `SaveFile` call: the response code, and the file and
/// filter label the user "picked".
type Script = Vec<(u32, Option<(&'static str, &'static str)>)>;
/// The options of each `SaveFile` call the mock portal received: current_name and the label of
/// current_filter.
type Seen = Arc<Mutex<Vec<(Option<String>, Option<String>)>>>;
/// A private session bus (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser` that
/// answers `SaveFile` from `script`. `None` when there is no `dbus-daemon` to run.
fn mock_portal(script: Script) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
mock_portal_with(script, false)
}
/// [`mock_portal`]; with `own_paths` it is a portal older than 0.9, which ignores `handle_token`,
/// answers on a request path of its own and returns that from `SaveFile`, as a real dialog does
/// once the user is done with it (here after 300 ms).
fn mock_portal_with(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
use std::io::BufRead;
let mut daemon =
std::process::Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(std::process::Stdio::piped()).spawn().ok()?;
let mut address = String::new();
std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
let address = address.trim().to_string();
let server = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap();
let seen: Seen = Arc::default();
let log = seen.clone();
std::thread::spawn(move || {
let mut script = script.into_iter();
for msg in zbus::blocking::MessageIterator::from(&server) {
let Ok(msg) = msg else { break };
let header = msg.header();
if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") {
continue;
}
let (_parent, _title, options): (String, String, HashMap<String, OwnedValue>) = msg.body().deserialize().unwrap();
let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok());
let filter = options.get("current_filter").and_then(|v| {
let (label, _): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
Some(label)
});
log.lock().unwrap().push((text("current_name"), filter));
let sender = header.sender().unwrap().as_str().trim_start_matches(':').replace('.', "_");
let token = if own_paths { format!("old{}", log.lock().unwrap().len()) } else { text("handle_token").unwrap() };
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
server.reply(&header, &OwnedObjectPath::try_from(path.as_str()).unwrap()).unwrap();
if own_paths {
std::thread::sleep(std::time::Duration::from_millis(300));
}
let (code, pick) = script.next().unwrap_or((2, None));
let mut results: HashMap<&str, Value<'_>> = HashMap::new();
if let Some((uri, label)) = pick {
results.insert("uris", Value::from(vec![uri.to_string()]));
results.insert("current_filter", Value::from((label.to_string(), vec![(0u32, "*.x".to_string())])));
}
server
.emit_signal(
header.sender().cloned().map(zbus::names::BusName::from),
path.as_str(),
"org.freedesktop.portal.Request",
"Response",
&(code, results),
)
.unwrap();
}
});
let client = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().build().unwrap();
Some((client, seen, daemon))
}
fn request(name: &str) -> SaveRequest {
let filters = [("PSD Document", &["psd", "psb"][..]), ("PNG", &["png"]), ("JPEG", &["jpg"])];
SaveRequest {
parent: String::new(),
name: Some(name.into()),
suggested: Some(name.into()),
folder: Some(PathBuf::from("/tmp")),
filters: filters.iter().map(|(l, e)| (l.to_string(), e.iter().map(|s| s.to_string()).collect())).collect(),
current: Some(0),
}
}
/// #6121 end to end through a real D-Bus: the name comes back as typed with the type picked,
/// and Save As writes it with that type's extension.
#[test]
fn save_as_follows_the_file_type_picked_in_the_portal_dialog() {
let dir = std::env::temp_dir().join(format!("photocraft-portal-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let typed = format!("file://{}/Untitled-1.psd", dir.display());
let typed: &'static str = Box::leak(typed.into_boxed_str());
let Some((conn, seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "PNG"))), (1, None), (0, Some((typed, "PSD Document")))]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
// PNG picked with `Untitled-1.psd` still in the name: written as Untitled-1.png.
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(dir.join("Untitled-1.png")));
assert_eq!(seen.lock().unwrap()[0], (Some("Untitled-1.psd".into()), Some("PSD Document".into())), "the request carried the name and type");
// Cancel.
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), None);
// The type the name already has: unchanged.
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(dir.join("Untitled-1.psd")));
daemon.kill().ok();
std::fs::remove_dir_all(&dir).ok();
}
/// The dialog opens on the bare name (`test`, see `services::dialog_file_name`), so GTK doesn't
/// ask to replace test.psd when PNG is picked: the type picked gives the extension, and a name
/// that then exists is asked about under that name.
#[test]
fn a_bare_name_takes_the_picked_type_and_an_existing_target_is_asked_about() {
let dir = std::env::temp_dir().join(format!("photocraft-portal-bare-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("test.psd"), b"open document").unwrap();
let uri = |name: &str| -> &'static str { Box::leak(format!("file://{}/{name}", dir.display()).into_boxed_str()) };
let Some((conn, seen, mut daemon)) = mock_portal(vec![
(0, Some((uri("test"), "PNG"))),
(0, Some((uri("test.psd"), "PNG"))),
(0, Some((uri("test"), "PSD Document"))),
(0, Some((uri("test.psd"), "PSD Document"))),
]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
// As File › Save As opens it: `test` shown, `test.psd` suggested.
let opened = || SaveRequest { suggested: Some("test.psd".into()), ..request("test") };
// PNG picked: test.png, and nothing was asked about test.psd.
assert_eq!(save_as(&conn, opened()).unwrap(), Some(dir.join("test.png")));
// The suggested extension back in the name (typed again, or GTK's completion) with PNG
// picked: test.png too, not a PSD written over test.psd.
assert_eq!(save_as(&conn, opened()).unwrap(), Some(dir.join("test.png")));
// The type untouched: test.psd, which exists, so the dialog asks again as test.psd (the
// portal's replace prompt is then about the file that is written).
assert_eq!(save_as(&conn, opened()).unwrap(), Some(dir.join("test.psd")));
let seen = seen.lock().unwrap().clone();
let names: Vec<_> = seen.iter().map(|(name, _)| name.as_deref()).collect();
assert_eq!(names, [Some("test"), Some("test"), Some("test"), Some("test.psd")], "{seen:?}");
assert_eq!(seen[3].1.as_deref(), Some("PSD Document"));
daemon.kill().ok();
std::fs::remove_dir_all(&dir).ok();
}
/// GNOME's dialog closed with Esc answers 2 ("ended some other way"): no file, like Cancel, and
/// no fallback to rfd, which opened a second Save As.
#[test]
fn a_dialog_closed_some_other_way_is_a_cancel() {
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(2, None)]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
assert_eq!(save_as(&conn, request("test")), Ok(None));
daemon.kill().ok();
}
/// An extension the user typed is theirs: `art.png` with the PSD type left as the dialog opened
/// saves a PNG, as it did before the type was read.
#[test]
fn a_typed_extension_wins_over_an_untouched_file_type() {
let typed: &'static str = "file:///tmp/photocraft-none/art.png";
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "PSD Document")))]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/art.png")));
daemon.kill().ok();
}
/// A portal older than 0.9 answers on a request path of its own: the wait follows the handle
/// `SaveFile` returns, as the Request documentation asks, instead of the predicted path.
#[test]
fn the_answer_is_awaited_on_the_handle_the_portal_returns() {
let typed: &'static str = "file:///tmp/photocraft-none/Untitled-1.psd";
let Some((conn, _seen, mut daemon)) = mock_portal_with(vec![(0, Some((typed, "PNG")))], true) else {
eprintln!("no dbus-daemon: skipped");
return;
};
assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Untitled-1.png")));
daemon.kill().ok();
}
/// When the extension the type gives names a file that exists, the dialog opens again under that
/// name (so the portal asks before replacing it) instead of overwriting it unasked.
#[test]
fn a_renamed_file_that_exists_is_confirmed_in_the_dialog() {
let dir = std::env::temp_dir().join(format!("photocraft-portal-exists-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("art.png"), b"old").unwrap();
let typed: &'static str = Box::leak(format!("file://{}/art.psd", dir.display()).into_boxed_str());
let confirmed: &'static str = Box::leak(format!("file://{}/art.png", dir.display()).into_boxed_str());
let Some((conn, seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "PNG"))), (0, Some((confirmed, "PNG")))]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
assert_eq!(save_as(&conn, request("art.psd")).unwrap(), Some(dir.join("art.png")));
let seen = seen.lock().unwrap().clone();
assert_eq!(seen.len(), 2, "asked again: {seen:?}");
assert_eq!(seen[1], (Some("art.png".into()), Some("PNG".into())), "under the new name, on the picked type");
assert_eq!(std::fs::read(dir.join("art.png")).unwrap(), b"old", "nothing written by the dialog");
daemon.kill().ok();
std::fs::remove_dir_all(&dir).ok();
}
/// With only one type offered (the Swatches panel's .aco) a typed extension is kept.
#[test]
fn a_single_file_type_keeps_the_typed_extension() {
let typed: &'static str = "file:///tmp/photocraft-none/Swatches.ase";
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "Color Swatches")))]) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let req = SaveRequest { filters: vec![("Color Swatches".into(), vec!["aco".into()])], ..request("Swatches.aco") };
assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.ase")));
daemon.kill().ok();
}
+121
View File
@@ -0,0 +1,121 @@
//! Launch overrides for how frames reach the display, so testers can compare input-to-pixel
//! latency without a rebuild (#1343): `PHOTOCRAFT_PRESENT_MODE` (`fifo`, `autovsync`, or `novsync`,
//! also spelled `immediate`, `mailbox` or `autonovsync`: no vsync, wgpu's Immediate → Mailbox → Fifo
//! by what the driver offers) and `PHOTOCRAFT_FRAME_LATENCY` (`1` to `3`, frames the swap chain may
//! queue). Unset, the platform default applies. A value that isn't one of these is ignored with a
//! warning in the log, never a failed start; a mode the driver may not offer is never requested.
//! The result is logged at start and shown in the stroke trace.
use eframe::egui_wgpu::SurfaceConfig;
use eframe::wgpu::PresentMode;
/// The present mode and frame latency this launch asked for (`None`: the default).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Overrides {
pub mode: Option<PresentMode>,
pub latency: Option<u32>,
}
/// Parse the two variables' values. Returns the overrides and a warning per rejected value.
pub fn parse(mode: Option<&str>, latency: Option<&str>) -> (Overrides, Vec<String>) {
let mut warnings = Vec::new();
let mode = mode.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| {
let m = match v.to_ascii_lowercase().as_str() {
// Only modes every surface takes: wgpu crashes on an explicit Immediate, Mailbox or
// FifoRelaxed the driver doesn't offer (X11/NVIDIA has no Mailbox, #1343). Without
// vsync, wgpu picks Immediate, else Mailbox, else Fifo.
"fifo" | "vsync" => PresentMode::Fifo,
"autovsync" => PresentMode::AutoVsync,
"novsync" | "immediate" | "mailbox" | "autonovsync" => PresentMode::AutoNoVsync,
_ => {
warnings.push(format!("PHOTOCRAFT_PRESENT_MODE={v:?} isn't fifo, autovsync or novsync (immediate, mailbox); ignored"));
return None;
}
};
Some(m)
});
let latency = latency.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| match v.parse::<u32>() {
Ok(n @ 1..=3) => Some(n),
_ => {
warnings.push(format!("PHOTOCRAFT_FRAME_LATENCY={v:?} isn't 1, 2 or 3; ignored"));
None
}
});
(Overrides { mode, latency }, warnings)
}
impl Overrides {
/// `--present-mode` / `--frame-latency` when given, else `PHOTOCRAFT_PRESENT_MODE` /
/// `PHOTOCRAFT_FRAME_LATENCY`; rejected values are logged.
pub fn from_args_or_env(mode: Option<&str>, latency: Option<&str>) -> Self {
let env_mode = std::env::var("PHOTOCRAFT_PRESENT_MODE").ok();
let env_latency = std::env::var("PHOTOCRAFT_FRAME_LATENCY").ok();
let (o, warnings) = parse(mode.or(env_mode.as_deref()), latency.or(env_latency.as_deref()));
for w in warnings {
log::warn!("{w}");
}
o
}
/// `surface` with the overrides applied.
pub fn apply(&self, surface: SurfaceConfig) -> SurfaceConfig {
SurfaceConfig {
present_mode: self.mode.unwrap_or(surface.present_mode),
desired_maximum_frame_latency: self.latency.or(surface.desired_maximum_frame_latency),
}
}
}
/// How `surface` presents, for the stroke trace and System Info: "fifo, latency 2", with
/// "(override)" after a value an environment variable set. wgpu's GL backend ignores the latency.
pub fn describe(surface: &SurfaceConfig, overrides: &Overrides) -> String {
let mode = match surface.present_mode {
PresentMode::AutoVsync => "autovsync",
PresentMode::AutoNoVsync => "autonovsync",
PresentMode::Fifo => "fifo",
PresentMode::FifoRelaxed => "fiforelaxed",
PresentMode::Immediate => "immediate",
PresentMode::Mailbox => "mailbox",
};
let latency = surface.desired_maximum_frame_latency.map_or("default".to_string(), |n| n.to_string());
let tag = |set: bool| if set { " (override)" } else { "" };
format!("{mode}{}, latency {latency}{}", tag(overrides.mode.is_some()), tag(overrides.latency.is_some()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn values_parse_and_bad_ones_are_ignored_with_a_warning() {
assert_eq!(parse(None, None), (Overrides::default(), vec![]));
assert_eq!(parse(Some(""), Some(" ")), (Overrides::default(), vec![]));
let (o, w) = parse(Some(" Mailbox "), Some("1"));
assert_eq!((o.mode, o.latency, w.len()), (Some(PresentMode::AutoNoVsync), Some(1), 0));
assert_eq!(parse(Some("vsync"), None).0.mode, Some(PresentMode::Fifo));
// Never a mode a driver may lack (wgpu crashes on it): no vsync falls back by support.
for v in ["IMMEDIATE", "mailbox", "novsync", "autonovsync"] {
assert_eq!(parse(Some(v), None).0.mode, Some(PresentMode::AutoNoVsync), "{v}");
}
for (mode, latency) in
[(Some("fast"), None), (None, Some("0")), (None, Some("4")), (None, Some("-1")), (None, Some("one")), (None, Some("99999999999"))]
{
let (o, w) = parse(mode, latency);
assert_eq!((o, w.len()), (Overrides::default(), 1), "{mode:?} {latency:?}");
}
}
#[test]
fn overrides_replace_only_what_they_set_and_are_described() {
let default = SurfaceConfig::HIGH_THROUGHPUT;
assert_eq!(Overrides::default().apply(default), default);
let latency_only = Overrides { mode: None, latency: Some(1) };
let s = latency_only.apply(default);
assert_eq!((s.present_mode, s.desired_maximum_frame_latency), (PresentMode::AutoVsync, Some(1)));
assert_eq!(describe(&s, &latency_only), "autovsync, latency 1 (override)");
let novsync = Overrides { mode: Some(PresentMode::AutoNoVsync), latency: None };
assert_eq!(describe(&novsync.apply(default), &novsync), "autonovsync (override), latency 2");
let none = SurfaceConfig { present_mode: PresentMode::Fifo, desired_maximum_frame_latency: None };
assert_eq!(describe(&none, &Overrides::default()), "fifo, latency default");
}
}
+133 -34
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", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr",
"pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw",
"pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
"pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "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"];
@@ -53,6 +53,7 @@ fn open_filters() -> Vec<(&'static str, Vec<String>)> {
/// keeps defaulting to PSD.
const SAVE_FILTERS: &[(&str, &[&str])] = &[
("PSD Document", &["psd", "psb"]),
("PDF", &["pdf"]),
("PhotoCraft", &["pcraft"]),
("OpenRaster", &["ora"]),
("PNG", &["png"]),
@@ -94,18 +95,51 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
v
}
/// The name the save dialog starts with. On Windows a visible extension stays put when the user
/// picks another type (with File name extensions shown, `image.psd` saved as PhotoCraft stayed
/// `image.psd`, #4394), while a bare name gets the chosen type's extension, so there the dialog
/// gets the stem and the suggested type (listed first by [`save_filters`]) supplies `.psd`. A
/// stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case
/// `path` with the extension of the file type picked in the save dialog (`chosen`), where the
/// dialog left the name alone (#6121): the format follows the extension, and the GTK file chooser
/// keeps the typed name when another type is picked. Only a name that still has the extension the
/// dialog opened with (`opened_with`, `Untitled-1.psd` with PNG picked) or none gets the type's
/// extension, as PolyMeilex/rfd#341 does. An extension the user typed that PhotoCraft saves
/// (`art.png` with the PSD type untouched, the way to save a PNG before this) is theirs and stays,
/// and so does one of the type's own (`.psb` for PSD). One PhotoCraft doesn't save (`scan.2024`)
/// is part of the name, and the type's extension is added after it.
#[cfg(any(target_os = "linux", test))]
pub(crate) fn with_chosen_type(path: &Path, opened_with: Option<&str>, chosen: &[String]) -> PathBuf {
let Some(first) = chosen.first() else { return path.to_path_buf() };
let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
let opened_ext = opened_with.and_then(|n| Path::new(n).extension()).and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
match ext {
Some(ext) if chosen.contains(&ext) => path.to_path_buf(),
Some(ext) if opened_ext.as_ref() == Some(&ext) => path.with_extension(first),
Some(ext) if SAVE_FILTERS.iter().chain(OTHER_SAVE_FILTERS).any(|(_, exts)| exts.contains(&ext.as_str())) => path.to_path_buf(),
_ => {
let mut name = path.as_os_str().to_owned();
name.push(".");
name.push(first);
PathBuf::from(name)
}
}
}
/// The name the save dialog starts with. Where the type picked gives a bare name its extension
/// (`adds_extension`), the dialog gets the stem and the suggested type (listed first by
/// [`save_filters`]) supplies `.psd`:
/// - On Windows a visible extension stays put when the user picks another type (with File name
/// extensions shown, `image.psd` saved as PhotoCraft stayed `image.psd`, #4394).
/// - On Linux the portal's dialog keeps the name as typed, and before returning asks whether to
/// replace a file of that name, so `test.psd` with PNG picked asked to replace test.psd, which
/// the save leaves alone (#6121). From the stem [`with_chosen_type`] gives the picked type's
/// extension, and a name that then exists is asked about under that name
/// (`portal_save::save_as`).
///
/// A stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case
/// extension (the user's spelling) keeps the whole name. Elsewhere the whole name is kept: the
/// macOS panel has no type list, and on Linux rfd doesn't report which type was picked.
fn dialog_file_name(suggested: &str, windows: bool) -> Option<String> {
/// macOS panel has no type list, and rfd's Linux dialogs don't say which type was picked.
fn dialog_file_name(suggested: &str, adds_extension: bool) -> Option<String> {
let path = Path::new(suggested);
let name = path.file_name()?.to_string_lossy().into_owned();
let (Some(stem), Some(ext)) = (path.file_stem(), path.extension().and_then(|e| e.to_str())) else { return Some(name) };
let bare = windows && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase();
let bare = adds_extension && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase();
Some(if bare { stem.to_string_lossy().into_owned() } else { name })
}
@@ -147,17 +181,52 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
}
FileDialogRequest::Save { suggested } => {
for (name, exts) in save_filters(&suggested) {
dialog = dialog.add_filter(name, &exts);
let filters = save_filters(&suggested);
let name = dialog_file_name(&suggested, cfg!(windows));
let dir = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()).map(Path::to_path_buf);
for (name, exts) in &filters {
dialog = dialog.add_filter(name, exts);
}
if let Some(name) = dialog_file_name(&suggested, cfg!(windows)) {
if let Some(name) = &name {
dialog = dialog.set_file_name(name);
}
if let Some(dir) = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()) {
if let Some(dir) = &dir {
dialog = dialog.set_directory(dir);
}
let picked = dialog.save_file();
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
// On Linux the portal's own dialog, which reports the file type picked (#6121); rfd if
// the portal can't show it.
#[cfg(target_os = "linux")]
{
use eframe::wgpu::rwh::HasWindowHandle;
let parent = crate::portal_save::parent_id(parent.and_then(|p| p.window_handle().ok()).map(|h| h.as_raw()));
let req = crate::portal_save::SaveRequest {
parent,
name: dialog_file_name(&suggested, true),
suggested: Path::new(&suggested).file_name().map(|n| n.to_string_lossy().into_owned()),
folder: dir,
filters,
current: Some(0),
};
Box::pin(async move {
let portal = zbus::blocking::connection::Builder::session()
.and_then(|b| b.build())
.map_err(|e| e.to_string())
.and_then(|conn| crate::portal_save::save_as(&conn, req));
match portal {
Ok(path) => path.map(|p| FileDialogAnswer::SaveTo(p.to_string_lossy().into_owned())),
Err(e) => {
log::info!("portal save dialog unavailable ({e}), using rfd");
dialog.save_file().await.map(|file| FileDialogAnswer::SaveTo(path_of(&file)))
}
}
})
}
#[cfg(not(target_os = "linux"))]
{
let _ = filters;
let picked = dialog.save_file();
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) })
}
}
};
// Without the thread the reply is dropped unanswered, which the app takes as Cancel.
@@ -195,8 +264,8 @@ fn block_on<T>(future: impl Future<Output = T>) -> T {
}
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else `<exe dir>/PhotoCraftData` in
/// portable mode, else the platform convention. Everything the app persists lives under it; see
/// [`crate::app_dirs`].
/// portable mode, else the platform convention. Everything the app persists lives under it
/// except, on Windows, crash-recovery autosaves; see [`crate::app_dirs`].
pub fn config_dir() -> Option<PathBuf> {
crate::app_dirs::config_dir()
}
@@ -211,8 +280,10 @@ pub fn presets_dir() -> Option<PathBuf> {
config_dir().map(|d| d.join("Presets"))
}
/// Crash-recovery autosaves: under the settings directory, except on Windows, where they stay
/// out of the roaming profile (see [`crate::app_dirs::recovery_dir`]).
fn recovery_dir() -> Option<PathBuf> {
config_dir().map(|d| d.join("Recovery"))
crate::app_dirs::recovery_dir()
}
/// Write `bytes` crash-safely (temp file beside the target, fsync, rename, directory fsync; see
@@ -228,20 +299,7 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R
}
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 {
opts.encode.jpeg_quality = q;
}
opts.encode.webp_lossless = settings.webp_lossless;
if let Some(q) = settings.webp_quality {
opts.encode.webp_quality = q;
}
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.psd_maximize_compatibility = settings.psd_maximize_compatibility;
opts
settings.io_options()
}
/// The share of a save's progress bar that encoding fills; writing the file fills the rest. A
@@ -448,6 +506,11 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
save_file: Some(Arc::new(save_file)),
file_dialog: Some(Box::new(show_file_dialog)),
write: Some(Box::new(|path: &str, bytes: &[u8]| write_atomic(Path::new(path), bytes))),
file_stamp: Some(Box::new(|path: &str| {
// Follows a symlink, as Save writes through it to the file it points at.
let meta = std::fs::metadata(path).ok()?;
Some(photocraft_ui_egui::FileStamp { len: meta.len(), modified: meta.modified().ok() })
})),
automation_read,
automation_write,
automation_open_pcraft,
@@ -597,6 +660,41 @@ mod tests {
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
use serde_json::{Value, json};
/// #6121: the file type picked in the save dialog gives the name its extension where the
/// dialog left the one it opened with, and only there.
#[test]
fn the_picked_file_type_gives_the_name_its_extension() {
let exts = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let (png, psd) = (exts(&["png"]), exts(&["psd", "psb"]));
let opened = Some("Untitled-1.psd");
let cases = [
// PNG picked, the name as the dialog opened (or renamed, its extension kept).
("/pics/Untitled-1.psd", opened, &png, "/pics/Untitled-1.png"),
("/pics/art.psd", opened, &png, "/pics/art.png"),
("/pics/ART.PSD", opened, &png, "/pics/ART.png"),
// No extension: the type's. One PhotoCraft doesn't save is part of the name.
("/pics/art", opened, &png, "/pics/art.png"),
("/pics/scan.2024", opened, &png, "/pics/scan.2024.png"),
// Typed, an extension PhotoCraft saves is the user's: kept, whatever the type says
// (the PSD type untouched, or PNG picked and .jpg typed).
("/pics/art.png", opened, &psd, "/pics/art.png"),
("/pics/art.jpg", opened, &png, "/pics/art.jpg"),
("/pics/art.pcraft", opened, &png, "/pics/art.pcraft"),
// Already the type's: kept, including its other extensions.
("/pics/art.png", opened, &png, "/pics/art.png"),
("/pics/art.PNG", opened, &png, "/pics/art.PNG"),
("/pics/big.psb", opened, &psd, "/pics/big.psb"),
// The dialog opened on a .pcraft: that is the extension the type replaces.
("/pics/art.pcraft", Some("art.pcraft"), &png, "/pics/art.png"),
("/pics/art.psd", Some("art.pcraft"), &png, "/pics/art.psd"),
];
for (typed, opened, chosen, want) in cases {
assert_eq!(with_chosen_type(Path::new(typed), opened, chosen), PathBuf::from(want), "{typed} (opened {opened:?})");
}
// No type picked: the name as typed.
assert_eq!(with_chosen_type(Path::new("/pics/art.psd"), opened, &[]), PathBuf::from("/pics/art.psd"));
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn open_filters_cover_uppercase_and_mixed_case_extensions() {
@@ -738,7 +836,8 @@ mod tests {
/// Every flat format Save As offers is one PhotoCraft writes, and ICO is among them (#4357).
#[test]
fn save_as_offers_only_writable_formats_including_ico() {
let layered = ["psd", "psb", "pcraft", "ora"];
// Layered formats, and PDF, are written by `photocraft-io`, not a codec.
let layered = ["psd", "psb", "pcraft", "ora", "pdf"];
for (name, exts) in SAVE_FILTERS {
for ext in exts.iter().filter(|e| !layered.contains(e)) {
let format = photocraft_codecs::from_extension(ext).unwrap_or_else(|| panic!("{name}: .{ext} is no known format"));
+648
View File
@@ -0,0 +1,648 @@
//! One PhotoCraft per user on Windows and Linux (#3708): a file opened from Explorer or a file
//! manager lands as a new tab in the running window, like Photoshop, instead of starting a second
//! copy of the program.
//!
//! The first launch takes an exclusive OS lock on `instance.lock` in the settings directory (the OS
//! drops it when the process ends, crash included), listens on a loopback port and writes the port
//! plus a fresh 256-bit token to `instance.json`. A later launch that finds the lock taken sends its
//! paths there; the primary queues them for the UI as [`OsEvent::Open`], which opens each one and
//! brings the window forward. Explorer starts one process per file when several are opened at once;
//! the lock decides the race, so they all end up as tabs in one window.
//!
//! The primary answers `ok` only once the UI has taken the paths. A launch that hears anything else
//! (the primary is still starting, hung, or quitting) keeps retrying the lock, so it becomes the
//! primary itself as soon as the old one lets go, and when its time runs out it starts a separate
//! instance: a file is never acknowledged and then dropped. [`Primary::shutdown`] runs when the
//! event loop ends (and before the CPU-compatibility relaunch), so a closing PhotoCraft refuses new
//! files and frees the lock.
//!
//! Only for the per-user settings folder ([`skip_reason`]): a portable copy's folder beside the
//! executable, or a `PHOTOCRAFT_CONFIG_DIR`, may be shared between users.
//! macOS doesn't need this: LaunchServices already sends a running app an open-documents event.
// Only the tests use it on macOS.
#![cfg_attr(target_os = "macos", allow(dead_code))]
use std::fs::{File, OpenOptions};
use std::io::{BufReader, Read, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{SyncSender, sync_channel};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use photocraft_automation::security::{LineRead, generate_token, read_bounded_line, token_matches};
use photocraft_ui_egui::{OsEvent, OsEventsFn};
use serde_json::{Value, json};
const LOCK_FILE: &str = "instance.lock";
const INFO_FILE: &str = "instance.json";
/// More paths than this in one request is not a file manager.
const MAX_PATHS: usize = 4096;
/// Launches served at once; a stalled client holds only its own slot, never the next launch.
const MAX_CLIENTS: usize = 8;
/// Kept out of the hand-off's budget, so the primary answers before the launch gives up on it.
const REPLY_MARGIN: Duration = Duration::from_millis(500);
/// How long each side waits (shortened in tests).
#[derive(Clone, Copy, Debug)]
pub struct Timing {
/// A later launch's whole budget for handing its paths over (or taking the lock over).
pub handoff: Duration,
/// How long the primary waits for its UI to take a launch's paths before refusing them.
pub ack: Duration,
/// Total time a connection gets to send its request line.
pub request: Duration,
}
/// Generous enough for a primary that is still opening its window.
pub const TIMING: Timing = Timing { handoff: Duration::from_secs(10), ack: Duration::from_secs(8), request: Duration::from_secs(5) };
/// Outcome of [`claim`].
pub enum Launch {
/// This process is the running instance; keep [`Primary`] alive and attach it to the app.
Primary(Primary),
/// The running instance took the paths; this process should exit.
Forwarded,
/// Couldn't do either (reason given): run as an independent instance.
Standalone(String),
}
/// What decides whether a launch takes part (see [`skip_reason`]).
#[derive(Clone, Copy)]
pub struct LaunchContext<'a> {
/// The settings folder is the platform's per-user one (not portable, not an override).
pub per_user_settings: bool,
/// `--new-instance`.
pub new_instance_flag: bool,
/// `PHOTOCRAFT_NEW_INSTANCE`, if set.
pub new_instance_env: Option<&'a str>,
/// `--control` or `PHOTOCRAFT_CONTROL_PORT`: automation gets its own process.
pub control: bool,
/// Paths that aren't valid Unicode: this launch reports them.
pub unreadable_paths: bool,
/// Options that only apply to the process they're given to (`--safe-gpu`, …).
pub launch_options: &'a [&'static str],
}
/// Why this launch runs as its own instance, or `None` to take part in single-instance.
pub fn skip_reason(c: &LaunchContext) -> Option<String> {
if !c.per_user_settings {
return Some("the settings folder isn't the per-user one (portable or PHOTOCRAFT_CONFIG_DIR)".into());
}
if c.new_instance_flag || c.new_instance_env.is_some_and(|v| !v.is_empty() && v != "0") {
return Some("--new-instance".into());
}
if c.control {
return Some("automation (--control) runs its own process".into());
}
if c.unreadable_paths {
return Some("some paths aren't valid Unicode".into());
}
if !c.launch_options.is_empty() {
return Some(format!("{} only applies to a new process", c.launch_options.join(", ")));
}
None
}
/// The running instance: holds the lock and receives other launches' paths.
pub struct Primary {
lock: Mutex<Option<File>>,
shared: Arc<Shared>,
}
struct Shared {
inbox: Mutex<Inbox>,
/// Set by [`Primary::shutdown`]: new connections are dropped, waiting ones refused.
closed: AtomicBool,
}
#[derive(Default)]
struct Inbox {
pending: Vec<Pending>,
ctx: Option<egui::Context>,
next_id: u64,
}
/// Paths waiting for the UI; `taken` tells the connection that sent them.
struct Pending {
id: u64,
paths: Vec<String>,
taken: SyncSender<()>,
}
impl Primary {
/// The queue the UI drains (see `Services::os_events`); a later launch wakes `ctx`.
pub fn connect(&self, ctx: &egui::Context) -> OsEventsFn {
let mut inbox = self.shared.inbox.lock().unwrap_or_else(PoisonError::into_inner);
if !inbox.pending.is_empty() {
ctx.request_repaint();
}
inbox.ctx = Some(ctx.clone());
drop(inbox);
let shared = Arc::clone(&self.shared);
Box::new(move || shared.take())
}
/// Stop taking files and release the lock: launches waiting on this one are refused and take
/// the lock over themselves. Call when the event loop has ended, and before relaunching.
pub fn shutdown(&self) {
self.shared.closed.store(true, Ordering::Release);
// Dropping the senders refuses every connection still waiting for the UI.
self.shared.inbox.lock().unwrap_or_else(PoisonError::into_inner).pending.clear();
drop(self.lock.lock().unwrap_or_else(PoisonError::into_inner).take());
}
}
impl Drop for Primary {
fn drop(&mut self) {
self.shutdown();
}
}
impl Shared {
/// Everything queued, acknowledged to the senders while the inbox is locked (see `hand_over`).
fn take(&self) -> Vec<OsEvent> {
let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
inbox
.pending
.drain(..)
.map(|p| {
let _ = p.taken.try_send(());
OsEvent::Open(p.paths)
})
.collect()
}
/// Queue `paths` for the UI and wait up to `wait` for it to take them. False (and nothing left
/// queued) when it didn't: the sender then retries or starts its own instance.
fn hand_over(&self, paths: Vec<String>, wait: Duration) -> bool {
let (taken, rx) = sync_channel(1);
let id = {
let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
if self.closed.load(Ordering::Acquire) {
return false;
}
let id = inbox.next_id;
inbox.next_id = id.wrapping_add(1);
inbox.pending.push(Pending { id, paths, taken });
if let Some(ctx) = &inbox.ctx {
ctx.request_repaint();
}
id
};
if rx.recv_timeout(wait).is_ok() {
return true;
}
// Timed out or refused: withdraw, unless the UI took them in the meantime (it acknowledges
// while holding the lock, so the answer is settled here).
let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
if rx.try_recv().is_ok() {
return true;
}
inbox.pending.retain(|p| p.id != id);
false
}
}
/// Become the running instance under `dir` or hand `paths` (absolute) to the one that is.
pub fn claim(dir: &Path, paths: &[String]) -> Launch {
claim_with(dir, paths, TIMING)
}
/// [`claim`] with explicit timeouts. Until `timing.handoff` runs out, each round tries the lock
/// (free once a quitting or crashed primary is gone) and then the running primary.
pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch {
if let Err(e) = std::fs::create_dir_all(dir) {
return Launch::Standalone(format!("{}: {e}", dir.display()));
}
let lock_path = dir.join(LOCK_FILE);
let lock = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&lock_path) {
Ok(lock) => lock,
Err(e) => return Launch::Standalone(format!("{}: {e}", lock_path.display())),
};
let deadline = Instant::now() + timing.handoff;
let mut last = "no instance.json".to_string();
loop {
match lock.try_lock() {
Ok(()) => {
return match serve(dir, lock, timing) {
Ok(primary) => Launch::Primary(primary),
Err(e) => Launch::Standalone(e),
};
}
Err(std::fs::TryLockError::WouldBlock) => {}
Err(std::fs::TryLockError::Error(e)) => return Launch::Standalone(format!("{}: {e}", lock_path.display())),
}
if let Some((port, token)) = read_info(dir) {
match send(port, &token, paths, deadline) {
Ok(()) => return Launch::Forwarded,
Err(e) => last = e,
}
}
if Instant::now() >= deadline {
return Launch::Standalone(format!("the running PhotoCraft didn't take the files ({last})"));
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn serve(dir: &Path, lock: File, timing: Timing) -> Result<Primary, String> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("loopback listener: {e}"))?;
let port = listener.local_addr().map_err(|e| format!("loopback listener: {e}"))?.port();
let token = generate_token().map_err(|e| e.to_string())?;
write_info(dir, port, &token)?;
let shared = Arc::new(Shared { inbox: Mutex::default(), closed: AtomicBool::new(false) });
let listening = Arc::clone(&shared);
let spawned = std::thread::Builder::new().name("single-instance".into()).spawn(move || {
let token = Arc::new(token);
let clients = Arc::new(AtomicUsize::new(0));
loop {
let stream = match listener.accept() {
Ok((stream, _)) => stream,
// Back off instead of spinning when accept keeps failing (e.g. out of descriptors).
Err(_) => {
std::thread::sleep(Duration::from_millis(100));
continue;
}
};
// Closing: the sender sees the connection drop and takes the lock over.
if listening.closed.load(Ordering::Acquire) {
continue;
}
// Each launch on its own thread, so one that connects and stalls doesn't hold the
// ones after it; past the cap a connection is dropped and that launch retries.
if clients.fetch_add(1, Ordering::AcqRel) >= MAX_CLIENTS {
clients.fetch_sub(1, Ordering::AcqRel);
continue;
}
let (token, shared, slot) = (Arc::clone(&token), Arc::clone(&listening), Arc::clone(&clients));
let spawned = std::thread::Builder::new().name("single-instance-client".into()).spawn(move || {
serve_client(stream, &token, &shared, timing);
slot.fetch_sub(1, Ordering::AcqRel);
});
if spawned.is_err() {
clients.fetch_sub(1, Ordering::AcqRel);
}
}
});
spawned.map_err(|e| format!("single-instance listener thread: {e}"))?;
Ok(Primary { lock: Mutex::new(Some(lock)), shared })
}
/// Read one request, hand its paths to the UI and answer `ok` only if the UI took them.
fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing) {
let taken = match read_request(&stream, timing.request) {
Some(line) => match parse_request(&line, token) {
Some((paths, wait)) => shared.hand_over(paths, wait.min(timing.ack)),
None => false,
},
None => return,
};
let reply = if taken { json!({"ok": true}) } else { json!({"ok": false, "error": "not taken"}) };
let mut out = stream;
let _ = out.set_write_timeout(Some(timing.request));
let _ = writeln!(out, "{reply}").and_then(|()| out.flush());
}
/// A reader that fails once `until` passes, however slowly the bytes trickle in.
struct Deadline<'a> {
stream: &'a TcpStream,
until: Instant,
}
impl Read for Deadline<'_> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let left = self.until.checked_duration_since(Instant::now()).filter(|d| !d.is_zero());
let left = left.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::TimedOut, "request deadline passed"))?;
self.stream.set_read_timeout(Some(left))?;
let mut stream = self.stream;
stream.read(buf)
}
}
/// The request line, if it arrives complete within `total`.
fn read_request(stream: &TcpStream, total: Duration) -> Option<String> {
let mut reader = BufReader::new(Deadline { stream, until: Instant::now() + total });
let mut line = String::new();
matches!(read_bounded_line(&mut reader, &mut line), Ok(LineRead::Line)).then_some(line)
}
/// Port and token, written to a temporary file and renamed so a reader never sees half of it.
/// On Unix the file is the user's alone (0600): another local user who read the token could make
/// this app open any path. (Windows keeps the per-user ACL of the settings folder.)
fn write_info(dir: &Path, port: u16, token: &str) -> Result<(), String> {
let path = dir.join(INFO_FILE);
let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id()));
let body = json!({"port": port, "token": token, "pid": std::process::id()}).to_string();
// A leftover from a crash could carry wider permissions; the mode applies only on create.
let _ = std::fs::remove_file(&tmp);
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let written = options.open(&tmp).and_then(|mut f| f.write_all(body.as_bytes())).and_then(|()| std::fs::rename(&tmp, &path));
written.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
format!("{}: {e}", path.display())
})
}
fn read_info(dir: &Path) -> Option<(u16, String)> {
let v: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).ok()?).ok()?;
let port = u16::try_from(v.get("port")?.as_u64()?).ok()?;
Some((port, v.get("token")?.as_str()?.to_string()))
}
/// `{"token":…,"open":[…],"wait_ms":…}`: the paths, and how long the sender will wait for the UI.
fn parse_request(line: &str, token: &str) -> Option<(Vec<String>, Duration)> {
let v: Value = serde_json::from_str(line).ok()?;
if !token_matches(token, v.get("token")?.as_str()?) {
return None;
}
let open = v.get("open")?.as_array()?;
if open.len() > MAX_PATHS {
return None;
}
let paths = open.iter().map(|p| p.as_str().map(str::to_string)).collect::<Option<Vec<_>>>()?;
let wait = Duration::from_millis(v.get("wait_ms").and_then(Value::as_u64).unwrap_or(0));
Some((paths, wait))
}
/// Offer `paths` to the primary on `port`; `Ok` once its UI has taken them.
fn send(port: u16, token: &str, paths: &[String], deadline: Instant) -> Result<(), String> {
let left = deadline.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()).ok_or("out of time")?;
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
let mut stream = TcpStream::connect_timeout(&addr, left).map_err(|e| e.to_string())?;
// The primary must answer before our deadline, so it waits a little less than we do.
let wait_ms = u64::try_from(left.saturating_sub(REPLY_MARGIN).as_millis()).unwrap_or(u64::MAX);
let request = json!({"token": token, "open": paths, "wait_ms": wait_ms});
stream.set_write_timeout(Some(left)).map_err(|e| e.to_string())?;
writeln!(stream, "{request}").and_then(|()| stream.flush()).map_err(|e| e.to_string())?;
let mut reader = BufReader::new(Deadline { stream: &stream, until: deadline });
let mut line = String::new();
match read_bounded_line(&mut reader, &mut line) {
Ok(LineRead::Line) => {}
Ok(_) => return Err("no reply".into()),
Err(e) => return Err(e.to_string()),
}
let ok = serde_json::from_str::<Value>(&line).ok().and_then(|v| v.get("ok")?.as_bool()) == Some(true);
if ok { Ok(()) } else { Err("not taken".into()) }
}
/// Absolute form of a command-line path, so the running instance (another working directory)
/// opens the same file.
pub fn absolute(path: &str) -> String {
std::path::absolute(path).map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|_| path.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::sync::mpsc::channel;
const FAST: Timing = Timing { handoff: Duration::from_millis(1500), ack: Duration::from_millis(500), request: Duration::from_millis(800) };
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("photocraft-single-instance-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
/// Launch with `paths` on another thread (it blocks until the primary's UI takes them).
fn launch(dir: &Path, paths: &[&str], timing: Timing) -> std::thread::JoinHandle<Launch> {
let (dir, paths): (PathBuf, Vec<String>) = (dir.to_path_buf(), paths.iter().map(|p| p.to_string()).collect());
std::thread::spawn(move || claim_with(&dir, &paths, timing))
}
/// Play the UI: drain until `n` events arrived (or 5 s passed).
fn drain(ui: &mut OsEventsFn, n: usize) -> Vec<OsEvent> {
let mut got = Vec::new();
let deadline = Instant::now() + Duration::from_secs(5);
while got.len() < n && Instant::now() < deadline {
got.extend(ui());
std::thread::sleep(Duration::from_millis(10));
}
got
}
fn primary(dir: &Path) -> Primary {
match claim_with(dir, &[], FAST) {
Launch::Primary(p) => p,
_ => panic!("first launch should be primary"),
}
}
#[test]
fn second_launch_hands_its_paths_to_the_first() {
let dir = temp_dir("handoff");
let primary = primary(&dir);
let mut ui = primary.connect(&egui::Context::default());
let paths = ["C:\\art\\one.psd", "/home/a/two é.png"];
let second = launch(&dir, &paths, TIMING);
assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(paths.iter().map(|p| p.to_string()).collect())]);
assert!(matches!(second.join().unwrap(), Launch::Forwarded));
// A plain second start (no files) just brings the window forward.
let third = launch(&dir, &[], TIMING);
assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(Vec::new())]);
assert!(matches!(third.join().unwrap(), Launch::Forwarded));
drop(primary);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn simultaneous_launches_all_land_in_one_instance() {
let dir = temp_dir("race");
let (tx, rx) = channel();
for i in 0..6 {
let (dir, tx) = (dir.clone(), tx.clone());
std::thread::spawn(move || {
let _ = tx.send(claim(&dir, &[format!("file{i}.psd")]));
});
}
drop(tx);
let (mut primaries, mut forwarded, mut events) = (Vec::new(), 0, Vec::new());
let mut ui: Option<OsEventsFn> = None;
let deadline = Instant::now() + Duration::from_secs(10);
while primaries.len() + forwarded < 6 && Instant::now() < deadline {
while let Ok(launch) = rx.try_recv() {
match launch {
Launch::Primary(p) => primaries.push(p),
Launch::Forwarded => forwarded += 1,
Launch::Standalone(reason) => panic!("standalone: {reason}"),
}
}
if ui.is_none()
&& let Some(p) = primaries.first()
{
ui = Some(p.connect(&egui::Context::default()));
}
if let Some(ui) = ui.as_mut() {
events.extend(ui());
}
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!((primaries.len(), forwarded, events.len()), (1, 5, 5));
drop(primaries);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_crashed_instance_leaves_nothing_in_the_way() {
let dir = temp_dir("stale");
std::fs::create_dir_all(&dir).unwrap();
// instance.json from a process that is gone; the lock is free.
std::fs::write(dir.join(INFO_FILE), r#"{"port":1,"token":"x","pid":1}"#).unwrap();
let primary = primary(&dir);
assert!(read_info(&dir).is_some_and(|(port, _)| port != 1));
drop(primary);
// Released with the handle, as on process exit.
assert!(matches!(claim_with(&dir, &[], FAST), Launch::Primary(_)));
let _ = std::fs::remove_dir_all(&dir);
}
/// Review of #3755, point 1 (and the quitting half of point 3): a launch waiting on a primary
/// that shuts down (closing, or about to relaunch itself in CPU mode) isn't told `ok`; it takes
/// the lock over and opens the file itself.
#[test]
fn a_launch_waiting_on_a_closing_instance_takes_over() {
let dir = temp_dir("closing");
let primary = primary(&dir);
// The UI never drains (it is shutting down, or never came up).
let waiting = launch(&dir, &["a.psd"], FAST);
std::thread::sleep(Duration::from_millis(200));
primary.shutdown();
assert!(matches!(waiting.join().unwrap(), Launch::Primary(_)), "the waiting launch becomes the primary");
// After shutdown new connections are refused too.
let (port, token) = read_info(&dir).unwrap();
drop(primary);
let short = Instant::now() + Duration::from_millis(300);
assert!(send(port, &token, &["b.psd".into()], short).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
/// Review of #3755, point 3: a primary whose UI never takes the paths (hung) doesn't
/// acknowledge them, and doesn't keep them queued to open later as well.
#[test]
fn a_hung_instance_never_swallows_a_file() {
let dir = temp_dir("hung-ui");
let primary = primary(&dir);
let mut ui = primary.connect(&egui::Context::default());
let started = Instant::now();
assert!(matches!(claim_with(&dir, &["a.psd".into()], FAST), Launch::Standalone(_)));
assert!(started.elapsed() < FAST.handoff + Duration::from_secs(1), "{:?}", started.elapsed());
assert!(ui().is_empty(), "withdrawn, not opened later");
drop(primary);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn requests_without_the_token_or_malformed_are_rejected() {
let token = generate_token().unwrap();
let ok = parse_request(&json!({"token": token, "open": ["a"], "wait_ms": 250}).to_string(), &token);
assert_eq!(ok, Some((vec!["a".to_string()], Duration::from_millis(250))));
let wrong = generate_token().unwrap();
assert_eq!(parse_request(&json!({"token": wrong, "open": ["a"]}).to_string(), &token), None);
for bad in ["", "not json", "{}", r#"{"token":5}"#, &json!({"token": token}).to_string(), &json!({"token": token, "open": [1]}).to_string()] {
assert_eq!(parse_request(bad, &token), None, "{bad:?}");
}
let many: Vec<String> = (0..=MAX_PATHS).map(|i| i.to_string()).collect();
assert_eq!(parse_request(&json!({"token": token, "open": many}).to_string(), &token), None);
}
#[test]
fn a_stalled_client_does_not_hold_up_the_next_launch() {
let dir = temp_dir("stalled");
let primary = primary(&dir);
let mut ui = primary.connect(&egui::Context::default());
let (port, _) = read_info(&dir).unwrap();
// Connects and never sends a line (each would otherwise hold the listener).
let stalled: Vec<TcpStream> = (0..3).map(|_| TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap()).collect();
let started = Instant::now();
let next = launch(&dir, &["b.psd"], FAST);
assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(vec!["b.psd".into()])]);
assert!(matches!(next.join().unwrap(), Launch::Forwarded));
assert!(started.elapsed() < FAST.request, "took {:?}", started.elapsed());
drop(stalled);
drop(primary);
let _ = std::fs::remove_dir_all(&dir);
}
/// A client that trickles a byte at a time can't keep its slot past the request deadline.
#[test]
fn a_trickling_client_is_cut_off_at_the_request_deadline() {
let dir = temp_dir("trickle");
let primary = primary(&dir);
let (port, _) = read_info(&dir).unwrap();
let mut slow = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
let started = Instant::now();
let mut cut_off = false;
while started.elapsed() < FAST.request * 4 {
if slow.write_all(b"{").is_err() {
cut_off = true;
break;
}
std::thread::sleep(Duration::from_millis(100));
}
assert!(cut_off, "still connected after {:?}", started.elapsed());
assert!(started.elapsed() < FAST.request * 3, "{:?}", started.elapsed());
drop(primary);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn the_token_file_is_private_to_the_user() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("mode");
std::fs::create_dir_all(&dir).unwrap();
// A wide leftover temporary file from a crashed start doesn't widen the new one.
let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id()));
std::fs::write(&tmp, "{}").unwrap();
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).unwrap();
write_info(&dir, 1, &generate_token().unwrap()).unwrap();
let mode = std::fs::metadata(dir.join(INFO_FILE)).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_unreachable_instance_falls_back_to_a_standalone_launch() {
let dir = temp_dir("unreachable");
std::fs::create_dir_all(&dir).unwrap();
// Someone holds the lock but nothing listens on the advertised port.
let held = File::create(dir.join(LOCK_FILE)).unwrap();
held.lock().unwrap();
let closed = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port();
write_info(&dir, closed, &generate_token().unwrap()).unwrap();
assert!(matches!(claim_with(&dir, &["a.psd".into()], FAST), Launch::Standalone(_)));
drop(held);
let _ = std::fs::remove_dir_all(&dir);
}
/// Review of #3755, point 2 and the smaller points: who takes part in single-instance.
#[test]
fn only_per_user_launches_without_process_options_take_part() {
let base = LaunchContext {
per_user_settings: true,
new_instance_flag: false,
new_instance_env: None,
control: false,
unreadable_paths: false,
launch_options: &[],
};
assert_eq!(skip_reason(&base), None);
// A portable copy's folder or a PHOTOCRAFT_CONFIG_DIR may be shared between users.
assert!(skip_reason(&LaunchContext { per_user_settings: false, ..base }).is_some());
assert!(skip_reason(&LaunchContext { new_instance_flag: true, ..base }).is_some());
assert!(skip_reason(&LaunchContext { new_instance_env: Some("1"), ..base }).is_some());
assert_eq!(skip_reason(&LaunchContext { new_instance_env: Some("0"), ..base }), None);
assert_eq!(skip_reason(&LaunchContext { new_instance_env: Some(""), ..base }), None);
assert!(skip_reason(&LaunchContext { control: true, ..base }).is_some());
assert!(skip_reason(&LaunchContext { unreadable_paths: true, ..base }).is_some());
let reason = skip_reason(&LaunchContext { launch_options: &["--safe-gpu"], ..base });
assert!(reason.is_some_and(|r| r.contains("--safe-gpu")));
}
}
+67 -6
View File
@@ -64,17 +64,35 @@ pub fn pen_needs_xwayland(session: DisplaySession, var: impl Fn(&str) -> Option<
#[cfg(any(target_os = "linux", test))]
const BTN_TOOL_PEN: u32 = 0x140;
/// Is a pen among the input devices `/proc/bus/input/devices` lists? Each device's `B: KEY=` line
/// is its key bitmap as hex words of the kernel's `long`, most significant first.
/// `ABS_X` and `ABS_Y` (linux/input-event-codes.h): the absolute axes every pen reports.
#[cfg(any(target_os = "linux", test))]
const ABS_X: u32 = 0x00;
#[cfg(any(target_os = "linux", test))]
const ABS_Y: u32 = 0x01;
/// Is a pen among the input devices `/proc/bus/input/devices` lists? A pen is a device whose
/// `B: KEY=` bitmap has `BTN_TOOL_PEN` *and* whose `B: ABS=` bitmap has `ABS_X` and `ABS_Y`
/// (libinput asks the same of a tablet tool). The key alone is not enough: a virtual keyboard
/// made with uinput (RustDesk's, for one) claims every key code, `BTN_TOOL_PEN` included, but
/// has no absolute axes. Taking it for a pen sent the window through Xwayland, where a
/// fractionally scaled Wayland desktop shows it upscaled and blurred (#1072, #639).
#[cfg(any(target_os = "linux", test))]
pub fn has_pen(devices: &str) -> bool {
let bits = usize::BITS;
let word = usize::try_from(BTN_TOOL_PEN / bits).unwrap_or(usize::MAX);
devices.lines().filter_map(|l| l.strip_prefix("B: KEY=")).any(|keys| {
keys.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (BTN_TOOL_PEN % bits)) & 1 == 1)
devices.split("\n\n").any(|device| {
let bitmap = |name: &str| device.lines().find_map(|l| l.strip_prefix(name));
bitmap("B: KEY=").is_some_and(|keys| bit_set(keys, BTN_TOOL_PEN)) && bitmap("B: ABS=").is_some_and(|axes| bit_set(axes, ABS_X) && bit_set(axes, ABS_Y))
})
}
/// Bit `bit` of a `/proc/bus/input/devices` bitmap: hex words of the kernel's `long`, most
/// significant first. Short or malformed bitmaps have no bits set.
#[cfg(any(target_os = "linux", test))]
fn bit_set(bitmap: &str, bit: u32) -> bool {
let bits = usize::BITS;
let word = usize::try_from(bit / bits).unwrap_or(usize::MAX);
bitmap.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (bit % bits)) & 1 == 1)
}
/// Start the XInput2 reader when eframe runs on X11 (Xwayland included). On native Wayland,
/// `$DISPLAY` is Xwayland, which sees none of this window's input (and a pen sample from another
/// X app would outlive a mouse stroke here), so nothing starts.
@@ -238,6 +256,49 @@ mod tests {
for keys in ["", "B: KEY=", "B: KEY=1", "B: KEY=zz 0 0 0 0 0", "B: KEY=ffffffffffffffffff 0 0 0 0 0", "B: KEY=0 0 0 0 0 0 0 0"] {
assert!(!has_pen(keys), "{keys:?}");
}
for abs in ["B: ABS=", "B: ABS=zz", "B: ABS=ffffffffffffffffff", "B: ABS=0 0"] {
assert!(!has_pen(&format!("B: KEY=1c03 0 0 0 0 0\n{abs}")), "{abs:?}");
}
}
/// #1072: RustDesk's uinput keyboard claims every key code, `BTN_TOOL_PEN` included, and has
/// no absolute axes (`EV=20013`: no `EV_ABS`, no `B: ABS=` line). It is no pen, so the window
/// stays on native Wayland, where the fractional display scale renders sharp. The bitmaps are
/// the device's own, from `/proc/bus/input/devices`.
const VIRTUAL_KEYBOARD: &str = "I: Bus=0003 Vendor=1234 Product=5678 Version=0111\n\
N: Name=\"RustDesk UInput Keyboard\"\n\
P: Phys=\n\
S: Sysfs=/devices/virtual/input/input54\n\
U: Uniq=\n\
H: Handlers=sysrq rfkill kbd event256 leds \n\
B: PROP=0\n\
B: EV=20013\n\
B: KEY=ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe\n\
B: MSC=10\n\
B: LED=7\n";
#[test]
fn a_virtual_keyboard_claiming_btn_tool_pen_is_no_pen() {
assert!(
bit_set(
"ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe",
BTN_TOOL_PEN
),
"the fixture claims the key"
);
assert!(!has_pen(VIRTUAL_KEYBOARD));
// Beside a real pen, the pen is still found; the keyboard's key bits never leak into the
// pen's device and the pen's axes never into the keyboard's.
let both = format!("{VIRTUAL_KEYBOARD}\n{WITH_PEN}");
assert!(has_pen(&both));
let pen_without_axes = WITH_PEN.lines().filter(|l| !l.starts_with("B: ABS=1000f")).collect::<Vec<_>>().join("\n");
assert!(!has_pen(&pen_without_axes), "a pen reports absolute positions");
// A pen display or pen-enabled touchscreen reports the pen with its own axes.
let pen_display = "N: Name=\"Wacom One Pen\"\nB: KEY=1c03 0 0 0 0 0\nB: ABS=3\n";
assert!(has_pen(pen_display));
// Axes without `ABS_X` or `ABS_Y` don't count.
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000002\n"));
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000001\n"));
}
#[test]
+77 -41
View File
@@ -81,17 +81,16 @@ mod platform {
HwndPlace::None,
POINT::new(),
SIZE::new(),
co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
}
/// How far a parked maximize reaches past its monitor on the left, top and right: the resize
/// How far a parked maximize reaches past a monitor/work-area edge: 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.
/// Keep a parked maximize inside all four work-area edges, including a top taskbar.
fn keep_clear_of_the_taskbar(hwnd: &HWND) {
if !hwnd.IsZoomed() {
return;
@@ -100,34 +99,58 @@ mod platform {
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 {
let Some((left, top, right, bottom)) =
clamped_rect((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 (Some(width), Some(height)) = (right.checked_sub(left), bottom.checked_sub(top)) else {
return;
};
// Moving the outer frame while winit pins the client rect to the work area must
// not copy the old frame's pixels into the custom title bar.
let _ = hwnd.SetWindowPos(
HwndPlace::None,
POINT::new(),
SIZE { cx: r.right - r.left, cy: height },
co::SWP::NOMOVE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
POINT { x: left, y: top },
SIZE { cx: width, cy: height },
co::SWP::FRAMECHANGED | co::SWP::NOCOPYBITS | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
let _ = hwnd.InvalidateRect(None, false);
}
/// 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> {
/// Return the corrected outer rect in desktop coordinates, preserving the resize border.
/// Each edge must be parked just outside either the monitor or work-area edge. Accepting
/// both makes the guard idempotent and handles Windows already correcting some edges.
/// Dragged/snapped windows are left alone; no monitor-origin or DPI assumptions are needed.
fn clamped_rect(rect: (i32, i32, i32, i32), monitor: (i32, i32, i32, i32), work: (i32, i32, i32, i32)) -> Option<(i32, i32, i32, 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)) {
let (ml, mt, mr, mb) = monitor;
let (wl, wt, wr, wb) = work;
if ml >= mr || mt >= mb || wl < ml || wt < mt || wr > mr || wb > mb || wl >= wr || wt >= wb {
return None;
}
if b <= wb + side {
// Infer the frame from an edge without a taskbar. On an obstructed edge, the
// monitor and work-area offsets can both look like valid borders (e.g. auto-hide).
let border = [
(ml == wl).then(|| ml.checked_sub(l)).flatten(),
(mt == wt).then(|| mt.checked_sub(t)).flatten(),
(mr == wr).then(|| r.checked_sub(mr)).flatten(),
(mb == wb).then(|| b.checked_sub(mb)).flatten(),
]
.into_iter()
.flatten()
.filter(|n| (0..=FRAME).contains(n))
.min()?;
let parked = |monitor: Option<i32>, work: Option<i32>| monitor == Some(border) || work == Some(border);
if !parked(ml.checked_sub(l), wl.checked_sub(l))
|| !parked(mt.checked_sub(t), wt.checked_sub(t))
|| !parked(r.checked_sub(mr), r.checked_sub(wr))
|| !parked(b.checked_sub(mb), b.checked_sub(wb))
{
return None;
}
Some(wb + side - t)
let corrected = (wl.checked_sub(border)?, wt.checked_sub(border)?, wr.checked_add(border)?, wb.checked_add(border)?);
(corrected != rect).then_some(corrected)
}
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
@@ -139,7 +162,7 @@ mod platform {
#[cfg(test)]
mod tests {
use super::{borderless, clamped_height};
use super::{borderless, clamped_rect};
use winsafe::co;
#[test]
@@ -156,34 +179,47 @@ mod platform {
}
#[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.
fn a_maximize_respects_taskbars_on_every_edge() {
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)));
let rect = (-8, -8, 1928, 1088);
for (work, expected) in [
((0, 0, 1920, 1046), (-8, -8, 1928, 1054)),
((0, 48, 1920, 1080), (-8, 40, 1928, 1088)),
((0, 2, 1920, 1080), (-8, -6, 1928, 1088)),
((0, 0, 1920, 1078), (-8, -8, 1928, 1086)),
((48, 0, 1920, 1080), (40, -8, 1928, 1088)),
((0, 0, 1872, 1080), (-8, -8, 1880, 1088)),
] {
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
assert_eq!(clamped_rect(expected, monitor, work), None, "already corrected");
}
}
#[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);
fn a_top_taskbar_keeps_its_offset_at_other_origins_and_frame_sizes() {
let monitor = (-1920, -1080, 0, 0);
let work = (-1920, -1032, 0, 0);
for border in [0, 8, 16, 32] {
let rect = (-1920 - border, -1080 - border, border, border);
let expected = (-1920 - border, -1032 - border, border, border);
assert_eq!(clamped_rect(rect, monitor, work), Some(expected));
assert_eq!(clamped_rect(expected, monitor, work), 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() {
fn a_maximize_without_a_reserved_taskbar_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");
assert_eq!(clamped_rect((-8, -8, 1928, 1088), monitor, monitor), None);
}
#[test]
fn a_maximize_being_dragged_or_snapped_is_left_alone() {
let monitor = (0, 0, 1920, 1080);
let work = (0, 48, 1920, 1080);
assert_eq!(clamped_rect((100, 200, 1100, 900), monitor, work), None, "moved");
assert_eq!(clamped_rect((-500, -8, 1428, 1088), monitor, work), None, "dragged");
assert_eq!(clamped_rect((0, 48, 960, 1080), monitor, work), None, "half-screen");
}
}
}
+12
View File
@@ -22,6 +22,18 @@ fn main() {
use photocraft_ui_egui::native_menu;
use serde_json::json;
// Without a harness nothing answers cargo-nextest's `--list`, so it ran the whole test as its
// listing and failed on the output. List the one test, as libtest's terse format would, and
// nothing for `--ignored` (nextest asks twice). Running it passes `<name> --exact`, which falls
// through to the test below.
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "--list") {
if !args.iter().any(|a| a == "--ignored") {
println!("mac_menu_appkit: test");
}
return;
}
let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread");
let nsapp = NSApplication::sharedApplication(mtm);
nsapp.finishLaunching();
+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"
>
<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="M6.5 12a5.5 5.5 0 1 0 5.5 5.5" stroke-dasharray="2 3" />
</svg>

After

Width:  |  Height:  |  Size: 466 B

+4
View File
@@ -158,3 +158,7 @@ sync_github_name = false
[people.yeniufuklar-com]
real_name = "Ufuk Efe"
sync_github_name = true
[people.sidney]
real_name = "Sidney Markowitz"
sync_github_name = true
+1 -1
View File
@@ -16,7 +16,7 @@ synth = []
[dependencies]
crc32fast = "1.5"
flate2 = { workspace = true }
ruzstd = "0.9"
ruzstd = "0.9.1"
[dev-dependencies]
proptest = { workspace = true }
+4 -2
View File
@@ -132,8 +132,10 @@ test fixtures, not an exporter.
## Safety limits
Every size is checked before it is allocated: 256 MiB per archive entry and 1 GiB per import by
default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no longer than the
Every size is checked before it is allocated: 256 MiB per archive entry, 1 GiB extracted per import
and 1 GiB of decoded pixels per import by default (`Limits`: extracted bytes and materialized pixels
are separate budgets, since a bitmap's declared size costs memory whatever its tiles hold), a 64 MiB
zstd window, 4096 saved revisions, array lengths no longer than the
bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before
anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
+1 -1
View File
@@ -6,5 +6,5 @@ use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let _ = photocraft_affinity::container::header(data);
let _ = photocraft_affinity::preview(data);
let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
let _ = photocraft_affinity::read(data, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 });
});
+1 -1
View File
@@ -7,5 +7,5 @@ use libfuzzer_sys::fuzz_target;
fuzz_target!(|stream: &[u8]| {
let _ = photocraft_affinity::stream::parse(stream);
let file = photocraft_affinity::synth::container(&[("doc.dat", stream, photocraft_affinity::synth::Method::Stored)], None);
let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20 });
let _ = photocraft_affinity::read(&file, photocraft_affinity::Limits { max_entry: 16 << 20, max_total: 64 << 20, max_pixel_bytes: 64 << 20 });
});
+6 -1
View File
@@ -26,11 +26,16 @@ pub struct Limits {
pub max_entry: usize,
/// Total bytes extracted over the whole import.
pub max_total: usize,
/// Bytes of decoded pixels one import may hold: every layer's RGBA together, plus the
/// temporary planes the layer being read decodes through. The entry limits bound what is
/// extracted, not what the layers materialized out of it cost (#4960).
pub max_pixel_bytes: usize,
}
impl Default for Limits {
fn default() -> Self {
Self { max_entry: 256 << 20, max_total: 1 << 30 }
// The gigabyte the other importers allow decoded pixels (`io`'s `MAX_PIXEL_BYTES`).
Self { max_entry: 256 << 20, max_total: 1 << 30, max_pixel_bytes: 1 << 30 }
}
}
+19 -3
View File
@@ -288,7 +288,7 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
let mut archive = Archive::open(bytes, limits)?;
let doc = archive.read("doc.dat")?;
let s = stream::parse(&doc)?;
Reader::new(&s, &mut archive).document()
Reader::new(&s, &mut archive, limits).document()
}
pub(crate) struct Reader<'s, 'a, 'b> {
@@ -298,11 +298,27 @@ pub(crate) struct Reader<'s, 'a, 'b> {
warnings: BTreeMap<String, usize>,
nodes: usize,
active: HashSet<ObjId>,
/// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so
/// far hold).
pixels_left: usize,
}
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() }
fn new(s: &'s Stream, archive: &'b mut Archive<'a>, limits: Limits) -> Self {
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: limits.max_pixel_bytes }
}
/// Admit a layer's decoded pixels before they are allocated (#4960). `rgba` is the layer's
/// output, which stays with the document; `planes` is the working set it decodes through and
/// releases. Every layer read so far is still live while this one decodes, so the peak is all
/// three together.
pub(crate) fn admit_pixels(&mut self, rgba: usize, planes: usize) -> Result<(), Error> {
let peak = rgba.saturating_add(planes);
if peak > self.pixels_left {
return Err(Error::Limit("decoded pixel layers exceed the pixel budget"));
}
self.pixels_left -= rgba;
Ok(())
}
pub(crate) fn warn(&mut self, what: impl Into<String>) {
+2 -5
View File
@@ -94,11 +94,8 @@ pub fn preview(bytes: &[u8]) -> Result<Preview<'_>, Error> {
}
image_data = true;
}
b"IEND" => {
if !image_data {
return Err(Error::Malformed("PNG has no image data"));
}
}
b"IEND" if !image_data => return Err(Error::Malformed("PNG has no image data")),
b"IEND" => {}
_ if kind.first().is_some_and(u8::is_ascii_uppercase) => {
return Err(Error::Unsupported("unknown critical PNG chunk"));
}
+5
View File
@@ -117,6 +117,11 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
return Ok(None);
}
};
// A layer is materialized from its declared size, whatever the tiles hold: admit the output
// and the planes it decodes through before either is allocated (#4960).
let pixels = (w as usize).checked_mul(h as usize).ok_or(Error::Limit("pixel layer size"))?;
let scratch = pixels.checked_mul(channels * bps).ok_or(Error::Limit("pixel layer size"))?;
r.admit_pixels(pixels.checked_mul(4).ok_or(Error::Limit("pixel layer size"))?, scratch)?;
let mut planes = Vec::with_capacity(channels);
let mut cache = HashMap::new();
for c in 1..=channels {
+81 -2
View File
@@ -251,9 +251,9 @@ mod archive {
broken[90] ^= 0x55;
let mut a = Archive::open(&broken, Limits::default()).unwrap();
assert!(a.read("doc.dat").is_err());
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap();
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX, ..Limits::default() }).unwrap();
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap();
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1, ..Limits::default() }).unwrap();
a.read("doc.dat").unwrap();
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
}
@@ -459,3 +459,82 @@ mod shapes {
assert_eq!(sub.segments[3][2], p(5.0, 20.0));
}
}
/// Aggregate materialized-pixel admission (#4960). The entry limits bound what the archive
/// extracts; these cover what the decoded layers and their working planes cost, which is declared
/// by each bitmap's `BmpW`/`BmpH` and needs no tile data at all.
mod pixels {
use crate::Error;
use crate::container::Limits;
use crate::model::{Document, Kind};
use crate::synth::{self, F, Method, tag};
/// A `Rstr` pixel layer whose bitmap declares `w`×`h` pixels and holds no tiles.
fn layer(id: u32, w: i32, h: i32) -> F {
F::Def(
id,
vec![tag(b"Rstr")],
vec![
(tag(b"Desc"), F::Str(format!("Layer {id}"))),
(tag(b"Bitm"), F::Def(id + 1, vec![tag(b"DyBm")], vec![(tag(b"BmpW"), F::I32(w)), (tag(b"BmpH"), F::I32(h)), (tag(b"Frmt"), F::Enum(0, 0))])),
],
)
}
/// A document whose one page holds a pixel layer per `sizes` entry.
fn document(sizes: &[(i32, i32)]) -> Vec<u8> {
let children: Vec<F> = sizes.iter().enumerate().map(|(i, &(w, h))| layer(10 + 2 * i as u32, w, h)).collect();
let data = synth::stream(&[(
tag(b"DocR"),
F::Def(
1,
vec![tag(b"DocN")],
vec![(
tag(b"Chld"),
F::Shared(vec![F::Def(
2,
vec![tag(b"Sprd")],
vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 256.0, 256.0])), (tag(b"Chld"), F::Shared(children))],
)]),
)],
),
)]);
synth::container(&[("doc.dat", &data, Method::Zlib)], None)
}
fn imported_layers(d: &Document) -> usize {
d.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count()
}
/// A 64×64 layer costs 16 KiB of RGBA and 16 KiB of planes, so 40 KiB admits one and not two.
const ONE_LAYER: usize = 40 << 10;
#[test]
fn a_pixel_layer_within_the_budget_imports() {
let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() };
let d = crate::model::read(&document(&[(64, 64)]), limits).unwrap();
assert_eq!(imported_layers(&d), 1, "{:?}", d.warnings);
}
#[test]
fn layers_past_the_pixel_budget_are_rejected_before_they_are_allocated() {
let limits = Limits { max_pixel_bytes: ONE_LAYER, ..Limits::default() };
// The document is a few hundred bytes: the sizes are in the headers, not the entries.
let bytes = document(&[(64, 64), (64, 64)]);
assert!(bytes.len() < 4096, "the fixture is {} bytes", bytes.len());
assert!(matches!(crate::model::read(&bytes, limits), Err(Error::Limit(_))), "the second layer materialized");
// Two layers fit once the budget covers their pixels together.
let room = Limits { max_pixel_bytes: ONE_LAYER + (32 << 10), ..Limits::default() };
assert_eq!(imported_layers(&crate::model::read(&bytes, room).unwrap()), 2);
}
#[test]
fn a_declared_size_larger_than_the_budget_is_rejected_on_its_own() {
// 2048×2048 is 16 MiB of RGBA and 16 MiB of planes, past a 1 MiB budget.
let limits = Limits { max_pixel_bytes: 1 << 20, ..Limits::default() };
assert!(
matches!(crate::model::read(&document(&[(2048, 2048)]), limits), Err(Error::Limit(_))),
"16 MiB of declared pixels materialized under a 1 MiB budget"
);
}
}
@@ -0,0 +1,35 @@
By downloading, copying, installing or using the software you agree to this license.
If you do not agree to this license, do not download, install,
copy or use the software.
License Agreement For libfacedetection
(3-clause BSD License)
Copyright (c) 2015-2019, Shiqi Yu, all rights reserved.
[email protected]
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the names of the copyright holders nor the names of the contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
This software is provided by the copyright holders and contributors "as is" and
any express or implied warranties, including, but not limited to, the implied
warranties of merchantability and fitness for a particular purpose are disclaimed.
In no event shall copyright holders or contributors be liable for any direct,
indirect, incidental, special, exemplary, or consequential damages
(including, but not limited to, procurement of substitute goods or services;
loss of use, data, or profits; or business interruption) however caused
and on any theory of liability, whether in contract, strict liability,
or tort (including negligence or otherwise) arising in any way out of
the use of this software, even if advised of the possibility of such damage.
Binary file not shown.
+126 -14
View File
@@ -79,23 +79,48 @@ fn mirror(i: isize, n: usize) -> usize {
const TAPS: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
/// Planes smaller than this run their rows on one thread: the filter builds a whole pyramid per
/// reference (up to 96), and splitting its small levels into one-row tasks cost more in rayon's
/// scheduling than the rows themselves (a fifth of a Shadows render on 36 MP, measured).
const PAR_MIN: usize = 1 << 15;
/// [`par_rows`] for a plane, on one thread when it is small (see [`PAR_MIN`]).
fn rows(buf: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32]) + Sync + Send) {
if buf.len() < PAR_MIN {
buf.chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| f(y, row));
} else {
par_rows(buf, w, 1, f);
}
}
/// The binomial 5-tap blur, sampled every `step` pixels from 0 (1 = full resolution).
fn blur_step(p: &Plane, step: usize) -> Plane {
let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step));
// Columns first at the kept rows, then rows at the kept columns.
// Columns first at the kept rows, then rows at the kept columns. Each sum adds the same terms
// in the same order as a `mirror` per tap would, so the result is bit for bit the same; the
// source rows are found once per row and the columns mirrored only near the edges.
let mut tmp = vec![0.0f32; p.w * h2];
par_rows(&mut tmp, p.w, 1, |y2, row| {
rows(&mut tmp, p.w, |y2, row| {
let y = (y2 * step) as isize;
let src: [&[f32]; 5] = std::array::from_fn(|k| {
let r = mirror(y + k as isize - 2, p.h);
&p.v[r * p.w..(r + 1) * p.w]
});
for (x, o) in row.iter_mut().enumerate() {
*o = (0..5).map(|k| TAPS[k] * p.v[mirror(y + k as isize - 2, p.h) * p.w + x]).sum();
*o = (0..5).map(|k| TAPS[k] * src[k][x]).sum();
}
});
let mut out = vec![0.0f32; w2 * h2];
par_rows(&mut out, w2, 1, |y2, row| {
rows(&mut out, w2, |y2, row| {
let src = &tmp[y2 * p.w..(y2 + 1) * p.w];
for (x2, o) in row.iter_mut().enumerate() {
let x = (x2 * step) as isize;
*o = (0..5).map(|k| TAPS[k] * src[mirror(x + k as isize - 2, p.w)]).sum();
let x = x2 * step;
*o = if x >= 2 && x + 2 < p.w {
let s = &src[x - 2..x + 3];
(0..5).map(|k| TAPS[k] * s[k]).sum()
} else {
(0..5).map(|k| TAPS[k] * src[mirror(x as isize + k as isize - 2, p.w)]).sum()
};
}
});
Plane { w: w2, h: h2, v: out }
@@ -107,14 +132,45 @@ fn down(p: &Plane) -> Plane {
}
/// `p` brought up to `w × h`: zeros between samples, then the blur times four.
///
/// Three samples in four are zero, so their taps are skipped instead of blurring a zero-filled
/// plane: the nonzero terms are added in the same order (skipping `+0.0` terms changes at most the
/// sign of a zero, and the filter only adds and exponentiates these values).
fn up(p: &Plane, w: usize, h: usize) -> Plane {
let mut z = Plane { w, h, v: vec![0.0; w * h] };
for y in 0..p.h.min(h.div_ceil(2)) {
for x in 0..p.w.min(w.div_ceil(2)) {
z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0;
}
let (lh, lw) = (p.h.min(h.div_ceil(2)), p.w.min(w.div_ceil(2)));
// The source sample behind index `i` of a zero-stuffed axis of length `n` holding `lim` samples.
let src = |i: isize, n: usize, lim: usize| {
let m = mirror(i, n);
(m.is_multiple_of(2) && m / 2 < lim).then_some(m / 2)
};
// Columns first, only at the even columns (the odd ones are all zero).
let mut tmp = vec![0.0f32; lw * h];
if lw > 0 {
rows(&mut tmp, lw, |y, row| {
let taps: [Option<&[f32]>; 5] = std::array::from_fn(|k| src(y as isize + k as isize - 2, h, lh).map(|r| &p.v[r * p.w..r * p.w + lw]));
for (j, o) in row.iter_mut().enumerate() {
*o = (0..5).filter_map(|k| taps[k].map(|s| TAPS[k] * (s[j] * 4.0))).sum();
}
});
}
blur_step(&z, 1)
let mut out = vec![0.0f32; w * h];
rows(&mut out, w, |y, row| {
let t = &tmp[y * lw..(y + 1) * lw];
for (x, o) in row.iter_mut().enumerate() {
*o = if x >= 2 && x + 2 < w {
// Away from the edges no tap mirrors.
(0..5)
.filter_map(|k| {
let i = x + k - 2;
(i.is_multiple_of(2) && i / 2 < lw).then(|| TAPS[k] * t[i / 2])
})
.sum()
} else {
(0..5).filter_map(|k| src(x as isize + k as isize - 2, w, lw).map(|j| TAPS[k] * t[j])).sum()
};
}
});
Plane { w, h, v: out }
}
/// Levels down to about 2 pixels on the short side.
@@ -154,7 +210,7 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
for k in 0..refs {
let r = lo + step * k as f32;
let mut m = Plane { w: u.w, h: u.h, v: u.v.clone() };
par_rows(&mut m.v, u.w, 1, |_, row| {
rows(&mut m.v, u.w, |_, row| {
for v in row.iter_mut() {
*v = remap(*v, r);
}
@@ -163,7 +219,7 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
for l in 0..n - 1 {
let coarse = up(&gr[l + 1], gr[l].w, gr[l].h);
let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w);
par_rows(&mut lap[l], lw, 1, |y, row| {
rows(&mut lap[l], lw, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = y * lw + x;
let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0);
@@ -337,6 +393,62 @@ mod tests {
const SRGB: Encoding = Encoding(0.0);
/// The plain blur: a `mirror` per tap over the whole plane, the reference for [`blur_step`].
fn blur_step_reference(p: &Plane, step: usize) -> Plane {
let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step));
let mut tmp = vec![0.0f32; p.w * h2];
for y2 in 0..h2 {
for x in 0..p.w {
tmp[y2 * p.w + x] = (0..5).map(|k| TAPS[k] * p.v[mirror((y2 * step) as isize + k as isize - 2, p.h) * p.w + x]).sum();
}
}
let mut out = vec![0.0f32; w2 * h2];
for y2 in 0..h2 {
for x2 in 0..w2 {
out[y2 * w2 + x2] = (0..5).map(|k| TAPS[k] * tmp[y2 * p.w + mirror((x2 * step) as isize + k as isize - 2, p.w)]).sum();
}
}
Plane { w: w2, h: h2, v: out }
}
/// The plain upsampling: a zero-stuffed plane blurred whole, the reference for [`up`].
fn up_reference(p: &Plane, w: usize, h: usize) -> Plane {
let mut z = Plane { w, h, v: vec![0.0; w * h] };
for y in 0..p.h.min(h.div_ceil(2)) {
for x in 0..p.w.min(w.div_ceil(2)) {
z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0;
}
}
blur_step_reference(&z, 1)
}
fn noise_plane(w: usize, h: usize, seed: u32) -> Plane {
let mut s = seed.wrapping_mul(2_654_435_761).max(1);
let v = (0..w * h)
.map(|_| {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
(s % 20_000) as f32 / 1000.0 - 16.0
})
.collect();
Plane { w, h, v }
}
#[test]
fn the_fast_pyramid_matches_the_plain_one_exactly() {
// Odd, even, tiny and large-enough-to-run-in-parallel sizes, as the pyramid's levels are.
for (i, (w, h)) in [(1, 1), (1, 7), (2, 2), (3, 5), (5, 3), (8, 6), (37, 23), (64, 64), (301, 211)].into_iter().enumerate() {
let p = noise_plane(w, h, i as u32 + 1);
for step in [1, 2] {
assert_eq!(blur_step(&p, step).v, blur_step_reference(&p, step).v, "blur {w}×{h} step {step}");
}
for (uw, uh) in [(w * 2, h * 2), (w * 2 - 1, h * 2 - 1), (w * 2, h * 2 - 1), (w.max(2) * 2 - 3, h * 2)] {
assert_eq!(up(&p, uw, uh).v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}");
}
}
}
fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> {
(0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect()
}
+154 -1
View File
@@ -474,8 +474,10 @@ pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bo
let mut cost_fg = vec![0.0f32; sw * sh];
let mut cost_bg = vec![0.0f32; sw * sh];
let mut fixed = vec![FREE; sw * sh];
// Always the hole's own edge, and around a thin selection each part's surroundings.
let keep = thin_band(sw, sh, &shole);
for i in 0..sw * sh {
if shole[i] || dist[i] <= 1.5 {
if shole[i] || dist[i] <= keep {
fixed[i] = HARD_FG;
continue;
}
@@ -508,6 +510,22 @@ pub fn auto_sampling_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bo
Ok(out)
}
/// How far around a thin selection (strings, wires, and any small parts selected with them, all
/// at most a quarter as thick as the selection is long) the Auto area always reaches, in cells of
/// a `w × h` grid: as far as the selection is thick, so that each part, however far from the
/// rest, keeps its own surroundings to copy from. 1.5 cells for any other selection.
fn thin_band(w: usize, h: usize, hole: &[bool]) -> f32 {
let inside = crate::selection::edt(&hole.iter().map(|h| !h).collect::<Vec<_>>(), w, h);
let thickness = 2.0 * inside.iter().zip(hole).filter(|(_, h)| **h).map(|(d, _)| *d).fold(0.0f32, f32::max);
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
for (i, _) in hole.iter().enumerate().filter(|(_, h)| **h) {
let (x, y) = (i % w, i / w);
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
}
let extent = x1.saturating_sub(x0).max(y1.saturating_sub(y0)) as f32;
if 4.0 * thickness <= extent { thickness.max(1.5) } else { 1.5 }
}
/// The Patch Tool's and the Content-Aware Move Tool's Color option (0–10) as the colour adaptation
/// of the copied content. Measured on Photoshop 25.4 with content brighter than its new place: 0
/// keeps it as it is; 3 moves it about 0.12 (32 of 42 levels, and 31 of 102: a limit, not a
@@ -751,6 +769,31 @@ mod tests {
}
}
#[test]
fn auto_sampling_keeps_the_surroundings_of_every_part_of_a_thin_selection() {
// A long thin selection across a grey ramp, and a small knob selected with it in a red
// corner. Each part keeps its own surroundings; a compact selection is unchanged.
let (w, h) = (900, 600);
let tex = noise(w, h, 4);
let img: Vec<f32> = (0..w * h * 3)
.map(|i| {
let (x, y, c) = ((i / 3) % w, (i / 3) / w, i % 3);
let base = if x > 690 && y > 420 { [0.7, 0.25, 0.2][c] } else { 0.2 + 0.5 * x as f32 / w as f32 };
base + (tex[i] - 0.5) * 0.04
})
.collect();
let line = |i: usize| (30..870).contains(&(i % w)) && (180..192).contains(&(i / w));
let knob = |i: usize| ((i % w) as f32 - 795.0).hypot((i / w) as f32 - 510.0) < 10.0;
let hole: Vec<bool> = (0..w * h).map(|i| line(i) || knob(i)).collect();
let area = auto_sampling(w, h, 3, &img, &hole);
let around_knob = (0..w * h).filter(|&i| !hole[i] && ((i % w) as f32 - 795.0).hypot((i / w) as f32 - 510.0) < 24.0);
let (n, kept) = around_knob.fold((0, 0), |(n, k), i| (n + 1, k + usize::from(area[i])));
assert!(kept as f32 > 0.9 * n as f32, "{kept} of {n} pixels around the knob");
assert!(thin_band(w, h, &hole) >= 20.0, "{}", thin_band(w, h, &hole));
let disc: Vec<bool> = (0..w * h).map(|i| ((i % w) as f32 - 450.0).hypot((i / w) as f32 - 300.0) < 90.0).collect();
assert_eq!(thin_band(w, h, &disc), 1.5);
}
#[test]
fn without_colour_adaptation_the_fill_copies_pixels_exactly() {
// A smooth texture with fine noise, so that every value is unique: a filled pixel equal to
@@ -798,6 +841,116 @@ mod tests {
assert!((shift(10) - 0.4).abs() < 0.01, "{}", shift(10));
}
/// A soft background (gradients, broad out-of-focus blobs of colour, fine noise) with thin, long bright
/// objects over it: three coiled strings 3 to 7 px wide and two straighter ones, each with an
/// anti-aliased edge and a faint halo. Returns the image; what removing the coils should give
/// (the background and the straighter strings, which stay); and the selection a user would draw
/// around the coils, `margin` px wide.
fn strings_scene(w: usize, h: usize, margin: f32) -> (Vec<f32>, Vec<f32>, Vec<bool>) {
let fine = noise(w, h, 11);
// Out-of-focus colour: broad blobs over two gradients.
let blob = noise(3, 3, 5);
let bg: Vec<f32> = (0..w * h * 3)
.map(|i| {
let (x, y, c) = (((i / 3) % w) as f32 / w as f32, ((i / 3) / w) as f32 / h as f32, i % 3);
let mut v = [0.18 + 0.25 * x, 0.22 + 0.1 * y, 0.3 - 0.1 * x][c];
for b in blob.as_chunks::<3>().0 {
let (bx, by, s) = (b[0] * 1.6 - 0.3, b[1] * 1.6 - 0.3, 0.1 + 0.25 * b[2]);
v += (b[(c + 1) % 3] - 0.2) * 0.5 * (-((x - bx).powi(2) + (y - by).powi(2) * 0.56) / (2.0 * s * s)).exp();
}
v + (fine[i] - 0.5) * 0.03
})
.collect();
// Signed distance to each object's surface, stamped from points along it.
let stamp = |sd: &mut [f32], pts: &mut dyn Iterator<Item = (f32, f32)>, half: f32| {
for (px, py) in pts {
let reach = (half + 4.0) as i32 + 1;
for dy in -reach..=reach {
for dx in -reach..=reach {
let (x, y) = (px as i32 + dx, py as i32 + dy);
if x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h {
let i = y as usize * w + x as usize;
sd[i] = sd[i].min((x as f32 - px).hypot(y as f32 - py) - half);
}
}
}
}
};
let mut coils = vec![f32::INFINITY; w * h];
for (k, half) in [1.5f32, 2.5, 3.5].into_iter().enumerate() {
let (y0, r) = ((0.22 + 0.28 * k as f32) * h as f32, 10.0 + 2.0 * k as f32);
let mut pts = (0..4000).map(|j| {
let t = j as f32 / 4000.0;
let a = 8.0 * std::f32::consts::TAU * t;
(18.0 + t * (w as f32 - 60.0) + r * a.cos(), y0 + 6.0 * (6.0 * t).sin() + 0.7 * r * a.sin())
});
stamp(&mut coils, &mut pts, half);
}
let mut straight = vec![f32::INFINITY; w * h];
for y in [0.07, 0.93] {
stamp(&mut straight, &mut (0..2000).map(|j| (j as f32 / 2000.0 * w as f32, y * h as f32 + 3.0 * (j as f32 / 300.0).sin())), 2.0);
}
let draw = |base: &[f32], sd: &[f32]| -> Vec<f32> {
let mut img = base.to_vec();
for i in 0..w * h {
let cover = (0.5 - sd[i]).clamp(0.0, 1.0);
let halo = if sd[i] > 0.0 { 0.08 * (-sd[i] * sd[i] / 2.9).exp() } else { 0.0 };
for (c, metal) in [0.9f32, 0.85, 0.7].into_iter().enumerate() {
let v = &mut img[i * 3 + c];
*v = *v * (1.0 - cover) + metal * cover + halo * (1.0 - cover);
}
}
img
};
let clean = draw(&bg, &straight);
let both: Vec<f32> = coils.iter().zip(&straight).map(|(a, b)| a.min(*b)).collect();
let img = draw(&bg, &both);
let hole = coils.iter().map(|d| *d <= margin).collect();
(img, clean, hole)
}
/// RMS over the hole of the error averaged over 7 × 7 pixels (in levels of 255): the noise
/// cancels out, a ghost of the removed outline or a blotch of another shade does not.
fn blotch_error(w: usize, h: usize, out: &[f32], clean: &[f32], hole: &[bool]) -> f32 {
let (mut sum, mut n) = (0.0f64, 0usize);
for y in 3..h - 3 {
for x in 3..w - 3 {
if !hole[y * w + x] {
continue;
}
let mut e = [0.0f32; 3];
for j in (y - 3..=y + 3).flat_map(|yy| (x - 3..=x + 3).map(move |xx| yy * w + xx)) {
for (c, ec) in e.iter_mut().enumerate() {
*ec += out[j * 3 + c] - clean[j * 3 + c];
}
}
sum += f64::from(e.iter().map(|v| (v / 49.0).powi(2)).sum::<f32>() / 3.0);
n += 1;
}
}
((sum / n.max(1) as f64).sqrt() * 255.0) as f32
}
/// Removing thin, long objects (guitar strings, wires) from a soft background (#1366): the
/// fill is the background, with no ghost of their outline and nothing of their colour copied
/// back in. Their selection is long but only a few pixels thick, so the fill is worked out at
/// a scale where it is still a few pixels thick rather than one where the strings' surroundings
/// have merged into a blur.
#[test]
fn thin_objects_leave_no_ghost_and_none_of_their_colour() {
let (w, h) = (360, 270);
let (img, clean, hole) = strings_scene(w, h, 2.0);
let source = auto_sampling(w, h, 3, &img, &hole);
let out = fill(w, h, 3, &img, &hole, &source, &FillOptions::default());
let blotch = blotch_error(w, h, &out, &clean, &hole);
let luma = |v: &[f32], i: usize| (v[i * 3] + v[i * 3 + 1] + v[i * 3 + 2]) / 3.0;
let bright = (0..w * h).filter(|&i| hole[i] && luma(&out, i) > luma(&clean, i) + 0.06).count();
// Before #1366 the outlines stayed as darker and lighter blotches (13 levels of 255) and
// 1121 pixels took the strings' colour.
assert!(blotch < 2.5, "ghosts and blotches: {blotch:.2} levels of 255");
assert_eq!(bright, 0, "filled pixels with the strings' colour");
}
#[test]
fn variant_geometry() {
let img = vec![0.5f32; 10 * 6];
+90
View File
@@ -0,0 +1,90 @@
//! Face detection: a port of libfacedetection's CNN (Shiqi Yu, BSD-3; `data/LICENSE-libfacedetection.txt`).
//!
//! The network is the YuNet design: a backbone of 1×1 pointwise and 3×3 depthwise convolutions with
//! 2×2 max pooling, a feature pyramid at strides 8, 16 and 32, and per-cell heads for the face score,
//! the box and five landmarks (eyes, nose tip, mouth corners). The weights (`data/libfacedetection.bin`,
//! converted to half precision from the library's `facedetectcnn-data.cpp`) are about 100 KB, and
//! inference is plain Rust: no runtime, so it works offline.
//!
//! [`detect`] takes straight RGBA in `0..=1` and returns faces in pixel coordinates of that image.
//!
//! The browser build (wasm32) leaves the network and its weights out, as `photocraft-raw` does with
//! its JPEG XL decoder: they would push the wasm over the per-file cap of static hosts
//! (packaging/web/package.sh). There [`AVAILABLE`] is false and [`detect`] finds nothing.
#[cfg(not(target_arch = "wasm32"))]
mod cnn;
/// Whether this build can detect faces (not the browser build: see the module docs).
pub const AVAILABLE: bool = cfg!(not(target_arch = "wasm32"));
/// Longest side the network looks at; larger images are scaled down first (faces smaller than
/// about 10 px at this scale are missed).
pub const MAX_SIDE: usize = 1280;
/// One detected face, in pixels of the image passed to [`detect`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Face {
/// Confidence `0..=1` (the library reports faces above 0.5 as reliable).
pub score: f32,
/// Left, top, width and height.
pub rect: [f32; 4],
/// Right eye, left eye (as seen in the image: the first is on the image's left), nose tip,
/// right mouth corner, left mouth corner.
pub landmarks: [[f32; 2]; 5],
}
/// Faces in `px` (straight RGBA `0..=1`, `w × h`, row-major) scoring at least `min_score`, best
/// first. Transparent pixels count as white. `None` when `px` doesn't match the size.
#[cfg(not(target_arch = "wasm32"))]
pub fn detect(px: &[[f32; 4]], w: usize, h: usize, min_score: f32) -> Option<Vec<Face>> {
if w == 0 || h == 0 || px.len() != w.checked_mul(h)? {
return None;
}
// Scale down (box filter) so the longer side is at most MAX_SIDE.
let step = w.max(h).div_ceil(MAX_SIDE).max(1);
let (sw, sh) = (w.div_ceil(step), h.div_ceil(step));
let mut bgr = vec![[0.0f32; 3]; sw * sh];
for y in 0..sh {
for x in 0..sw {
let (mut acc, mut n) = ([0.0f32; 3], 0.0f32);
for yy in y * step..((y + 1) * step).min(h) {
for xx in x * step..((x + 1) * step).min(w) {
let [r, g, b, a] = px[yy * w + xx];
let a = a.clamp(0.0, 1.0);
for (k, v) in [b, g, r].into_iter().enumerate() {
acc[k] += v.clamp(0.0, 1.0) * a + (1.0 - a);
}
n += 1.0;
}
}
bgr[y * sw + x] = acc.map(|v| (v / n.max(1.0) * 255.0).round());
}
}
let s = step as f32;
let faces = cnn::network(&bgr, sw, sh)?
.into_iter()
.filter(|f| f.score >= min_score)
.map(|f| Face { score: f.score, rect: f.rect.map(|v| v * s), landmarks: f.landmarks.map(|[x, y]| [x * s, y * s]) })
.collect();
Some(faces)
}
/// The browser build has no detector (see the module docs): always `None`.
#[cfg(target_arch = "wasm32")]
pub fn detect(_px: &[[f32; 4]], _w: usize, _h: usize, _min_score: f32) -> Option<Vec<Face>> {
None
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
#[test]
fn no_faces_in_flat_or_tiny_images_and_bad_sizes_are_refused() {
assert_eq!(detect(&vec![[0.5, 0.5, 0.5, 1.0]; 64 * 48], 64, 48, 0.5), Some(vec![]));
assert_eq!(detect(&[[1.0, 1.0, 1.0, 1.0]], 1, 1, 0.5), Some(vec![]));
assert_eq!(detect(&[[1.0; 4]; 3], 2, 2, 0.5), None);
assert_eq!(detect(&[], 0, 0, 0.5), None);
}
}
+323
View File
@@ -0,0 +1,323 @@
//! The network itself (native builds only: see the parent module's docs).
use super::Face;
use crate::par;
/// The weights: `PCFACE1\0`, the layer count (u16), then per layer its input channels (u16), filters
/// (u16) and kind (u8: 0 pointwise, 1 depthwise), the weights and the biases, all little-endian f16.
static WEIGHTS: &[u8] = include_bytes!("../../data/libfacedetection.bin");
const MAGIC: &[u8] = b"PCFACE1\0";
const LAYERS: usize = 53;
/// Faces whose boxes overlap more than this (intersection over union) are one face.
const NMS_OVERLAP: f32 = 0.45;
/// Candidates kept before non-maximum suppression, as the library does.
const TOP_K: usize = 1000;
struct Layer {
channels: usize,
filters: usize,
depthwise: bool,
weights: Vec<f32>,
biases: Vec<f32>,
}
/// A feature map: `rows × cols` cells of `channels` values each.
#[derive(Clone)]
struct Blob {
rows: usize,
cols: usize,
channels: usize,
data: Vec<f32>,
}
impl Blob {
fn new(rows: usize, cols: usize, channels: usize) -> Self {
Blob { rows, cols, channels, data: vec![0.0; rows * cols * channels] }
}
}
fn f16_to_f32(h: u16) -> f32 {
let sign = if h & 0x8000 != 0 { -1.0 } else { 1.0 };
let exp = i32::from((h >> 10) & 0x1f);
let frac = f32::from(h & 0x3ff);
match exp {
0 => sign * frac * (2.0f32).powi(-24),
31 => {
if frac == 0.0 {
sign * f32::INFINITY
} else {
f32::NAN
}
}
_ => sign * (1.0 + frac / 1024.0) * (2.0f32).powi(exp - 15),
}
}
fn parse(bytes: &[u8]) -> Option<Vec<Layer>> {
let rest = bytes.strip_prefix(MAGIC)?;
let mut at = 0usize;
let u16_at = |n: &mut usize| -> Option<u16> {
let b = rest.get(*n..*n + 2)?;
*n += 2;
Some(u16::from_le_bytes([b[0], b[1]]))
};
let count = usize::from(u16_at(&mut at)?);
if count != LAYERS {
return None;
}
let mut layers = Vec::with_capacity(count);
for _ in 0..count {
let channels = usize::from(u16_at(&mut at)?);
let filters = usize::from(u16_at(&mut at)?);
let depthwise = *rest.get(at)? == 1;
at += 1;
let nw = if depthwise { 9 * channels } else { filters * channels };
let mut read = |n: usize| -> Option<Vec<f32>> { (0..n).map(|_| u16_at(&mut at).map(f16_to_f32)).collect() };
let weights = read(nw)?;
let biases = read(filters)?;
if depthwise && filters != channels {
return None;
}
layers.push(Layer { channels, filters, depthwise, weights, biases });
}
(at == rest.len()).then_some(layers)
}
/// A 1×1 or 3×3 depthwise convolution with bias, then ReLU when `relu`.
fn conv(x: &Blob, l: &Layer, relu: bool) -> Option<Blob> {
if x.channels != l.channels {
return None;
}
let mut out = Blob::new(x.rows, x.cols, l.filters);
let (rows, cols, cin, cout) = (x.rows, x.cols, x.channels, l.filters);
par::chunks_mut(&mut out.data, cols * cout, rows * cols * cout > 1 << 16, |row, line| {
for col in 0..cols {
let o = &mut line[col * cout..(col + 1) * cout];
if l.depthwise {
o.copy_from_slice(&l.biases);
for r in row.saturating_sub(1)..(row + 2).min(rows) {
for c in col.saturating_sub(1)..(col + 2).min(cols) {
let k = (r + 1 - row) * 3 + (c + 1 - col);
let input = &x.data[(r * cols + c) * cin..(r * cols + c + 1) * cin];
let w = &l.weights[k * cin..(k + 1) * cin];
for ((o, i), w) in o.iter_mut().zip(input).zip(w) {
*o += i * w;
}
}
}
} else {
let input = &x.data[(row * cols + col) * cin..(row * cols + col + 1) * cin];
for (f, o) in o.iter_mut().enumerate() {
let w = &l.weights[f * cin..(f + 1) * cin];
*o = l.biases[f] + input.iter().zip(w).map(|(i, w)| i * w).sum::<f32>();
}
}
if relu {
for v in o.iter_mut() {
*v = v.max(0.0);
}
}
}
});
Some(out)
}
/// Pointwise then depthwise (ReLU after the depthwise one when `relu`).
fn conv_dp(x: &Blob, p: &Layer, d: &Layer, relu: bool) -> Option<Blob> {
conv(&conv(x, p, false)?, d, relu)
}
fn unit(x: &Blob, l: &[Layer]) -> Option<Blob> {
let [p1, d1, p2, d2] = l else { return None };
conv_dp(&conv_dp(x, p1, d1, true)?, p2, d2, true)
}
fn max_pool(x: &Blob) -> Blob {
let rows = x.rows.saturating_sub(3).div_ceil(2) + 1;
let cols = x.cols.saturating_sub(3).div_ceil(2) + 1;
let mut out = Blob::new(rows, cols, x.channels);
for r in 0..rows {
for c in 0..cols {
let o = (r * cols + c) * x.channels;
for ch in 0..x.channels {
let mut m = f32::NEG_INFINITY;
for rr in r * 2..(r * 2 + 2).min(x.rows) {
for cc in c * 2..(c * 2 + 2).min(x.cols) {
m = m.max(x.data[(rr * x.cols + cc) * x.channels + ch]);
}
}
out.data[o + ch] = m;
}
}
}
out
}
/// `small` upsampled ×2 (nearest) plus `big`.
fn up_add(small: &Blob, big: &Blob) -> Option<Blob> {
if small.rows * 2 != big.rows || small.cols * 2 != big.cols || small.channels != big.channels {
return None;
}
let mut out = big.clone();
let ch = big.channels;
for r in 0..big.rows {
for c in 0..big.cols {
let s = ((r / 2) * small.cols + c / 2) * ch;
let o = (r * big.cols + c) * ch;
for k in 0..ch {
out.data[o + k] += small.data[s + k];
}
}
}
Some(out)
}
/// The network's input: each cell holds the 3×3 neighbourhood (stride 2) of BGR values `0..=255`,
/// so the first, 3×3 stride-2 convolution is a pointwise one. The size is padded to 32.
fn input(bgr: &[[f32; 3]], w: usize, h: usize) -> Blob {
let rows = h.div_ceil(32) * 16;
let cols = w.div_ceil(32) * 16;
let mut b = Blob::new(rows, cols, 32);
for r in 0..rows {
for c in 0..cols {
let cell = (r * cols + c) * 32;
for fy in 0..3 {
let Some(y) = (r * 2 + fy).checked_sub(1).filter(|&y| y < h) else { continue };
for fx in 0..3 {
let Some(x) = (c * 2 + fx).checked_sub(1).filter(|&x| x < w) else { continue };
let o = cell + (fy * 3 + fx) * 3;
b.data[o..o + 3].copy_from_slice(&bgr[y * w + x]);
}
}
}
}
b
}
/// The heads of one pyramid level, decoded to candidate faces (in network pixels).
fn candidates(feat: &Blob, l: &[Layer], heads: [usize; 4], stride: f32, out: &mut Vec<Face>) -> Option<()> {
let head = |i: usize| conv_dp(feat, l.get(i)?, l.get(i + 1)?, false);
let (cls, reg, obj, kps) = (head(heads[0])?, head(heads[1])?, head(heads[2])?, head(heads[3])?);
if cls.channels != 1 || obj.channels != 1 || reg.channels != 4 || kps.channels != 10 {
return None;
}
let sigmoid = |v: f32| 1.0 / (1.0 + (-v.clamp(-88.0, 88.0)).exp());
for r in 0..feat.rows {
for c in 0..feat.cols {
let i = r * feat.cols + c;
let score = (sigmoid(cls.data[i]) * sigmoid(obj.data[i])).sqrt();
if score < 0.2 {
continue;
}
let (px, py) = (c as f32 * stride, r as f32 * stride);
let b = &reg.data[i * 4..i * 4 + 4];
let (cx, cy) = (b[0] * stride + px, b[1] * stride + py);
let (bw, bh) = (b[2].exp() * stride, b[3].exp() * stride);
let k = &kps.data[i * 10..i * 10 + 10];
let mut landmarks = [[0.0; 2]; 5];
for (n, lm) in landmarks.iter_mut().enumerate() {
*lm = [k[2 * n] * stride + px, k[2 * n + 1] * stride + py];
}
out.push(Face { score, rect: [cx - bw / 2.0, cy - bh / 2.0, bw, bh], landmarks });
}
}
Some(())
}
fn iou(a: &[f32; 4], b: &[f32; 4]) -> f32 {
let w = ((a[0] + a[2]).min(b[0] + b[2]) - a[0].max(b[0])).max(0.0);
let h = ((a[1] + a[3]).min(b[1] + b[3]) - a[1].max(b[1])).max(0.0);
let i = w * h;
if i <= 0.0 { 0.0 } else { i / (a[2] * a[3] + b[2] * b[3] - i) }
}
/// The heads (cls, reg, obj, kps: their first layer) of the stride 8, 16 and 32 levels.
const HEADS: [[usize; 4]; 3] = [[29, 35, 41, 47], [31, 37, 43, 49], [33, 39, 45, 51]];
/// The backbone and feature pyramid: the stride 8, 16 and 32 feature maps the heads read.
fn features(bgr: &[[f32; 3]], w: usize, h: usize, l: &[Layer]) -> Option<[Blob; 3]> {
let x = conv(&input(bgr, w, h), l.first()?, true)?;
let x = max_pool(&conv_dp(&x, l.get(1)?, l.get(2)?, true)?);
let x = unit(&unit(&x, l.get(3..7)?)?, l.get(7..11)?)?;
let f8 = unit(&max_pool(&x), l.get(11..15)?)?;
let f16 = unit(&max_pool(&f8), l.get(15..19)?)?;
let f32_ = unit(&max_pool(&f16), l.get(19..23)?)?;
let f32_ = conv_dp(&f32_, l.get(27)?, l.get(28)?, true)?;
let f16 = conv_dp(&up_add(&f32_, &f16)?, l.get(25)?, l.get(26)?, true)?;
let f8 = conv_dp(&up_add(&f16, &f8)?, l.get(23)?, l.get(24)?, true)?;
Some([f8, f16, f32_])
}
pub(super) fn network(bgr: &[[f32; 3]], w: usize, h: usize) -> Option<Vec<Face>> {
let l = parse(WEIGHTS)?;
let levels = features(bgr, w, h, &l)?;
let mut faces = Vec::new();
// Heads (cls, reg, obj, kps) per level, as in the library's model.
for ((feat, heads), stride) in levels.iter().zip(HEADS).zip([8.0, 16.0, 32.0]) {
candidates(feat, &l, heads, stride, &mut faces)?;
}
faces.sort_by(|a, b| b.score.total_cmp(&a.score));
faces.truncate(TOP_K);
let mut kept: Vec<Face> = Vec::new();
for f in faces {
if kept.iter().all(|k| iou(&k.rect, &f.rect) <= NMS_OVERLAP) {
kept.push(f);
}
}
Some(kept)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn weights_parse() {
let l = parse(WEIGHTS).expect("bundled weights");
assert_eq!(l.len(), LAYERS);
assert_eq!((l[0].channels, l[0].filters, l[0].depthwise), (32, 16, false));
assert_eq!((l[2].channels, l[2].filters, l[2].depthwise), (16, 16, true));
assert!(l.iter().all(|l| l.weights.iter().chain(&l.biases).all(|v| v.is_finite())));
// Damaged weights are refused, not read past their end.
assert!(parse(&WEIGHTS[..WEIGHTS.len() - 1]).is_none());
assert!(parse(b"nope").is_none());
}
/// The sum of every head's raw output on a fixed test image matches libfacedetection's own C++
/// (computed with its f32 weights; ours are f16, hence the tolerance).
#[test]
fn heads_match_the_reference_implementation() {
let (w, h) = (96usize, 72usize);
let bgr: Vec<[f32; 3]> = (0..w * h)
.map(|i| {
let (x, y) = (i % w, i / w);
let (r, g, b) = ((x * 7 + y * 3) % 256, (x * x + y * 5) % 256, (x * y * 3 + 11) % 256);
[b as f32, g as f32, r as f32]
})
.collect();
let l = parse(WEIGHTS).unwrap();
let levels = features(&bgr, w, h, &l).unwrap();
let reference =
[27.647039, 549.579923, -1645.299678, 914.784787, -6.149318, 35.705980, -454.742148, 163.847111, 1.267630, 28.415509, -85.662257, 62.294208];
let mut got = Vec::new();
for (feat, heads) in levels.iter().zip(HEADS) {
for first in heads {
let o = conv_dp(feat, &l[first], &l[first + 1], false).unwrap();
got.push(o.data.iter().map(|&v| f64::from(v)).sum::<f64>());
}
}
for (g, r) in got.iter().zip(reference) {
assert!((g - r).abs() <= 0.01 * r.abs().max(10.0), "{got:?}");
}
}
#[test]
fn f16_decoding() {
assert_eq!(f16_to_f32(0x3c00), 1.0);
assert_eq!(f16_to_f32(0xc000), -2.0);
assert_eq!(f16_to_f32(0x0000), 0.0);
assert_eq!(f16_to_f32(0x3555), 0.333_251_95);
assert_eq!(f16_to_f32(0x0001), 2.0f32.powi(-24));
}
}
+262 -54
View File
@@ -60,25 +60,98 @@ impl Rng {
}
}
/// Columns per band of the vertical blur pass: 64 f32 = 256 bytes = four cache lines per row, so
/// walking down a band touches whole lines, and the band's working set stays in cache.
const BLUR_BAND: usize = 64;
/// Box blur of radius `r` (edges clamped): a running f32 sum along rows, then along columns.
///
/// Both passes are parallel and give bit for bit what the one-line-at-a-time version gave: every
/// output sums its window in the same order. Rows are independent. For the column pass, the
/// image is cut into bands of columns; each column keeps its own f32 running sum, started from
/// its first window summed in order and updated in the same order, so only the loop nest changes
/// (the inner loop runs across a band's columns and vectorises), never any column's arithmetic.
fn blur(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()];
let (n, m) = if horizontal { (h, w) } else { (w, h) };
for line in 0..n {
let at = |k: i64| {
let k = k.clamp(0, m as i64 - 1) as usize;
if horizontal { src[line * w + k] } else { src[k * w + line] }
};
let mut acc: f32 = (-(r as i64)..=r as i64).map(at).sum();
for k in 0..m {
let i = if horizontal { line * w + k } else { k * w + line };
dst[i] = acc / (2 * r + 1) as f32;
acc += at(k as i64 + r as i64 + 1) - at(k as i64 - r as i64);
if w == 0 || h == 0 {
return Vec::new();
}
let den = (2 * r + 1) as f32;
let mut tmp = vec![0.0f32; w * h];
par_rows(&mut tmp, w, |line, dst| {
let src = &v[line * w..(line + 1) * w];
let at = |k: i64| src[k.clamp(0, w as i64 - 1) as usize];
let mut acc: f32 = (-(r as i64)..=r as i64).map(at).sum();
for (k, o) in dst.iter_mut().enumerate() {
*o = acc / den;
acc += at(k as i64 + r as i64 + 1) - at(k as i64 - r as i64);
}
});
let mut dst = vec![0.0f32; w * h];
// One list of row segments per band, each segment a `&mut` slice of `dst`: no `unsafe`.
let mut bands: Vec<Vec<&mut [f32]>> = (0..w.div_ceil(BLUR_BAND)).map(|_| Vec::with_capacity(h)).collect();
for row in dst.chunks_exact_mut(w) {
for (band, seg) in bands.iter_mut().zip(row.chunks_mut(BLUR_BAND)) {
band.push(seg);
}
}
let src = &tmp;
let run = |bi: usize, segs: &mut [&mut [f32]], acc: &mut Vec<f32>| {
let (x0, bw) = (bi * BLUR_BAND, segs.first().map_or(0, |s| s.len()));
let row = |k: i64| {
let o = (k.clamp(0, h as i64 - 1) as usize) * w + x0;
&src[o..o + bw]
};
// The first window, summed in order from the same start as `Iterator::sum` uses.
acc.clear();
acc.resize(bw, std::iter::empty::<f32>().sum());
for k in -(r as i64)..=r as i64 {
for (t, &x) in acc.iter_mut().zip(row(k)) {
*t += x;
}
}
for (k, seg) in segs.iter_mut().enumerate() {
let (add, sub) = (row(k as i64 + r as i64 + 1), row(k as i64 - r as i64));
for (((o, t), &p), &m) in seg.iter_mut().zip(acc.iter_mut()).zip(add).zip(sub) {
*o = *t / den;
*t += p - m;
}
}
dst
};
pass(&pass(v, true), false)
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
bands.into_par_iter().enumerate().for_each_init(Vec::new, |acc, (bi, mut segs)| run(bi, &mut segs, acc));
}
#[cfg(target_arch = "wasm32")]
{
let mut acc = Vec::new();
for (bi, mut segs) in bands.into_iter().enumerate() {
run(bi, &mut segs, &mut acc);
}
}
dst
}
/// Runs `f(y, row)` over the `w`-wide rows of `buf`, in parallel on native targets.
fn par_rows(buf: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32]) + Sync + Send) {
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
buf.par_chunks_mut(w).enumerate().for_each(|(y, row)| f(y, row));
}
#[cfg(target_arch = "wasm32")]
buf.chunks_mut(w).enumerate().for_each(|(y, row)| f(y, row));
}
/// [`par_rows`] over three row-aligned buffers in lockstep.
fn par_rows3(a: &mut [f32], b: &mut [f32], c: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32], &mut [f32], &mut [f32]) + Sync + Send) {
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
a.par_chunks_mut(w).zip(b.par_chunks_mut(w)).zip(c.par_chunks_mut(w)).enumerate().for_each(|(y, ((a, b), c))| f(y, a, b, c));
}
#[cfg(target_arch = "wasm32")]
a.chunks_mut(w).zip(b.chunks_mut(w)).zip(c.chunks_mut(w)).enumerate().for_each(|(y, ((a, b), c))| f(y, a, b, c));
}
/// Harris corners: at most `max` corners at least `min_dist` pixels apart, strongest first,
@@ -88,28 +161,40 @@ pub fn harris(w: usize, h: usize, img: &[f32], max: usize, min_dist: f32, valid:
return Vec::new();
}
let (mut xx, mut yy, mut xy) = (vec![0.0f32; w * h], vec![0.0f32; w * h], vec![0.0f32; w * h]);
for y in 1..h - 1 {
// Rows are independent (each output pixel reads only `img`), so they run in parallel.
par_rows3(&mut xx, &mut yy, &mut xy, w, |y, xxr, yyr, xyr| {
if y == 0 || y + 1 == h {
return;
}
for x in 1..w - 1 {
let p = |dx: i64, dy: i64| img[((y as i64 + dy) as usize) * w + (x as i64 + dx) as usize];
// Sobel.
let gx = (p(1, -1) + 2.0 * p(1, 0) + p(1, 1)) - (p(-1, -1) + 2.0 * p(-1, 0) + p(-1, 1));
let gy = (p(-1, 1) + 2.0 * p(0, 1) + p(1, 1)) - (p(-1, -1) + 2.0 * p(0, -1) + p(1, -1));
let i = y * w + x;
xx[i] = gx * gx;
yy[i] = gy * gy;
xy[i] = gx * gy;
xxr[x] = gx * gx;
yyr[x] = gy * gy;
xyr[x] = gx * gy;
}
}
});
// Two box passes approximate the Gaussian window.
let (xx, yy, xy) = (blur(w, h, &blur(w, h, &xx, 1), 1), blur(w, h, &blur(w, h, &yy, 1), 1), blur(w, h, &blur(w, h, &xy, 1), 1));
let r: Vec<f32> = (0..w * h).map(|i| xx[i] * yy[i] - xy[i] * xy[i] - 0.04 * (xx[i] + yy[i]).powi(2)).collect();
let mut r = vec![0.0f32; w * h];
par_rows(&mut r, w, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = y * w + x;
*o = xx[i] * yy[i] - xy[i] * xy[i] - 0.04 * (xx[i] + yy[i]).powi(2);
}
});
let peak = r.iter().copied().fold(0.0f32, f32::max);
if peak <= 0.0 {
return Vec::new();
}
let m = PATCH_RADIUS + 1;
let mut cand: Vec<Corner> = Vec::new();
for y in m..h - m {
// Rows are scanned in parallel and joined in row order; the sort below fully orders the
// candidates anyway (score, then y, then x), so the order they arrive in cannot matter.
let rows = par_map((h - m).saturating_sub(m), |i| {
let y = m + i;
let mut found: Vec<Corner> = Vec::new();
for x in m..w - m {
let v = r[y * w + x];
if v < peak * 0.01 {
@@ -125,9 +210,11 @@ pub fn harris(w: usize, h: usize, img: &[f32], max: usize, min_dist: f32, valid:
continue;
}
}
cand.push(Corner { x: x as f32, y: y as f32, score: v });
found.push(Corner { x: x as f32, y: y as f32, score: v });
}
}
found
});
let mut cand: Vec<Corner> = rows.into_iter().flatten().collect();
cand.sort_by(|a, b| b.score.total_cmp(&a.score).then(a.y.total_cmp(&b.y)).then(a.x.total_cmp(&b.x)));
// Minimum spacing via a coarse occupancy grid.
let cell = min_dist.max(1.0);
@@ -176,37 +263,37 @@ pub fn describe(w: usize, h: usize, img: &[f32], corners: &[Corner], upright: bo
let (xi, yi) = ((x.round() as i64).clamp(0, w as i64 - 1) as usize, (y.round() as i64).clamp(0, h as i64 - 1) as usize);
smooth[yi * w + xi]
};
corners
.iter()
.map(|c| {
let (s, co) = if upright {
(0.0, 1.0)
} else {
let (mut m10, mut m01) = (0.0f32, 0.0f32);
let r = PATCH_RADIUS as i64;
for dy in -r..=r {
for dx in -r..=r {
if dx * dx + dy * dy <= r * r {
let v = at(c.x + dx as f32, c.y + dy as f32);
m10 += dx as f32 * v;
m01 += dy as f32 * v;
}
// Every descriptor depends only on its own corner and the smoothed image: computed in
// parallel, kept in corner order.
par_map(corners.len(), |ci| {
let c = &corners[ci];
let (s, co) = if upright {
(0.0, 1.0)
} else {
let (mut m10, mut m01) = (0.0f32, 0.0f32);
let r = PATCH_RADIUS as i64;
for dy in -r..=r {
for dx in -r..=r {
if dx * dx + dy * dy <= r * r {
let v = at(c.x + dx as f32, c.y + dy as f32);
m10 += dx as f32 * v;
m01 += dy as f32 * v;
}
}
m01.atan2(m10).sin_cos()
};
let mut d = [0u64; 4];
for (i, p) in pairs().iter().enumerate() {
let rot = |x: f32, y: f32| (c.x + x * co - y * s, c.y + x * s + y * co);
let (ax, ay) = rot(p[0], p[1]);
let (bx, by) = rot(p[2], p[3]);
if at(ax, ay) < at(bx, by) {
d[i / 64] |= 1 << (i % 64);
}
}
d
})
.collect()
m01.atan2(m10).sin_cos()
};
let mut d = [0u64; 4];
for (i, p) in pairs().iter().enumerate() {
let rot = |x: f32, y: f32| (c.x + x * co - y * s, c.y + x * s + y * co);
let (ax, ay) = rot(p[0], p[1]);
let (bx, by) = rot(p[2], p[3]);
if at(ax, ay) < at(bx, by) {
d[i / 64] |= 1 << (i % 64);
}
}
d
})
}
pub fn hamming(a: &Descriptor, b: &Descriptor) -> u32 {
@@ -560,3 +647,124 @@ mod tests {
assert_eq!(hamming(&[0, 0, 0, 0], &[1, 3, 0, u64::MAX]), 67);
}
}
/// The row / column-band parallel `blur`, `harris` and `describe` against the serial code they
/// replaced. Every output must stay bit for bit the same.
#[cfg(test)]
mod parallel_tests {
use super::*;
/// The serial blur exactly as it was before the parallelisation.
fn ref_blur(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()];
let (n, m) = if horizontal { (h, w) } else { (w, h) };
for line in 0..n {
let at = |k: i64| {
let k = k.clamp(0, m as i64 - 1) as usize;
if horizontal { src[line * w + k] } else { src[k * w + line] }
};
let mut acc: f32 = (-(r as i64)..=r as i64).map(at).sum();
for k in 0..m {
let i = if horizontal { line * w + k } else { k * w + line };
dst[i] = acc / (2 * r + 1) as f32;
acc += at(k as i64 + r as i64 + 1) - at(k as i64 - r as i64);
}
}
dst
};
pass(&pass(v, true), false)
}
fn mix(mut z: u64) -> u64 {
z = z.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Integer-only data with negatives, signed zeros and large values, so a different order of
/// additions cannot hide.
fn noise(len: usize, seed: u64) -> Vec<f32> {
(0..len as u64)
.map(|i| {
let z = mix(seed.wrapping_mul(0x1234_5678_9ABC).wrapping_add(i));
let v = (z >> 40) as f32 / 16_777_216.0;
match z & 31 {
0 => -v,
1 => -0.0,
2 => v * 1000.0,
_ => v,
}
})
.collect()
}
/// Blocks of random brightness: lots of strong corners, no libm in the making. Only the
/// recorded-hash test below uses it, and that one is Linux x86_64 only.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
fn blocks(w: usize, h: usize, seed: u64) -> Vec<f32> {
(0..w * h).map(|i| (mix(seed + ((i % w) / 8) as u64 * 4096 + ((i / w) / 8) as u64) >> 40) as f32 / 16_777_216.0).collect()
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
fn fnv(parts: &[u64]) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325u64;
for p in parts {
h ^= p;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
#[test]
fn blur_matches_the_serial_code() {
// Widths around the 64-column band (1 band, band + 1, several bands, a ragged last one),
// 1-wide and 1-high images, and radii 0, 1, 2, larger than the image.
let widths = [1usize, 2, 3, 31, 63, 64, 65, 127, 128, 129, 200];
let heights = [1usize, 2, 3, 5, 17, 40];
for (a, &w) in widths.iter().enumerate() {
for (b, &h) in heights.iter().enumerate() {
let v = noise(w * h, (a * 10 + b) as u64);
for r in [0usize, 1, 2, 3, 7, 50] {
let (got, want) = (blur(w, h, &v, r), ref_blur(w, h, &v, r));
let (g, e): (Vec<u32>, Vec<u32>) = (got.iter().map(|x| x.to_bits()).collect(), want.iter().map(|x| x.to_bits()).collect());
assert_eq!(g, e, "blur {w}x{h} r={r}");
}
}
}
assert!(blur(0, 0, &[], 1).is_empty());
}
// `harris` and `describe` go through the platform's hypot / ln / cos / atan2 / sin_cos, so the
// hashes recorded from the old code only hold where those are the same functions.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
#[test]
fn harris_and_describe_hashes_recorded_from_the_serial_code() {
// Sizes: a whole number of 64-column bands and a ragged one; with and without a validity mask.
for (w, h, max, md, want_corners, want_desc) in
[(211usize, 157usize, 300usize, 6.0f32, 0xeccc6928d3ae3b8cu64, 0xd1e6caae4d68f253u64), (128, 97, 60, 3.0, 0xe3f6615117fa7da7, 0x818ae7e965ecf88f)]
{
let img = blocks(w, h, 7);
let valid: Vec<bool> = (0..w * h).map(|i| !((i % w) > w / 2 && (i / w) < h / 3)).collect();
let mut parts = Vec::new();
for v in [None, Some(valid.as_slice())] {
let c = harris(w, h, &img, max, md, v);
for k in &c {
parts.extend([k.x.to_bits() as u64, k.y.to_bits() as u64, k.score.to_bits() as u64]);
}
parts.push(c.len() as u64);
}
assert_eq!(fnv(&parts), want_corners, "harris {w}x{h}");
let c = harris(w, h, &img, max, md, None);
assert!(c.len() > 10, "{} corners", c.len());
let mut parts = Vec::new();
for upright in [false, true] {
for d in describe(w, h, &img, &c, upright) {
parts.extend(d);
}
}
assert_eq!(fnv(&parts), want_desc, "describe {w}x{h}");
}
}
}
+100 -4
View File
@@ -16,6 +16,15 @@
//! source (the usual case), the vote copies the source pixels exactly; only along the seams
//! between copied pieces does it blend.
//!
//! A thin hole (a selected wire, string or hair: long, but only a few patches thick) is
//! measured by its thickness, not its bounds (#1366): the pyramid stops where it is still about
//! two patches thick. That level is big, so the onion peel starts it only where its exhaustive
//! search stays affordable; otherwise a membrane across the hole does, which over so short a
//! distance is the surroundings themselves. Shrunk to where its bounds are four patches across,
//! a long thin hole swallows its surroundings, the matches found there copy whatever happens to
//! look like the blur (a dark blotch, or a piece of another string), and the finer levels,
//! which only refine those matches, keep them: the removed object's outline stays visible.
//!
//! This is what Photoshop's Content-Aware Fill does, measured as a black box on Photoshop 25.4
//! (see `content_aware`): with colour adaptation off its fill is pieces of the image copied pixel
//! for pixel, typically 15–40 px across, joined by seams a few pixels wide; with colour
@@ -278,6 +287,38 @@ fn hole_bbox(w: usize, h: usize, hole: &[bool]) -> Option<(usize, usize, usize,
b
}
/// A hole at most `1 / THIN_RATIO` as thick as its bounds are long counts as thin (a wire, a
/// string, a hair, a long brush stroke): see the module docs.
const THIN_RATIO: usize = 4;
/// How thick the hole within bounds `hb` of a `w`-wide role grid is: twice the largest distance
/// from a hole pixel to the nearest pixel that is not one (a long, thin hole is thin however long
/// its bounds).
fn hole_thickness(w: usize, hb: Bounds, px: &[Px]) -> usize {
// The bounds and a ring of non-hole cells around them (also past the grid's edge).
let (gw, gh) = (hb.width() + 2, hb.height() + 2);
let outside: Vec<bool> = par::map(gw * gh, gw * gh >= PAR_MIN, |i| {
let (gx, gy) = (i % gw, i / gw);
if gx == 0 || gy == 0 || gx > hb.width() || gy > hb.height() {
return true;
}
px.get((hb.y0 + gy - 1) * w + hb.x0 + gx - 1) != Some(&Px::Hole)
});
let deepest = crate::selection::edt(&outside, gw, gh).into_iter().fold(0.0f32, f32::max);
(2.0 * deepest).ceil() as usize
}
/// Most target pixels × sources the onion peel of a thin hole may compare (its search is
/// exhaustive, though most comparisons stop early: about a second at this many).
const PEEL_BUDGET: usize = 1 << 28;
/// Whether the onion peel of the hole cells within `hb` of a `w`-wide role grid from `sources`
/// source centres stays within [`PEEL_BUDGET`].
fn peel_affordable(w: usize, px: &[Px], hb: Bounds, sources: usize) -> bool {
let holes: usize = (hb.y0..hb.y1).map(|y| px.get(y * w + hb.x0..y * w + hb.x1).map_or(0, |row| row.iter().filter(|k| **k == Px::Hole).count())).sum();
holes.saturating_mul(sources) <= PEEL_BUDGET
}
/// Bounds of the hole pixels of a role grid.
fn hole_bounds(w: usize, h: usize, px: &[Px]) -> Option<Bounds> {
// Per row the first and last hole column; the union of the rows' spans.
@@ -1575,14 +1616,20 @@ fn complete_cow<'a>(
let r = p.patch_radius.max(1);
let psz = 2 * r + 1;
// Pyramid: shrink until the hole is about four patches across (a deeper pyramid decides the
// structure where the texture has averaged out, and fills flat).
// structure where the texture has averaged out, and fills flat). A thin hole stops while it
// is still about two patches thick: through it, not along it, is what counts (#1366).
let mut levels = vec![Level { w, h, img, px: Cow::Borrowed(px) }];
let mut boxes = vec![hb0];
let mut ext = hb0.width().max(hb0.height());
let thickness = hole_thickness(w, hb0, px);
let thin = thickness.saturating_mul(THIN_RATIO) <= ext;
if thin {
ext = thickness;
}
let mut exts = vec![ext];
while let Some(l) = levels.last() {
ctl.check()?;
if ext <= 4 * psz || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
if ext <= if thin { 2 * psz } else { 4 * psz } || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
break;
}
let d = downsample(l, ch);
@@ -1638,9 +1685,21 @@ fn complete_cow<'a>(
}
}
}
None if sources.len() > 0 => onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)?,
// A thin hole, when peeling it would take too long, takes the membrane below: a few
// patches thick here, a membrane across it is its surroundings.
None if sources.len() > 0 && (!thin || peel_affordable(lw, &lpx, hb, sources.len())) => {
onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)?
}
None => {
let unknown: Vec<bool> = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect();
let mut unknown: Vec<bool> = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect();
// Across a thin hole the membrane starts a patch radius out, past the
// anti-aliased fringe or halo that a selection drawn close to the object leaves.
if thin {
let hole: Vec<bool> = lpx.iter().map(|k| *k == Px::Hole).collect();
for (u, d) in unknown.iter_mut().zip(crate::selection::edt(&hole, lw, lh)) {
*u |= d <= r as f32;
}
}
let m = membrane_fill(lw, lh, ch, &limg, &unknown);
for (i, k) in lpx.iter().enumerate() {
if *k == Px::Hole {
@@ -2114,6 +2173,43 @@ mod tests {
.collect()
}
#[test]
fn a_hole_is_as_thick_as_it_is_through_not_along() {
let (w, h) = (240, 120);
let all = Bounds { x0: 0, y0: 0, x1: w, y1: h };
let thickness = |hole: &[bool]| {
let px: Vec<Px> = hole.iter().map(|&o| if o { Px::Hole } else { Px::Source }).collect();
hole_thickness(w, hole_bounds(w, h, &px).unwrap_or(all), &px)
};
// A band 200 px long and 9 px thick; a disc of radius 20; the band touching the edge
// counts the outside of the image as not hole.
let band: Vec<bool> = (0..w * h).map(|i| (20..220).contains(&(i % w)) && (50..59).contains(&(i / w))).collect();
assert!((9..=11).contains(&thickness(&band)), "{}", thickness(&band));
assert!((39..=42).contains(&thickness(&disc(w, h, 120.0, 60.0, 20.0))));
let edge: Vec<bool> = (0..w * h).map(|i| i / w < 9).collect();
assert!((9..=11).contains(&thickness(&edge)), "{}", thickness(&edge));
}
/// A long thin hole goes the thin way (a shallow pyramid that starts from a membrane): it is
/// filled, only it changes, and the result does not depend on the thread count either.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn thin_holes_are_filled_the_same_on_any_number_of_threads() {
let (w, h) = (300, 200);
let img = busy(w, h, 3, 5);
// A wavy line 6 px thick across the image.
let hole: Vec<bool> = (0..w * h).map(|i| (((i % w) as f32 * 0.05).sin() * 30.0 + 100.0 - (i / w) as f32).abs() < 3.0).collect();
let px = roles(w, h, &hole);
let run = |threads: usize| {
let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap();
pool.install(|| complete_masked(w, h, 3, &img, &px, &CompleteParams::default(), &photocraft_raster::Interrupt::NONE)).unwrap().unwrap()
};
let one = run(1);
assert_eq!(bits(&one), bits(&run(7)));
assert!((0..w * h).all(|i| hole[i] || (0..3).all(|c| one[i * 3 + c].to_bits() == img[i * 3 + c].to_bits())));
assert!((0..w * h).filter(|&i| hole[i]).all(|i| (0..3).all(|c| one[i * 3 + c].is_finite())));
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn results_do_not_depend_on_the_thread_count() {
+1
View File
@@ -32,6 +32,7 @@ mod distort;
mod distort2;
pub mod erase;
pub mod exif;
pub mod faces;
pub mod features;
mod fxutil;
mod gallery;
+587 -90
View File
@@ -35,7 +35,7 @@
#![allow(clippy::needless_range_loop)] // index loops mirror the maths (pixels × channels)
use crate::features::{self, Descriptor, Model};
use crate::photo_util::{Normal, Rng, par_map, solve_dense};
use crate::photo_util::{Normal, Rng, par_map, par_rows, solve_dense};
use crate::transform::Homography;
/// Photomerge layouts.
@@ -996,58 +996,99 @@ pub fn blend_levels(w: usize, h: usize) -> usize {
n
}
/// `a[i] ⊕ b[i]` for every `i` (the shorter length wins, like `zip`), in parallel on native
/// targets. Purely elementwise, so the result is the serial result bit for bit.
fn par_zip(a: &[f32], b: &[f32], f: impl Fn(f32, f32) -> f32 + Sync + Send) -> Vec<f32> {
const CHUNK: usize = 1 << 14;
let mut out = vec![0.0f32; a.len().min(b.len())];
let fill = |o: &mut [f32], a: &[f32], b: &[f32]| {
for ((o, &a), &b) in o.iter_mut().zip(a).zip(b) {
*o = f(a, b);
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
out.par_chunks_mut(CHUNK).zip(a.par_chunks(CHUNK)).zip(b.par_chunks(CHUNK)).for_each(|((o, a), b)| fill(o, a, b));
}
#[cfg(target_arch = "wasm32")]
out.chunks_mut(CHUNK).zip(a.chunks(CHUNK)).zip(b.chunks(CHUNK)).for_each(|((o, a), b)| fill(o, a, b));
out
}
/// `f(a[i])` for every `i`, in parallel on native targets (elementwise, so bit for bit serial).
fn par_map1(a: &[f32], f: impl Fn(f32) -> f32 + Sync + Send) -> Vec<f32> {
par_zip(a, a, |x, _| f(x))
}
// `reduce`, `expand` and `push_pull` run one pyramid level for one image at a time, and the
// images themselves only fill part of a many-core machine, so each of them also runs its rows
// in parallel. Every output element is computed by the same expression, in the same order, as
// the one-thread loops they replace (rows never read each other's output), so the result is
// bit for bit unchanged.
fn reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> (usize, usize, Vec<f32>) {
let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
let k = [1.0f32, 4.0, 6.0, 4.0, 1.0];
// Separable 5-tap binomial, then decimate.
let mut tmp = vec![0.0f32; nw * h * ch];
for y in 0..h {
par_rows(&mut tmp, nw, ch, |y, row| {
let line = &src[y * w * ch..(y + 1) * w * ch];
for x in 0..nw {
// The clamped source column of each tap is the same for every channel.
let xs: [usize; 5] = std::array::from_fn(|t| (2 * x as i64 + t as i64 - 2).clamp(0, w as i64 - 1) as usize * ch);
for c in 0..ch {
let mut s = 0.0;
for (t, kv) in k.iter().enumerate() {
let xx = (2 * x as i64 + t as i64 - 2).clamp(0, w as i64 - 1) as usize;
s += kv * src[(y * w + xx) * ch + c];
for (kv, xx) in k.iter().zip(xs) {
s += kv * line[xx + c];
}
tmp[(y * nw + x) * ch + c] = s / 16.0;
row[x * ch + c] = s / 16.0;
}
}
}
});
let mut out = vec![0.0f32; nw * nh * ch];
for y in 0..nh {
for x in 0..nw {
for c in 0..ch {
let mut s = 0.0;
for (t, kv) in k.iter().enumerate() {
let yy = (2 * y as i64 + t as i64 - 2).clamp(0, h as i64 - 1) as usize;
s += kv * tmp[(yy * nw + x) * ch + c];
}
out[(y * nw + x) * ch + c] = s / 16.0;
par_rows(&mut out, nw, ch, |y, row| {
// Each output row is five source rows added in tap order: the same sum per element as
// before (`0 + k0·a + k1·b + …`), but over whole rows so it vectorises.
for (t, kv) in k.iter().enumerate() {
let yy = (2 * y as i64 + t as i64 - 2).clamp(0, h as i64 - 1) as usize;
for (o, &v) in row.iter_mut().zip(&tmp[yy * nw * ch..(yy + 1) * nw * ch]) {
*o += kv * v;
}
}
}
for o in row.iter_mut() {
*o /= 16.0;
}
});
(nw, nh, out)
}
fn expand(sw: usize, sh: usize, ch: usize, src: &[f32], w: usize, h: usize) -> Vec<f32> {
// Bilinear upsampling at half-pixel-aligned positions.
let mut out = vec![0.0f32; w * h * ch];
for y in 0..h {
let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32);
let (y0, ty) = (fy.floor() as usize, fy - fy.floor());
let y1 = (y0 + 1).min(sh - 1);
for x in 0..w {
// Source columns and weights depend on x only: worked out once instead of for every row.
// The clamped position is never negative, so truncating is `floor` (and spares the libm call).
let cols: Vec<(usize, usize, f32)> = (0..w)
.map(|x| {
let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sw - 1) as f32);
let (x0, tx) = (fx.floor() as usize, fx - fx.floor());
let x1 = (x0 + 1).min(sw - 1);
for c in 0..ch {
let p = |xx: usize, yy: usize| src[(yy * sw + xx) * ch + c];
let top = p(x0, y0) + (p(x1, y0) - p(x0, y0)) * tx;
let bot = p(x0, y1) + (p(x1, y1) - p(x0, y1)) * tx;
out[(y * w + x) * ch + c] = top + (bot - top) * ty;
let x0 = fx as usize;
(x0 * ch, (x0 + 1).min(sw - 1) * ch, fx - x0 as f32)
})
.collect();
par_rows(&mut out, w, ch, |y, row| {
let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32);
let y0 = fy as usize;
let ty = fy - y0 as f32;
let y1 = (y0 + 1).min(sh - 1);
let (r0, r1) = (&src[y0 * sw * ch..(y0 + 1) * sw * ch], &src[y1 * sw * ch..(y1 + 1) * sw * ch]);
for (px, &(x0, x1, tx)) in row.chunks_exact_mut(ch.max(1)).zip(&cols) {
for (c, o) in px.iter_mut().enumerate() {
let top = r0[x0 + c] + (r0[x1 + c] - r0[x0 + c]) * tx;
let bot = r1[x0 + c] + (r1[x1 + c] - r1[x0 + c]) * tx;
*o = top + (bot - top) * ty;
}
}
}
});
out
}
@@ -1058,41 +1099,75 @@ fn push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32]) {
return;
}
let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
// Coarse sums. A coarse row is fed by two fine rows; walking them in raster order adds each
// coarse cell's (up to) four fine pixels in the order the serial scatter-add did, from zero.
let mut cpx = vec![0.0f32; nw * nh * ch];
let mut cw = vec![0.0f32; nw * nh];
for y in 0..h {
for x in 0..w {
let (i, j) = (y * w + x, (y / 2) * nw + x / 2);
let a = wt[i];
cw[j] += a;
for c in 0..ch {
cpx[j * ch + c] += a * px[i * ch + c];
par_rows(&mut cw, nw, 1, |cy, row| {
for y in 2 * cy..(2 * cy + 2).min(h) {
for (x, &a) in wt[y * w..(y + 1) * w].iter().enumerate() {
row[x / 2] += a;
}
}
}
for j in 0..nw * nh {
if cw[j] > 0.0 {
for c in 0..ch {
cpx[j * ch + c] /= cw[j];
});
let px_in: &[f32] = px;
par_rows(&mut cpx, nw, ch, |cy, row| {
for y in 2 * cy..(2 * cy + 2).min(h) {
for x in 0..w {
let (i, j) = (y * w + x, x / 2);
let a = wt[i];
for c in 0..ch {
row[j * ch + c] += a * px_in[i * ch + c];
}
}
cw[j] = 1.0;
}
});
// Normalise by the weight (this reads `cw` before it is reset to 0 / 1 below).
par_rows(&mut cpx, nw, ch, |cy, row| {
for cx in 0..nw {
let wj = cw[cy * nw + cx];
if wj > 0.0 {
for c in 0..ch {
row[cx * ch + c] /= wj;
}
}
}
});
for v in cw.iter_mut() {
if *v > 0.0 {
*v = 1.0;
}
}
if cw.contains(&0.0) && cw.iter().any(|v| *v > 0.0) {
push_pull(nw, nh, ch, &mut cpx, &cw);
}
for y in 0..h {
par_rows(px, w, ch, |y, row| {
for x in 0..w {
let i = y * w + x;
if wt[i] < 1.0 {
let j = (y / 2) * nw + x / 2;
let a = wt[i];
for c in 0..ch {
px[i * ch + c] = a * px[i * ch + c] + (1.0 - a) * cpx[j * ch + c];
row[x * ch + c] = a * row[x * ch + c] + (1.0 - a) * cpx[j * ch + c];
}
}
}
}
});
}
/// One image's pyramid in [`multiband`]: the grid origin `(gx0, gy0)` at level 0, the Laplacian
/// levels `(w, h, colours)` and the Gaussian weight levels.
type PyramidBand = (i32, i32, Vec<(usize, usize, Vec<f32>)>, Vec<Vec<f32>>);
/// The part of `band` on panorama row `py` of level `l`: the panorama column its first pixel
/// lands on, its width, its weight row and its colour row. `None` when it misses the row.
fn band_row(band: &PyramidBand, l: usize, ch: usize, py: usize) -> Option<(usize, usize, &[f32], &[f32])> {
let (ox, oy) = ((band.0 >> l) as usize, (band.1 >> l) as usize);
let (lw, lh, colours) = &band.2[l];
(py >= oy && py - oy < *lh).then(|| {
let y = py - oy;
(ox, *lw, &band.3[l][y * lw..(y + 1) * lw], &colours[y * lw * ch..(y + 1) * lw * ch])
})
}
/// Multi-band blend of `imgs` into a `w × h` panorama (origin 0,0) with one weight map per image
@@ -1119,22 +1194,37 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32);
let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32);
let (gw, gh) = ((gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize);
// Each grid pixel takes the image pixel that lands on it (every image pixel lands on at
// most one), so rows of the grid are independent and fill in parallel.
let src_of = |gx: usize, gy: usize| -> Option<usize> {
let (x, y) = (gx as i64 + gx0 as i64 - im.x0 as i64, gy as i64 + gy0 as i64 - im.y0 as i64);
(x >= 0 && y >= 0 && (x as usize) < im.w && (y as usize) < im.h).then(|| y as usize * im.w + x as usize)
};
let mut px = vec![0.0f32; gw * gh * ch];
let mut cov = vec![0.0f32; gw * gh];
let mut wt = vec![0.0f32; gw * gh];
for y in 0..im.h {
for x in 0..im.w {
let (gx, gy) = (im.x0 + x as i32 - gx0, im.y0 + y as i32 - gy0);
if gx < 0 || gy < 0 || gx as usize >= gw || gy as usize >= gh {
continue;
par_rows(&mut px, gw, ch, |gy, row| {
for gx in 0..gw {
if let Some(i) = src_of(gx, gy) {
row[gx * ch..(gx + 1) * ch].copy_from_slice(&im.px[i * ch..(i + 1) * ch]);
}
let (i, g) = (y * im.w + x, gy as usize * gw + gx as usize);
cov[g] = im.alpha[i];
wt[g] = weights[k][i] * im.alpha[i].min(1.0);
px[g * ch..(g + 1) * ch].copy_from_slice(&im.px[i * ch..(i + 1) * ch]);
}
}
let bin: Vec<f32> = cov.iter().map(|a| if *a > 0.5 { 1.0 } else { 0.0 }).collect();
});
let mut cov = vec![0.0f32; gw * gh];
par_rows(&mut cov, gw, 1, |gy, row| {
for (gx, o) in row.iter_mut().enumerate() {
if let Some(i) = src_of(gx, gy) {
*o = im.alpha[i];
}
}
});
let mut wt = vec![0.0f32; gw * gh];
par_rows(&mut wt, gw, 1, |gy, row| {
for (gx, o) in row.iter_mut().enumerate() {
if let Some(i) = src_of(gx, gy) {
*o = weights[k][i] * im.alpha[i].min(1.0);
}
}
});
let bin: Vec<f32> = par_map1(&cov, |a| if a > 0.5 { 1.0 } else { 0.0 });
push_pull(gw, gh, ch, &mut px, &bin);
// Laplacian of the colours, Gaussian of the weights.
let mut lap: Vec<(usize, usize, Vec<f32>)> = Vec::new();
@@ -1148,7 +1238,7 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
}
let (nw, nh, down) = reduce(cw, chh, ch, &cur);
let up = expand(nw, nh, ch, &down, cw, chh);
lap.push((cw, chh, cur.iter().zip(&up).map(|(a, b)| a - b).collect()));
lap.push((cw, chh, par_zip(&cur, &up, |a, b| a - b)));
gau.push(cwt.clone());
let (_, _, wdown) = reduce(cw, chh, 1, &cwt);
cw = nw;
@@ -1158,41 +1248,57 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
}
(gx0, gy0, lap, gau)
});
for (gx0, gy0, lap, gau) in &bands {
for (l, ((lw, lh, band), g)) in lap.iter().zip(gau).enumerate() {
let (ox, oy) = ((gx0 >> l) as usize, (gy0 >> l) as usize);
let (pw, ph) = sizes[l];
for y in 0..*lh {
let py = oy + y;
if py >= ph {
break;
}
for x in 0..*lw {
// Accumulate the bands. Each pixel adds the bands in the same order as the serial loop (per
// level, bands one after another), but rows of a level are independent, so they run in
// parallel: every row walks the bands itself.
for l in 0..levels {
let pw = sizes[l].0;
par_rows(&mut wsum[l], pw, 1, |py, row| {
for b in &bands {
let Some((ox, lw, g, _)) = band_row(b, l, ch, py) else { continue };
for x in 0..lw {
let px = ox + x;
if px >= pw {
break;
}
let (i, p) = (y * lw + x, py * pw + px);
let k = g[i];
let k = g[x];
if k <= 0.0 {
continue;
}
row[px] += k;
}
}
});
par_rows(&mut acc[l], pw, ch, |py, row| {
for b in &bands {
let Some((ox, lw, g, band)) = band_row(b, l, ch, py) else { continue };
for x in 0..lw {
let px = ox + x;
if px >= pw {
break;
}
let k = g[x];
if k <= 0.0 {
continue;
}
wsum[l][p] += k;
for c in 0..ch {
acc[l][p * ch + c] += k * band[i * ch + c];
row[px * ch + c] += k * band[x * ch + c];
}
}
}
}
});
}
for l in 0..levels {
let (pw, ph) = sizes[l];
for p in 0..pw * ph {
let k = wsum[l][p];
for c in 0..ch {
acc[l][p * ch + c] = if k > 1e-8 { acc[l][p * ch + c] / k } else { 0.0 };
let pw = sizes[l].0;
let ws = &wsum[l];
par_rows(&mut acc[l], pw, ch, |py, row| {
for x in 0..pw {
let k = ws[py * pw + x];
for c in 0..ch {
row[x * ch + c] = if k > 1e-8 { row[x * ch + c] / k } else { 0.0 };
}
}
}
});
}
// Collapse.
// `levels ≥ 1`, so there is always a coarsest level.
@@ -1201,22 +1307,27 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
let (pw, ph) = sizes[l];
let (sw, sh) = sizes[l + 1];
let up = expand(sw, sh, ch, &cur, pw, ph);
cur = acc[l].iter().zip(&up).map(|(a, b)| a + b).collect();
cur = par_zip(&acc[l], &up, |a, b| a + b);
}
// Coverage: union of the images.
// Coverage: union of the images. Rows are independent; each walks the images in order, so a
// pixel sees the same sequence of `max` calls as in the serial loop.
let mut cov = vec![0.0f32; w * h];
for im in imgs {
for y in 0..im.h {
par_rows(&mut cov, w, 1, |py, row| {
for im in imgs {
let y = py as i64 - im.y0 as i64;
if y < 0 || y as usize >= im.h {
continue;
}
for x in 0..im.w {
let (px, py) = (im.x0 + x as i32, im.y0 + y as i32);
if px < 0 || py < 0 || px as usize >= w || py as usize >= h {
let px = im.x0 + x as i32;
if px < 0 || px as usize >= w {
continue;
}
let p = py as usize * w + px as usize;
cov[p] = cov[p].max(im.alpha[y * im.w + x]);
let p = px as usize;
row[p] = row[p].max(im.alpha[y as usize * im.w + x]);
}
}
}
});
(cur, cov)
}
@@ -1431,3 +1542,389 @@ mod tests {
assert!(cov.iter().all(|c| *c == 1.0));
}
}
/// The row-parallel `reduce` / `expand` / `push_pull` / `multiband` against the serial code they
/// replaced, kept below verbatim as the reference. Every output must stay bit for bit the same.
#[cfg(test)]
mod parallel_tests {
use super::*;
fn mix(mut z: u64) -> u64 {
z = z.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Deterministic data from integer arithmetic only (no libm): mostly `0..1`, with negatives,
/// signed zeros and large values so a different order of operations cannot hide.
fn noise(len: usize, seed: u64) -> Vec<f32> {
(0..len as u64)
.map(|i| {
let z = mix(seed.wrapping_mul(0x1234_5678_9ABC).wrapping_add(i));
let v = (z >> 40) as f32 / 16_777_216.0;
match z & 31 {
0 => -v,
1 => -0.0,
2 => v * 1000.0,
3 => 0.0,
_ => v,
}
})
.collect()
}
/// Weights with exact zeros, exact ones and partial values, in blobs and noise.
fn weights(w: usize, h: usize, seed: u64) -> Vec<f32> {
(0..w * h)
.map(|i| {
let (x, y) = (i % w, i / w);
let z = mix(seed + i as u64);
if (x / 5 + y / 3) % 3 == 0 {
0.0
} else if z.is_multiple_of(7) {
((z >> 20) % 100) as f32 / 100.0
} else {
1.0
}
})
.collect()
}
fn bits(v: &[f32]) -> Vec<u32> {
v.iter().map(|x| x.to_bits()).collect()
}
fn fnv(parts: &[&[f32]]) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325u64;
for p in parts {
for x in *p {
h ^= x.to_bits() as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
}
h
}
/// Sizes that cross the odd / even and tiny cases (1 wide, 1 high, 1×1, odd, power of two + 1).
const SIZES: [(usize, usize); 12] = [(1, 1), (1, 9), (9, 1), (2, 2), (3, 5), (4, 4), (17, 13), (64, 40), (65, 41), (97, 61), (130, 7), (7, 130)];
fn ref_reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> (usize, usize, Vec<f32>) {
let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
let k = [1.0f32, 4.0, 6.0, 4.0, 1.0];
// Separable 5-tap binomial, then decimate.
let mut tmp = vec![0.0f32; nw * h * ch];
for y in 0..h {
for x in 0..nw {
for c in 0..ch {
let mut s = 0.0;
for (t, kv) in k.iter().enumerate() {
let xx = (2 * x as i64 + t as i64 - 2).clamp(0, w as i64 - 1) as usize;
s += kv * src[(y * w + xx) * ch + c];
}
tmp[(y * nw + x) * ch + c] = s / 16.0;
}
}
}
let mut out = vec![0.0f32; nw * nh * ch];
for y in 0..nh {
for x in 0..nw {
for c in 0..ch {
let mut s = 0.0;
for (t, kv) in k.iter().enumerate() {
let yy = (2 * y as i64 + t as i64 - 2).clamp(0, h as i64 - 1) as usize;
s += kv * tmp[(yy * nw + x) * ch + c];
}
out[(y * nw + x) * ch + c] = s / 16.0;
}
}
}
(nw, nh, out)
}
fn ref_expand(sw: usize, sh: usize, ch: usize, src: &[f32], w: usize, h: usize) -> Vec<f32> {
// Bilinear upsampling at half-pixel-aligned positions.
let mut out = vec![0.0f32; w * h * ch];
for y in 0..h {
let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32);
let (y0, ty) = (fy.floor() as usize, fy - fy.floor());
let y1 = (y0 + 1).min(sh - 1);
for x in 0..w {
let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sw - 1) as f32);
let (x0, tx) = (fx.floor() as usize, fx - fx.floor());
let x1 = (x0 + 1).min(sw - 1);
for c in 0..ch {
let p = |xx: usize, yy: usize| src[(yy * sw + xx) * ch + c];
let top = p(x0, y0) + (p(x1, y0) - p(x0, y0)) * tx;
let bot = p(x0, y1) + (p(x1, y1) - p(x0, y1)) * tx;
out[(y * w + x) * ch + c] = top + (bot - top) * ty;
}
}
}
out
}
fn ref_push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32]) {
if w <= 1 && h <= 1 {
return;
}
let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
let mut cpx = vec![0.0f32; nw * nh * ch];
let mut cw = vec![0.0f32; nw * nh];
for y in 0..h {
for x in 0..w {
let (i, j) = (y * w + x, (y / 2) * nw + x / 2);
let a = wt[i];
cw[j] += a;
for c in 0..ch {
cpx[j * ch + c] += a * px[i * ch + c];
}
}
}
for j in 0..nw * nh {
if cw[j] > 0.0 {
for c in 0..ch {
cpx[j * ch + c] /= cw[j];
}
cw[j] = 1.0;
}
}
if cw.contains(&0.0) && cw.iter().any(|v| *v > 0.0) {
ref_push_pull(nw, nh, ch, &mut cpx, &cw);
}
for y in 0..h {
for x in 0..w {
let i = y * w + x;
if wt[i] < 1.0 {
let j = (y / 2) * nw + x / 2;
let a = wt[i];
for c in 0..ch {
px[i * ch + c] = a * px[i * ch + c] + (1.0 - a) * cpx[j * ch + c];
}
}
}
}
}
fn ref_multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], levels: usize) -> (Vec<f32>, Vec<f32>) {
let ch = imgs.first().map_or(1, |i| i.ch);
let levels = levels.max(1);
let align = 1i32 << (levels - 1);
// Level sizes of the panorama.
let mut sizes = vec![(w, h)];
for _ in 1..levels {
let Some(&(pw, ph)) = sizes.last() else { break };
sizes.push((pw.div_ceil(2), ph.div_ceil(2)));
}
let mut acc: Vec<Vec<f32>> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh * ch]).collect();
let mut wsum: Vec<Vec<f32>> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh]).collect();
let margin = align * 4;
// Each image on its ROI grown by a margin and aligned to the coarsest level's grid.
let bands = par_map(imgs.len(), |k| {
let im = &imgs[k];
let gx0 = ((im.x0 - margin).max(0) / align) * align;
let gy0 = ((im.y0 - margin).max(0) / align) * align;
let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32);
let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32);
let (gw, gh) = ((gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize);
let mut px = vec![0.0f32; gw * gh * ch];
let mut cov = vec![0.0f32; gw * gh];
let mut wt = vec![0.0f32; gw * gh];
for y in 0..im.h {
for x in 0..im.w {
let (gx, gy) = (im.x0 + x as i32 - gx0, im.y0 + y as i32 - gy0);
if gx < 0 || gy < 0 || gx as usize >= gw || gy as usize >= gh {
continue;
}
let (i, g) = (y * im.w + x, gy as usize * gw + gx as usize);
cov[g] = im.alpha[i];
wt[g] = weights[k][i] * im.alpha[i].min(1.0);
px[g * ch..(g + 1) * ch].copy_from_slice(&im.px[i * ch..(i + 1) * ch]);
}
}
let bin: Vec<f32> = cov.iter().map(|a| if *a > 0.5 { 1.0 } else { 0.0 }).collect();
ref_push_pull(gw, gh, ch, &mut px, &bin);
// Laplacian of the colours, Gaussian of the weights.
let mut lap: Vec<(usize, usize, Vec<f32>)> = Vec::new();
let mut gau: Vec<Vec<f32>> = Vec::new();
let (mut cw, mut chh, mut cur, mut cwt) = (gw, gh, px, wt);
for l in 0..levels {
if l + 1 == levels {
lap.push((cw, chh, cur.clone()));
gau.push(cwt.clone());
break;
}
let (nw, nh, down) = ref_reduce(cw, chh, ch, &cur);
let up = ref_expand(nw, nh, ch, &down, cw, chh);
lap.push((cw, chh, cur.iter().zip(&up).map(|(a, b)| a - b).collect()));
gau.push(cwt.clone());
let (_, _, wdown) = ref_reduce(cw, chh, 1, &cwt);
cw = nw;
chh = nh;
cur = down;
cwt = wdown;
}
(gx0, gy0, lap, gau)
});
for (gx0, gy0, lap, gau) in &bands {
for (l, ((lw, lh, band), g)) in lap.iter().zip(gau).enumerate() {
let (ox, oy) = ((gx0 >> l) as usize, (gy0 >> l) as usize);
let (pw, ph) = sizes[l];
for y in 0..*lh {
let py = oy + y;
if py >= ph {
break;
}
for x in 0..*lw {
let px = ox + x;
if px >= pw {
break;
}
let (i, p) = (y * lw + x, py * pw + px);
let k = g[i];
if k <= 0.0 {
continue;
}
wsum[l][p] += k;
for c in 0..ch {
acc[l][p * ch + c] += k * band[i * ch + c];
}
}
}
}
}
for l in 0..levels {
let (pw, ph) = sizes[l];
for p in 0..pw * ph {
let k = wsum[l][p];
for c in 0..ch {
acc[l][p * ch + c] = if k > 1e-8 { acc[l][p * ch + c] / k } else { 0.0 };
}
}
}
// Collapse.
// `levels ≥ 1`, so there is always a coarsest level.
let Some(mut cur) = acc.pop() else { return (vec![0.0; w * h * ch], vec![0.0; w * h]) };
for l in (0..levels - 1).rev() {
let (pw, ph) = sizes[l];
let (sw, sh) = sizes[l + 1];
let up = ref_expand(sw, sh, ch, &cur, pw, ph);
cur = acc[l].iter().zip(&up).map(|(a, b)| a + b).collect();
}
// Coverage: union of the images.
let mut cov = vec![0.0f32; w * h];
for im in imgs {
for y in 0..im.h {
for x in 0..im.w {
let (px, py) = (im.x0 + x as i32, im.y0 + y as i32);
if px < 0 || py < 0 || px as usize >= w || py as usize >= h {
continue;
}
let p = py as usize * w + px as usize;
cov[p] = cov[p].max(im.alpha[y * im.w + x]);
}
}
}
(cur, cov)
}
#[test]
fn reduce_and_expand_match_the_serial_code() {
for (n, &(w, h)) in SIZES.iter().enumerate() {
for ch in [1usize, 3, 4] {
let src = noise(w * h * ch, n as u64 * 7 + ch as u64);
let (nw, nh, got) = reduce(w, h, ch, &src);
let (rw, rh, want) = ref_reduce(w, h, ch, &src);
assert_eq!((nw, nh), (rw, rh));
assert_eq!(bits(&got), bits(&want), "reduce {w}x{h}x{ch}");
// Expand back to the original size and to sizes that are not twice the source's.
for (ew, eh) in [(w, h), (2 * nw, 2 * nh), (2 * nw - 1, 2 * nh + 1), (w + 3, h + 5)] {
let got = expand(nw, nh, ch, &want, ew, eh);
let want_up = ref_expand(nw, nh, ch, &want, ew, eh);
assert_eq!(bits(&got), bits(&want_up), "expand {nw}x{nh}x{ch} to {ew}x{eh}");
}
}
}
}
#[test]
fn push_pull_matches_the_serial_code() {
for (n, &(w, h)) in SIZES.iter().enumerate() {
for ch in [1usize, 3] {
for variant in 0..3u64 {
let wt = match variant {
0 => weights(w, h, n as u64),
1 => vec![1.0; w * h],
// A single covered pixel: every level of the recursion has to fill from it.
_ => (0..w * h).map(|i| if i == (w * h) / 3 { 1.0 } else { 0.0 }).collect(),
};
let px = noise(w * h * ch, 100 + n as u64);
let (mut got, mut want) = (px.clone(), px);
push_pull(w, h, ch, &mut got, &wt);
ref_push_pull(w, h, ch, &mut want, &wt);
assert_eq!(bits(&got), bits(&want), "push_pull {w}x{h}x{ch} variant {variant}");
}
}
}
}
/// ROIs of assorted sizes and offsets (some sticking out of the canvas), with holes in the alpha.
fn rois(w: usize, h: usize, ch: usize, seed: u64) -> (Vec<RoiImage>, Vec<Vec<f32>>) {
let specs =
[(0i32, 0i32, w * 2 / 3, h), (w as i32 / 3, -(h as i32) / 5, w * 2 / 3 + 1, h * 3 / 4 + 1), (-(w as i32) / 4, h as i32 / 2, w / 2 + 1, h / 2 + 3)];
let imgs: Vec<RoiImage> = specs
.iter()
.enumerate()
.map(|(k, &(x0, y0, iw, ih))| {
let (iw, ih) = (iw.max(1), ih.max(1));
let mut alpha = vec![1.0f32; iw * ih];
for (i, a) in alpha.iter_mut().enumerate() {
let z = mix(seed + k as u64 * 1000 + i as u64);
if (i % iw) / 4 % 5 == 2 && (i / iw) % 6 < 3 {
*a = 0.0;
} else if z.is_multiple_of(11) {
*a = 0.3;
}
}
RoiImage { x0, y0, w: iw, h: ih, ch, px: noise(iw * ih * ch, seed + k as u64), alpha }
})
.collect();
let wts = imgs.iter().enumerate().map(|(k, r)| weights(r.w, r.h, seed + 50 + k as u64)).collect();
(imgs, wts)
}
#[test]
fn multiband_matches_the_serial_code() {
for (n, &(w, h)) in [(1usize, 1usize), (5, 3), (40, 30), (97, 61), (130, 70), (64, 64), (201, 129)].iter().enumerate() {
for ch in [1usize, 3] {
let (imgs, wts) = rois(w, h, ch, n as u64 * 31 + ch as u64);
for levels in [1usize, 2, 3, 5, 7] {
let got = multiband(w, h, &imgs, &wts, levels);
let want = ref_multiband(w, h, &imgs, &wts, levels);
assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels}");
assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels}");
}
}
}
// No images at all, and zero levels.
assert_eq!(multiband(9, 7, &[], &[], 3).0.len(), ref_multiband(9, 7, &[], &[], 3).0.len());
}
/// Hashes recorded from the code before it was made parallel (plain f32 arithmetic only, so
/// they hold on every platform).
#[test]
fn multiband_hashes_recorded_from_the_serial_code() {
let (imgs, wts) = rois(97, 61, 3, 5);
let (px, cov) = multiband(97, 61, &imgs, &wts, 5);
assert_eq!(fnv(&[&px, &cov]), 0xf89a5726082ebb6c, "97x61 rgb, 5 levels");
let (imgs, wts) = rois(130, 70, 1, 9);
let (px, cov) = multiband(130, 70, &imgs, &wts, 7);
assert_eq!(fnv(&[&px, &cov]), 0xb7d7ddc59d201730, "130x70 grey, 7 levels");
let wt = weights(65, 41, 3);
let mut px = noise(65 * 41 * 3, 4);
push_pull(65, 41, 3, &mut px, &wt);
assert_eq!(fnv(&[&px]), 0x50ea36a84ca9a68f, "push_pull 65x41 rgb");
let (nw, nh, r) = reduce(65, 41, 3, &px);
assert_eq!(fnv(&[&r, &expand(nw, nh, 3, &r, 65, 41)]), 0x51823acfb4206dd8, "reduce + expand 65x41 rgb");
}
}
+45
View File
@@ -448,6 +448,51 @@ pub fn clean_mask(mask: &[bool], w: usize, h: usize, frac: f32, hole_frac: f32)
out
}
/// Fills the holes of `mask` (4-connected background areas not touching the border) by what
/// they look like: a hole up to `speck_frac` of the mask's area always fills; a larger one, up
/// to `hole_frac`, only when its pixels fit the foreground model `fg` better than `bg` on
/// average, so an enclosed patch of backdrop stays open.
pub fn fill_holes_by_model(mask: &[bool], img: &RgbImage, fg: &Gmm, bg: &Gmm, speck_frac: f32, hole_frac: f32) -> Vec<bool> {
let (w, h) = (img.w, img.h);
let mut out = mask.to_vec();
let area = out.iter().filter(|v| **v).count();
let max_speck = (area as f32 * speck_frac) as usize;
let max_hole = (area as f32 * hole_frac) as usize;
let inv: Vec<bool> = out.iter().map(|v| !v).collect();
let (hl, hs) = components4(&inv, w, h);
let mut touches = vec![false; hs.len()];
for x in 0..w {
touches[hl[x] as usize] = true;
touches[hl[(h - 1) * w + x] as usize] = true;
}
for y in 0..h {
touches[hl[y * w] as usize] = true;
touches[hl[y * w + w - 1] as usize] = true;
}
// Summed data costs per hole; only holes small enough to be candidates are evaluated.
let mut cost = vec![(0.0f64, 0.0f64); hs.len()];
for (i, l) in hl.iter().enumerate() {
let l = *l as usize;
if l == 0 || touches[l] || hs[l] > max_hole || hs[l] <= max_speck {
continue;
}
if let Some(p) = img.px.get(i) {
cost[l].0 += f64::from(fg.neg_log(*p));
cost[l].1 += f64::from(bg.neg_log(*p));
}
}
for (o, l) in out.iter_mut().zip(&hl) {
let l = *l as usize;
if l == 0 || touches[l] || hs[l] > max_hole {
continue;
}
if hs[l] <= max_speck || cost[l].0 <= cost[l].1 {
*o = true;
}
}
out
}
fn components4(mask: &[bool], w: usize, h: usize) -> (Vec<u32>, Vec<usize>) {
let mut lab = vec![0u32; w * h];
let mut sizes = vec![0usize];
+65 -3
View File
@@ -13,8 +13,17 @@
//! S. Süsstrunk, "Frequency-tuned Salient Region Detection", CVPR 2009): distance of the
//! lightly blurred CIELAB image from its mean colour.
//! 4. Otsu's threshold on the saliency map seeds a GrabCut trimap (low-saliency border pixels
//! are definite background); the result is cleaned (small islands dropped, small holes
//! filled) and re-cut at full resolution.
//! are definite background); the result is cleaned (small islands dropped).
//! 5. A second GrabCut whose background model only knows the backdrop: a colour model fitted
//! to the image's frame marks every pixel it explains as probable background, and the rest
//! (the blob, plus whatever the backdrop cannot account for) starts as probable foreground.
//! Parts of the subject whose colour is close to the backdrop (a cream shirt on a near-white
//! wall) have low saliency and in step 4 land in the background model, which then explains
//! them away; here they start as foreground and the cut keeps them (#2788). Only what
//! touches the first pass's result survives, so the pass cannot pull in unrelated things.
//! 6. Holes are filled only when their colour fits the foreground model better than the
//! background's, so a patch of backdrop enclosed by the subject (between an arm and the
//! torso) stays open; specks still fill. Then the mask is re-cut at full resolution.
//!
//! Works well for a distinct object on a comparatively plain background; it is not a semantic
//! segmenter.
@@ -25,6 +34,7 @@ use std::collections::BinaryHeap;
use photocraft_geom::Rect;
use photocraft_raster::{Cancelled, Interrupt};
use super::gmm::Gmm;
use super::grabcut::{self, BG, PR_BG, PR_FG};
use super::slic::{rgb_to_lab, slic_lab};
use super::{Region, Sampler};
@@ -72,13 +82,65 @@ pub fn select_subject_with(sampler: &dyn Sampler, canvas: Rect, ctl: &Interrupt)
}
let Some((fg, bg)) = grabcut::grabcut_with(&img, &mut trimap, 6, ctl)? else { return Ok(None) };
let low: Vec<bool> = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect();
let low = super::clean_mask(&low, w, h, 0.2, 0.05);
let low = super::clean_mask(&low, w, h, 0.2, 0.0);
if !low.iter().any(|v| *v) {
return Ok(None);
}
let (low, fg, bg) = backdrop_pass(&img, &low, ctl)?.unwrap_or((low, fg, bg));
ctl.check()?;
let low = super::fill_holes_by_model(&low, &img, &fg, &bg, 0.002, 0.05);
super::finish_region_with(sampler, canvas, step, &low, w, h, Some((&fg, &bg)), ctl)
}
/// Step 5: GrabCut again with a background model that only knows the backdrop. A colour model
/// is fitted to the image's frame (the outer pixels, minus a margin around `blob` in case the
/// subject runs out of the picture); the frame is definite background, every other pixel it
/// explains well is probable background, and the rest (the blob, and anything the backdrop
/// cannot account for, such as a cream shirt on a near-white wall) starts as probable
/// foreground. `Ok(None)` when the frame cannot be modelled or the cut keeps nothing that
/// touches the blob; `Err(Cancelled)` once `ctl` is cancelled, as in the first pass.
fn backdrop_pass(img: &super::RgbImage, blob: &[bool], ctl: &Interrupt) -> Result<Option<(Vec<bool>, Gmm, Gmm)>, Cancelled> {
let (w, h) = (img.w, img.h);
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize);
for (i, on) in blob.iter().enumerate() {
if *on {
let (x, y) = (i % w, i / w);
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
}
}
if x1 < x0 || y1 < y0 {
return Ok(None);
}
let margin = (((x1 - x0 + 1).max(y1 - y0 + 1) as f32 * 0.15).ceil() as usize).max(4);
let (bx0, by0) = (x0.saturating_sub(margin), y0.saturating_sub(margin));
let (bx1, by1) = ((x1 + margin).min(w - 1), (y1 + margin).min(h - 1));
let frame = ((w.min(h) as f32 * 0.06).round() as usize).max(2);
let in_frame = |x: usize, y: usize| x < frame || y < frame || x + frame >= w || y + frame >= h;
let near_blob = |x: usize, y: usize| (bx0..=bx1).contains(&x) && (by0..=by1).contains(&y);
let samples: Vec<[f32; 3]> = (0..w * h).filter(|i| in_frame(i % w, i / w) && !near_blob(i % w, i / w)).map(|i| img.px[i]).collect();
let Some(backdrop) = Gmm::fit(&super::subsample(&samples, 40_000), grabcut::K, super::gmm::DEFAULT_REG) else { return Ok(None) };
// What the frame itself costs under its own model bounds "explained well".
let mut frame_costs: Vec<f32> = samples.iter().map(|p| backdrop.neg_log(*p)).collect();
frame_costs.sort_by(|a, b| a.total_cmp(b));
let Some(limit) = frame_costs.get(frame_costs.len() * 99 / 100).map(|c| c + 4.0) else { return Ok(None) };
let mut trimap: Vec<u8> = (0..w * h)
.map(|i| {
let (x, y) = (i % w, i / w);
if in_frame(x, y) && !near_blob(x, y) {
BG
} else if blob[i] || backdrop.neg_log(img.px[i]) > limit {
PR_FG
} else {
PR_BG
}
})
.collect();
let Some((fg, bg)) = grabcut::grabcut_with(img, &mut trimap, 4, ctl)? else { return Ok(None) };
let cut: Vec<bool> = trimap.iter().map(|v| *v == PR_FG || *v == grabcut::FG).collect();
let low = super::keep_seeded(&cut, blob, w, h);
Ok(low.iter().any(|v| *v).then_some((low, fg, bg)))
}
/// Per-pixel saliency in `0..=1` (see the module docs).
pub fn saliency(img: &super::RgbImage) -> Vec<f32> {
let (w, h) = (img.w, img.h);
+151
View File
@@ -761,3 +761,154 @@ fn object_at_on_the_background_or_off_canvas_finds_nothing() {
assert!(grabcut::object_at(&s, canvas, f32::NAN, 10.0, 20_000).is_none());
assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none());
}
// ---------- Select Subject on the cases reported in #2788 ----------
/// A figure whose upper part is close to the background colour (a cream shirt on a near-white
/// backdrop): the whole figure is the subject. Returns (image, truth, the low-contrast part).
pub(crate) fn low_contrast_figure(seed: u64) -> (RgbImage, Vec<bool>, Vec<bool>) {
let (w, h) = (240usize, 200usize);
let mut rng = Rng::new(seed);
let mut img = RgbImage::new(w, h);
let mut truth = vec![false; w * h];
let mut cream = vec![false; w * h];
for y in 0..h {
for x in 0..w {
let body = (90..150).contains(&x) && (70..190).contains(&y);
let shoulders = (80..160).contains(&x) && (40..70).contains(&y);
let print = (105..135).contains(&x) && (100..130).contains(&y);
let n = 0.012 * rng.normal();
let i = y * w + x;
truth[i] = body || shoulders;
cream[i] = shoulders;
img.px[i] = if print {
[0.9 + n, 0.15 + n, 0.1 + n]
} else if body {
[0.12 + n, 0.2 + n, 0.45 + n]
} else if shoulders {
// Cream, a few levels off the backdrop.
[0.90 + n, 0.88 + n, 0.80 + n]
} else {
[0.96 + n, 0.96 + n, 0.95 + n]
}
.map(|v| v.clamp(0.0, 1.0));
}
}
(img, truth, cream)
}
fn coverage(region: &Region, part: &[bool], w: usize, h: usize) -> f32 {
let (mut hit, mut n) = (0usize, 0usize);
for y in 0..h {
for x in 0..w {
if part[y * w + x] {
n += 1;
hit += (region.at(x as i32, y as i32) >= 0.5) as usize;
}
}
}
hit as f32 / n.max(1) as f32
}
#[test]
fn select_subject_keeps_parts_close_to_the_background_colour() {
let (img, truth, cream) = low_contrast_figure(7);
let s = ImageSampler { img: &img, origin: (0, 0) };
let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200)).unwrap();
let v = iou(&reg, &truth, 240, 200);
let c = coverage(&reg, &cream, 240, 200);
assert!(c > 0.85, "the cream shoulders are part of the subject: covered {c}");
assert!(v > 0.9, "IoU {v}");
}
#[test]
fn select_subject_leaves_a_background_coloured_gap_open() {
// A dark ring (arms on hips) around a patch of plain backdrop: the patch is not the subject.
let (w, h) = (220usize, 200usize);
let mut rng = Rng::new(11);
let mut img = RgbImage::new(w, h);
let mut truth = vec![false; w * h];
let mut gap = vec![false; w * h];
for y in 0..h {
for x in 0..w {
let d = (x as f32 - 110.0).hypot(y as f32 - 100.0);
let ring = (17.0..80.0).contains(&d);
let hole = d < 17.0;
let n = 0.012 * rng.normal();
let i = y * w + x;
truth[i] = ring;
gap[i] = hole;
img.px[i] = if ring { [0.2 + n, 0.15 + n, 0.35 + n] } else { [0.95 + n, 0.94 + n, 0.92 + n] }.map(|v| v.clamp(0.0, 1.0));
}
}
let s = ImageSampler { img: &img, origin: (0, 0) };
let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32)).unwrap();
let g = coverage(&reg, &gap, w, h);
assert!(g < 0.1, "the backdrop inside the ring stays unselected: covered {g}");
let v = iou(&reg, &truth, w, h);
assert!(v > 0.9, "IoU {v}");
}
/// Timing on a 6 MP figure, for before/after numbers (`cargo test --release -- --ignored select_subject_timing --nocapture`).
#[test]
#[ignore = "timing only"]
fn select_subject_timing() {
let (w, h) = (3000usize, 2000usize);
let img = RgbImage::from_fn(w, h, |x, y| {
let body = (1100..1900).contains(&x) && (700..1900).contains(&y);
let shoulders = (1000..2000).contains(&x) && (400..700).contains(&y);
let t = ((x * 7 + y * 13) % 17) as f32 / 1700.0;
if body {
[0.12 + t, 0.2, 0.45]
} else if shoulders {
[0.90, 0.88 + t, 0.80]
} else {
[0.96, 0.96 + t, 0.95]
}
});
let s = ImageSampler { img: &img, origin: (0, 0) };
for _ in 0..2 {
let t0 = std::time::Instant::now();
let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32)).unwrap();
eprintln!("select_subject 6 MP: {:?} bbox {:?}", t0.elapsed(), reg.bbox);
}
}
/// Writes the #2788 scenes and the Select Subject mask over them as PPM files into
/// `$SUBJECT_DUMP` (for the PR's before/after pictures).
#[test]
#[ignore = "writes pictures"]
fn select_subject_dump_pictures() {
let Ok(dir) = std::env::var("SUBJECT_DUMP") else { return };
let write = |name: &str, img: &RgbImage, reg: Option<&Region>| {
let mut out = format!("P6\n{} {}\n255\n", img.w, img.h).into_bytes();
for y in 0..img.h {
for x in 0..img.w {
let mut c = img.at(x, y);
if let Some(r) = reg
&& r.at(x as i32, y as i32) >= 0.5
{
c = [c[0] * 0.5 + 0.5, c[1] * 0.5, c[2] * 0.5 + 0.5];
}
out.extend(c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8));
}
}
std::fs::write(format!("{dir}/{name}.ppm"), out).unwrap();
};
let (img, _, _) = low_contrast_figure(7);
let s = ImageSampler { img: &img, origin: (0, 0) };
let reg = subject::select_subject(&s, Rect::new(0, 0, 240, 200));
write("figure", &img, None);
write("figure-selected", &img, reg.as_ref());
let (w, h) = (220usize, 200usize);
let mut rng = Rng::new(11);
let img = RgbImage::from_fn(w, h, |x, y| {
let d = (x as f32 - 110.0).hypot(y as f32 - 100.0);
let n = 0.012 * rng.normal();
if (17.0..80.0).contains(&d) { [0.2 + n, 0.15 + n, 0.35 + n] } else { [0.95 + n, 0.94 + n, 0.92 + n] }.map(|v| v.clamp(0.0, 1.0))
});
let s = ImageSampler { img: &img, origin: (0, 0) };
let reg = subject::select_subject(&s, Rect::new(0, 0, w as i32, h as i32));
write("ring", &img, None);
write("ring-selected", &img, reg.as_ref());
}
+338 -49
View File
@@ -23,24 +23,83 @@ pub enum Stat {
Variance,
}
fn stat(v: &mut [f32], s: Stat) -> f32 {
/// Reusable per-chunk scratch for [`Stat::Entropy`]: an 8-bit histogram plus the list of bins the
/// current pixel touched. Allocated once per parallel chunk instead of one `HashMap` per channel
/// per pixel (the old cost: ~4.6 s for 3 × 24 MP).
struct EntropyScratch {
counts: [u32; 256],
touched: Vec<u8>,
}
impl EntropyScratch {
fn new() -> Self {
Self { counts: [0; 256], touched: Vec::new() }
}
}
/// Shannon entropy (bits) of the 8-bit values in `v`, over log2(N). `v.len() >= 2`.
fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 {
let n = v.len() as f32;
for x in v {
let bin = (x.clamp(0.0, 1.0) * 255.0).round() as u8;
let c = &mut scratch.counts[usize::from(bin)];
if *c == 0 {
scratch.touched.push(bin);
}
*c += 1;
}
// Sum the bins in ascending order. The old HashMap summed in its (randomly seeded) iteration
// order, and float addition is not associative, so the old result could differ in the last
// bit from run to run; a fixed order makes it deterministic. Sorting only the few touched
// bins keeps this O(samples), not O(256), per pixel.
scratch.touched.sort_unstable();
let h: f32 = scratch
.touched
.iter()
.map(|&bin| {
let p = scratch.counts[usize::from(bin)] as f32 / n;
-p * p.log2()
})
.sum();
// Reset only the bins used, so the 256-entry array is never cleared wholesale.
for &bin in &scratch.touched {
scratch.counts[usize::from(bin)] = 0;
}
scratch.touched.clear();
h / n.log2()
}
fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 {
let n = v.len() as f32;
if v.is_empty() {
return 0.0;
}
// Modes that need neither the mean nor the moments return before computing them (the old code
// computed the mean for every mode and threw it away; skipping it changes no result).
match s {
Stat::Summation => return v.iter().sum::<f32>(),
Stat::Minimum => return v.iter().copied().fold(f32::MAX, f32::min),
Stat::Maximum => return v.iter().copied().fold(f32::MIN, f32::max),
Stat::Range => return v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min),
Stat::Median => {
// `total_cmp` is a total order whose ties are bit-identical values, so an unstable
// sort gives exactly the stable sort's result.
v.sort_unstable_by(f32::total_cmp);
let k = v.len();
return if k % 2 == 1 { v[k / 2] } else { (v[k / 2 - 1] + v[k / 2]) / 2.0 };
}
Stat::Entropy => {
if v.len() < 2 {
return 0.0;
}
return entropy(v, scratch);
}
_ => {}
}
let mean = v.iter().sum::<f32>() / n;
let m = |k: i32| v.iter().map(|x| (x - mean).powi(k)).sum::<f32>() / n;
match s {
Stat::Mean => mean,
Stat::Summation => v.iter().sum::<f32>(),
Stat::Minimum => v.iter().copied().fold(f32::MAX, f32::min),
Stat::Maximum => v.iter().copied().fold(f32::MIN, f32::max),
Stat::Range => v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min),
Stat::Median => {
v.sort_by(f32::total_cmp);
let k = v.len();
if k % 2 == 1 { v[k / 2] } else { (v[k / 2 - 1] + v[k / 2]) / 2.0 }
}
Stat::Variance => m(2),
Stat::StandardDeviation => m(2).sqrt(),
Stat::Skewness => {
@@ -53,55 +112,55 @@ fn stat(v: &mut [f32], s: Stat) -> f32 {
// 0..10 → 0..1.
if var < 1e-12 { 0.0 } else { (m(4) / (var * var) / 10.0).clamp(0.0, 1.0) }
}
Stat::Entropy => {
if v.len() < 2 {
return 0.0;
}
let mut counts: std::collections::HashMap<u8, u32> = std::collections::HashMap::new();
for x in v.iter() {
*counts.entry((x.clamp(0.0, 1.0) * 255.0).round() as u8).or_default() += 1;
}
let h: f32 = counts
.values()
.map(|&c| {
let p = c as f32 / n;
-p * p.log2()
})
.sum();
h / n.log2()
}
// Handled above; a no-op keeps the match exhaustive without a panic path.
Stat::Summation | Stat::Minimum | Stat::Maximum | Stat::Range | Stat::Median | Stat::Entropy => 0.0,
}
}
/// Output pixels per parallel task: big enough to amortise the per-task scratch buffers and
/// rayon's scheduling, small enough (6000 tasks for 24 MP) to balance across many cores.
const CHUNK: usize = 4096;
/// 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];
let mut samples: [Vec<f32>; 3] = Default::default();
for (i, o) in out.iter_mut().enumerate() {
for v in samples.iter_mut() {
v.clear();
}
let mut alpha = 0.0f32;
for l in layers {
if let Some(p) = l.get(i)
&& p[3] > 0.0
{
for c in 0..3 {
samples[c].push(p[c]);
}
alpha = alpha.max(p[3]);
// Every output pixel depends only on the input pixels at the same index, so chunks of the
// output are independent: `par_rows` over a `CHUNK`-wide "row" with one channel (it runs
// serially on wasm). The per-pixel arithmetic is unchanged, hence the result is bit-identical
// to the serial loop for every mode (Entropy: see [`entropy`]).
crate::photo_util::par_rows(&mut out, CHUNK, 1, |chunk, outs| {
// Scratch is per chunk, not per pixel: the old loop's three `Vec`s were already reused
// across pixels, so keep that, and give each task its own set.
let mut samples: [Vec<f32>; 3] = [0, 1, 2].map(|_| Vec::with_capacity(layers.len()));
let mut scratch = EntropyScratch::new();
let base = chunk * CHUNK;
for (j, o) in outs.iter_mut().enumerate() {
let i = base + j;
for v in samples.iter_mut() {
v.clear();
}
let mut alpha = 0.0f32;
for l in layers {
if let Some(p) = l.get(i)
&& p[3] > 0.0
{
for c in 0..3 {
samples[c].push(p[c]);
}
alpha = alpha.max(p[3]);
}
}
if alpha <= 0.0 {
continue;
}
for c in 0..3 {
o[c] = stat(&mut samples[c], s, &mut scratch);
}
o[3] = alpha;
}
if alpha <= 0.0 {
continue;
}
for c in 0..3 {
o[c] = stat(&mut samples[c], s);
}
o[3] = alpha;
}
});
out
}
@@ -168,4 +227,234 @@ mod tests {
let layers = vec![px(0.5), px(0.5), px(1.0)];
assert_eq!(combine(&layers, Stat::Median)[0][0], 0.5);
}
/// Five layers over six pixels (the last layer is short). Covers opaque, partly transparent,
/// fully transparent, HDR / negative, single-sample and tied-value pixels.
fn fixture() -> Vec<Vec<[f32; 4]>> {
vec![
vec![[0.10, 0.20, 0.30, 1.0], [0.5, 0.5, 0.5, 1.0], [0.9, 0.9, 0.9, 0.0], [-2.0, 4.0, 0.0, 1.0], [0.7, 0.2, 0.1, 0.5], [0.25, 0.25, 0.25, 1.0]],
vec![[0.90, 0.60, 0.30, 1.0], [0.0, 0.0, 0.0, 0.0], [0.8, 0.8, 0.8, 0.0], [3.0, -1.0, 0.5, 0.75], [0.0, 0.0, 0.0, 0.0], [0.25, 0.75, 0.25, 1.0]],
vec![[0.20, 0.90, 0.30, 0.5], [0.1, 0.2, 0.3, 1.0], [0.7, 0.7, 0.7, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 0.0, 0.0], [0.5, 0.75, 0.25, 0.25]],
vec![[0.30, 0.05, 0.30, 0.25], [0.3, 0.4, 0.5, 0.5], [0.6, 0.6, 0.6, 0.0], [1.5, 1.5, 1.5, 1.0], [0.0, 0.0, 0.0, 0.0], [0.25, 0.75, 0.5, 1.0]],
vec![[0.10, 0.70, 0.99, 1.0], [0.9, 0.1, 0.2, 1.0], [0.5, 0.5, 0.5, 0.0], [0.0, 0.0, 0.0, 1.0]],
]
}
fn bits(px: &[[f32; 4]]) -> Vec<[u32; 4]> {
px.iter().map(|p| p.map(f32::to_bits)).collect()
}
/// Every mode except Entropy is bit-for-bit what the old serial loop produced. The expected
/// bit patterns were recorded by running the unmodified `combine` on [`fixture`].
#[test]
fn statistics_are_bit_identical_to_the_serial_implementation() {
let layers = fixture();
let expected: Vec<(Stat, Vec<[u32; 4]>)> = vec![
(
Stat::Kurtosis,
[
[0x3e96956b, 0x3e182e3f, 0x3ea66664, 0x3f800000],
[0x3e3d004c, 0x3e0b4396, 0x3e030e1f, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x3e594eb2, 0x3e6ef420, 0x3e7ffffe, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x3f000000],
[0x3e6eeeee, 0x3e6eeeee, 0x3e6eeeee, 0x3f800000],
]
.to_vec(),
),
(
Stat::Maximum,
[
[0x3f666666, 0x3f666666, 0x3f7d70a4, 0x3f800000],
[0x3f666666, 0x3f000000, 0x3f000000, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x40400000, 0x40800000, 0x3fc00000, 0x3f800000],
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
[0x3f000000, 0x3f400000, 0x3f000000, 0x3f800000],
]
.to_vec(),
),
(
Stat::Mean,
[
[0x3ea3d70a, 0x3efae148, 0x3ee0418a, 0x3f800000],
[0x3ee66666, 0x3e99999a, 0x3ec00000, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x3f19999a, 0x3f800000, 0x3f000000, 0x3f800000],
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
[0x3ea00000, 0x3f200000, 0x3ea00000, 0x3f800000],
]
.to_vec(),
),
(
Stat::Median,
[
[0x3e4ccccd, 0x3f19999a, 0x3e99999a, 0x3f800000],
[0x3ecccccd, 0x3e99999a, 0x3ecccccd, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x3f000000, 0x3f000000, 0x3f000000, 0x3f800000],
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
[0x3e800000, 0x3f400000, 0x3e800000, 0x3f800000],
]
.to_vec(),
),
(
Stat::Minimum,
[
[0x3dcccccd, 0x3d4ccccd, 0x3e99999a, 0x3f800000],
[0x3dcccccd, 0x3dcccccd, 0x3e4ccccd, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0xc0000000, 0xbf800000, 0x00000000, 0x3f800000],
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
[0x3e800000, 0x3e800000, 0x3e800000, 0x3f800000],
]
.to_vec(),
),
(
Stat::Range,
[
[0x3f4ccccc, 0x3f599999, 0x3f30a3d7, 0x3f800000],
[0x3f4ccccc, 0x3ecccccd, 0x3e99999a, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x40a00000, 0x40a00000, 0x3fc00000, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x3f000000],
[0x3e800000, 0x3f000000, 0x3e800000, 0x3f800000],
]
.to_vec(),
),
(
Stat::Skewness,
[
[0x3f522026, 0x3ee7cf76, 0x3f600000, 0x3f800000],
[0x3f1bd152, 0x3efffffe, 0x3ee4a11b, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x3eedb65a, 0x3f2ea754, 0x3f3a6c7a, 0x3f800000],
[0x3f000000, 0x3f000000, 0x3f000000, 0x3f000000],
[0x3f49e69e, 0x3e58658a, 0x3f49e69e, 0x3f800000],
]
.to_vec(),
),
(
Stat::StandardDeviation,
[
[0x3e99421e, 0x3ea23b6b, 0x3e8d4fdf, 0x3f800000],
[0x3e97739e, 0x3e21e89b, 0x3e050581, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x3fd3e0b1, 0x3fd9f9e5, 0x3f0c378c, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x3f000000],
[0x3dddb3d7, 0x3e5db3d7, 0x3dddb3d7, 0x3f800000],
]
.to_vec(),
),
(
Stat::Summation,
[
[0x3fcccccd, 0x401ccccd, 0x400c28f6, 0x3f800000],
[0x3fe66666, 0x3f99999a, 0x3fc00000, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x40400000, 0x40a00000, 0x40200000, 0x3f800000],
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
[0x3fa00000, 0x40200000, 0x3fa00000, 0x3f800000],
]
.to_vec(),
),
(
Stat::Variance,
[
[0x3db78033, 0x3dcd9e83, 0x3d9c022a, 0x3f800000],
[0x3db33332, 0x3ccccccd, 0x3c8a3d70, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x402f5c29, 0x4039999a, 0x3e99999a, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x3f000000],
[0x3c400000, 0x3d400000, 0x3c400000, 0x3f800000],
]
.to_vec(),
),
];
for (mode, want) in expected {
assert_eq!(bits(&combine(&layers, mode)), want, "{mode:?}");
}
}
/// Entropy against values worked out by hand (bits of the 8-bit histogram, over log2(N)).
#[test]
fn entropy_matches_hand_computed_values() {
let out = combine(&fixture(), Stat::Entropy);
let h = |ps: &[f64]| -> f64 {
let n: f64 = ps.iter().sum();
ps.iter().map(|c| -(c / n) * (c / n).log2()).sum::<f64>() / n.log2()
};
// Pixel 1: four distinct values -> 2 bits / log2(4) = exactly 1.
assert_eq!(out[1][0], 1.0);
// Pixel 2: every sample transparent -> untouched.
assert_eq!(out[2], [0.0; 4]);
// Pixel 3, red: -2 and 0 land in bin 0, 3 and 1.5 clamp into bin 255, 0.5 is alone.
assert!((f64::from(out[3][0]) - h(&[2.0, 2.0, 1.0])).abs() < 1e-6, "{}", out[3][0]);
// Pixel 4: a single contributing sample has no entropy.
assert_eq!(out[4], [0.0, 0.0, 0.0, 0.5]);
// Pixel 5: three samples share a bin, one is alone, in every channel.
for (c, v) in out[5].iter().take(3).enumerate() {
assert!((f64::from(*v) - h(&[3.0, 1.0])).abs() < 1e-6, "channel {c}: {v}");
}
}
/// The old HashMap summed in a random order, so Entropy could differ by an ulp between runs.
/// Now the bins are summed in ascending order: repeated runs (and any thread count) agree.
#[test]
fn entropy_is_deterministic() {
let layers: Vec<Vec<[f32; 4]>> = (0..7)
.map(|k| {
(0..3 * CHUNK + 17).map(|i| [((i * 7 + k * 13) % 11) as f32 / 10.0, ((i + k) % 5) as f32 / 4.0, ((i * k) % 9) as f32 / 8.0, 1.0]).collect()
})
.collect();
let first = bits(&combine(&layers, Stat::Entropy));
for _ in 0..8 {
assert_eq!(bits(&combine(&layers, Stat::Entropy)), first);
}
}
/// Chunking is invisible: a multi-chunk image (with a ragged last chunk and layers of unequal
/// length) gives, pixel by pixel, what combining that pixel on its own gives.
#[test]
fn chunked_result_equals_per_pixel_result() {
let len = 2 * CHUNK + 5;
let layer = |k: usize, n: usize| -> Vec<[f32; 4]> {
(0..n)
.map(|i| {
[
((i * 31 + k * 7) % 101) as f32 / 100.0,
((i * 17 + k) % 53) as f32 / 52.0 - 0.2,
((i + 3 * k) % 29) as f32 / 28.0,
if (i + k).is_multiple_of(4) { 0.0 } else { 0.5 + ((i + k) % 3) as f32 / 4.0 },
]
})
.collect()
};
let layers = vec![layer(1, len), layer(2, CHUNK + 3), layer(3, len - 1), layer(4, 0), layer(5, len)];
for mode in [
Stat::Entropy,
Stat::Kurtosis,
Stat::Maximum,
Stat::Mean,
Stat::Median,
Stat::Minimum,
Stat::Range,
Stat::Skewness,
Stat::StandardDeviation,
Stat::Summation,
Stat::Variance,
] {
let whole = combine(&layers, mode);
assert_eq!(whole.len(), len);
for i in (0..len).step_by(97).chain([CHUNK - 1, CHUNK, CHUNK + 1, 2 * CHUNK - 1, 2 * CHUNK, len - 1]) {
let one: Vec<Vec<[f32; 4]>> = layers.iter().map(|l| l.get(i).map(|p| vec![*p]).unwrap_or_default()).collect();
assert_eq!(whole[i].map(f32::to_bits), combine(&one, mode)[0].map(f32::to_bits), "{mode:?} pixel {i}");
}
}
}
#[test]
fn no_layers_gives_an_empty_result() {
assert!(combine(&[], Stat::Median).is_empty());
}
}
+43 -7
View File
@@ -281,7 +281,7 @@ fn warp(
// 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),
None => ((f64::from(src_rect.x0) + f64::from(src_rect.x1)) / 2.0, (f64::from(src_rect.y0) + f64::from(src_rect.y1)) / 2.0),
};
let scale = h.local_scale(cx, cy);
let reduced;
@@ -366,10 +366,10 @@ fn warp(
return Ok(None);
}
let fp: Vec<(f64, f64)> = poly.iter().map(|&(x, y)| inv.apply(x, y)).collect();
let fx0 = fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32 - 3;
let fy0 = fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32 - 3;
let fx1 = fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32 + 3;
let fy1 = fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32 + 3;
let fx0 = (fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3);
let fy0 = (fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32).saturating_sub(3);
let fx1 = (fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3);
let fy1 = (fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32).saturating_add(3);
// Without a one-signed forward w, a tile straddling the inverse horizon has no bounded footprint.
let straddles = side.is_none() && wc.iter().any(|w| *w <= 0.0) && wc.iter().any(|w| *w >= 0.0);
let foot = if straddles { src_rect } else { Rect::new(fx0, fy0, fx1, fy1).intersect(&src_rect) };
@@ -392,6 +392,15 @@ fn warp(
px[((y - foot.y0) as usize * fw + (x - foot.x0) as usize) * n + c] as f64
}
};
// Samples past the source rect repeat its edge, so a stretched edge row or column keeps its
// colour and opacity (the "pixel stretch", #3773); the outline is antialiased by coverage
// instead. `foot_u`/`foot_v` are a destination pixel's extent in source pixels.
let clamped = |x: i32, y: i32, c: usize| at(x.clamp(src_rect.x0, src_rect.x1 - 1), y.clamp(src_rect.y0, src_rect.y1 - 1), c);
let (mx, my) = ((t.x0 + t.x1) as f64 / 2.0, (t.y0 + t.y1) as f64 / 2.0);
let (o, dx, dy) = (inv.apply(mx, my), inv.apply(mx + 1.0, my), inv.apply(mx, my + 1.0));
let extent = |e: f64| if e.is_finite() && e > 1e-12 { e } else { 1.0 };
let foot_u = extent((dx.0 - o.0).abs() + (dy.0 - o.0).abs());
let foot_v = extent((dx.1 - o.1).abs() + (dy.1 - o.1).abs());
let w = t.width() as usize;
let len = w.checked_mul(t.height() as usize).and_then(|v| v.checked_mul(n)).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
let bytes = len.checked_mul(std::mem::size_of::<f32>()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
@@ -424,7 +433,7 @@ fn warp(
for (dy, wy) in [(0, 1.0 - fy), (1, fy)] {
for (dx, wx) in [(0, 1.0 - fx), (1, fx)] {
for (c, s) in acc.iter_mut().enumerate().take(n) {
*s += at(ix + dx, iy + dy, c) * wx * wy;
*s += clamped(ix + dx, iy + dy, c) * wx * wy;
}
}
}
@@ -436,12 +445,21 @@ fn warp(
for (i, wxi) in wx.iter().enumerate() {
let k = wxi * wyj;
for (c, s) in acc.iter_mut().enumerate().take(n) {
*s += at(ix - 1 + i as i32, iy - 1 + j as i32, c) * k;
*s += clamped(ix - 1 + i as i32, iy - 1 + j as i32, c) * k;
}
}
}
}
}
if interp != Interp::Nearest {
// How much of this pixel the warped source rect covers, per axis.
let (uc, vc) = (u + 0.5, v + 0.5);
let cu = ((uc - src_rect.x0 as f64).min(src_rect.x1 as f64 - uc) / foot_u + 0.5).clamp(0.0, 1.0);
let cv = ((vc - src_rect.y0 as f64).min(src_rect.y1 as f64 - vc) / foot_v + 0.5).clamp(0.0, 1.0);
for s in acc.iter_mut().take(n) {
*s *= cu * cv;
}
}
let al = acc[a].clamp(0.0, 1.0);
if al <= 0.0 {
continue;
@@ -539,6 +557,24 @@ mod tests {
Surface::new(PixelFormat::RGBA8)
}
/// The "pixel stretch" (#3773): a 1 px column scaled 100x stays solid across the stretch, as in
/// Photoshop. Samples past the source edge repeat the edge, and only the outline is antialiased.
#[test]
fn stretched_single_column_stays_opaque() {
let mut s = rgba();
s.fill_rect(Rect::new(0, 0, 1, 4), &[0.0, 1.0, 0.0, 1.0]);
for interp in [Interp::Bicubic, Interp::Bilinear] {
let h = Homography::rect_to_quad([0.0, 0.0, 1.0, 4.0], [[0.0, 0.0], [100.0, 0.0], [100.0, 4.0], [0.0, 4.0]]).unwrap();
let w = warp_surface(&s, Rect::new(0, 0, 1, 4), &h, interp).unwrap();
assert_eq!(w.content_bounds(), Rect::new(0, 0, 100, 4), "{interp:?}");
for x in [0, 1, 25, 50, 75, 99] {
for y in 0..4 {
assert!((w.sample_channel(x, y, 3) - 1.0).abs() < 1e-6, "{interp:?} alpha at ({x}, {y}) = {}", w.sample_channel(x, y, 3));
}
}
}
}
/// Photoshop 27.11's Free Transform, Bicubic, 200 % about the centre of a 64 px image whose left
/// half is grey 64 and right half grey 192: the row across the edge, columns 26..36.
#[test]
+68 -4
View File
@@ -4,8 +4,10 @@
//! The source rectangle is subdivided into a fine grid whose vertices are mapped forward; each
//! cell's two triangles are rasterized in destination space with the source position
//! interpolated barycentrically, then the source is sampled there (premultiplied, nearest /
//! bilinear / Photoshop's Keys bicubic, like Free Transform). Cells are a few source pixels across, so
//! the piecewise-affine inverse is within a small fraction of a pixel of the true one; affine
//! bilinear / Photoshop's Keys bicubic, like Free Transform). Cells are a few pixels across in
//! both the source and the destination (a thin layer stretched far gets as many cells as its
//! warped size needs), so the piecewise-affine inverse is within a small fraction of a pixel of
//! the true one; affine
//! maps (the identity included) are reproduced exactly. Where the warp shrinks the source, each
//! pixel instead averages a grid of bilinear subsamples over its source footprint (about one per
//! source pixel), so thin lines don't alias. Where the warp folds over itself the
@@ -77,8 +79,11 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) ->
return Surface::new(PixelFormat::new(fmt.mode, fmt.sample, true));
}
let (sw, sh) = (f64::from(src_rect.width()), f64::from(src_rect.height()));
let nx = ((sw / CELL).ceil() as usize).clamp(1, MAX_CELLS);
let ny = ((sh / CELL).ceil() as usize).clamp(1, MAX_CELLS);
// Cells are a few pixels across on both sides of the map: a thin source stretched far (a
// 3 px column scaled into a wide band, then bent) needs many cells, or it comes out straight.
let (lu, lv) = mapped_lengths(src_rect, f);
let cells = |s: f64, d: f64| ((s.max(d) / CELL).ceil() as usize).clamp(1, MAX_CELLS);
let (nx, ny) = (cells(sw, lu), cells(sh, lv));
// Forward-mapped vertices (and their source positions).
let mut verts = Vec::with_capacity((nx + 1) * (ny + 1));
for j in 0..=ny {
@@ -101,6 +106,43 @@ pub fn warp_mesh_surface(src: &Surface, src_rect: Rect, f: &(dyn Fn(f64, f64) ->
warp_triangles(src, src_rect, &verts, &tris, interp)
}
/// Probe lines per axis [`mapped_lengths`] follows through the map.
const PROBE: usize = 16;
/// The longest destination length of a source row and of a source column under `f`, measured
/// along polylines through a [`PROBE`]-step grid. Non-finite steps count as zero.
fn mapped_lengths(src_rect: Rect, f: &(dyn Fn(f64, f64) -> (f64, f64) + Sync)) -> (f64, f64) {
let (x0, y0) = (f64::from(src_rect.x0), f64::from(src_rect.y0));
let (sw, sh) = (f64::from(src_rect.width()), f64::from(src_rect.height()));
let n = PROBE + 1;
let mut pts = Vec::with_capacity(n * n);
for j in 0..n {
for i in 0..n {
let (dx, dy) = f(x0 + sw * i as f64 / PROBE as f64, y0 + sh * j as f64 / PROBE as f64);
pts.push([dx, dy]);
}
}
let step = |a: [f64; 2], b: [f64; 2]| {
let d = (b[0] - a[0]).hypot(b[1] - a[1]);
if d.is_finite() { d } else { 0.0 }
};
let (mut lu, mut lv) = (0.0f64, 0.0f64);
for k in 0..n {
let (mut row, mut col) = (0.0, 0.0);
for m in 1..n {
if let (Some(&a), Some(&b)) = (pts.get(k * n + m - 1), pts.get(k * n + m)) {
row += step(a, b);
}
if let (Some(&a), Some(&b)) = (pts.get((m - 1) * n + k), pts.get(m * n + k)) {
col += step(a, b);
}
}
lu = lu.max(row);
lv = lv.max(col);
}
(lu, lv)
}
/// Rasterizes textured triangles: each vertex is `(destination, source)` in document pixels and
/// each triangle samples `src` (restricted to `src_rect`) through the barycentric
/// interpolation of its source positions. Later triangles in `tris` draw over earlier ones (the
@@ -399,6 +441,28 @@ mod tests {
surf
}
#[test]
fn a_thin_layer_stretched_into_a_curve_keeps_the_curve() {
// #5793: a 3 px wide column stretched 100x and bent into an arch. The grid was sized in
// source pixels (one cell across), so the arch came out as a straight band.
let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
src.fill_rect(Rect::new(0, 0, 3, 40), &[1.0, 0.0, 0.0, 1.0]);
let arch = |x: f64, y: f64| {
let dx = x * 100.0;
let t = (dx - 150.0) / 150.0;
(dx, y + 40.0 * (1.0 - t * t))
};
let out = warp_mesh_surface(&src, Rect::new(0, 0, 3, 40), &arch, Interp::Bilinear);
let alpha = |x: i32, y: i32| {
let mut p = [0.0f32; 4];
out.read_pixel(x, y, &mut p);
p[3]
};
// The band follows the arch: 40 px lower in the middle, about 36 px a third of the way in.
assert!(alpha(150, 60) > 0.99 && alpha(150, 20) < 0.01, "mid {} {}", alpha(150, 60), alpha(150, 20));
assert!(alpha(100, 55) > 0.99 && alpha(100, 20) < 0.01, "third {} {}", alpha(100, 55), alpha(100, 20));
}
#[test]
fn a_mesh_warp_far_off_canvas_writes_only_the_two_regions() {
// #995: the old and warped regions were cleared as one dense `src ∪ warped` buffer, about
+59
View File
@@ -0,0 +1,59 @@
use photocraft_algo::transform::{Homography, Interp, warp_surface, warp_surface_clipped};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
#[test]
fn clipped_warp_matches_the_existing_kernel_at_every_depth() {
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
source.fill_rect(Rect::new(8, 12, 40, 44), &[0.8, 0.2, 0.4, 0.7]);
source.fill_rect(Rect::new(20, 24, 32, 36), &[0.1, 0.7, 0.3, 0.4]);
let clip = Rect::new(10, 10, 52, 52);
for interpolation in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] {
for h in [Homography([1.2, -0.3, 7.0, 0.3, 1.2, -5.0, 0.0, 0.0, 1.0]), Homography([0.125, 0.0, 24.0, 0.0, 0.125, 30.0, 0.0, 0.0, 1.0])] {
let full = warp_surface(&source, source.content_bounds(), &h, interpolation).unwrap();
let bounded = warp_surface_clipped(&source, source.content_bounds(), &h, interpolation, clip).unwrap();
assert_eq!(full.read_region(clip), bounded.read_region(clip));
assert!(clip.contains_rect(&bounded.content_bounds()));
assert!(bounded.tiles().count() <= clip.tiles().count());
}
}
}
}
#[test]
fn huge_scaled_warp_allocates_only_the_destination_clip() {
let mut source = Surface::new(PixelFormat::RGBA8);
source.fill_rect(Rect::new(8, 8, 12, 12), &[0.2, 0.7, 0.4, 1.0]);
let clip = Rect::new(0, 0, 64, 64);
let h = Homography([10_000.0, 0.0, -99_968.0, 0.0, 10_000.0, -99_968.0, 0.0, 0.0, 1.0]);
let output = warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Bicubic, clip).unwrap();
assert!(clip.contains_rect(&output.content_bounds()));
assert!(output.tiles().count() <= clip.tiles().count());
assert_eq!(output.pixel(32, 32), source.pixel(10, 10));
assert!(warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Bicubic, Rect::EMPTY).unwrap().tiles().next().is_none());
}
#[test]
fn inverse_footprint_handles_extreme_finite_affines() {
let mut source = Surface::new(PixelFormat::RGBA8);
source.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 0.0, 0.0, 1.0]);
// A strongly skewed inverse maps a small visible tile beyond i32 limits.
let h = Homography([1.0, 1e12, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
let clip = Rect::new(0, 0, 32, 32);
let output = warp_surface_clipped(&source, source.content_bounds(), &h, Interp::Nearest, clip).unwrap();
assert!(clip.contains_rect(&output.content_bounds()));
assert!(output.tiles().count() <= clip.tiles().count());
}
#[test]
fn source_center_does_not_add_extreme_coordinates_in_i32() {
let mut source = Surface::new(PixelFormat::RGBA8);
let origin = 1_500_000_000;
let bounds = Rect::new(origin, origin, origin + 4, origin + 4);
source.fill_rect(bounds, &[0.2, 0.7, 0.4, 1.0]);
let h = Homography([1.0, 0.0, -f64::from(origin), 0.0, 1.0, -f64::from(origin), 0.0, 0.0, 1.0]);
let output = warp_surface_clipped(&source, bounds, &h, Interp::Nearest, Rect::new(0, 0, 8, 8)).unwrap();
assert_eq!(output.pixel(2, 2), source.pixel(origin + 2, origin + 2));
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "photocraft-automation"
description = "MCP server (rmcp) over the Photocraft engine, plus a bridge to a running GUI"
description = "MCP server (rmcp) over the PhotoCraft engine, plus a bridge to a running GUI"
version.workspace = true
edition.workspace = true
license.workspace = true
+71 -18
View File
@@ -29,8 +29,8 @@ use serde_json::{Value, json};
use crate::budgets::{BatchReplyBudget, write_reply};
use crate::security::{
ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, discard_rest_of_line,
read_bounded_line, repeat_auth_reply,
AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply,
configure_stream, discard_rest_of_line, read_bounded_line, repeat_auth_reply,
};
use crate::{AutomationError, Headless};
@@ -91,17 +91,9 @@ impl Headless {
let all = self.command_list();
Ok(match str_of(&p, "filter").map(str::to_lowercase) {
None => all,
Some(n) => Value::Array(
all.as_array()
.into_iter()
.flatten()
.filter(|c| {
let hay = format!("{} {}", c["id"].as_str().unwrap_or(""), c["label"].as_str().unwrap_or(""));
hay.to_lowercase().contains(&n)
})
.cloned()
.collect(),
),
Some(n) => {
Value::Array(all.as_array().into_iter().flatten().filter(|c| photocraft_engine::commands::list_entry_matches(c, &n)).cloned().collect())
}
})
}
"session.list" => Ok(self.session_list()),
@@ -270,10 +262,12 @@ pub fn serve_lines(h: &Mutex<Headless>, mut r: impl BufRead, mut w: impl Write)
}
}
fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token: &str) -> std::io::Result<()> {
/// Serve one connection. It must complete `auth` within `auth_deadline` of connecting, however
/// much it sends meanwhile (#4965); after that only the idle timeout applies.
fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token: &str, auth_deadline: std::time::Duration) -> std::io::Result<()> {
configure_stream(&stream)?;
let read = stream.try_clone()?;
let mut reader = std::io::BufReader::new(read);
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(read, auth_deadline));
let mut out = stream;
let mut line = String::new();
let mut authenticated = false;
@@ -298,6 +292,9 @@ fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token:
} else {
let (reply, ok) = authentication_reply(&line, token);
authenticated = ok;
if ok {
reader.get_mut().authenticated()?;
}
reply
};
write_reply(&mut out, &reply)?;
@@ -330,7 +327,7 @@ pub fn serve_tcp(addr: &str, h: Arc<Mutex<Headless>>, token: String, ready: impl
let token = Arc::clone(&token);
std::thread::spawn(move || {
let _permit = permit;
let _ = serve_tcp_connection(&h, stream, &token);
let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE);
});
}
Ok(())
@@ -365,7 +362,7 @@ mod tests {
/// Pins the reply shape documented in docs/control-protocol.md › Command list replies
/// (#3684): a bare JSON array (not `{"commands": [...]}`), one object per command with
/// exactly these keys, from `engine.commands` (filtered or not) and `command.list`.
/// exactly these keys (plus an optional `keywords` string), from `engine.commands` (filtered or not) and `command.list`.
#[test]
fn command_list_replies_are_documented_array_of_objects() {
let h = session();
@@ -377,8 +374,10 @@ mod tests {
let items = reply.as_array().unwrap_or_else(|| panic!("{name} must reply with a bare array, got {reply}"));
assert!(!items.is_empty(), "{name}");
for c in items {
let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).collect();
// `keywords` is optional: only commands with other search names carry it (#5060).
let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).filter(|k| *k != "keywords").collect();
keys.sort_unstable();
assert!(c.get("keywords").is_none_or(Value::is_string), "{name}: {c}");
assert_eq!(keys, ["disabledReason", "enabled", "id", "label", "menu", "needsDocument", "params", "shortcut"], "{name}: {c}");
assert!(c["id"].is_string() && c["label"].is_string() && c["params"].is_string() && c["enabled"].is_boolean(), "{c}");
assert!(c["needsDocument"].is_boolean(), "{c}");
@@ -820,6 +819,60 @@ mod tests {
assert!(v["result"].as_array().unwrap().iter().any(|m| m == "batch"));
}
/// #4965: a connection that keeps sending without authenticating is closed at the deadline,
/// and one that authenticated in time keeps being served after it.
#[test]
fn tcp_connections_must_authenticate_before_the_deadline_whatever_they_send() {
use std::io::{BufReader, Write as _};
use std::time::{Duration, Instant};
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let h = Mutex::new(Headless::new());
let mut results = Vec::new();
for _ in 0..2 {
let (stream, _) = listener.accept().unwrap();
let started = Instant::now();
let r = serve_tcp_connection(&h, stream, TOKEN, Duration::from_millis(300));
results.push((r.map_err(|e| e.kind()), started.elapsed()));
}
results
});
// Never a newline, a byte every 20 ms: each read succeeds long before the idle timeout.
let mut slow = std::net::TcpStream::connect(addr).unwrap();
for _ in 0..150 {
if slow.write_all(b" ").is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
drop(slow);
// Authenticates at once, then asks again 300 ms after the deadline.
let mut s = std::net::TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
writeln!(s, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();
let mut reader = BufReader::new(s.try_clone().unwrap());
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["result"]["authenticated"], true);
std::thread::sleep(Duration::from_millis(600));
writeln!(s, r#"{{"id":"a","method":"methods"}}"#).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["id"], "a");
drop(reader);
drop(s);
let results = server.join().unwrap();
let (slow_result, slow_time) = &results[0];
assert!(matches!(slow_result, Err(std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock)), "{slow_result:?}");
assert!(*slow_time < Duration::from_secs(3), "the trickle did not extend the deadline: {slow_time:?}");
assert_eq!(results[1].0, Ok(()), "the authenticated connection ended with its client");
}
#[test]
fn tcp_repeat_auth_reports_already_authenticated_and_keeps_serving() {
use std::io::{BufReader, Write as _};
+150 -3
View File
@@ -1,11 +1,11 @@
//! Shared security primitives for the loopback JSON control transports.
use std::fs::OpenOptions;
use std::io::{BufRead, Write};
use std::io::{BufRead, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use std::time::{Duration, Instant};
use serde_json::{Value, json};
@@ -21,6 +21,10 @@ pub const MAX_CONNECTIONS: usize = 16;
pub const MAX_BATCH_STEPS: usize = 256;
/// Idle/read and write timeout for loopback TCP connections.
pub const IO_TIMEOUT: Duration = Duration::from_secs(30);
/// How long a new TCP connection has to authenticate, however active it is. [`IO_TIMEOUT`] starts
/// again with every read, so a client that kept sending without ever completing `auth` held one
/// of the [`MAX_CONNECTIONS`] permits for as long as it liked (#4965).
pub const AUTH_DEADLINE: Duration = Duration::from_secs(10);
const TOKEN_BYTES: usize = 32;
const TOKEN_HEX_LEN: usize = TOKEN_BYTES * 2;
@@ -131,7 +135,27 @@ pub fn repeat_auth_reply(line: &str) -> Option<Value> {
}
fn read_token_file(path: &Path) -> Result<String, AutomationError> {
let token = std::fs::read_to_string(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
// Validate what we actually opened, not just the name: an existing control-token file must be
// a regular file that no other local principal can read. A freshly created one is 0600
// (`create_token_file`); an operator-supplied one is checked here so a group- or world-readable
// token is refused rather than trusted as the control credential (#4966).
let mut file = std::fs::File::open(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
let meta = file.metadata().map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
if !meta.is_file() {
return Err(AutomationError::Io(format!("{}: control token file is not a regular file", path.display())));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if meta.mode() & 0o077 != 0 {
return Err(AutomationError::Io(format!(
"{}: control token file is readable by group or others (mode {:o}); restrict it to its owner (e.g. chmod 600)",
path.display(),
meta.mode() & 0o777
)));
}
}
let token = std::io::read_to_string(&mut file).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
let token = token.trim().to_owned();
validate_token(&token)?;
Ok(token)
@@ -242,6 +266,44 @@ pub fn configure_stream(stream: &std::net::TcpStream) -> std::io::Result<()> {
stream.set_write_timeout(Some(IO_TIMEOUT))
}
/// The read half of a control connection. Until [`Self::authenticated`] is called, reads fail
/// with `TimedOut` once the authentication deadline has passed, whatever arrived in the meantime;
/// the caller then drops the connection and its [`ConnectionPermit`].
pub struct AuthDeadlineStream {
stream: std::net::TcpStream,
/// `None` once authenticated.
deadline: Option<Instant>,
}
impl AuthDeadlineStream {
/// `stream` must authenticate within `limit` from now (normally [`AUTH_DEADLINE`]).
pub fn new(stream: std::net::TcpStream, limit: Duration) -> Self {
// A limit too large to represent expires at once rather than never.
let now = Instant::now();
Self { stream, deadline: Some(now.checked_add(limit).unwrap_or(now)) }
}
/// The connection authenticated: only the idle timeout applies from here on.
pub fn authenticated(&mut self) -> std::io::Result<()> {
self.deadline = None;
self.stream.set_read_timeout(Some(IO_TIMEOUT))
}
}
impl Read for AuthDeadlineStream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if let Some(deadline) = self.deadline {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "not authenticated in time"));
}
// Wait no longer than what is left, so a silent client is dropped at the deadline too.
self.stream.set_read_timeout(Some(left.min(IO_TIMEOUT)))?;
}
self.stream.read(buf)
}
}
/// Environment-aware token inputs shared by the desktop app and CLI.
pub fn token_inputs(supplied: Option<String>, token_file: Option<PathBuf>) -> (Option<String>, Option<PathBuf>) {
let supplied = supplied.or_else(|| std::env::var("PHOTOCRAFT_CONTROL_TOKEN").ok());
@@ -327,6 +389,91 @@ mod tests {
std::fs::remove_file(path).unwrap();
}
/// A connected loopback pair: the accepted (server) end and the client end.
fn tcp_pair() -> (std::net::TcpStream, std::net::TcpStream) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let (server, _) = listener.accept().unwrap();
configure_stream(&server).unwrap();
(server, client)
}
/// #4965: activity must not extend the time a connection may stay unauthenticated.
#[test]
fn an_active_connection_is_dropped_at_the_authentication_deadline() {
let (server, mut client) = tcp_pair();
// A byte every 20 ms and never a newline: each read succeeds long before the idle timeout.
let trickle = std::thread::spawn(move || {
for _ in 0..150 {
if client.write_all(b"x").is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
});
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(300)));
let mut line = String::new();
let started = Instant::now();
let err = read_bounded_line(&mut reader, &mut line).unwrap_err();
assert!(matches!(err.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock), "{err}");
assert!(started.elapsed() >= Duration::from_millis(250), "not before the deadline: {:?}", started.elapsed());
assert!(started.elapsed() < Duration::from_secs(3), "the trickle did not extend it: {:?}", started.elapsed());
// It stays expired.
assert!(read_bounded_line(&mut reader, &mut line).is_err());
drop(reader);
trickle.join().unwrap();
}
#[test]
fn a_silent_connection_is_dropped_at_the_authentication_deadline() {
let (server, _client) = tcp_pair();
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200)));
let mut line = String::new();
let started = Instant::now();
assert!(read_bounded_line(&mut reader, &mut line).is_err());
assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed());
}
#[test]
fn an_authenticated_connection_outlives_the_authentication_deadline() {
let (server, mut client) = tcp_pair();
let sender = std::thread::spawn(move || {
client.write_all(b"one\n").unwrap();
std::thread::sleep(Duration::from_millis(500));
client.write_all(b"two\n").unwrap();
});
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200)));
let mut line = String::new();
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
assert_eq!(line, "one\n");
reader.get_mut().authenticated().unwrap();
// Sent 300 ms after the deadline: only the idle timeout applies now.
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
assert_eq!(line, "two\n");
sender.join().unwrap();
}
#[cfg(unix)]
#[test]
fn an_existing_token_file_must_be_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("photocraft-token-perm-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("token.txt");
let token = "0123456789abcdef".repeat(4); // 64 hex chars, passes validate_token
std::fs::write(&path, &token).unwrap();
// A group- or world-readable token is refused: the control credential must not be shared (#4966).
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(read_token_file(&path).is_err(), "a 0644 token file must be refused");
// Owner-only is opened, read and validated.
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
assert_eq!(read_token_file(&path).unwrap(), token, "a 0600 token file must be read");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn connection_limiter_releases_capacity() {
let limiter = ConnectionLimiter::new(1);
+10 -6
View File
@@ -205,7 +205,7 @@ pub struct SelectParams {
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ListParams {
/// Only commands whose id or label contains this text (case-insensitive).
/// Only commands whose id, label or keywords contain this text (case-insensitive).
#[serde(default)]
pub filter: Option<String>,
/// Only commands that can run right now.
@@ -320,11 +320,16 @@ pub struct UiSetParams {
/// grid, diagonal, triangle, goldenRatio, goldenSpiral), cropOverlayShow (auto, always, never),
/// cropOverlayOrientation (0..3), cropShield (an object patching show_cropped_area, enabled,
/// 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,
/// auto_adjust, classic_mode and auto_center_preview), cropFill (background or contentAware,
/// the Crop tool's Fill), shapeStroke (an object patching width, align, cap, join, miterLimit,
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
/// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool,
/// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the
/// active layer's storage tiles). Other fields are an error.
/// active layer's storage tiles). layerFilter (an object patching by ("kind", "name" or
/// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a
/// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off":
/// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not
/// over this channel). Other fields are an error.
pub fields: Value,
}
@@ -821,9 +826,8 @@ impl PhotocraftMcp {
.unwrap_or_else(|| all.get("commands").and_then(Value::as_array).cloned().unwrap_or_default())
.into_iter()
.filter(|c| {
let hay =
format!("{} {}", c.get("id").and_then(Value::as_str).unwrap_or(""), c.get("label").and_then(Value::as_str).unwrap_or("")).to_lowercase();
needle.as_ref().is_none_or(|n| hay.contains(n)) && (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true))
needle.as_ref().is_none_or(|n| photocraft_engine::commands::list_entry_matches(c, n))
&& (!enabled_only || c.get("enabled").and_then(Value::as_bool).unwrap_or(true))
})
.collect();
Ok(ok_json(&Value::Array(items)))
+13 -1
View File
@@ -363,12 +363,16 @@ fn preference_uses_ambient_filesystem(path: &str) -> bool {
fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
match id {
"actions.load" | "brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
"actions.load" | "brush.presets.importAbr" | "brush.presets.importImage" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => {
// `data` wins over `path` in these commands; any `path` without it reads the filesystem.
params.get("data").is_none() && params.get("path").is_some()
}
// With a `path` the file is written there; without one the bytes come back as `data`.
"actions.save" | "swatches.export" => params.get("path").is_some(),
// Shortcut sets: a .kys read or written by path, or a Summarize page written by path.
"edit.keyboardShortcuts" => {
params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some())
}
"plugin.reload" => true,
_ => false,
}
@@ -712,6 +716,12 @@ mod tests {
assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err());
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err());
assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "srgb"})).is_ok());
// Shortcut sets: text in and out is fine; a path reads or writes the filesystem.
let ks = |p: serde_json::Value| authorize_engine_command("edit.keyboardShortcuts", &p);
assert!(ks(serde_json::json!({"exportKys": true, "summarize": true, "importKys": "<photoshop-keyboard-shortcuts/>"})).is_ok());
for key in ["importKys", "exportKys", "summarize"] {
assert!(ks(serde_json::json!({ key: {"path": "/outside/set.kys"} })).is_err(), "{key}");
}
assert!(authorize_engine_command("filter.distort.displace", &serde_json::json!({"mapPath": "outside.png"})).is_err());
assert!(authorize_engine_command("layer.setAdjustment", &serde_json::json!({"file": "outside.cube"})).is_err());
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "colorSettings.workingRgb", "value": "outside.icc"})).is_err());
@@ -758,6 +768,7 @@ mod tests {
fn automation_rejects_ambient_path_commands_and_preferences() {
for (id, params) in [
("brush.presets.importAbr", serde_json::json!({"path": "/outside/set.abr"})),
("brush.presets.importImage", serde_json::json!({"path": "/outside/tip.png"})),
("gradient.presets.importGrd", serde_json::json!({"path": "/outside/set.grd"})),
("plugin.install", serde_json::json!({"path": "/outside/plugin.wasm"})),
("plugin.reload", serde_json::json!({"path": "/outside/plugins"})),
@@ -772,6 +783,7 @@ mod tests {
}
for (id, params) in [
("brush.presets.importAbr", serde_json::json!({"data": "QUJD"})),
("brush.presets.importImage", serde_json::json!({"data": "QUJD"})),
("gradient.presets.importGrd", serde_json::json!({"data": "QUJD"})),
("plugin.install", serde_json::json!({"data": "QUJD"})),
("swatches.import", serde_json::json!({"data": "QUJD"})),
+3
View File
@@ -167,6 +167,9 @@ async fn command_list_filters() {
let blur = json_of(&call(&client, "command_list", json!({"filter": "blur"})).await);
assert!(!blur.as_array().unwrap().is_empty() && blur.as_array().unwrap().len() < n_all);
assert!(blur.as_array().unwrap().iter().all(|c| c["params"].is_string()));
// The Elliptical Marquee is a `select.rect` option, found by its keywords (#5060).
let marquee = json_of(&call(&client, "command_list", json!({"filter": "ellipse"})).await);
assert!(marquee.as_array().unwrap().iter().any(|c| c["id"] == "select.rect"), "{marquee}");
let enabled = json_of(&call(&client, "command_list", json!({"enabled_only": true})).await);
assert!(enabled.as_array().unwrap().iter().any(|c| c["id"] == "file.new"));
assert!(!enabled.as_array().unwrap().iter().any(|c| c["id"] == "layer.new.layer"), "needs a document");
@@ -0,0 +1,259 @@
//! Stored smart-filter parameters must not grant automation ambient filesystem access.
use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
use photocraft_automation::{AuthorizedWorkspace, Headless, workspace::authorize_desktop_engine_step};
use photocraft_doc::{Document, LayerContent, LayerId};
use photocraft_engine::{Session, actions_cmds::Action, jobs::Started, smart_cmds};
use serde_json::{Value, json};
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let base = std::env::temp_dir().join(format!("pc-filter-auth-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&base).unwrap();
std::fs::create_dir(base.join("workspace")).unwrap();
std::fs::create_dir(base.join("outside")).unwrap();
let f = Self(base);
std::fs::write(f.root().join("stripes.png"), png(|x| if (x / 2) % 2 == 0 { 0 } else { 255 })).unwrap();
std::fs::write(f.outside("black.png"), png(|_| 0)).unwrap();
std::fs::write(f.outside("white.png"), png(|_| 255)).unwrap();
std::fs::write(f.outside("black.cube"), cube(0)).unwrap();
std::fs::write(f.outside("white.cube"), cube(1)).unwrap();
f
}
fn root(&self) -> PathBuf {
self.0.join("workspace")
}
fn outside(&self, name: &str) -> PathBuf {
self.0.join("outside").join(name)
}
fn headless(&self) -> Headless {
Headless::with_workspace(AuthorizedWorkspace::new(Some(&self.root()), Some(&self.root())).unwrap())
}
fn setup(&self, lookup: bool) -> (Headless, LayerId) {
let mut h = self.headless();
h.handle("doc.new", json!({"width": 32, "height": 32, "background": "#808080"})).unwrap();
h.handle("doc.new", json!({"width": 32, "height": 32})).unwrap();
let layer = LayerId(h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap());
let (id, params) = if lookup {
("image.adjustments.colorLookup", json!({"layer": layer.0, "lut": "none"}))
} else {
("filter.distort.displace", json!({"layer": layer.0, "mapDocument": 0, "horizontal": 50, "vertical": 0}))
};
h.session.execute(id, params).unwrap();
(h, layer)
}
fn saved_filter(&self, lookup: bool) -> (Document, LayerId) {
let (h, layer) = self.setup(lookup);
let mut doc = (*h.session.active().unwrap().doc).clone();
let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!("smart object") };
sm.smart_filters[0].params = path_params(lookup, &self.outside(if lookup { "white.cube" } else { "white.png" }));
let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap();
std::fs::write(self.root().join("stored.pcraft"), bytes).unwrap();
(doc, layer)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn png(value: impl Fn(u32) -> u8) -> Vec<u8> {
let mut pixels = Vec::new();
for _ in 0..32 {
for x in 0..32 {
pixels.extend([value(x); 3]);
}
}
let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, pixels).unwrap();
photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()
}
fn cube(value: u8) -> String {
format!("LUT_3D_SIZE 2\n{}", format!("{value} {value} {value}\n").repeat(8))
}
fn path_params(lookup: bool, path: &Path) -> Value {
if lookup { json!({"file": path}) } else { json!({"mapPath": path, "mapDocument": null}) }
}
fn assert_denied<T: std::fmt::Debug>(result: Result<T, impl std::fmt::Display>) {
match result {
Err(error) => assert!(error.to_string().contains("ambient filesystem"), "{error}"),
Ok(value) => panic!("ambient file access was accepted: {value:?}"),
}
}
#[test]
fn parameter_edits_reject_outside_images_and_luts_without_changing_the_document() {
for lookup in [false, true] {
let f = Fixture::new();
let (mut h, layer) = f.setup(lookup);
let before = h.render_png(None, 64).unwrap();
let doc = h.session.active().unwrap().doc.clone();
let journal = h.session.journal.len();
for name in if lookup { ["black.cube", "white.cube"] } else { ["black.png", "white.png"] } {
let params = path_params(lookup, &f.outside(name));
let id = if lookup { "image.adjustments.colorLookup" } else { "filter.distort.displace" };
assert_denied(h.session.execute(id, params.clone()));
assert_denied(h.handle("engine.execute", json!({"command": "layer.smartFilter.setParams", "params": {"layer": layer.0, "params": params}})));
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
assert_eq!(h.session.journal.len(), journal);
assert_eq!(h.render_png(None, 64).unwrap(), before);
}
// Safe numeric and embedded-data edits remain usable.
let safe = if lookup { json!({"data": cube(0), "fileName": "embedded.cube"}) } else { json!({"horizontal": 10}) };
h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap();
}
}
#[test]
fn action_playback_cannot_hide_a_smart_filter_path_edit() {
let f = Fixture::new();
let (mut h, layer) = f.setup(false);
let doc = h.session.active().unwrap().doc.clone();
h.session.actions.list.push(Action::new(
"edit filter",
vec![("layer.smartFilter.setParams".into(), json!({"layer": layer.0, "params": path_params(false, &f.outside("white.png"))}))],
));
let result = h.session.execute("actions.play", json!({"action": "edit filter"})).unwrap();
assert!(result["failed"]["error"].as_str().unwrap().contains("ambient filesystem"), "{result}");
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
}
#[test]
fn loaded_filters_are_checked_after_merging_even_when_the_edit_contains_no_path() {
for lookup in [false, true] {
let f = Fixture::new();
let (_, layer) = f.saved_filter(lookup);
let mut h = f.headless();
h.open(Path::new("stored.pcraft")).unwrap();
let doc = h.session.active().unwrap().doc.clone();
assert_denied(h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": {"horizontal": 10, "dither": false}})));
assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc));
// Removing an ambient input makes the filter usable again.
let safe = if lookup { json!({"file": "", "lut": "none"}) } else { json!({"mapPath": ""}) };
h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap();
}
}
#[test]
fn loaded_filters_cannot_read_on_rerender_or_reuse_a_trusted_cached_stage() {
for lookup in [false, true] {
let f = Fixture::new();
let (doc, layer) = f.saved_filter(lookup);
for warm_cache in [false, true] {
if warm_cache {
let mut trusted = doc.clone();
assert!(smart_cmds::refresh(&mut trusted, layer).unwrap());
}
let mut h = f.headless();
h.open(Path::new("stored.pcraft")).unwrap();
let before = h.session.active().unwrap().doc.clone();
assert_denied(h.session.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})));
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
}
}
}
#[test]
fn background_rerender_keeps_the_desktop_request_authority() {
for lookup in [false, true] {
let f = Fixture::new();
let (doc, layer) = f.saved_filter(lookup);
let mut s = Session::new();
s.add_document(doc, None);
s.authorize = Some(authorize_desktop_engine_step);
let before = s.active().unwrap().doc.clone();
let started = s.start("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap();
// The desktop restores UI authority as soon as the automation request returns.
s.authorize = None;
let Started::Job(job) = started else { panic!("expected a background render") };
assert_denied(s.wait_job(job));
assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc));
// The worker's restriction must not leak into subsequent trusted UI work.
s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap();
}
}
#[test]
fn transferring_stored_filters_cannot_read_outside_files() {
for lookup in [false, true] {
let f = Fixture::new();
let (_, from) = f.saved_filter(lookup);
let mut h = f.headless();
h.open(Path::new("stored.pcraft")).unwrap();
let to = h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap();
let before = h.session.active().unwrap().doc.clone();
for copy in [false, true] {
assert_denied(h.session.execute("layer.smartFilter.transfer", json!({"from": from.0, "to": to, "copy": copy})));
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
}
}
}
#[test]
fn trusted_parameter_edits_still_read_maps_and_luts_after_a_denied_request() {
for lookup in [false, true] {
let f = Fixture::new();
let (mut h, layer) = f.setup(lookup);
let edit = |path: &Path| json!({"layer": layer.0, "params": path_params(lookup, path)});
assert_denied(h.session.execute("layer.smartFilter.setParams", edit(&f.outside("white.png"))));
let mut trusted = Headless::trusted_local();
trusted.session.add_document((*h.session.active().unwrap().doc).clone(), None);
let mut renders = Vec::new();
for name in if lookup { ["black.cube", "white.cube", "black.cube"] } else { ["black.png", "white.png", "black.png"] } {
trusted.session.execute("layer.smartFilter.setParams", edit(&f.outside(name))).unwrap();
renders.push(trusted.render_png(None, 64).unwrap());
}
assert_ne!(renders[0], renders[1], "trusted path-based filter still works");
assert_eq!(renders[0], renders[2], "trusted rendering remains deterministic");
}
}
#[test]
fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_unfiltered() {
// link-watch and transform paths re-render through Session::edit, not Session::execute or
// jobs::run; the gate must reach them and the denial must not silently pass pixels through
// into the process-wide stack cache.
for lookup in [false, true] {
let f = Fixture::new();
let (doc, layer) = f.saved_filter(lookup);
let mut h = f.headless();
h.open(Path::new("stored.pcraft")).unwrap();
let before = h.session.active().unwrap().doc.clone();
// Session::edit is the outside-execute mutation path (an edit closure the UI or a
// linked-file change runs); the stored filter must be denied there too.
assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) {
Ok(_) => Ok(()),
Err(e) => Err(e),
}));
assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc));
// A trusted session (no gate) still renders the same document.
let mut trusted = Session::new();
trusted.add_document(doc, None);
let mut d = (*trusted.active().unwrap().doc).clone();
assert!(smart_cmds::refresh(&mut d, layer).is_ok());
}
}
#[test]
fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() {
// The denial path must fail the render before any stage is cached process-wide.
for lookup in [false, true] {
let f = Fixture::new();
let (doc, layer) = f.saved_filter(lookup);
let mut s = Session::new();
s.add_document(doc, None);
s.authorize = Some(authorize_desktop_engine_step);
let bytes = smart_cmds::stack_cache_bytes();
assert_denied(s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})));
assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage");
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
# photocraft-cms
Pure-Rust ICC colour management for Photocraft. No workspace dependencies (L0, publishable),
Pure-Rust ICC colour management for PhotoCraft. No workspace dependencies (L0, publishable),
no C, no `unsafe`. Implemented from the public ICC.1:2010 (v4.3) and ICC.1:2001-04 (v2)
specifications, CIE 15, and Adobe's published black point compensation paper.
@@ -53,13 +53,13 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
| `rec2020` | Rec. 2020 primaries, Rec. 709 OETF |
| `gray-gamma-2.2`, `sgray` | gray gamma 2.2; gray with the sRGB curve (default gray working space) |
| `lab-d50` | Lab identity (v4 encoding) |
| `coated-cmyk` | **Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)** — default CMYK |
| `coated-cmyk` | **PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)** — default CMYK |
### The CMYK profile
Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39) are proprietary, and the freely
downloadable characterisation-based profiles (ECI/FOGRA, colord, Ghostscript) carry
redistribution conditions or unclear licences, so Photocraft generates its own
redistribution conditions or unclear licences, so PhotoCraft generates its own
(`src/synth.rs`): a Yule–Nielsen modified Neugebauer model (n = 2) over the 16 overprints with
ISO 12647-2 coated-paper Lab aims for paper/C/M/Y/RGB overprints, parabolic dot gain (14 % CMY,
17 % K at 50 %), GCR black generation (starts at 20 % gray component, K max 95 %), total ink
Binary file not shown.
+22 -12
View File
@@ -26,6 +26,8 @@ pub enum Builtin {
ProPhotoCompat,
/// sRGB primaries with a linear transfer function.
LinearSrgb,
/// ACEScg: AP1 primaries, the ACES white point (~D60), linear transfer. The ACES working space for CG renders.
AcesCg,
/// Rec. 2020 primaries, D65, Rec. 709 transfer function.
Rec2020,
/// Gray, gamma 2.2.
@@ -43,12 +45,13 @@ pub enum Builtin {
}
impl Builtin {
pub const ALL: [Builtin; 11] = [
pub const ALL: [Builtin; 12] = [
Builtin::Srgb,
Builtin::DisplayP3,
Builtin::AdobeRgbCompat,
Builtin::ProPhotoCompat,
Builtin::LinearSrgb,
Builtin::AcesCg,
Builtin::Rec2020,
Builtin::GrayGamma22,
Builtin::SGray,
@@ -65,6 +68,7 @@ impl Builtin {
Builtin::AdobeRgbCompat => "adobe-rgb-compat",
Builtin::ProPhotoCompat => "prophoto-compat",
Builtin::LinearSrgb => "linear-srgb",
Builtin::AcesCg => "aces-cg",
Builtin::Rec2020 => "rec2020",
Builtin::GrayGamma22 => "gray-gamma-2.2",
Builtin::SGray => "sgray",
@@ -79,14 +83,15 @@ impl Builtin {
match self {
Builtin::Srgb => "sRGB IEC61966-2.1",
Builtin::DisplayP3 => "Display P3",
Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (Photocraft)",
Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, Photocraft)",
Builtin::LinearSrgb => "Linear sRGB (Photocraft)",
Builtin::Rec2020 => "Rec. ITU-R BT.2020 (Photocraft)",
Builtin::GrayGamma22 => "Gray Gamma 2.2 (Photocraft)",
Builtin::SGray => "sGray (sRGB tone curve, Photocraft)",
Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, Photocraft)",
Builtin::LabD50 => "Lab D50 identity (Photocraft)",
Builtin::AdobeRgbCompat => "Adobe RGB (1998)-compatible (PhotoCraft)",
Builtin::ProPhotoCompat => "ProPhoto RGB-compatible (ROMM primaries, PhotoCraft)",
Builtin::LinearSrgb => "Linear sRGB (PhotoCraft)",
Builtin::AcesCg => "ACEScg (AP1, linear, PhotoCraft)",
Builtin::Rec2020 => "Rec. ITU-R BT.2020 (PhotoCraft)",
Builtin::GrayGamma22 => "Gray Gamma 2.2 (PhotoCraft)",
Builtin::SGray => "sGray (sRGB tone curve, PhotoCraft)",
Builtin::DotGain20Compat => "Dot Gain 20%-compatible (gamma 1.732, PhotoCraft)",
Builtin::LabD50 => "Lab D50 identity (PhotoCraft)",
Builtin::CoatedCmyk => crate::synth::DESCRIPTION,
}
}
@@ -104,6 +109,7 @@ impl Builtin {
"gray" | "graygamma22" | "gamma22" => Some(Builtin::GrayGamma22),
"sgray" | "graysrgb" => Some(Builtin::SGray),
"dotgain20" | "dotgain20compat" | "dotgain" => Some(Builtin::DotGain20Compat),
"aces-cg" | "acescg" => Some(Builtin::AcesCg),
"lab" | "labd50" | "cielab" => Some(Builtin::LabD50),
"cmyk" | "coatedcmyk" | "defaultcmyk" | "photocraftcoatedcmyk" => Some(Builtin::CoatedCmyk),
_ => None,
@@ -113,7 +119,7 @@ impl Builtin {
/// The profile (built once per process).
pub fn profile(self) -> &'static Profile {
static CELLS: [OnceLock<Profile>; 11] = [const { OnceLock::new() }; 11];
static CELLS: [OnceLock<Profile>; 12] = [const { OnceLock::new() }; 12];
// `ALL` lists the variants in declaration order (checked by `all_in_declaration_order`),
// so the discriminant is the cell index.
CELLS[self as usize].get_or_init(|| build(self))
@@ -140,7 +146,7 @@ pub fn srgb() -> &'static Profile {
/// only assigned to such files, never offered as a working space.
pub fn linear_gray() -> &'static Profile {
static PROFILE: OnceLock<Profile> = OnceLock::new();
PROFILE.get_or_init(|| gray("Linear Gray (Photocraft)", Curve::Gamma(1.0)))
PROFILE.get_or_init(|| gray("Linear Gray (PhotoCraft)", Curve::Gamma(1.0)))
}
/// The ICC D65 used for the RGB spaces (chromaticity 0.3127, 0.3290).
@@ -256,12 +262,13 @@ fn lab() -> Profile {
pub const COATED_CMYK_ICC: &[u8] = include_bytes!("../profiles/photocraft-coated-cmyk.icc");
fn build(b: Builtin) -> Profile {
let (srgb_p, p3, adobe, romm, bt2020) = (
let (srgb_p, p3, adobe, romm, bt2020, ap1) = (
[[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]],
[[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]],
[[0.64, 0.33], [0.21, 0.71], [0.15, 0.06]],
[[0.7347, 0.2653], [0.1596, 0.8404], [0.0366, 0.0001]],
[[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]],
[[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]],
);
match b {
Builtin::Srgb => rgb(b.description(), srgb_p, d65(), srgb_trc()),
@@ -269,6 +276,9 @@ fn build(b: Builtin) -> Profile {
Builtin::AdobeRgbCompat => rgb(b.description(), adobe, d65(), Curve::Gamma(563.0 / 256.0)),
Builtin::ProPhotoCompat => rgb(b.description(), romm, math::D50, Curve::Gamma(1.8)),
Builtin::LinearSrgb => rgb(b.description(), srgb_p, d65(), Curve::Gamma(1.0)),
// ACEScg is scene-linear on the AP1 primaries with the ACES white point (~D60,
// xy 0.32168/0.33767, the ACES 2065-1 neutral).
Builtin::AcesCg => rgb(b.description(), ap1, math::xy_to_xyz([0.32168, 0.33767]), Curve::Gamma(1.0)),
Builtin::Rec2020 => {
// Rec. 709 OETF inverse: V = 1.099 L^0.45 − 0.099 (L ≥ 0.018), 4.5 L below.
let a = 1.099;
+1 -1
View File
@@ -1,4 +1,4 @@
//! Photocraft colour management: a pure-Rust ICC v2/v4 colour management module.
//! PhotoCraft colour management: a pure-Rust ICC v2/v4 colour management module.
//!
//! * [`Profile`] parses ICC profiles (matrix/TRC RGB, gray TRC, `mft1`/`mft2`/`mAB `/`mBA `
//! LUT profiles incl. CMYK↔Lab, `curv`/`para` curves) and writes ICC v4 profiles.
+1 -1
View File
@@ -245,7 +245,7 @@ fn smooth(x: f32) -> f32 {
x * x * (3.0 - 2.0 * x)
}
/// Generated looks shipped with Photocraft (procedural, CC0): `(id, label)`.
/// Generated looks shipped with PhotoCraft (procedural, CC0): `(id, label)`.
pub const BUILTIN: [(&str, &str); 8] = [
("warm", "Warm Filter"),
("cool", "Cool Filter"),
+148 -36
View File
@@ -429,10 +429,18 @@ impl<'a> Reader<'a> {
Ok(self.u32(off)? as i32 as f64 / 65536.0)
}
fn xyz(&self, off: usize) -> Result<[f64; 3], CmsError> {
Ok([self.s15f16(off)?, self.s15f16(off + 4)?, self.s15f16(off + 8)?])
let r = Reader { b: self.slice(off, 12)? };
Ok([r.s15f16(0)?, r.s15f16(4)?, r.s15f16(8)?])
}
}
/// `base + n * size` for a count or offset read from the file, or `Truncated` when it does not
/// fit a `usize`. Such fields reach `u32::MAX`, which overflows on 32-bit targets (wasm32):
/// a panic with overflow checks on, a wrapped offset that reads the wrong bytes without (#4976).
fn extent(base: usize, n: usize, size: usize) -> Result<usize, CmsError> {
n.checked_mul(size).and_then(|len| base.checked_add(len)).ok_or(CmsError::Truncated)
}
fn sig(s: &[u8; 4]) -> u32 {
u32::from_be_bytes(*s)
}
@@ -572,14 +580,15 @@ fn parse_curve(d: &[u8]) -> Result<(Curve, usize), CmsError> {
match r.slice(0, 4)? {
b"curv" => {
let n = r.u32(8)? as usize;
// Before any arithmetic on it: `n * 2` overflows a 32-bit `usize` (#4976).
if n > 1 << 20 {
return Err(CmsError::Invalid("curve too long".into()));
}
let len = 12 + n * 2;
match n {
0 => Ok((Curve::Identity, len)),
1 => Ok((Curve::Gamma(r.u16(12)? as f64 / 256.0), len)),
_ => {
if n > 1 << 20 {
return Err(CmsError::Invalid("curve too long".into()));
}
let s = r.slice(12, n * 2)?;
let t = s.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect();
Ok((Curve::Table(t), len))
@@ -684,36 +693,8 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result<Lut, CmsError> {
return Err(CmsError::Invalid("bad lutAB dimensions".into()));
}
let (ob, om, omc, oc, oa) = (r.u32(12)? as usize, r.u32(16)? as usize, r.u32(20)? as usize, r.u32(24)? as usize, r.u32(28)? as usize);
let matrix = |off: usize| -> Result<Stage, CmsError> {
let mut m = vec![0.0; 9];
for (k, v) in m.iter_mut().enumerate() {
*v = r.s15f16(off + k * 4)?;
}
let offset = vec![r.s15f16(off + 36)?, r.s15f16(off + 40)?, r.s15f16(off + 44)?];
Ok(Stage::Matrix { rows: 3, cols: 3, m, offset })
};
let clut = |off: usize, cin: usize, cout: usize| -> Result<Stage, CmsError> {
let mut grid = Vec::with_capacity(cin);
for k in 0..cin {
let g = r.u8(off + k)? as usize;
if g < 1 {
return Err(CmsError::Invalid("zero grid points".into()));
}
grid.push(g);
}
let prec = r.u8(off + 16)? as usize;
let nodes: u128 = grid.iter().map(|g| *g as u128).product();
if nodes * cout as u128 > MAX_CLUT_VALUES as u128 {
return Err(CmsError::Invalid("CLUT too large".into()));
}
let total = nodes as usize * cout;
let data: Vec<f32> = match prec {
1 => r.slice(off + 20, total)?.iter().map(|b| *b as f32 / 255.0).collect(),
2 => r.slice(off + 20, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(),
_ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))),
};
Ok(Stage::Clut(Clut::new(grid, cout, data)))
};
let matrix = |off: usize| lut_ab_matrix(&r, off);
let clut = |off: usize, cin: usize, cout: usize| lut_ab_clut(&r, off, cin, cout);
let mut stages = Vec::new();
if ty == b"mAB " {
// A curves → CLUT → M curves → matrix → B curves.
@@ -760,6 +741,45 @@ fn parse_lut(d: &[u8], a2b: bool) -> Result<Lut, CmsError> {
}
}
/// The 3×3 matrix and offset of a `mAB `/`mBA ` element at `off`, an offset stored in the file.
fn lut_ab_matrix(r: &Reader, off: usize) -> Result<Stage, CmsError> {
// One bounds-checked slice, then offsets inside it: `off + 44` need not fit a `usize`.
let e = Reader { b: r.slice(off, 48)? };
let mut m = vec![0.0; 9];
for (k, v) in m.iter_mut().enumerate() {
*v = e.s15f16(k * 4)?;
}
let offset = vec![e.s15f16(36)?, e.s15f16(40)?, e.s15f16(44)?];
Ok(Stage::Matrix { rows: 3, cols: 3, m, offset })
}
/// The CLUT of a `mAB `/`mBA ` element at `off`, an offset stored in the file.
fn lut_ab_clut(r: &Reader, off: usize, cin: usize, cout: usize) -> Result<Stage, CmsError> {
// Grid points (16 bytes), precision and padding; the samples follow.
let head = Reader { b: r.slice(off, 20)? };
let mut grid = Vec::with_capacity(cin);
for k in 0..cin {
let g = head.u8(k)? as usize;
if g < 1 {
return Err(CmsError::Invalid("zero grid points".into()));
}
grid.push(g);
}
let prec = head.u8(16)? as usize;
let nodes: u128 = grid.iter().map(|g| *g as u128).product();
if nodes * cout as u128 > MAX_CLUT_VALUES as u128 {
return Err(CmsError::Invalid("CLUT too large".into()));
}
let total = nodes as usize * cout;
let samples = extent(off, 20, 1)?;
let data: Vec<f32> = match prec {
1 => r.slice(samples, total)?.iter().map(|b| *b as f32 / 255.0).collect(),
2 => r.slice(samples, total * 2)?.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]]) as f32 / 65535.0).collect(),
_ => return Err(CmsError::Invalid(format!("CLUT precision {prec}"))),
};
Ok(Stage::Clut(Clut::new(grid, cout, data)))
}
/// Text from `desc` (textDescriptionType), `mluc` (English or first record) or `text`.
fn parse_text(d: &[u8]) -> String {
let r = Reader { b: d };
@@ -775,8 +795,10 @@ fn parse_text(d: &[u8]) -> String {
let (Ok(n), Ok(size)) = (r.u32(8), r.u32(12)) else { return String::new() };
let mut best: Option<(usize, usize)> = None;
for k in 0..(n as usize).min(256) {
let o = 16 + k * size as usize;
let (Ok(lang), Ok(len), Ok(off)) = (r.u16(o), r.u32(o + 4), r.u32(o + 8)) else { break };
// The record size comes from the file: `k * size` can overflow a 32-bit `usize`.
let Ok(rec) = extent(16, k, size as usize).and_then(|o| r.slice(o, 12)) else { break };
let rec = Reader { b: rec };
let (Ok(lang), Ok(len), Ok(off)) = (rec.u16(0), rec.u32(4), rec.u32(8)) else { break };
if best.is_none() || lang == u16::from_be_bytes(*b"en") {
best = Some((off as usize, len as usize));
if lang == u16::from_be_bytes(*b"en") {
@@ -792,3 +814,93 @@ fn parse_text(d: &[u8]) -> String {
_ => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn be(v: u32) -> [u8; 4] {
v.to_be_bytes()
}
/// #4976: offsets and counts stored in a profile reach `u32::MAX`, which is `usize::MAX` on
/// 32-bit targets. The arithmetic on them must report a truncated profile, not overflow.
#[test]
fn offsets_at_the_top_of_the_address_space_are_truncated_not_overflowed() {
let bytes = [0u8; 64];
let r = Reader { b: &bytes };
for off in [usize::MAX, usize::MAX - 3, usize::MAX - 11, usize::MAX - 19, usize::MAX - 47] {
assert_eq!(r.xyz(off), Err(CmsError::Truncated), "xyz at {off}");
assert!(matches!(lut_ab_matrix(&r, off), Err(CmsError::Truncated)), "matrix at {off}");
assert!(matches!(lut_ab_clut(&r, off, 3, 3), Err(CmsError::Truncated)), "clut at {off}");
}
assert_eq!(extent(16, usize::MAX, 12), Err(CmsError::Truncated));
assert_eq!(extent(usize::MAX, 1, 1), Err(CmsError::Truncated));
assert_eq!(extent(16, 3, 12), Ok(52));
}
#[test]
fn a_curve_count_is_checked_before_its_length_is_computed() {
let mut d = b"curv\0\0\0\0".to_vec();
d.extend(be(u32::MAX));
assert_eq!(parse_curve(&d).map(|c| c.1), Err(CmsError::Invalid("curve too long".into())));
// Controls: the identity and a two-point table still parse with their lengths.
let mut d = b"curv\0\0\0\0".to_vec();
d.extend(be(0));
assert_eq!(parse_curve(&d).map(|c| c.1), Ok(12));
let mut d = b"curv\0\0\0\0".to_vec();
d.extend(be(2));
d.extend([0, 0, 0xFF, 0xFF]);
assert_eq!(parse_curve(&d).map(|c| c.1), Ok(16));
}
#[test]
fn lut_ab_elements_at_hostile_offsets_are_rejected() {
// A 3→3 lutAtoB with identity B curves at byte 32; then its B-curve, matrix and CLUT
// offsets in turn point at the end of a 32-bit file.
let lut = |b: u32, matrix: u32, m: u32, clut: u32, a: u32| {
let mut d = b"mAB \0\0\0\0".to_vec();
d.extend([3, 3, 0, 0]);
for v in [b, matrix, m, clut, a] {
d.extend(be(v));
}
for _ in 0..3 {
d.extend(b"curv\0\0\0\0");
d.extend(be(0));
}
d
};
assert!(parse_lut(&lut(32, 0, 0, 0, 0), true).is_ok(), "the control parses");
let top = u32::MAX - 8;
assert!(parse_lut(&lut(top, 0, 0, 0, 0), true).is_err(), "B curves offset");
assert!(parse_lut(&lut(32, top, 32, 0, 0), true).is_err(), "matrix offset");
assert!(parse_lut(&lut(32, 0, 0, top, 32), true).is_err(), "CLUT offset");
}
#[test]
fn multilingual_records_with_a_huge_record_size_give_the_first_record() {
let record = |lang: &[u8; 2], len: u32, off: u32| {
let mut r = lang.to_vec();
r.extend(b"US");
r.extend(be(len));
r.extend(be(off));
r
};
// Two records of the standard 12 bytes: the English one wins.
let mut d = b"mluc\0\0\0\0".to_vec();
d.extend(be(2));
d.extend(be(12));
d.extend(record(b"de", 4, 40));
d.extend(record(b"en", 4, 44));
d.extend([0, b'D', 0, b'E', 0, b'E', 0, b'N']);
assert_eq!(parse_text(&d), "EN");
// A record size of u32::MAX: the second record lies past any file, so the first is used.
d[12..16].copy_from_slice(&be(u32::MAX));
assert_eq!(parse_text(&d), "DE");
// No records at all.
let mut d = b"mluc\0\0\0\0".to_vec();
d.extend(be(u32::MAX));
d.extend(be(u32::MAX));
assert_eq!(parse_text(&d), "");
}
}
+2 -2
View File
@@ -2,7 +2,7 @@
//!
//! Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39 …) are proprietary, and the
//! freely downloadable characterisation-based profiles come with redistribution conditions, so
//! Photocraft ships a CMYK profile it generates itself from a documented printer model:
//! PhotoCraft ships a CMYK profile it generates itself from a documented printer model:
//!
//! * **Forward model** (CMYK → XYZ): Yule–Nielsen modified Neugebauer (n = 2) over the 16
//! ink overprints, with Demichel coverage weights and a parabolic tone value increase (dot
@@ -29,7 +29,7 @@ use crate::math;
use crate::pipeline::Stage;
use crate::profile::{ColorSpace, Lut, LutKind, Pcs, Profile, ProfileClass};
pub const DESCRIPTION: &str = "Photocraft Coated CMYK (synthetic, 300% TAC, medium GCR)";
pub const DESCRIPTION: &str = "PhotoCraft Coated CMYK (synthetic, 300% TAC, medium GCR)";
/// Parameters of the synthetic profile.
#[derive(Clone, Debug)]
+11 -2
View File
@@ -38,7 +38,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| WebP | yes | yes | U8 | RGB, RGBA | yes | yes | yes | yes | no | no | yes (VP8, when `webp_lossless` is off) | `image-webp` / built-in VP8 |
| GIF | yes | yes | U8 | RGBA | 1-bit | no | no | no | no | no | yes (256-colour palette) | `image` |
| BMP | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` (read) / built in (RLE write, one scanline per packet) |
| ICO | yes | yes | U8 | RGBA (at most 256×256) | yes | no | no | no | no | no | no | `image` |
| DDS | yes | **no** | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | n/a | built in |
| Netpbm (PBM/PGM/PPM/PAM/PFM) | yes | yes | U8, U16, F32 (float: PFM gray/RGB only) | all six (PAM `CMYK`/`CMYK_ALPHA`) | yes (PAM) | no | no | no | no | no | no | built in |
@@ -47,6 +47,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` |
| FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in |
| DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in |
| Cineon | yes | **no** | U16 (10-bit code values) | Gray, RGB | no | no | no | no | no | no | n/a | 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` |
@@ -93,7 +94,15 @@ embedded table (and a PNG's transparent entry) so `photocraft-io` can open them
big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for
U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma.
Other descriptors, signed or run-length-encoded data and other bit depths return
`CodecError::Unsupported` naming the field. Cineon (`.cin`) is not supported.
`CodecError::Unsupported` naming the field.
* **Cineon (read-only).** Kodak's 10-bit logarithmic film-scan format (the one DPX grew from),
read from the same Kodak header family: the generic 1024-byte header, big-endian, one (Gray)
or three (RGB) channels, three 10-bit samples packed into each 32-bit word, lines on a word
boundary. The 10-bit code values open as U16 `round(c × 65535 / 1023)`; the file's black/white
reference points are a log-conversion choice and are not applied. A depth other than 10 bits or
a channel count other than 1/3 returns `CodecError::Unsupported`; truncation errors rather than
panicking. There is no Cineon writer (`ASYMMETRIC_EXCEPTIONS`); save as DPX, TIFF or EXR.
* **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
+174 -21
View File
@@ -23,7 +23,7 @@ use half::f16;
use crate::Format;
use crate::error::CodecError;
use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, Image, SampleType};
use crate::options::Limits;
use crate::options::{Limits, alloc_zeroed};
const F: Format = Format::OpenExr;
@@ -39,9 +39,15 @@ fn unsup(reason: impl Into<String>) -> CodecError {
// Every length and offset comes from the file, so every read is bounds-checked and a short
// read is a malformed file, never a panic.
fn read_exact(cur: &mut Cursor<&[u8]>, n: usize, what: &str) -> Result<Vec<u8>, CodecError> {
let mut v = vec![0u8; n];
cur.read_exact(&mut v).map_err(|_| err(format!("truncated file while reading {what}")))?;
fn read_exact<'a>(cur: &mut Cursor<&'a [u8]>, n: usize, what: &str) -> Result<&'a [u8], CodecError> {
// Borrow packed payloads from the input. Copying them here allocates before count-table
// admission and needlessly duplicates the table even when the second pass just skips it.
let truncated = || err(format!("truncated file while reading {what}"));
let start = usize::try_from(cur.position()).map_err(|_| truncated())?;
let end = start.checked_add(n).ok_or_else(truncated)?;
let bytes = *cur.get_ref();
let v = bytes.get(start..end).ok_or_else(truncated)?;
cur.set_position(end as u64);
Ok(v)
}
@@ -81,10 +87,11 @@ fn differences_to_samples(buf: &mut [u8]) {
}
/// Undo the ZIP half-split: the stream is `[first half][second half]` with the second half
/// holding every second byte of the original; weave them back together.
fn interleave_byte_blocks(buf: &mut [u8]) {
/// holding every second byte of the original; weave them back together. The scratch copy is
/// allocated fallibly, so a buffer that passed `max_alloc` but not the allocator is an error.
fn interleave_byte_blocks(buf: &mut [u8]) -> Result<(), CodecError> {
let half = buf.len().div_ceil(2);
let mut out = vec![0u8; buf.len()];
let mut out = alloc_zeroed::<u8>(buf.len())?;
let mut k = 0usize;
for i in 0..buf.len() / 2 {
let (a, b) = (buf.get(i).copied(), buf.get(half + i).copied());
@@ -105,12 +112,29 @@ fn interleave_byte_blocks(buf: &mut [u8]) {
}
}
buf.copy_from_slice(&out);
Ok(())
}
/// Make room in `out` for `extra` more bytes, `limit` in all. Grows by doubling like `Vec`,
/// but never reserves past `limit`: callers admit exactly that size against `max_alloc`, and
/// plain doubling reserved up to twice as much. Fallible, so a failed allocation is an error.
fn reserve_up_to(out: &mut Vec<u8>, extra: usize, limit: usize, what: &str) -> Result<(), CodecError> {
let need = out.len().saturating_add(extra);
if need > limit {
return Err(err(format!("{what}: data expands past {limit} bytes")));
}
if need > out.capacity() {
let target = out.capacity().saturating_mul(2).max(need).min(limit);
out.try_reserve_exact(target.saturating_sub(out.len())).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {target} bytes for {what}")))?;
}
Ok(())
}
/// OpenEXR RLE tokens: a negative byte `n` means `-n` literal bytes follow, a non-negative
/// byte `n` means the next byte repeats `n + 1` times (as `ImfRle.cpp` and the `exr` crate).
fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecError> {
let mut out = Vec::with_capacity(expected.min(1 << 20));
let mut out = Vec::new();
reserve_up_to(&mut out, expected.min(1 << 20), expected, what)?;
let mut i = 0;
while i < src.len() {
let count = src[i] as i8;
@@ -119,6 +143,7 @@ fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecE
if out.len().saturating_add(n) > expected {
return Err(err(format!("{what}: RLE data expands past {expected} bytes")));
}
reserve_up_to(&mut out, n, expected, what)?;
if count < 0 {
let end = i.saturating_add(n);
let run = src.get(i..end.min(src.len())).ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?;
@@ -143,7 +168,10 @@ fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecE
/// has the uncompressed length is stored raw whatever the declared compression.
fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &str) -> Result<Vec<u8>, CodecError> {
if packed.len() == expected {
return Ok(packed.to_vec());
let mut out = Vec::new();
reserve_up_to(&mut out, expected, expected, what)?;
out.extend_from_slice(packed);
return Ok(out);
}
match compression {
Compression::Uncompressed => Err(err(format!("{what}: expected {expected} uncompressed bytes, found {}", packed.len()))),
@@ -151,20 +179,38 @@ fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &s
// Like ZIPS, RLE runs over the predicted, byte-split stream.
let mut out = rle_decode(packed, expected, what)?;
differences_to_samples(&mut out);
interleave_byte_blocks(&mut out);
interleave_byte_blocks(&mut out)?;
Ok(out)
}
Compression::ZIP1 => {
let mut out = Vec::with_capacity(expected.min(1 << 20));
flate2::read::ZlibDecoder::new(packed)
.take(expected as u64 + 1)
.read_to_end(&mut out)
.map_err(|_| err(format!("{what}: zlib data is malformed")))?;
// Inflate straight into `out`, grown with `reserve_up_to` rather than by
// `read_to_end`, whose doubling reserves past `expected`. Once `expected` bytes are
// in, a one-byte read checks that the stream ends there.
let malformed = || err(format!("{what}: zlib data is malformed"));
let mut zlib = flate2::read::ZlibDecoder::new(packed);
let mut out = Vec::new();
let mut filled = 0;
while filled < expected {
if filled == out.len() {
let grow = out.len().max(1 << 13).min(expected - filled);
reserve_up_to(&mut out, grow, expected, what)?;
out.resize(filled + grow, 0);
}
let n = zlib.read(out.get_mut(filled..).unwrap_or_default()).map_err(|_| malformed())?;
if n == 0 {
break;
}
filled += n;
}
out.truncate(filled);
if filled == expected && zlib.read(&mut [0u8; 1]).map_err(|_| malformed())? != 0 {
return Err(err(format!("{what}: zlib data expands past {expected} bytes")));
}
if out.len() != expected {
return Err(err(format!("{what}: zlib data expands to {} bytes, expected {expected}", out.len())));
}
differences_to_samples(&mut out);
interleave_byte_blocks(&mut out);
interleave_byte_blocks(&mut out)?;
Ok(out)
}
other => Err(unsup(format!("deep data with {other:?} compression (NONE, RLE and ZIPS are supported)"))),
@@ -340,13 +386,41 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
if packed_table > remaining || packed_data > remaining.saturating_sub(packed_table) {
return Err(err("deep chunk sizes point past the end of the file"));
}
let table_bytes = read_exact(&mut cur, packed_table as usize, "the sample-count table")?;
let what = "the sample-count table";
let table_bytes = read_exact(&mut cur, packed_table as usize, what)?;
let expected_table = block_pixels.len().checked_mul(4).ok_or_else(|| err("count table overflows"))?;
// A compressed clipped table also needs its scratch copy. The data-window check only
// budgets four bytes per channel per pixel, insufficient for a one-channel image.
// An equal-sized payload takes decompress's raw path, which allocates no scratch.
let buffers = if table_bytes.len() == expected_table { 1 } else { 2 };
let clipped_need = (expected_table as u64).saturating_mul(buffers);
if clipped_need > limits.max_alloc {
return Err(CodecError::LimitExceeded(format!(
"{what} needs {clipped_need} bytes including decompression scratch, exceeding max_alloc {}",
limits.max_alloc
)));
}
// An edge tile's table may still be stored at the full tile size (its pixels first, in
// rows of the clipped width, then unused entries), as the OpenEXR library writes it.
let full_table = tile_w.saturating_mul(tile_h).saturating_mul(4).max(expected_table);
let raw = decompress(compression, &table_bytes, full_table, "the sample-count table")
.or_else(|_| decompress(compression, &table_bytes, expected_table, "the sample-count table"))?;
// That size comes from the header's tile size alone, which nothing ties to the data
// window, so it is tried only when `max_alloc` has room for the table and for the copy
// that undoes its byte split. Otherwise only the clipped size is read.
let full_table = tile_w.checked_mul(tile_h).and_then(|n| n.checked_mul(4));
let raw = match full_table {
// Scan lines and interior tiles: the full size is the clipped size.
Some(full) if full <= expected_table => decompress(compression, table_bytes, expected_table, what)?,
Some(full) if (full as u64).saturating_mul(2) <= limits.max_alloc => {
decompress(compression, table_bytes, full, what).or_else(|_| decompress(compression, table_bytes, expected_table, what))?
}
_ => decompress(compression, table_bytes, expected_table, what).map_err(|_| {
// Four bytes per pixel of the tile, twice: the table and its scratch copy.
let need = (tile_w as u64).saturating_mul(tile_h as u64).saturating_mul(8);
CodecError::LimitExceeded(format!(
"{what} of a {tile_w}x{tile_h} deep tile does not decode at the clipped size, and at the full tile size it needs {need} bytes with its scratch copy, exceeding max_alloc {}",
limits.max_alloc
))
})?,
};
// The table is cumulative within each row of the block (a tile restarts at every row);
// `prefix` is cumulative over the whole block, and each pixel's own count is the difference.
@@ -423,7 +497,7 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res
// The count table sits between the sizes and the sample data; skip its bytes.
read_exact(&mut cur, packed_table as usize, "the sample-count table")?;
let data_bytes = read_exact(&mut cur, chunk.packed_data as usize, "the deep sample data")?;
let raw = decompress(compression, &data_bytes, chunk.unpacked_data as usize, "the deep sample data")?;
let raw = decompress(compression, data_bytes, chunk.unpacked_data as usize, "the deep sample data")?;
// Row by row (a scan line block is one row), and within a row channel by channel, as
// flat tiles are laid out: each pixel's samples are contiguous within its channel.
let mut in_off = 0usize;
@@ -611,3 +685,82 @@ pub(crate) fn flatten(deep: &DeepImage) -> Result<Image, CodecError> {
img.warnings.push(DecodeWarning::DeepFlattened { format: F, max_samples_per_pixel: max_samples });
Ok(img)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
#[test]
fn packed_reads_borrow_input_and_reject_bad_sizes_before_allocation() {
let bytes = [1u8, 2, 3, 4];
let mut cur = Cursor::new(bytes.as_slice());
let prefix = read_exact(&mut cur, 3, "x").expect("three input bytes");
assert_eq!(prefix, &[1, 2, 3]);
assert_eq!(prefix.as_ptr(), bytes.as_ptr());
assert_eq!(cur.position(), 3);
assert!(matches!(read_exact(&mut cur, usize::MAX, "x"), Err(CodecError::Malformed { .. })));
assert_eq!(cur.position(), 3);
assert_eq!(read_exact(&mut cur, 1, "x").expect("last byte"), &[4]);
assert!(matches!(read_exact(&mut cur, 1, "x"), Err(CodecError::Malformed { .. })));
cur.set_position(u64::MAX);
assert!(matches!(read_exact(&mut cur, 1, "x"), Err(CodecError::Malformed { .. })));
}
/// What the writer stores before compressing: the byte split, then the +128 predictor.
fn predicted(raw: &[u8]) -> Vec<u8> {
let half = raw.len().div_ceil(2);
let mut out = vec![0u8; raw.len()];
for (i, &v) in raw.iter().enumerate() {
out[if i % 2 == 0 { i / 2 } else { half + i / 2 }] = v;
}
let mut prev = 0i16;
for (i, b) in out.iter_mut().enumerate() {
let v = i16::from(*b);
if i > 0 {
*b = (v - prev + 128) as u8;
}
prev = v;
}
out
}
/// #4958: callers admit `expected` bytes for a decompressed buffer (twice that for a count
/// table, with the copy that undoes its byte split), so decompression must not reserve
/// more. `Vec`'s doubling gave a 4 MiB ZIPS table 8 MiB of capacity and a 3 MiB RLE table
/// 4 MiB, so the "admitted" table and its copy took up to three times the table size.
#[test]
fn decompressed_buffers_reserve_no_more_than_expected() {
for n in [1usize << 20, 2 << 20, (3 << 20) + 1, 4 << 20, 5 << 20] {
let raw = vec![0u8; n];
let p = predicted(&raw);
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
z.write_all(&p).expect("write to a Vec");
let zips = z.finish().expect("finish a Vec");
// RLE: the first byte as a literal, then runs of up to 128 copies of the next.
let mut rle = vec![0xFF, p[0]];
for run in p[1..].chunks(128) {
rle.extend_from_slice(&[(run.len() - 1) as u8, run[0]]);
}
for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] {
let out = decompress(compression, packed, n, "the table").expect("decompresses");
assert_eq!(out, raw, "{compression:?}, {n} bytes");
assert!(out.capacity() <= n, "{compression:?}: a {n} byte buffer reserved {} bytes", out.capacity());
}
// One byte short, the same data expands past the expected size and is refused.
for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] {
assert!(decompress(compression, packed, n - 1, "the table").is_err(), "{compression:?}, {n} bytes");
}
}
}
/// A size past the limit is malformed data, and a reservation the allocator refuses is an
/// error rather than an abort.
#[test]
fn reservations_past_the_limit_or_the_allocator_are_errors() {
let mut v = Vec::new();
assert!(matches!(reserve_up_to(&mut v, 2, 1, "x"), Err(CodecError::Malformed { .. })));
assert!(matches!(reserve_up_to(&mut v, isize::MAX as usize + 1, usize::MAX, "x"), Err(CodecError::LimitExceeded(_))));
assert_eq!(v.capacity(), 0);
}
}
+128
View File
@@ -337,6 +337,78 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
Ok(img)
}
// ---------------------------------------------------------------- Cineon (Kodak .cin)
//
// Cineon is the 10-bit film-scan format DPX grew from (same Kodak header family, big-endian).
// The generic header is 1024 bytes: the file information fields, then at offset 192 the image
// orientation (1 byte), the channel count (1 byte) and up to eight 28-byte channel descriptors
// (the first carries the bits per pixel and the line/image dimensions). Image data is three
// 10-bit samples packed into each 32-bit word, lines on a word boundary — the same layout as
// DPX's 10-bit packing method A, so the unpacking is shared.
/// The Cineon magic number (`802A5FD7` hex), big-endian.
pub(crate) fn is_cineon(b: &[u8]) -> bool {
b.get(..4) == Some(&[0x80, 0x2A, 0x5F, 0xD7])
}
/// The EXIF orientation (1–8) for a Cineon header's orientation code (the same 0–7 order as DPX).
pub(crate) fn cineon_orientation(b: &[u8]) -> u16 {
let Some(code) = b.get(192).copied() else { return 1 };
match code {
1 => 2,
2 => 4,
3 => 3,
4 => 5,
5 => 6,
6 => 8,
7 => 7,
_ => 1,
}
}
/// Decode a Cineon (`.cin`) frame: 10-bit logarithmic film scans, big-endian, one (gray) or
/// three (RGB) channels. The 10-bit code values become U16 as `round(c × 65535 / 1023)`; the
/// file's black/white reference points are not applied (a log conversion is a display choice).
pub(crate) fn decode_cineon(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
if !is_cineon(bytes) {
return Err(err("missing the Cineon magic number (802A5FD7)"));
}
let o = Order::Big;
let missing = |what: &str| err(format!("the header ends before {what}"));
let image_offset = usize::try_from(o.u32(bytes, 4).ok_or_else(|| missing("the image data offset"))?).map_err(|_| err("image data offset out of range"))?;
let channels = *bytes.get(193).ok_or_else(|| missing("the channel count"))?;
let bits = *bytes.get(198).ok_or_else(|| missing("the bits per pixel"))?;
if bits != 10 {
return Err(unsupported(format!("{bits}-bit samples (Cineon frames are 10-bit)")));
}
let layout = match channels {
1 => ChannelLayout::Gray,
3 => ChannelLayout::Rgb,
n => return Err(unsupported(format!("{n} channels (Cineon frames have 1 or 3)"))),
};
let width = o.u32(bytes, 200).ok_or_else(|| missing("the pixels per line"))?;
let height = o.u32(bytes, 204).ok_or_else(|| missing("the lines per image"))?;
limits.check(width, height, layout, SampleType::U16)?;
let (w, h) = (width as usize, height as usize);
let per_line = w.checked_mul(layout.channels()).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?;
// Three 10-bit samples per 32-bit word, lines on a word boundary (DPX packing 1).
let line_len = line_bytes(per_line, 10, 1).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?;
let data = bytes.get(image_offset..).ok_or_else(|| err("the image data offset is past the end of the file"))?;
let need = line_len.checked_mul(h).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?;
if data.len() < need {
return Err(err("truncated image data"));
}
let mut codes = vec![0u16; per_line.checked_mul(h).ok_or_else(|| CodecError::LimitExceeded("image too large".into()))?];
for (y, row) in codes.chunks_exact_mut(per_line).enumerate() {
let start = y * line_len;
let line = data.get(start..start + line_len).ok_or_else(|| err("truncated image data"))?;
unpack_line(line, o, 10, 1, row)?;
}
let wide: Vec<u16> = codes.iter().map(|&c| widen(c, 1023)).collect();
Image::from_u16(width, height, layout, &wide)
}
/// The kept header fields as text metadata, in [`KEYS`] order; undefined ones are left out.
fn header_text(b: &[u8], o: Order, e: Element) -> Vec<(String, String)> {
let mut text = Vec::new();
@@ -653,4 +725,60 @@ mod tests {
let img = decode(&b, &Limits::default()).unwrap();
assert_eq!(img.data(), &[1, 2, 3, 4, 5, 6]);
}
/// A minimal Cineon: a 1024-byte generic header, then `w × h` 10-bit samples packed three
/// to a 32-bit word (RGB in word order, or gray three pixels per word).
fn cineon(w: u32, h: u32, channels: u8, px: &[[u16; 3]]) -> Vec<u8> {
let mut b = vec![0u8; 1024];
b[0..4].copy_from_slice(&[0x80, 0x2A, 0x5F, 0xD7]); // magic (802A5FD7)
b[4..8].copy_from_slice(&1024u32.to_be_bytes()); // image data offset
b[192] = 0; // orientation: left-to-right, top-to-bottom
b[193] = channels;
b[198] = 10; // bits per pixel (channel 1)
b[200..204].copy_from_slice(&w.to_be_bytes()); // pixels per line
b[204..208].copy_from_slice(&h.to_be_bytes()); // lines per image
let word = |a: u16, c: u16, e: u16| ((u32::from(a) << 22) | (u32::from(c) << 12) | (u32::from(e) << 2)).to_be_bytes();
if channels == 3 {
for p in px {
b.extend_from_slice(&word(p[0], p[1], p[2]));
}
} else {
let g = |i: usize| px.get(i).map_or(0, |p| p[0]);
for i in (0..px.len()).step_by(3) {
b.extend_from_slice(&word(g(i), g(i + 1), g(i + 2)));
}
}
b
}
#[test]
fn cineon_reads_ten_bit_rgb() {
let px = [[0, 512, 1023], [1023, 0, 256]];
let b = cineon(2, 1, 3, &px);
assert!(is_cineon(&b));
assert_eq!(order(&b), None, "Cineon is not DPX");
let img = decode_cineon(&b, &Limits::default()).unwrap();
assert_eq!((img.width(), img.height(), img.layout()), (2, 1, ChannelLayout::Rgb));
assert_eq!(
img.to_u16_samples().unwrap(),
vec![widen(0, 1023), widen(512, 1023), widen(1023, 1023), widen(1023, 1023), widen(0, 1023), widen(256, 1023)]
);
}
#[test]
fn cineon_reads_grayscale_and_rejects_other_depths() {
let px = [[10, 0, 0], [20, 0, 0], [30, 0, 0], [40, 0, 0]];
let b = cineon(4, 1, 1, &px);
let img = decode_cineon(&b, &Limits::default()).unwrap();
assert_eq!(img.layout(), ChannelLayout::Gray);
assert_eq!(img.to_u16_samples().unwrap(), vec![widen(10, 1023), widen(20, 1023), widen(30, 1023), widen(40, 1023)]);
// A depth other than 10 is unsupported, and truncation errors rather than panicking.
let mut bad = cineon(1, 1, 3, &[[0, 0, 0]]);
bad[198] = 8;
assert!(matches!(decode_cineon(&bad, &Limits::default()), Err(CodecError::Unsupported { .. })));
let full = cineon(4, 4, 3, &[[0; 3]; 16]);
for cut in [0, 4, 200, 1024, full.len() - 1] {
assert!(decode_cineon(&full[..cut.min(full.len())], &Limits::default()).is_err(), "cut {cut}");
}
}
}
+15
View File
@@ -187,6 +187,21 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
Ok(img)
}
/// The size of the part [`decode`] opens, from the headers alone. Deep files are refused (their
/// flattened size comes from the deep decoder), as is a part [`decode`] cannot show.
pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32), CodecError> {
let meta = read_meta(bytes, limits)?;
if meta.requirements.has_deep_data {
return Err(CodecError::unsupported(F, "no header-only size probe for deep data"));
}
let pick = pick_part(&meta);
let info = meta.headers.get(pick).map(|h| part_info(pick, h)).ok_or_else(|| err("no parts"))?;
if info.color_rank() == 0 {
return Err(CodecError::unsupported(F, "no R/G/B or Y channels"));
}
Ok((info.width, info.height))
}
/// Decode one part of a multi-part file by its index into [`info`]'s list. A file with
/// deep parts is not mixed: the deep decoder composites those instead.
pub(crate) fn decode_part(bytes: &[u8], index: usize, limits: &Limits) -> Result<Image, CodecError> {
+16 -1
View File
@@ -530,7 +530,11 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
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());
// 0..1 as documented: a value above 2 would make readers (ours too)
// take the data as ADU and scale it by 65535. NaN is written as 0.
let v = d[idx(p, y, x)];
let v = if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) };
out.extend_from_slice(&v.to_be_bytes());
}
}
}
@@ -632,6 +636,17 @@ mod tests {
assert_eq!(img.to_f32_samples().unwrap(), vec![1.0, 0.0]);
}
#[test]
fn hdr_float_saves_clamped_and_reopens_as_saved() {
// One value above 2 used to make the reader scale everything by 65535 (all black).
let img = Image::from_f32(2, 1, ChannelLayout::Gray, &[0.5, 3.0]).unwrap();
let opts = EncodeOptions::default();
let bytes = encode(&img, crate::fidelity::plan(&img, F, &opts), &opts).unwrap();
let back = decode(&bytes, &Limits::default()).unwrap().to_f32_samples().unwrap();
assert!((back[0] - 0.5).abs() < 1e-6, "{back:?}");
assert!((back[1] - 1.0).abs() < 1e-6, "{back:?}");
}
#[test]
fn signed_negative_data_falls_back_to_float() {
let mut b = header(&["SIMPLE = T", "BITPIX = 16", "NAXIS = 2", "NAXIS1 = 2", "NAXIS2 = 1"]);
+1
View File
@@ -10,6 +10,7 @@ pub(crate) mod jpeg;
pub(crate) mod pcx;
pub(crate) mod png;
pub(crate) mod pnm;
pub(crate) mod tga;
pub(crate) mod tiff;
pub(crate) mod tiff_exif;
pub(crate) mod tiff_ifd;
+16 -1
View File
@@ -66,6 +66,21 @@ impl<'a> Reader<'a> {
}
}
/// PAM ends its header with an LF-terminated line, unlike P4–P6's single separator.
fn pam_endhdr(&mut self) -> Result<(), CodecError> {
while let Some(&c) = self.b.get(self.pos) {
self.pos += 1;
if c == b'\n' {
// Stop at the first LF: whitespace and '#' after it are raster bytes.
return Ok(());
}
if !c.is_ascii_whitespace() {
return Err(err("unexpected data after PAM ENDHDR"));
}
}
Err(err("unterminated PAM ENDHDR line"))
}
fn rest(&self) -> &'a [u8] {
&self.b[self.pos.min(self.b.len())..]
}
@@ -269,7 +284,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result<Image, CodecError>
_ => return Err(err("unknown PAM header field")),
}
}
r.single_ws()?;
r.pam_endhdr()?;
let (w, h, depth, maxval) = match (w, h, depth, maxval) {
(Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d),
_ => return Err(err("incomplete PAM header")),
+90
View File
@@ -0,0 +1,90 @@
//! Targa writer. The `image` crate still reads Targa ([`super::via_image`]); writing is ours
//! because its run-length packets ran on from the end of one row into the next. TGA 2.0 says a
//! packet must not cross a scanline, and readers that decode a row at a time (Pillow, texture
//! converters, game engines) misread such files (#6382).
//!
//! Like Photoshop's Targa: RLE (types 10 and 11), rows stored bottom-up (the bottom-left origin
//! every reader assumes, also those that ignore the origin bits), alpha bits in the descriptor,
//! and the TGA 2.0 footer.
use crate::Format;
use crate::error::CodecError;
use crate::image::{ChannelLayout, Image, SampleType};
/// Most pixels one packet holds (a 7-bit count, plus one).
const MAX_PACKET: usize = 128;
/// The TGA 2.0 footer: no extension or developer area, then the signature.
const FOOTER: &[u8; 26] = b"\0\0\0\0\0\0\0\0TRUEVISION-XFILE.\0";
/// Encodes an 8-bit Gray, Gray+alpha, RGB or RGBA image as a run-length encoded Targa.
pub(crate) fn encode(img: &Image) -> Result<Vec<u8>, CodecError> {
let (w, h) = img.dimensions();
if img.sample_type() != SampleType::U8 {
return Err(CodecError::encode(Format::Tga, format!("{:?} samples (Targa stores 8 bits)", img.sample_type())));
}
let (bpp, image_type, alpha_bits) = match img.layout() {
ChannelLayout::Gray => (1, 11, 0),
ChannelLayout::GrayA => (2, 11, 8),
ChannelLayout::Rgb => (3, 10, 0),
ChannelLayout::Rgba => (4, 10, 8),
l => return Err(CodecError::encode(Format::Tga, format!("no Targa layout for {l:?}"))),
};
let (Ok(w16), Ok(h16)) = (u16::try_from(w), u16::try_from(h)) else {
return Err(CodecError::encode(Format::Tga, format!("dimensions exceed 65535x65535: {w}x{h}")));
};
let row = w as usize * bpp;
let data = img.data();
if data.len() != row * h as usize {
return Err(CodecError::InvalidImage("buffer size mismatch".into()));
}
let mut out = Vec::with_capacity(18 + data.len() + data.len() / MAX_PACKET + FOOTER.len());
out.extend_from_slice(&[0, 0, image_type, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
out.extend_from_slice(&w16.to_le_bytes());
out.extend_from_slice(&h16.to_le_bytes());
// Bits per pixel; the descriptor holds the alpha bits, origin bits 0 (bottom-left).
out.extend_from_slice(&[(bpp * 8) as u8, alpha_bits]);
let mut px = vec![0u8; row];
for y in (0..h as usize).rev() {
px.copy_from_slice(&data[y * row..(y + 1) * row]);
// Targa stores colour as BGR.
if bpp >= 3 {
for p in px.chunks_exact_mut(bpp) {
p.swap(0, 2);
}
}
rle_row(&px, bpp, &mut out);
}
out.extend_from_slice(FOOTER);
Ok(out)
}
/// One scanline as run-length packets that end with it: a run of two or more equal pixels is a
/// run packet, anything else goes out in raw packets.
fn rle_row(row: &[u8], bpp: usize, out: &mut Vec<u8>) {
let px: Vec<&[u8]> = row.chunks_exact(bpp).collect();
let n = px.len();
let mut i = 0;
while i < n {
let mut run = 1;
while i + run < n && run < MAX_PACKET && px[i + run] == px[i] {
run += 1;
}
if run >= 2 {
out.push(0x80 | (run - 1) as u8);
out.extend_from_slice(px[i]);
i += run;
continue;
}
// Raw until two equal pixels start a run, the packet is full or the row ends. The first
// pixel always goes in: it differs from the one after it, or it is the row's last.
let start = i;
while i < n && i - start < MAX_PACKET && !(i > start && i + 1 < n && px[i] == px[i + 1]) {
i += 1;
}
out.push((i - start - 1) as u8);
for p in &px[start..i] {
out.extend_from_slice(p);
}
}
}
+69 -5
View File
@@ -532,17 +532,24 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
} else {
None
};
// CIE L*a*b*: CIELab (8; a* and b* signed) and ICCLab (9; a* and b* offset) are handed to the
// `tiff` crate as RGB, then labelled Lab with a* and b* in PhotoCraft's encoding
// (`convert_ab`).
let lab = matches!(photometric, 8 | 9);
if lab && (spp < 3 || !matches!(src, Src::U8 | Src::U16)) {
return Err(CodecError::unsupported(F, format!("Lab image with {spp} samples of {bits} bits")));
}
// TIFF 6.0 lets any extra sample be the alpha (#1349). For RGB and CMYK the `tiff` crate
// returns the alpha only when it is the first extra sample: otherwise it is asked for every
// sample (as multiband gray) and the colour and alpha samples are picked here. An alpha
// position past the samples the file has is ignored.
let color = match photometric {
2 => 3,
2 | 8 | 9 => 3,
5 => 4,
_ => 1,
};
let alpha_at = extra.iter().position(|&e| e == 1 || e == 2).filter(|&a| (color + a as u64) < spp);
let all_samples = palette.is_none() && matches!(photometric, 2 | 5) && alpha_at.is_some_and(|a| a > 0);
let all_samples = palette.is_none() && matches!(photometric, 2 | 5 | 8 | 9) && alpha_at.is_some_and(|a| a > 0);
let mut patches: Vec<(u64, Vec<u8>)> = Vec::new();
let encode_u = |v: u64, len: usize| -> Vec<u8> {
let b = if f.le { v.to_le_bytes() } else { v.to_be_bytes() };
@@ -557,14 +564,14 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
// The directory is seen as the last of its chain: the chain was walked already, and a
// cycle through it must not fail this page.
patches.push((dir.next_at, vec![0; if f.big { 8 } else { 4 }]));
if invert || palette.is_some() || all_samples {
if invert || palette.is_some() || all_samples || lab {
let e = photometric_entry.ok_or_else(|| err("missing PhotometricInterpretation"))?;
let len = match e.ty {
3 if e.count == 1 => 2,
4 if e.count == 1 => 4,
_ => return Err(err("malformed PhotometricInterpretation")),
};
patches.push((e.value_at, encode_u(1, len)));
patches.push((e.value_at, encode_u(if lab && !all_samples { 2 } else { 1 }, len)));
}
let tl = {
@@ -585,7 +592,7 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
_ if palette.is_some() => (ChannelLayout::Rgb, 1),
tiff::ColorType::Gray(_) => (ChannelLayout::Gray, 1),
tiff::ColorType::Multiband { num_samples, .. } if all_samples => {
(if photometric == 2 { ChannelLayout::Rgba } else { ChannelLayout::CmykA }, usize::from(num_samples))
(if photometric == 5 { ChannelLayout::CmykA } else { ChannelLayout::Rgba }, usize::from(num_samples))
}
tiff::ColorType::Multiband { num_samples, .. } if photometric <= 1 => {
(if gray_alpha { ChannelLayout::GrayA } else { ChannelLayout::Gray }, usize::from(num_samples))
@@ -596,6 +603,12 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
tiff::ColorType::CMYKA(_) => (ChannelLayout::CmykA, 5),
other => return Err(CodecError::unsupported(F, format!("colour type {other:?}"))),
};
let out_layout = match out_layout {
ChannelLayout::Rgb if lab => ChannelLayout::Lab,
ChannelLayout::Rgba if lab => ChannelLayout::LabA,
_ if lab => return Err(err("Lab image that does not read as three colour samples")),
l => l,
};
let chunk_spp = if planar { 1 } else { chunk_spp };
// The sample (chunky) or plane (planar) that holds the alpha: the colour samples come first.
let alpha_sample = out_layout.color_channels() + alpha_at.unwrap_or(0);
@@ -676,6 +689,9 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
if tally.decoded == 0 && tally.missing > 0 {
return Err(err("the image data is missing (cut off or past the end of the file)"));
}
if lab {
convert_ab(&mut data, out_layout.channels(), out, photometric == 8, false);
}
let mut img = Image::from_raw(w, h, out_layout, out, data)?;
if tally.missing > 0 {
img.warnings.push(DecodeWarning::Truncated { format: F });
@@ -683,6 +699,35 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
Ok(img)
}
/// The a* and b* samples (1 and 2 of every pixel) of a Lab image between the file's encoding and
/// PhotoCraft's (8 bits: `a + 128`; 16 bits: `(a + 128) · 257`). In the file, CIELab holds them
/// signed (8 bits: `a`; 16 bits: `a · 256`) and ICCLab offset (8 bits: `a + 128`; 16 bits:
/// `(a + 128) · 256`). `to_file` picks the direction; samples are native-endian.
fn convert_ab(data: &mut [u8], channels: usize, sample: SampleType, signed: bool, to_file: bool) {
match sample {
SampleType::U8 if signed => data.chunks_exact_mut(channels).for_each(|px| {
px[1] ^= 0x80;
px[2] ^= 0x80;
}),
SampleType::U16 => data.chunks_exact_mut(channels * 2).for_each(|px| {
for c in [1, 2] {
let v = u16::from_ne_bytes([px[2 * c], px[2 * c + 1]]);
// The file's value as `a · 256` (signed) and back.
let file_a256 = |v: u16| if signed { f64::from(v as i16) } else { f64::from(v) - 32768.0 };
let out = if to_file {
let a256 = (f64::from(v) - 32896.0) * 256.0 / 257.0;
let a256 = a256.round().clamp(-32768.0, 32767.0);
if signed { (a256 as i16) as u16 } else { (a256 + 32768.0) as u16 }
} else {
(file_a256(v) * 257.0 / 256.0 + 32896.0).round().clamp(0.0, 65535.0) as u16
};
px[2 * c..2 * c + 2].copy_from_slice(&out.to_ne_bytes());
}
}),
_ => {}
}
}
/// A zero-filled buffer, allocated fallibly. On native targets the zeroing (and the first
/// touch of every page, which dominates on a fresh multi-hundred-megabyte buffer) is spread over
/// all cores.
@@ -1021,6 +1066,11 @@ custom_colortype!(GrayA16, u16, PI::BlackIsZero, &[16, 16], &[SampleFormat::Uint
custom_colortype!(GrayA32F, f32, PI::BlackIsZero, &[32, 32], &[SampleFormat::IEEEFP; 2], float);
custom_colortype!(CmykA16, u16, PI::CMYK, &[16; 5], &[SampleFormat::Uint; 5], int);
custom_colortype!(CmykA32F, f32, PI::CMYK, &[32; 5], &[SampleFormat::IEEEFP; 5], float);
// CIELab as Photoshop writes it: a* and b* signed, the sample format left unsigned.
custom_colortype!(Lab8, u8, PI::CIELab, &[8; 3], &[SampleFormat::Uint; 3], int);
custom_colortype!(Lab16, u16, PI::CIELab, &[16; 3], &[SampleFormat::Uint; 3], int);
custom_colortype!(LabA8, u8, PI::CIELab, &[8; 4], &[SampleFormat::Uint; 4], int);
custom_colortype!(LabA16, u16, PI::CIELab, &[16; 4], &[SampleFormat::Uint; 4], int);
struct TagSet<'a> {
icc: Option<&'a [u8]>,
@@ -1119,6 +1169,20 @@ fn write_image<K: TiffKind>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>, K>, img:
(L::CmykA, S::U8) => write_one::<colortype::CMYKA8, K>(enc, w, h, d8, tags),
(L::CmykA, S::U16) => write_one::<CmykA16, K>(enc, w, h, &u16s(), tags),
(L::CmykA, S::F32) => write_one::<CmykA32F, K>(enc, w, h, &f32s(), tags),
(L::Lab | L::LabA, _) => {
// CIELab has no float form: 32-bit Lab is written with 16 bits.
let sample = if img.sample_type() == S::U8 { S::U8 } else { S::U16 };
let mut file = img.convert(img.layout(), sample);
let channels = file.layout().channels();
convert_ab(file.data_mut(), channels, sample, true, true);
let u16s = || file.to_u16_samples().unwrap_or_default();
match (file.layout(), sample) {
(L::Lab, S::U8) => write_one::<Lab8, K>(enc, w, h, file.data(), tags),
(L::Lab, _) => write_one::<Lab16, K>(enc, w, h, &u16s(), tags),
(_, S::U8) => write_one::<LabA8, K>(enc, w, h, file.data(), tags),
_ => write_one::<LabA16, K>(enc, w, h, &u16s(), tags),
}
}
(l, s) => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("unsupported {l:?} {s:?}")).into()),
}
}
+26 -3
View File
@@ -1,5 +1,6 @@
//! Formats delegated to the `image` crate (pure-Rust features only):
//! GIF, BMP, TGA, ICO, QOI, Radiance HDR, and AVIF encode (feature `avif`).
//! GIF, BMP, TGA, ICO, QOI, Radiance HDR, and AVIF encode (feature `avif`). Targa is written by
//! [`super::tga`] and only read here.
use std::io::Cursor;
@@ -37,7 +38,8 @@ pub(crate) fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecErr
Ok(decoder.get_palette().map(|palette| palette.to_vec()))
}
pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
/// The `image` decoder for `f` with our limits, its header read and its size checked.
fn open<'a>(f: Format, bytes: &'a [u8], limits: &Limits) -> Result<impl ImageDecoder + 'a, CodecError> {
let fmt = image_format(f).ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?;
let mut reader = image::ImageReader::with_format(Cursor::new(bytes), fmt);
let mut il = image::Limits::default();
@@ -45,9 +47,14 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image,
il.max_image_height = Some(limits.max_height);
il.max_alloc = Some(limits.max_alloc);
reader.limits(il);
let mut dec = reader.into_decoder().map_err(|e| map_err(f, e))?;
let dec = reader.into_decoder().map_err(|e| map_err(f, e))?;
let (w, h) = dec.dimensions();
limits.check_bytes(w, h, dec.color_type().bytes_per_pixel() as u64)?;
Ok(dec)
}
pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let mut dec = open(f, bytes, limits)?;
let icc = dec.icc_profile().ok().flatten();
let exif = dec.exif_metadata().ok().flatten();
let dynimg = DynamicImage::from_decoder(dec).map_err(|e| map_err(f, e))?;
@@ -60,6 +67,18 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image,
Ok(img)
}
/// The stored size and EXIF block [`decode`] would find, from the decoder's header alone: the
/// same reader, limits and size check, without decoding pixels. AVIF is refused: its decoder
/// decodes the image as it opens.
pub(crate) fn probe(f: Format, bytes: &[u8], limits: &Limits) -> Result<(u32, u32, Option<Vec<u8>>), CodecError> {
if f == Format::Avif {
return Err(CodecError::unsupported(f, "no header-only size probe"));
}
let mut dec = open(f, bytes, limits)?;
let (w, h) = dec.dimensions();
Ok((w, h, dec.exif_metadata().ok().flatten()))
}
/// Counts a GIF's frames (image descriptors) by walking its blocks, without decompressing any.
/// The total is unknown when the blocks end before the trailer or break off.
fn gif_more_frames(b: &[u8]) -> Option<DecodeWarning> {
@@ -145,6 +164,10 @@ pub(crate) fn encode(f: Format, src: &Image, plan: Plan, opts: &EncodeOptions) -
return Err(CodecError::encode(f, format!("dimensions exceed {mw}x{mh}")));
}
let img = src.converted(plan.layout, plan.sample);
// Targa is written by our encoder: the image crate's RLE packets cross scanlines (#6382).
if f == Format::Tga {
return super::tga::encode(&img);
}
let dynimg = to_dynamic(&img)?;
let mut cursor = Cursor::new(Vec::new());
match f {
+16 -1
View File
@@ -17,7 +17,10 @@ fn err(e: impl std::fmt::Display) -> CodecError {
CodecError::malformed(F, e)
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
type Decoder<'a> = image_webp::WebPDecoder<Cursor<&'a [u8]>>;
/// The decoder with its chunks read, the stored size and the output layout, the size checked.
fn open<'a>(bytes: &'a [u8], limits: &Limits) -> Result<(Decoder<'a>, u32, u32, ChannelLayout), CodecError> {
let mut dec = image_webp::WebPDecoder::new(Cursor::new(bytes)).map_err(err)?;
dec.set_memory_limit(limits.alloc_usize());
let (w, h) = dec.dimensions();
@@ -26,6 +29,11 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
// VP8L back-references and VP8 skipped macroblocks compress flat areas extremely well, so the
// ratio is generous; it still stops a header-only file from asking for a gigabyte.
Limits::check_plausible(u64::from(w) * u64::from(h) * layout.channels() as u64, bytes.len(), 1 << 16)?;
Ok((dec, w, h, layout))
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let (mut dec, w, h, layout) = open(bytes, limits)?;
let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?;
let mut buf = alloc_zeroed::<u8>(size)?;
dec.read_image(&mut buf).map_err(err)?;
@@ -41,6 +49,13 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
Ok(img)
}
/// The stored size and EXIF block [`decode`] would find, from the container's chunks alone: the
/// same header reads and size checks, without decoding the bitstream.
pub(crate) fn probe(bytes: &[u8], limits: &Limits) -> Result<(u32, u32, Option<Vec<u8>>), CodecError> {
let (mut dec, w, h, _) = open(bytes, limits)?;
Ok((w, h, dec.exif_metadata().ok().flatten()))
}
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
let img = src.converted(plan.layout, plan.sample);
if !opts.webp_lossless {
+14 -3
View File
@@ -30,6 +30,10 @@ pub enum FidelityWarning {
CmykConverted {
to: ChannelLayout,
},
/// L*a*b* data will be converted to another model (sRGB, D50 to D65).
LabConverted {
to: ChannelLayout,
},
/// Colour data will be reduced to grayscale.
ColorToGray,
/// The image will be quantized to a 256-colour palette.
@@ -84,6 +88,7 @@ impl fmt::Display for FidelityWarning {
CmykConverted { to } => {
write!(f, "CMYK will be converted to {to:?} (not colour-managed)")
}
LabConverted { to } => write!(f, "Lab will be converted to {to:?} (sRGB)"),
ColorToGray => write!(f, "colour will be converted to grayscale"),
PaletteQuantized => write!(f, "colours will be quantized to a 256-colour palette"),
LossyCompression => write!(f, "lossy compression"),
@@ -129,8 +134,8 @@ fn pick_layout(src: ChannelLayout, layouts: &[ChannelLayout], alpha: bool) -> Ch
if layouts.contains(&cand) {
return cand;
}
// CMYK falls back to RGB; gray expands to RGB.
if cand.is_cmyk() || cand.is_gray() {
// CMYK and Lab fall back to RGB; gray expands to RGB.
if cand.is_cmyk() || cand.is_lab() || cand.is_gray() {
cand = if cand.has_alpha() { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
}
if layouts.contains(&cand) {
@@ -158,6 +163,8 @@ pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan
layout: if layout == ChannelLayout::GrayA { ChannelLayout::Gray } else { pick_layout(layout, c.layouts, c.alpha) },
sample: crate::codecs::dpx::plan_sample(opts.dpx_bits, sample),
},
// CIELab has no float form: float Lab is written with 16 bits.
Format::Tiff if layout.is_lab() && sample.is_float() => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: SampleType::U16 },
_ => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: pick_sample(sample, c.depths) },
}
}
@@ -195,7 +202,8 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
if lossy_depth {
w.push(W::DepthReduced { from: ss, to: p.sample });
}
if ss.is_float() && !p.sample.is_float() && image.has_out_of_range() {
// FITS stores floats as 0..1 (see `codecs::fits`), so out-of-range values are clipped too.
if ss.is_float() && (!p.sample.is_float() || format == Format::Fits) && image.has_out_of_range() {
w.push(W::RangeClipped);
}
@@ -209,6 +217,9 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
if sl.is_cmyk() && !p.layout.is_cmyk() {
w.push(W::CmykConverted { to: p.layout });
}
if sl.is_lab() && !p.layout.is_lab() {
w.push(W::LabConverted { to: p.layout });
}
if !sl.is_gray() && p.layout.is_gray() {
w.push(W::ColorToGray);
}
+19 -2
View File
@@ -33,6 +33,8 @@ pub enum Format {
Fits,
/// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames.
Dpx,
/// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from): read-only.
Cineon,
}
/// What a format can hold **and** what this crate reads/writes for it.
@@ -78,6 +80,11 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.",
),
(
Format::Cineon,
"Cineon frames are imported for film work; this crate has no Cineon writer, so save an \
imported frame as DPX, TIFF or EXR instead.",
),
(
Format::Heif,
"HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \
@@ -92,10 +99,12 @@ use SampleType as S;
const RGB_GRAY: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba];
const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::Cmyk, L::CmykA];
/// TIFF also stores CIE L*a*b* (CIELab).
const TIFF_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::Cmyk, L::CmykA, L::Lab, L::LabA];
impl Format {
/// Every format known to the crate (enabled or not).
pub const ALL: [Format; 19] = [
pub const ALL: [Format; 20] = [
Format::Png,
Format::Jpeg,
Format::Tiff,
@@ -115,6 +124,7 @@ impl Format {
Format::Heif,
Format::Fits,
Format::Dpx,
Format::Cineon,
];
pub fn caps(self) -> FormatCaps {
@@ -148,6 +158,7 @@ impl Format {
Format::Heif => "HEIF",
Format::Fits => "FITS",
Format::Dpx => "DPX",
Format::Cineon => "Cineon",
}
}
@@ -174,6 +185,7 @@ impl Format {
Format::Heif => &["heic", "heif", "hif"],
Format::Fits => &["fits", "fit", "fts"],
Format::Dpx => &["dpx"],
Format::Cineon => &["cin"],
}
}
@@ -198,6 +210,7 @@ impl Format {
Format::Heif => "image/heif",
Format::Fits => "image/fits",
Format::Dpx => "image/x-dpx",
Format::Cineon => "image/x-cin",
}
}
@@ -233,7 +246,7 @@ pub fn caps(format: Format) -> FormatCaps {
FormatCaps { depths: &[S::U8, S::U16], layouts: RGB_GRAY, icc: true, exif: true, xmp: true, dpi: true, text: true, animation: true, ..base }
}
Format::Jpeg => FormatCaps { layouts: &[L::Gray, L::Rgb, L::Cmyk], alpha: false, icc: true, exif: true, xmp: true, dpi: true, lossy: true, ..base },
Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, icc: true, exif: true, xmp: true, dpi: true, text: true, ..base },
Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: TIFF_LAYOUTS, icc: true, exif: true, xmp: true, dpi: true, text: true, ..base },
Format::WebP => FormatCaps { icc: true, exif: true, xmp: true, animation: true, ..base },
Format::Gif => FormatCaps { layouts: &[L::Rgba], animation: true, lossy: true, ..base },
Format::Bmp => base,
@@ -253,6 +266,7 @@ pub fn caps(format: Format) -> FormatCaps {
Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base },
// `text`: the DPX header fields only (see `fidelity_warnings_with`).
Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base },
Format::Cineon => FormatCaps { read: true, write: false, depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
}
}
@@ -307,6 +321,9 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
if b.starts_with(b"SDPX") || b.starts_with(b"XPDS") {
return Some(Format::Dpx);
}
if crate::codecs::dpx::is_cineon(b) {
return Some(Format::Cineon);
}
if b.starts_with(b"SIMPLE =") {
return Some(Format::Fits);
}
+26 -6
View File
@@ -21,19 +21,31 @@ pub enum ChannelLayout {
/// Ink amounts, `0 = no ink`, `max = full ink` (not Adobe-inverted).
Cmyk,
CmykA,
/// CIE L*a*b* (D50), as PhotoCraft's Lab documents hold it: L* 0..100 as 0..1, a* and b*
/// −128..127 as `(v + 128) / 255` (Photoshop's unsigned encoding).
Lab,
LabA,
}
impl ChannelLayout {
pub const ALL: [ChannelLayout; 6] =
[ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba, ChannelLayout::Cmyk, ChannelLayout::CmykA];
pub const ALL: [ChannelLayout; 8] = [
ChannelLayout::Gray,
ChannelLayout::GrayA,
ChannelLayout::Rgb,
ChannelLayout::Rgba,
ChannelLayout::Cmyk,
ChannelLayout::CmykA,
ChannelLayout::Lab,
ChannelLayout::LabA,
];
/// Total number of channels including alpha.
pub const fn channels(self) -> usize {
match self {
ChannelLayout::Gray => 1,
ChannelLayout::GrayA => 2,
ChannelLayout::Rgb => 3,
ChannelLayout::Rgba | ChannelLayout::Cmyk => 4,
ChannelLayout::Rgb | ChannelLayout::Lab => 3,
ChannelLayout::Rgba | ChannelLayout::Cmyk | ChannelLayout::LabA => 4,
ChannelLayout::CmykA => 5,
}
}
@@ -44,7 +56,7 @@ impl ChannelLayout {
}
pub const fn has_alpha(self) -> bool {
matches!(self, ChannelLayout::GrayA | ChannelLayout::Rgba | ChannelLayout::CmykA)
matches!(self, ChannelLayout::GrayA | ChannelLayout::Rgba | ChannelLayout::CmykA | ChannelLayout::LabA)
}
pub const fn is_gray(self) -> bool {
@@ -59,12 +71,17 @@ impl ChannelLayout {
matches!(self, ChannelLayout::Cmyk | ChannelLayout::CmykA)
}
pub const fn is_lab(self) -> bool {
matches!(self, ChannelLayout::Lab | ChannelLayout::LabA)
}
/// The same colour model with alpha added.
pub const fn with_alpha(self) -> Self {
match self {
ChannelLayout::Gray | ChannelLayout::GrayA => ChannelLayout::GrayA,
ChannelLayout::Rgb | ChannelLayout::Rgba => ChannelLayout::Rgba,
ChannelLayout::Cmyk | ChannelLayout::CmykA => ChannelLayout::CmykA,
ChannelLayout::Lab | ChannelLayout::LabA => ChannelLayout::LabA,
}
}
@@ -74,12 +91,13 @@ impl ChannelLayout {
ChannelLayout::Gray | ChannelLayout::GrayA => ChannelLayout::Gray,
ChannelLayout::Rgb | ChannelLayout::Rgba => ChannelLayout::Rgb,
ChannelLayout::Cmyk | ChannelLayout::CmykA => ChannelLayout::Cmyk,
ChannelLayout::Lab | ChannelLayout::LabA => ChannelLayout::Lab,
}
}
/// `true` if both layouts share a colour model (ignoring alpha).
pub const fn same_model(self, other: Self) -> bool {
(self.is_gray() && other.is_gray()) || (self.is_rgb() && other.is_rgb()) || (self.is_cmyk() && other.is_cmyk())
(self.is_gray() && other.is_gray()) || (self.is_rgb() && other.is_rgb()) || (self.is_cmyk() && other.is_cmyk()) || (self.is_lab() && other.is_lab())
}
}
@@ -723,6 +741,7 @@ pub(crate) fn convert_layout(src: &[f32], from: ChannelLayout, to: ChannelLayout
ChannelLayout::Gray | ChannelLayout::GrayA => [color[0]; 3],
ChannelLayout::Rgb | ChannelLayout::Rgba => [color[0], color[1], color[2]],
ChannelLayout::Cmyk | ChannelLayout::CmykA => cmyk_to_rgb(color[0], color[1], color[2], color[3]),
ChannelLayout::Lab | ChannelLayout::LabA => crate::lab::lab_to_srgb([color[0], color[1], color[2]]),
};
match to {
ChannelLayout::Gray | ChannelLayout::GrayA => {
@@ -730,6 +749,7 @@ pub(crate) fn convert_layout(src: &[f32], from: ChannelLayout, to: ChannelLayout
}
ChannelLayout::Rgb | ChannelLayout::Rgba => out.extend_from_slice(&rgb),
ChannelLayout::Cmyk | ChannelLayout::CmykA => out.extend_from_slice(&rgb_to_cmyk(rgb[0], rgb[1], rgb[2])),
ChannelLayout::Lab | ChannelLayout::LabA => out.extend_from_slice(&crate::lab::srgb_to_lab(rgb)),
}
}
if to.has_alpha() {
+56
View File
@@ -0,0 +1,56 @@
//! CIE L*a*b* (D50) ⇄ encoded sRGB for [`crate::ChannelLayout::Lab`] samples, the same formulas
//! and constants as `photocraft-color` (this crate stays standalone). Samples are normalized as
//! PhotoCraft holds Lab: L* 0..100 as 0..1, a* and b* −128..127 as `(v + 128) / 255`.
const D50: [f32; 3] = [0.964_22, 1.0, 0.825_21];
/// Linear sRGB → D50 XYZ (Bradford-adapted, as in the ICC sRGB profile).
const SRGB_TO_XYZ_D50: [[f32; 3]; 3] = [[0.436_074, 0.385_064, 0.143_080], [0.222_504, 0.716_878, 0.060_618], [0.013_932, 0.097_104, 0.714_173]];
/// D50 XYZ → linear sRGB.
const XYZ_D50_TO_SRGB: [[f32; 3]; 3] = [[3.133_856, -1.616_867, -0.490_615], [-0.978_768, 1.916_142, 0.033_454], [0.071_945, -0.228_991, 1.405_243]];
fn mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
m.map(|r| r[0] * v[0] + r[1] * v[1] + r[2] * v[2])
}
fn to_linear(v: f32) -> f32 {
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
}
fn to_encoded(v: f32) -> f32 {
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
}
/// Normalized Lab → encoded sRGB in 0..1 (out-of-gamut colours clipped).
pub(crate) fn lab_to_srgb(n: [f32; 3]) -> [f32; 3] {
let (l, a, b) = (n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0);
let fy = (l + 16.0) / 116.0;
let (fx, fz) = (fy + a / 500.0, fy - b / 200.0);
let finv = |t: f32| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f32 / 29.0).powi(2) * (t - 4.0 / 29.0) };
let xyz = [finv(fx) * D50[0], finv(fy) * D50[1], finv(fz) * D50[2]];
mul(&XYZ_D50_TO_SRGB, xyz).map(|v| to_encoded(v.clamp(0.0, 1.0)))
}
/// Encoded sRGB → normalized Lab.
pub(crate) fn srgb_to_lab(rgb: [f32; 3]) -> [f32; 3] {
let xyz = mul(&SRGB_TO_XYZ_D50, rgb.map(|v| to_linear(v.clamp(0.0, 1.0))));
let f = |t: f32| if t > (6.0f32 / 29.0).powi(3) { t.cbrt() } else { t / (3.0 * (6.0f32 / 29.0).powi(2)) + 4.0 / 29.0 };
let (fx, fy, fz) = (f(xyz[0] / D50[0]), f(xyz[1] / D50[1]), f(xyz[2] / D50[2]));
let (l, a, b) = (116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz));
[(l / 100.0).clamp(0.0, 1.0), ((a + 128.0) / 255.0).clamp(0.0, 1.0), ((b + 128.0) / 255.0).clamp(0.0, 1.0)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn white_black_and_round_trips() {
let w = srgb_to_lab([1.0; 3]);
assert!((w[0] - 1.0).abs() < 1e-3 && (w[1] - 128.0 / 255.0).abs() < 1e-3 && (w[2] - 128.0 / 255.0).abs() < 1e-3, "{w:?}");
assert!(srgb_to_lab([0.0; 3])[0].abs() < 1e-4);
for rgb in [[0.2, 0.5, 0.8], [0.9, 0.1, 0.3], [0.5; 3]] {
let back = lab_to_srgb(srgb_to_lab(rgb));
assert!(rgb.iter().zip(back).all(|(a, b)| (a - b).abs() < 2e-3), "{rgb:?} → {back:?}");
}
}
}
+25
View File
@@ -19,6 +19,7 @@ pub mod exif_color;
mod fidelity;
mod format;
mod image;
mod lab;
mod options;
pub mod orientation;
pub mod resolution;
@@ -79,6 +80,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
Format::Dds => dds::decode(bytes, l),
Format::Fits => fits::decode(bytes, l),
Format::Dpx => dpx::decode(bytes, l),
Format::Cineon => dpx::decode_cineon(bytes, l),
Format::OpenExr => exr::decode(bytes, l),
Format::Heif => heif::decode(bytes, l, opts.keep_orientation),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l),
@@ -97,11 +99,33 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
Format::Tiff => tiff::orientation(bytes, None),
Format::Heif => 1,
Format::Dpx => dpx::orientation(bytes),
Format::Cineon => dpx::cineon_orientation(bytes),
_ => img.meta.exif.as_deref().map_or(1, exif_orientation),
};
finish_decode(img, o, opts)
}
/// The upright pixel size [`decode`] would return, read from the file's header without decoding
/// pixels, for WebP, OpenEXR (flat) and the GIF/BMP/TGA/ICO/QOI/HDR family. It runs the same
/// header reads and size checks as the decoder (default [`Limits`]) and swaps the size for an
/// EXIF orientation of 5–8, so wherever [`decode`] succeeds the two agree. Other formats (and
/// deep EXR or AVIF) return [`CodecError::Unsupported`]: decode those to learn their size.
pub fn probe_size(bytes: &[u8]) -> Result<(u32, u32), CodecError> {
let format = detect(bytes).ok_or(CodecError::UnknownFormat)?;
if !caps(format).read {
return Err(CodecError::unsupported(format, "decoding is not available for this format"));
}
let l = &Limits::default();
let (w, h, exif) = match format {
Format::WebP => webp::probe(bytes, l)?,
Format::OpenExr => exr::probe(bytes, l).map(|(w, h)| (w, h, None))?,
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::probe(format, bytes, l)?,
_ => return Err(CodecError::unsupported(format, "no header-only size probe")),
};
let o = exif.as_deref().map_or(1, exif_orientation);
Ok(if (5..=8).contains(&o) { (h, w) } else { (w, h) })
}
/// Returns the embedded colour table for an indexed BMP, if it has one.
pub fn bmp_palette(bytes: &[u8]) -> Result<Option<Vec<[u8; 3]>>, CodecError> {
via_image::bmp_palette(bytes)
@@ -231,6 +255,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
Format::Dds => Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")),
Format::Fits => fits::encode(image, plan, opts),
Format::Dpx => dpx::encode(image, plan, opts),
Format::Cineon => Err(CodecError::unsupported(format, "Cineon is import-only; save as DPX, TIFF or EXR instead")),
Format::OpenExr => exr::encode(image, plan, opts),
Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")),
Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts),
+151 -1
View File
@@ -118,6 +118,23 @@ fn gen_deep(
tile: Option<(usize, usize)>,
counts: &[u32],
sample: &dyn Fn(usize, usize) -> Vec<f32>,
) -> Vec<u8> {
gen_deep_tables(w, h, channels, compression, tile, counts, sample, false)
}
/// [`gen_deep`]; with `full_tile_tables` every tile's count table is stored at the full tile
/// size, as the OpenEXR library writes edge tiles: the block's entries first (in rows of its
/// clipped width), then zeros.
#[allow(clippy::too_many_arguments)]
fn gen_deep_tables(
w: usize,
h: usize,
channels: &[Chan],
compression: u8,
tile: Option<(usize, usize)>,
counts: &[u32],
sample: &dyn Fn(usize, usize) -> Vec<f32>,
full_tile_tables: bool,
) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&0x0131_2f76u32.to_le_bytes());
@@ -216,6 +233,10 @@ fn gen_deep(
table.extend_from_slice(&acc.to_le_bytes());
}
}
assert_eq!(table.len(), table_len(b));
if let (true, Some((tw, th))) = (full_tile_tables, tile) {
table.resize(tw * th * 4, 0);
}
let mut data = Vec::new();
for row in b.pixels.chunks(b.row_w) {
for (ci, ch) in channels.iter().enumerate() {
@@ -237,7 +258,6 @@ fn gen_deep(
c.extend_from_slice(&(data.len() as u64).to_le_bytes());
c.extend_from_slice(&ct);
c.extend_from_slice(&cd);
assert_eq!(table.len(), table_len(b));
assert_eq!(data.len(), data_len(b));
chunks.push((bi, c));
}
@@ -608,6 +628,136 @@ fn hostile_sizes_and_offsets_error() {
assert!(matches!(decode_deep_exr(&far, &Limits::none()), Err(CodecError::Malformed { .. })));
}
/// #4958: a deep tile's count table may be stored at the full tile size, and that size comes
/// from the header alone: nothing ties it to the data window. One 1024×1024 tile over a 4×4
/// image, its table stored at that size, makes a compressed file of tens of KB whose table
/// inflates to 4 MiB, plus as much again for the copy that undoes the byte split. Under a
/// budget smaller than both the table must be refused before it is allocated; with room for
/// both it decodes. Tables stored at the clipped size, and the full-size edge tables of
/// ordinary tiles, still decode under a small budget.
#[test]
fn deep_tile_count_table_is_admitted_against_max_alloc() {
let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() };
let tight = budget(1 << 20);
let table: u64 = 1024 * 1024 * 4; // the full tile's count table, in bytes
let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines");
for compression in [0u8, 1, 2] {
let big = gen_deep_tables(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba, true);
assert_eq!(decode_deep_exr(&big, &Limits::none()).expect("full-size table, no limits"), scan, "compression {compression}");
let got = decode_deep_exr(&big, &tight);
assert!(
matches!(got, Err(CodecError::LimitExceeded(_))),
"compression {compression}: a 4 MiB count table under a 1 MiB budget gave {:?}",
got.map(|d| d.total_samples())
);
let flat = photocraft_codecs::decode_with(&big, &photocraft_codecs::DecodeOptions { limits: tight, ..Default::default() });
assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "compression {compression}: the flat decode gave {:?}", flat.map(|i| i.width()));
// The same tile with its table stored at the clipped size fits the small budget.
let clipped = gen_deep(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba);
assert_eq!(decode_deep_exr(&clipped, &tight).expect("clipped table, 1 MiB budget"), scan, "compression {compression}");
if compression != 0 {
assert!(big.len() < 1 << 20, "compression {compression}: the file is {} bytes", big.len());
}
// The table and its scratch copy are admitted together: 8 MiB.
assert_eq!(decode_deep_exr(&big, &budget(2 * table)).expect("room for the table and its copy"), scan, "compression {compression}");
let got = decode_deep_exr(&big, &budget(2 * table - 1));
assert!(matches!(got, Err(CodecError::LimitExceeded(_))), "compression {compression}: one byte short gave {:?}", got.map(|d| d.total_samples()));
}
// Ordinary tiles: full-size edge tables decode under the small budget, like clipped ones.
let counts: Vec<u32> = (0..35).map(|p| (p * 3) % 4).collect();
let sample = |p: usize, ci: usize| -> Vec<f32> { (0..counts[p]).map(|k| [0.5, 0.25, 0.125 * k as f32, 1.0, 10.0 - k as f32][ci]).collect() };
for compression in [0u8, 1, 2] {
let full = gen_deep_tables(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample, true);
let clipped = gen_deep(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample);
assert_eq!(
decode_deep_exr(&full, &tight).expect("full-size edge tables"),
decode_deep_exr(&clipped, &tight).expect("clipped edge tables"),
"compression {compression}"
);
}
}
/// #4958, the edges of that admission. A tile of the largest size exr accepts (2^30 - 2 a side,
/// a 2^62 byte count table) decodes from a clipped table under every budget, including no limits,
/// where its full size passes the check and is only tried as the data arrives. Each tile is
/// admitted on its own, because its table is freed before the next chunk: three 16×16 tiles over
/// a 48×1 image are all edge tiles, so every full-size table reaches the check (an interior tile's
/// full size equals its clipped size, so it uses the clipped check). They need 2 KiB each for
/// the table and its copy, 6 KiB in all, and decode under a budget of 4096 bytes. Half and uint
/// channels decode after full-size tables like float ones.
#[test]
fn deep_tile_table_admission_edges() {
let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() };
let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines");
let side = (1 << 30) - 2;
for compression in [0u8, 1, 2] {
let bytes = gen_deep(4, 4, &RGBAZ, compression, Some((side, side)), COUNTS_ONE, &sample_rgba);
for limits in [Limits::none(), Limits::default(), budget(1 << 20)] {
assert_eq!(decode_deep_exr(&bytes, &limits).expect("the largest tile, clipped table"), scan, "compression {compression}, {limits:?}");
}
}
let counts: Vec<u32> = (0..48).map(|p| (p % 3) as u32).collect();
let half: [Chan; 5] = RGBAZ.map(|c| Chan { ty: 1, ..c });
let [a, b, g, r, z] = RGBAZ;
let with_id = [a, b, g, r, z, Chan { name: "id", ty: 0 }];
for channels in [&RGBAZ[..], &half[..], &with_id[..]] {
let sample = |p: usize, ci: usize| -> Vec<f32> { (0..counts[p]).map(|k| [0.5, 0.25, 0.125, 1.0, 2.0 + k as f32, 7.0][ci]).collect() };
for compression in [0u8, 1, 2] {
let full = gen_deep_tables(48, 1, channels, compression, Some((16, 16)), &counts, &sample, true);
let clipped = gen_deep(48, 1, channels, compression, Some((16, 16)), &counts, &sample);
let what = format!("{} channels, compression {compression}", channels.len());
let want = decode_deep_exr(&clipped, &Limits::none()).expect("clipped tables");
assert_eq!(decode_deep_exr(&full, &budget(4096)).expect("full-size tables, admitted one at a time"), want, "{what}");
}
}
}
/// A one-channel data-window check admits only four bytes per pixel, but a compressed count
/// table and its scratch copy need eight. Cover scanlines, interior tiles and the clipped fallback;
/// the existing multichannel cases have enough pixel allowance to hide this gap.
#[test]
fn clipped_count_tables_include_scratch_in_the_budget() {
let channels = [Chan { name: "Y", ty: 2 }];
let counts = vec![0u32; 64];
let sample = |_: usize, _: usize| Vec::new();
let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() };
for tile in [None, Some((64, 1)), Some((128, 2))] {
for compression in [0u8, 1, 2] {
let bytes = gen_deep(64, 1, &channels, compression, tile, &counts, &sample);
let what = format!("tile {tile:?}, compression {compression}");
let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode");
let required = if compression == 0 { 256 } else { 512 };
assert_eq!(decode_deep_exr(&bytes, &budget(required)).expect("table and any scratch fit exactly"), want, "{what}");
assert!(matches!(decode_deep_exr(&bytes, &budget(required - 1)), Err(CodecError::LimitExceeded(_))), "{what}");
let flat = photocraft_codecs::decode_with(&bytes, &photocraft_codecs::DecodeOptions { limits: budget(required - 1), ..Default::default() });
assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "{what}");
if compression == 0 {
assert_eq!(decode_deep_exr(&bytes, &budget(511)).expect("raw table needs no scratch"), want, "{what}");
}
}
}
}
/// RLE and ZIPS store incompressible tables raw too. Their actual payload, not the header's
/// compression tag, decides whether a scratch buffer is needed.
#[test]
fn raw_count_tables_need_no_scratch_even_with_compression() {
let channels = [Chan { name: "Y", ty: 2 }];
let limits = Limits { max_alloc: 4, ..Limits::none() };
for tile in [None, Some((1, 1)), Some((2, 2))] {
for compression in [0u8, 1, 2] {
let bytes = gen_deep(1, 1, &channels, compression, tile, &[0], &|_, _| Vec::new());
let header_end = header_end_of(&bytes);
let chunk = u64::from_le_bytes(bytes[header_end..header_end + 8].try_into().expect("chunk offset")) as usize;
let table_size = chunk + if tile.is_some() { 16 } else { 4 };
let packed = u64::from_le_bytes(bytes[table_size..table_size + 8].try_into().expect("table size"));
assert_eq!(packed, 4, "the four-byte count table is stored raw");
let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode");
assert_eq!(decode_deep_exr(&bytes, &limits).expect("raw table fits"), want, "tile {tile:?}, compression {compression}");
}
}
}
/// Walk the attributes like the format prescribes: the offset table starts after the
/// header's terminating zero byte.
fn header_end_of(bytes: &[u8]) -> usize {
+11
View File
@@ -187,6 +187,17 @@ fn detect_magic_hdr() {
assert_eq!(detect(b"#?RGBE\n"), Some(Format::Hdr));
}
#[test]
fn detect_magic_cineon() {
let mut b = vec![0u8; 208];
b[0..4].copy_from_slice(&[0x80, 0x2A, 0x5F, 0xD7]);
assert_eq!(detect(&b), Some(Format::Cineon));
assert_eq!(from_extension("scan.CIN"), Some(Format::Cineon));
assert!(caps(Format::Cineon).read && !caps(Format::Cineon).write, "Cineon is read-only");
assert!(ASYMMETRIC_EXCEPTIONS.iter().any(|(f, _)| *f == Format::Cineon));
// A bare magic is an error, never a panic.
assert!(decode_as(Format::Cineon, &b).is_err());
}
#[test]
fn detect_magic_avif() {
assert_eq!(detect(b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"), Some(Format::Avif));
assert_eq!(detect(b"\0\0\0\x1cftypavis\0\0\0\0avismsf1miaf"), Some(Format::Avif));
+16 -1
View File
@@ -21,7 +21,13 @@ fn png_u16_rgba_is_clean() {
fn tiff_is_clean_for_all_integer_and_f32_layouts() {
for l in ChannelLayout::ALL {
for s in [SampleType::U8, SampleType::U16, SampleType::F32, SampleType::F16] {
assert!(warns(&test_image(l, s), Format::Tiff).is_empty(), "{l:?} {s:?}");
let w = warns(&test_image(l, s), Format::Tiff);
if l.is_lab() && s.is_float() {
// CIELab has no float form: it is written with 16 bits, and says so.
assert_eq!(w, vec![FidelityWarning::DepthReduced { from: s, to: SampleType::U16 }], "{l:?} {s:?}");
} else {
assert!(w.is_empty(), "{l:?} {s:?}");
}
}
}
}
@@ -115,6 +121,12 @@ fn in_range_float_not_clipped() {
assert!(!w.contains(&W::RangeClipped));
}
#[test]
fn hdr_float_to_fits_clips() {
let img = Image::from_f32(2, 1, ChannelLayout::Gray, &[0.5, 3.0]).unwrap();
assert!(warns(&img, Format::Fits).contains(&W::RangeClipped));
assert!(!warns(&test_image(ChannelLayout::Gray, SampleType::F32), Format::Fits).contains(&W::RangeClipped));
}
#[test]
fn float_to_exr_and_tiff_is_clean() {
for f in [Format::OpenExr, Format::Tiff, Format::Pnm] {
assert!(warns(&test_image(ChannelLayout::Rgb, SampleType::F32), f).is_empty(), "{f:?}");
@@ -172,6 +184,7 @@ fn every_warning_has_a_message() {
W::AlphaDiscarded,
W::AlphaBinarized,
W::CmykConverted { to: ChannelLayout::Rgb },
W::LabConverted { to: ChannelLayout::Rgb },
W::ColorToGray,
W::PaletteQuantized,
W::LossyCompression,
@@ -203,6 +216,8 @@ fn warnings_match_actual_roundtrip_behaviour() {
assert_eq!(w.contains(&W::AlphaDiscarded), alpha_lost, "{f:?} {l:?} {s:?}");
let cmyk_lost = l.is_cmyk() && !back.layout().is_cmyk();
assert_eq!(w.iter().any(|w| matches!(w, W::CmykConverted { .. })), cmyk_lost, "{f:?} {l:?} {s:?}");
let lab_lost = l.is_lab() && !back.layout().is_lab();
assert_eq!(w.iter().any(|w| matches!(w, W::LabConverted { .. })), lab_lost, "{f:?} {l:?} {s:?}");
}
}
}
+22
View File
@@ -236,6 +236,7 @@ fn webp_opens_upright() {
// WebP has no gray mode: it comes back as RGB.
let img = decode(&out).unwrap().convert(ChannelLayout::Gray, SampleType::U8);
assert_eq!((img.dimensions(), img.data()), ((w as u32, h as u32), &up[..]), "o={o}");
assert_eq!(probe_size(&out).unwrap(), (w as u32, h as u32), "the header probe turns upright too, o={o}");
}
}
@@ -554,3 +555,24 @@ fn long_orientation_reads_and_rewrites_in_both_byte_orders() {
assert_eq!(kept.strip_prefix(b"Exif\0\0").unwrap_or(kept), &ifd(big, &[long(1)], &[])[..]);
}
}
/// `probe_size` reads the size `decode` returns from the header alone, for every format it
/// covers, and a file cut short inside its header is refused rather than given a size.
#[test]
fn probe_size_matches_decode() {
let img = synth(7, 5, ChannelLayout::Rgb, SampleType::U8, 3, 0.0);
let probed = [Format::WebP, Format::Gif, Format::Bmp, Format::Tga, Format::Qoi, Format::Hdr, Format::Ico, Format::OpenExr];
assert!(probed.iter().all(|f| rw_formats().contains(f)));
for format in rw_formats() {
let bytes = encode(&img, format, &EncodeOptions::default()).unwrap();
let decoded = decode(&bytes).unwrap().dimensions();
match probe_size(&bytes) {
Ok(size) => assert_eq!(size, decoded, "{format:?}"),
Err(_) => assert!(!probed.contains(&format), "{format:?} should be probed"),
}
if probed.contains(&format) {
assert!(probe_size(&bytes[..10]).is_err(), "{format:?} cut inside its header");
}
}
assert!(probe_size(&[]).is_err());
}
+82
View File
@@ -0,0 +1,82 @@
use photocraft_codecs::{ChannelLayout, CodecError, Format, SampleType, decode, decode_as};
const PAM_GRAY: &[u8] = b"P7\nWIDTH 1\nHEIGHT 1\nDEPTH 1\nMAXVAL 255\nENDHDR";
const ENDINGS: &[&[u8]] = &[b"\n", b" \n", b"\t \n", b"\r\n", b" \t\r\n"];
#[test]
fn pam_consumes_the_complete_endhdr_line() {
for ending in ENDINGS {
let mut bytes = PAM_GRAY.to_vec();
bytes.extend_from_slice(ending);
bytes.push(0x80);
let img = decode(&bytes).unwrap();
assert_eq!(img.dimensions(), (1, 1));
assert_eq!(img.layout(), ChannelLayout::Gray);
assert_eq!(img.data(), &[0x80], "ENDHDR ending {ending:?}");
}
}
#[test]
fn pam_preserves_every_possible_first_raster_byte() {
// In particular, LF, CR, space, tab and '#' must not be skipped as header syntax.
for ending in ENDINGS {
for first in 0..=255u8 {
let mut bytes = PAM_GRAY.to_vec();
bytes.extend_from_slice(ending);
bytes.push(first);
assert_eq!(decode(&bytes).unwrap().data(), &[first], "ENDHDR ending {ending:?}, raster byte {first}");
}
}
}
#[test]
fn pam_crlf_header_preserves_u16_samples_and_alpha() {
for ending in ENDINGS {
let mut bytes = b"P7\r\nWIDTH 1\r\nHEIGHT 1\r\nDEPTH 2\r\nMAXVAL 65535\r\nTUPLTYPE GRAYSCALE_ALPHA\r\nENDHDR".to_vec();
bytes.extend_from_slice(ending);
bytes.extend_from_slice(b" \n#\r");
let img = decode(&bytes).unwrap();
assert_eq!(img.layout(), ChannelLayout::GrayA);
assert_eq!(img.sample_type(), SampleType::U16);
assert_eq!(img.to_u16_samples().unwrap(), vec![0x200a, 0x230d], "ENDHDR ending {ending:?}");
}
}
#[test]
fn pam_rejects_unterminated_or_invalid_endhdr_lines() {
for suffix in [b"".as_slice(), b" ", b"\r", b" \t\r", b" \x80", b" \x80\n\x7f", b"#comment\n\x7f"] {
let mut bytes = PAM_GRAY.to_vec();
bytes.extend_from_slice(suffix);
assert!(matches!(decode_as(Format::Pnm, &bytes), Err(CodecError::Malformed { .. })), "ENDHDR suffix {suffix:?}");
}
}
#[test]
fn pam_truncation_never_uses_header_whitespace_as_pixels() {
for ending in ENDINGS {
let mut bytes = PAM_GRAY.to_vec();
bytes.extend_from_slice(ending);
bytes.push(0x80);
for cut in 0..bytes.len() {
assert!(decode_as(Format::Pnm, &bytes[..cut]).is_err(), "ENDHDR ending {ending:?}, truncated at {cut}");
}
}
}
#[test]
fn binary_pnm_keeps_its_single_whitespace_separator() {
for separator in *b" \t\r\n" {
for first in *b" \t\r\n#" {
for (header, channels) in [(b"P5\n1 1\n255".as_slice(), 1), (b"P6\n1 1\n255", 3)] {
let mut bytes = header.to_vec();
bytes.push(separator);
bytes.extend(std::iter::repeat_n(first, channels));
assert_eq!(decode(&bytes).unwrap().data(), vec![first; channels]);
}
let mut bytes = b"P4\n8 1".to_vec();
bytes.extend_from_slice(&[separator, first]);
let expected: Vec<u8> = (0..8).map(|bit| if first & (0x80 >> bit) == 0 { 255 } else { 0 }).collect();
assert_eq!(decode(&bytes).unwrap().data(), expected);
}
}
}
+8
View File
@@ -196,6 +196,14 @@ fn tiff_all_compressions_lossless() {
for comp in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] {
let opts = EncodeOptions { tiff_compression: comp, ..Default::default() };
let back = decode(&encode(&img, Format::Tiff, &opts).unwrap()).unwrap();
if layout.is_lab() && sample != SampleType::U8 {
// CIELab holds a* and b* as 256·a at 16 bits and has no float form: 16-bit and
// float Lab come back within one 16-bit step (the PSD's 16-bit Lab does the same).
let (a, b) = (img.to_normalized(), back.convert(layout, sample).to_normalized());
let worst = a.iter().zip(&b).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max);
assert!(worst <= 1.5 / 65535.0, "{layout:?} {sample:?} {comp:?}: {worst}");
continue;
}
assert_eq!(back, img.clone().with_meta(back.meta.clone()), "{layout:?} {sample:?} {comp:?}");
}
}
Loaded 100 of 487 files, more files were not shown because too many files have changed in this diff. Show more